mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
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:
parent
ed2050cfd8
commit
cc24eb14fc
2 changed files with 119 additions and 61 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -105,79 +105,107 @@ 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(
|
|
||||||
"BF_DB=postgres: foundation present (pg-adapter.ts) but Repository " +
|
|
||||||
"is still on the sync sqlite path. Pending refactor — keep BF_DB " +
|
|
||||||
"unset (defaults to sqlite) or set BF_DB=sqlite explicitly.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath);
|
const { PgAdapter } = await import("./pg-adapter.js");
|
||||||
obs.log.info("opening sqlite at {path}", { path });
|
const adapter = new PgAdapter(pgUrl);
|
||||||
|
|
||||||
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)
|
// Run PG migrations. Track version in schema_migrations table.
|
||||||
try {
|
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
const versionRow = await adapter.get<{ version: number }>(
|
||||||
} catch (err) {
|
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
||||||
obs.log.warn("mkdir failed for {dir}: {err}", {
|
).catch(() => undefined);
|
||||||
dir: dirname(path),
|
const currentVersion = versionRow?.version ?? 0;
|
||||||
err: (err as Error).message,
|
if (currentVersion < TENANT_MIGRATIONS.length) {
|
||||||
});
|
obs.log.info("running PG migrations from {from} to {to}", {
|
||||||
}
|
from: currentVersion,
|
||||||
|
to: TENANT_MIGRATIONS.length,
|
||||||
this.db = new DatabaseSync(path);
|
});
|
||||||
|
// Ensure schema_migrations exists (bootstrap).
|
||||||
// SQLite pragmas for an embedded one-writer setup
|
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
this.db.exec("PRAGMA journal_mode = WAL");
|
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
||||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
this.db.exec("PRAGMA foreign_keys = ON");
|
PRIMARY KEY (schema_name, version)
|
||||||
this.db.exec("PRAGMA busy_timeout = 10000");
|
)`);
|
||||||
|
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
||||||
// Track schema version via SQLite's built-in user_version PRAGMA.
|
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
||||||
// Each migration entry runs exactly once across all server boots.
|
await adapter.run(
|
||||||
const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
|
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
||||||
const currentVersion = row.user_version;
|
[i + 1],
|
||||||
const targetVersion = MIGRATIONS.length;
|
);
|
||||||
|
|
||||||
if (currentVersion < targetVersion) {
|
|
||||||
obs.log.info("running migrations from {from} to {to}", {
|
|
||||||
from: currentVersion,
|
|
||||||
to: targetVersion,
|
|
||||||
});
|
|
||||||
for (let i = currentVersion; i < targetVersion; i++) {
|
|
||||||
const entry = MIGRATIONS[i];
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
this.db.exec(entry);
|
|
||||||
} else if (typeof entry === "function") {
|
|
||||||
entry(this.db);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
obs.log.info("PG schema up to date (version {v})", { v: currentVersion });
|
||||||
}
|
}
|
||||||
this.db.exec(`PRAGMA user_version = ${targetVersion}`);
|
|
||||||
|
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 {
|
} else {
|
||||||
obs.log.info("schema up to date (version {v})", { v: currentVersion });
|
// SQLite path (default).
|
||||||
}
|
const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath);
|
||||||
|
obs.log.info("opening sqlite at {path}", { path });
|
||||||
|
|
||||||
// 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 adapter = SqliteAdapter.fromExisting(this.db);
|
|
||||||
|
|
||||||
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 });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
obs.log.warn("broadcast store.changed failed: {err}", {
|
obs.log.warn("mkdir failed for {dir}: {err}", {
|
||||||
|
dir: dirname(path),
|
||||||
err: (err as Error).message,
|
err: (err as Error).message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
this.db = new DatabaseSync(path);
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||||
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
this.db.exec("PRAGMA busy_timeout = 10000");
|
||||||
|
|
||||||
|
const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
|
||||||
|
const currentVersion = row.user_version;
|
||||||
|
const targetVersion = MIGRATIONS.length;
|
||||||
|
|
||||||
|
if (currentVersion < targetVersion) {
|
||||||
|
obs.log.info("running migrations from {from} to {to}", {
|
||||||
|
from: currentVersion,
|
||||||
|
to: targetVersion,
|
||||||
|
});
|
||||||
|
for (let i = currentVersion; i < targetVersion; i++) {
|
||||||
|
const entry = MIGRATIONS[i];
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
this.db.exec(entry);
|
||||||
|
} else if (typeof entry === "function") {
|
||||||
|
entry(this.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.db.exec(`PRAGMA user_version = ${targetVersion}`);
|
||||||
|
} else {
|
||||||
|
obs.log.info("schema up to date (version {v})", { v: currentVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { SqliteAdapter } = await import("./sqlite-adapter.js");
|
||||||
|
const adapter = SqliteAdapter.fromExisting(this.db);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
registerRepo(this._repo);
|
registerRepo(this._repo);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue