feat(db): wire PostgreSQL switch + docker-compose postgres service

BF_DB=postgres + BF_PG_URL activates the PgAdapter path. Service-store
detects driver, creates PgAdapter with connection pool, runs
TENANT_MIGRATIONS from migrations-pg.ts, tracks version in
schema_migrations table.

docker-compose.coolify.yml gains a postgres service (postgres:17-alpine)
behind the "postgres" profile — disabled by default. Set BF_DB=postgres
in Coolify env to activate. Server env auto-constructs BF_PG_URL from
BF_PG_USER/PASSWORD/DB vars.

SQLite remains default — no change for existing deployments.
This commit is contained in:
Mitchell R 2026-05-23 02:13:28 +02:00
parent ed2050cfd8
commit cc24eb14fc
No known key found for this signature in database
2 changed files with 119 additions and 61 deletions

View file

@ -28,6 +28,9 @@ services:
- BF_NODERED_URL=http://nodered:1880 - BF_NODERED_URL=http://nodered:1880
- BF_SELF_URL=http://server:18080 - BF_SELF_URL=http://server:18080
- BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}} - BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}}
# PostgreSQL: set BF_DB=postgres to switch from SQLite.
- BF_DB=${BF_DB:-sqlite}
- BF_PG_URL=${BF_PG_URL:-postgres://${BF_PG_USER:-betterframe}:${BF_PG_PASSWORD:-betterframe}@postgres:5432/${BF_PG_DB:-betterframe}}
volumes: volumes:
- betterframe-data:/var/lib/betterframe - betterframe-data:/var/lib/betterframe
expose: expose:
@ -80,11 +83,38 @@ services:
networks: networks:
- betterframe - betterframe
# PostgreSQL — optional. Set BF_DB=postgres to switch from SQLite.
# Omit or disable this service to keep using SQLite (default).
postgres:
image: postgres:17-alpine
container_name: betterframe-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${BF_PG_USER:-betterframe}
- POSTGRES_PASSWORD=${BF_PG_PASSWORD:-betterframe}
- POSTGRES_DB=${BF_PG_DB:-betterframe}
volumes:
- postgres-data:/var/lib/postgresql/data
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${BF_PG_USER:-betterframe}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- betterframe
profiles:
- postgres
volumes: volumes:
betterframe-data: betterframe-data:
name: ${BF_DATA_VOLUME_NAME:-betterframe-data} name: ${BF_DATA_VOLUME_NAME:-betterframe-data}
nodered-data: nodered-data:
name: ${NODERED_DATA_VOLUME_NAME:-nodered-data} name: ${NODERED_DATA_VOLUME_NAME:-nodered-data}
postgres-data:
name: ${BF_PG_VOLUME_NAME:-betterframe-postgres}
networks: networks:
betterframe: betterframe:

View file

@ -105,22 +105,57 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
async init(obs: Observable): Promise<void> { async init(obs: Observable): Promise<void> {
const driver = envStr("BF_DB", this.config.driver) as "sqlite" | "postgres"; const driver = envStr("BF_DB", this.config.driver) as "sqlite" | "postgres";
if (driver === "postgres") { if (driver === "postgres") {
// Repository conversion to the async DbAdapter interface is in progress. const pgUrl = envStr("BF_PG_URL", this.config.pgUrl ?? "");
// Until that lands, refuse to start under postgres rather than corrupt if (!pgUrl) throw new Error("BF_DB=postgres requires BF_PG_URL");
// data via half-converted code paths. See db-adapter.ts / pg-adapter.ts obs.log.info("connecting to postgres at {url}", { url: pgUrl.replace(/:[^:@]+@/, ":***@") });
// for the foundation already in place.
throw new Error( const { PgAdapter } = await import("./pg-adapter.js");
"BF_DB=postgres: foundation present (pg-adapter.ts) but Repository " + const adapter = new PgAdapter(pgUrl);
"is still on the sync sqlite path. Pending refactor — keep BF_DB " +
"unset (defaults to sqlite) or set BF_DB=sqlite explicitly.", // Run PG migrations. Track version in schema_migrations table.
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
const versionRow = await adapter.get<{ version: number }>(
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
).catch(() => undefined);
const currentVersion = versionRow?.version ?? 0;
if (currentVersion < TENANT_MIGRATIONS.length) {
obs.log.info("running PG migrations from {from} to {to}", {
from: currentVersion,
to: TENANT_MIGRATIONS.length,
});
// Ensure schema_migrations exists (bootstrap).
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (schema_name, version)
)`);
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
await adapter.exec(TENANT_MIGRATIONS[i]!);
await adapter.run(
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
[i + 1],
); );
} }
} else {
obs.log.info("PG schema up to date (version {v})", { v: currentVersion });
}
this._repo = new Repository(adapter, async (table, op, id) => {
try {
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
} catch (err) {
obs.log.warn("broadcast store.changed failed: {err}", {
err: (err as Error).message,
});
}
});
} else {
// SQLite path (default).
const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath); const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath);
obs.log.info("opening sqlite at {path}", { path }); obs.log.info("opening sqlite at {path}", { path });
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)
try { try {
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
} catch (err) { } catch (err) {
@ -131,15 +166,11 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
this.db = new DatabaseSync(path); this.db = new DatabaseSync(path);
// SQLite pragmas for an embedded one-writer setup
this.db.exec("PRAGMA journal_mode = WAL"); this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA synchronous = NORMAL"); this.db.exec("PRAGMA synchronous = NORMAL");
this.db.exec("PRAGMA foreign_keys = ON"); this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec("PRAGMA busy_timeout = 10000"); this.db.exec("PRAGMA busy_timeout = 10000");
// Track schema version via SQLite's built-in user_version PRAGMA.
// Each migration entry runs exactly once across all server boots.
const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number }; const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
const currentVersion = row.user_version; const currentVersion = row.user_version;
const targetVersion = MIGRATIONS.length; const targetVersion = MIGRATIONS.length;
@ -162,14 +193,10 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
obs.log.info("schema up to date (version {v})", { v: currentVersion }); obs.log.info("schema up to date (version {v})", { v: currentVersion });
} }
// Wrap the already-configured DatabaseSync in a SqliteAdapter for the
// Repository's async DbAdapter interface. Migrations already ran on
// this.db above — SqliteAdapter just wraps it for query access.
const { SqliteAdapter } = await import("./sqlite-adapter.js"); const { SqliteAdapter } = await import("./sqlite-adapter.js");
const adapter = SqliteAdapter.fromExisting(this.db); const adapter = SqliteAdapter.fromExisting(this.db);
this._repo = new Repository(adapter, async (table, op, id) => { this._repo = new Repository(adapter, async (table, op, id) => {
// Best-effort broadcast — never let a failed event-bus call fail a write.
try { try {
await this.events.emitBroadcast("store.changed", obs, { table, op, id }); await this.events.emitBroadcast("store.changed", obs, { table, op, id });
} catch (err) { } catch (err) {
@ -178,6 +205,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}); });
} }
}); });
}
registerRepo(this._repo); registerRepo(this._repo);