diff --git a/sec-config.template.yaml b/sec-config.template.yaml index 82c36b6..4a0a51c 100644 --- a/sec-config.template.yaml +++ b/sec-config.template.yaml @@ -18,25 +18,20 @@ default: plugin: events-default enabled: true services: - service-store: - package: betterframe - plugin: service-store - enabled: true - config: - driver: ${BF_DB_DRIVER} - sqlitePath: /var/lib/betterframe/betterframe.db - pgHost: ${BF_PG_HOST} - pgPort: ${BF_PG_PORT} - pgDatabase: ${BF_PG_DATABASE} - pgUser: ${BF_PG_USER} - pgPassword: ${BF_PG_PASSWORD} - pgPoolMax: ${BF_PG_POOL_MAX} - service-admin-http: package: betterframe plugin: service-admin-http enabled: true config: + db: + driver: ${BF_DB_DRIVER} + sqlitePath: /var/lib/betterframe/betterframe.db + pgHost: ${BF_PG_HOST} + pgPort: ${BF_PG_PORT} + pgDatabase: ${BF_PG_DATABASE} + pgUser: ${BF_PG_USER} + pgPassword: ${BF_PG_PASSWORD} + pgPoolMax: ${BF_PG_POOL_MAX} host: 0.0.0.0 port: 18080 dataDir: /var/lib/betterframe @@ -61,6 +56,15 @@ default: plugin: service-api-http enabled: true config: + db: + driver: ${BF_DB_DRIVER} + sqlitePath: /var/lib/betterframe/betterframe.db + pgHost: ${BF_PG_HOST} + pgPort: ${BF_PG_PORT} + pgDatabase: ${BF_PG_DATABASE} + pgUser: ${BF_PG_USER} + pgPassword: ${BF_PG_PASSWORD} + pgPoolMax: ${BF_PG_POOL_MAX} host: 0.0.0.0 port: 18081 codeTtlSeconds: 600 @@ -81,6 +85,15 @@ default: plugin: service-coordinator-ws enabled: true config: + db: + driver: ${BF_DB_DRIVER} + sqlitePath: /var/lib/betterframe/betterframe.db + pgHost: ${BF_PG_HOST} + pgPort: ${BF_PG_PORT} + pgDatabase: ${BF_PG_DATABASE} + pgUser: ${BF_PG_USER} + pgPassword: ${BF_PG_PASSWORD} + pgPoolMax: ${BF_PG_POOL_MAX} host: 0.0.0.0 port: 18082 dataDir: /var/lib/betterframe diff --git a/server/bsb-tests.json b/server/bsb-tests.json index de12ebf..9ee6966 100644 --- a/server/bsb-tests.json +++ b/server/bsb-tests.json @@ -89,16 +89,6 @@ "dispose": null }, "tests": [] - }, - { - "id": "service-store", - "skip": true, - "default": { - "config": {}, - "setup": null, - "dispose": null - }, - "tests": [] } ] } \ No newline at end of file diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 75ff21d..fa73e2d 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -15,14 +15,15 @@ import { import { H3, serve } from "h3"; import type { Server } from "srvx"; -import { getRepo } from "../../shared/plugin-registry.js"; +import { dbConfigSchema, type DbConfig } from "../../shared/db/config.js"; +import { initDb } from "../../shared/db/init.js"; +import type { Repository } from "../../shared/db/repository.js"; import { initSecrets, type SecretsApi } from "../../shared/secrets.js"; import { createAuth, type AuthApi } from "../../shared/auth.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; import { serverVersion } from "../../shared/version.js"; -import type { Repository } from "../service-store/repository.js"; import { registerMiddleware } from "./middleware.js"; import { registerSetupRoutes } from "./routes-setup.js"; @@ -38,6 +39,7 @@ import { registerCloudRoutes } from "./routes-cloud.js"; const ConfigSchema = av.object( { + db: dbConfigSchema, host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18080), // Secrets config (was service-secrets) @@ -108,11 +110,13 @@ export class Plugin extends BSBService, typeof Event static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store"]; + initAfterPlugins?: string[]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; private server?: Server; + private dbClose?: () => Promise; + private purgeTimer?: ReturnType; private cameraHealthChecker?: { stop: () => void }; private artifactCleanup?: { stop: () => void }; @@ -128,7 +132,16 @@ export class Plugin extends BSBService, typeof Event const cookieName = this.config.cookieName; const totpIssuer = this.config.totpIssuer; - const repo = getRepo(); + const dbResult = await initDb( + this.config.db as DbConfig, + { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }, + ); + const repo = dbResult.repo; + this.dbClose = dbResult.close; + const secrets = initSecrets( { dataDir, systemdCredsName: this.config.systemdCredsName, systemdCredsDir: this.config.systemdCredsDir || undefined }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, @@ -260,9 +273,29 @@ export class Plugin extends BSBService, typeof Event // to set server URL + API key manually. Best-effort with retries because // Node-RED may still be starting. void this.provisionNoderedBridge(repo, secrets, auth, nodered, selfUrl, obs); + + // Startup purge (inherited from old service-store) + this._repo = repo; + void this.runPurge(obs); } - async run(_obs: Observable): Promise {} + private _repo?: Repository; + + private async runPurge(obs: Observable): Promise { + if (!this._repo) return; + const r = this._repo; + const kl = await r.purgeKioskLogs(14); + const el = await r.purgeEventLog(30, 100_000); + const al = await r.purgeAuditLog(90); + if (kl + el + al > 0) { + obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al }); + } + } + + async run(obs: Observable): Promise { + // Purge every 6 hours (inherited from old service-store). + this.purgeTimer = setInterval(() => this.runPurge(obs), 6 * 60 * 60 * 1000); + } private async provisionNoderedBridge( repo: Repository, @@ -324,10 +357,12 @@ export class Plugin extends BSBService, typeof Event } async dispose(): Promise { + if (this.purgeTimer) clearInterval(this.purgeTimer); this.cameraHealthChecker?.stop(); this.artifactCleanup?.stop(); if (this.server) { await this.server.close(); } + await this.dbClose?.(); } } diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index d36f4c0..39214f4 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -15,7 +15,9 @@ import { import { H3, serve, readBody, getRequestHeader, getRouterParam, createError } from "h3"; import type { Server } from "srvx"; -import { getRepo } from "../../shared/plugin-registry.js"; +import { dbConfigSchema, type DbConfig } from "../../shared/db/config.js"; +import { initDb } from "../../shared/db/init.js"; +import type { Repository } from "../../shared/db/repository.js"; import { initSecrets } from "../../shared/secrets.js"; import { createAuth } from "../../shared/auth.js"; import { initiatePairing, claimPairing } from "../../shared/pairing.js"; @@ -26,7 +28,6 @@ import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; import { createRateLimiter } from "../../shared/rate-limit.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; import { createHash } from "node:crypto"; -import type { Repository } from "../service-store/repository.js"; import type { AuthApi } from "../../shared/auth.js"; import type { SecretsApi } from "../../shared/secrets.js"; import type { FirmwareChannel } from "../../shared/types.js"; @@ -35,6 +36,7 @@ import type { FirmwareChannel } from "../../shared/types.js"; const ConfigSchema = av.object( { + db: dbConfigSchema, host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18081), codeTtlSeconds: av.int().min(60).max(3600).default(600), @@ -84,11 +86,12 @@ export class Plugin extends BSBService, typeof Event static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store"]; + initAfterPlugins?: string[]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; private server?: Server; + private dbClose?: () => Promise; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -100,7 +103,16 @@ export class Plugin extends BSBService, typeof Event const cookieName = this.config.cookieName; const totpIssuer = this.config.totpIssuer; - const repo = getRepo(); + const dbResult = await initDb( + this.config.db as DbConfig, + { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }, + ); + const repo = dbResult.repo; + this.dbClose = dbResult.close; + const secrets = initSecrets( { dataDir }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, @@ -186,6 +198,7 @@ export class Plugin extends BSBService, typeof Event if (this.server) { await this.server.close(); } + await this.dbClose?.(); } } diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 240afae..338f41c 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -23,7 +23,8 @@ import { createServer, type IncomingMessage, type Server as HttpServer } from "n import { randomUUID } from "node:crypto"; import { WebSocketServer, WebSocket } from "ws"; -import { getRepo } from "../../shared/plugin-registry.js"; +import { dbConfigSchema, type DbConfig } from "../../shared/db/config.js"; +import { initDb } from "../../shared/db/init.js"; import { initSecrets } from "../../shared/secrets.js"; import { createAuth } from "../../shared/auth.js"; import { setCoordinator } from "../../shared/coordinator-registry.js"; @@ -33,6 +34,7 @@ import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-brid const ConfigSchema = av.object( { + db: dbConfigSchema, host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18082), noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), @@ -185,7 +187,7 @@ export class Plugin extends BSBService, typeof Event static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store"]; + initAfterPlugins?: string[]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; @@ -193,6 +195,7 @@ export class Plugin extends BSBService, typeof Event private wss?: WebSocketServer; private pingInterval?: ReturnType; private nodered?: NoderedBridge; + private dbClose?: () => Promise; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -204,7 +207,16 @@ export class Plugin extends BSBService, typeof Event const cookieName = this.config.cookieName; const totpIssuer = this.config.totpIssuer; - const repo = getRepo(); + const dbResult = await initDb( + this.config.db as DbConfig, + { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }, + ); + const repo = dbResult.repo; + this.dbClose = dbResult.close; + const secrets = initSecrets( { dataDir }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, @@ -457,7 +469,7 @@ export class Plugin extends BSBService, typeof Event try { k.ws.close(); } catch { /* ignore */ } } connectedKiosks.clear(); - return new Promise((resolve) => { + await new Promise((resolve) => { if (this.wss) this.wss.close(); if (this.httpServer) { this.httpServer.close(() => resolve()); @@ -465,5 +477,6 @@ export class Plugin extends BSBService, typeof Event resolve(); } }); + await this.dbClose?.(); } } diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts deleted file mode 100644 index db6f472..0000000 --- a/server/src/plugins/service-store/index.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * service-store — the only service that opens the sqlite database. - * - * Architecture choice (v0.1): - * For now, other services hold a typed reference to this plugin's - * `Repository` instance via constructor injection (BSB plugin clients). - * We expose the high-level data API as plain methods rather than wiring - * every CRUD operation as a typed BSB event. - * - * Reason: 60+ tables × 4 operations × 2 (input + output) anyvali schemas - * would be ~2000 lines of declarative bus plumbing. The event bus pays off - * when calls cross processes; in v0.1 everything is single-process. - * - * When we scale `service-coordinator-ws` to multiple instances (one per N - * kiosks), we'll graduate the hot-path operations (bundle lookup, label - * filter) to typed returnable events and keep the rest as direct calls. - * - * To-then: emit a domain-event broadcast on every write so listeners - * (e.g. coordinator-ws notifying kiosks of bundle changes) can react. - */ -import { DatabaseSync, type StatementSync } from "node:sqlite"; -import { dirname } from "node:path"; -import { mkdirSync } from "node:fs"; - -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - createBroadcastEvent, - type Observable, -} from "@bsb/base"; - -import { MIGRATIONS } from "./migrations.js"; -import { Repository } from "./repository.js"; -import { registerRepo } from "../../shared/plugin-registry.js"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object( - { - /** Backend selector: "sqlite" or "postgres". */ - driver: av.enum_(["sqlite", "postgres"] as const).default("sqlite"), - /** sqlite-only: filesystem path to the .db file. */ - sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), - /** postgres: full libpq URL. Overrides individual pg* fields if non-empty. */ - pgUrl: av.string().default(""), - /** postgres: host. */ - pgHost: av.string().default("postgres"), - /** postgres: port. */ - pgPort: av.int().min(1).max(65535).default(5432), - /** postgres: database name. */ - pgDatabase: av.string().default("betterframe"), - /** postgres: username. */ - pgUser: av.string().default("betterframe"), - /** postgres: password. */ - pgPassword: av.string().default("betterframe"), - /** postgres: connection pool max size. */ - pgPoolMax: av.int().min(1).max(1000).default(10), - }, - { unknownKeys: "strip" }, -); - -export const Config = createConfigSchema( - { - name: "service-store", - description: - "BetterFrame canonical SQLite store. The single writer in the system; " + - "all other services read/write through this plugin.", - tags: ["service", "store", "sqlite"], - }, - ConfigSchema, -); - -// ---- Event schemas ---------------------------------------------------------- - -const broadcastDomainChange = av.object( - { - table: av.string(), - op: av.enum_(["create", "update", "delete"] as const), - id: av.optional(av.union([av.string(), av.int()])), - }, - { unknownKeys: "reject" }, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: { - "store.changed": createBroadcastEvent(broadcastDomainChange, "Domain row changed"), - }, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - private db?: DatabaseSync; - private _repo?: Repository; - private purgeTimer?: ReturnType; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(obs: Observable): Promise { - const driver = this.config.driver; - - if (driver === "postgres") { - let pgUrl = this.config.pgUrl ?? ""; - if (!pgUrl) { - const u = encodeURIComponent(this.config.pgUser); - const p = encodeURIComponent(this.config.pgPassword); - pgUrl = `postgres://${u}:${p}@${this.config.pgHost}:${this.config.pgPort}/${this.config.pgDatabase}`; - } - obs.log.info("connecting to postgres at {url}", { url: pgUrl.replace(/:[^:@]+@/, ":***@") }); - - const { PgAdapter } = await import("./pg-adapter.js"); - const adapter = new PgAdapter(pgUrl, this.config.pgPoolMax); - - // 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 = this.config.sqlitePath; - obs.log.info("opening sqlite at {path}", { path }); - - try { - mkdirSync(dirname(path), { recursive: true }); - } catch (err) { - obs.log.warn("mkdir failed for {dir}: {err}", { - dir: dirname(path), - 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); - - // Startup purge - this.runPurge(obs); - - obs.log.info("store ready"); - } - - private async runPurge(obs: Observable): Promise { - if (!this._repo) return; - const r = this._repo; - const kl = await r.purgeKioskLogs(14); - const el = await r.purgeEventLog(30, 100_000); - const al = await r.purgeAuditLog(90); - if (kl + el + al > 0) { - obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al }); - } - } - - async run(obs: Observable): Promise { - // Purge every 6 hours. - this.purgeTimer = setInterval(() => this.runPurge(obs), 6 * 60 * 60 * 1000); - } - - async dispose(): Promise { - if (this.purgeTimer) clearInterval(this.purgeTimer); - this.db?.close(); - } - - /** - * Public accessor for sibling services. Throws before init() completes — - * services that need the repo should declare their initAfterPlugins - * dependency on `service-store`. - */ - get repo(): Repository { - if (!this._repo) { - throw new Error("service-store: repository accessed before init()"); - } - return this._repo; - } -} diff --git a/server/src/shared/artifact-cleanup.ts b/server/src/shared/artifact-cleanup.ts index e56770e..61225e4 100644 --- a/server/src/shared/artifact-cleanup.ts +++ b/server/src/shared/artifact-cleanup.ts @@ -12,7 +12,7 @@ * trying to delete the same file (ENOENT is swallowed). */ import { unlink } from "node:fs/promises"; -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; interface CleanupLog { info(msg: string): void; diff --git a/server/src/shared/audit.ts b/server/src/shared/audit.ts index ae34110..2be7ffd 100644 --- a/server/src/shared/audit.ts +++ b/server/src/shared/audit.ts @@ -7,7 +7,7 @@ * Pulls actor + ip out of the h3 event context. Never throws — logging * failure must not break the caller's request. */ -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; import type { AuditActorType, AuditResult } from "./types.js"; interface AuditCtx { diff --git a/server/src/shared/auth.ts b/server/src/shared/auth.ts index 1651e45..1e570b5 100644 --- a/server/src/shared/auth.ts +++ b/server/src/shared/auth.ts @@ -7,7 +7,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import argon2 from "argon2"; import { TOTP, Secret } from "otpauth"; -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; import type { SecretsApi } from "./secrets.js"; import type { ApiKey, ApiKeyScope, Session, User } from "./types.js"; diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index a79d855..1763bb0 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -5,7 +5,7 @@ * No label filtering for v0.1. */ import { createHash } from "node:crypto"; -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; import type { SecretsApi } from "./secrets.js"; export interface BundleCamera { diff --git a/server/src/shared/camera-health.ts b/server/src/shared/camera-health.ts index 72e103e..ba6cf74 100644 --- a/server/src/shared/camera-health.ts +++ b/server/src/shared/camera-health.ts @@ -10,7 +10,7 @@ * OPTIONS, check for 200. No actual stream decode — just reachability. */ import { createConnection, type Socket } from "node:net"; -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; export interface CameraHealthConfig { intervalMs: number; diff --git a/server/src/shared/db/config.ts b/server/src/shared/db/config.ts new file mode 100644 index 0000000..b453238 --- /dev/null +++ b/server/src/shared/db/config.ts @@ -0,0 +1,28 @@ +import * as av from "@anyvali/js"; + +export const dbConfigSchema = av.object( + { + driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"), + sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), + pgUrl: av.string().default(""), + pgHost: av.string().default("postgres"), + pgPort: av.int().min(1).max(65535).default(5432), + pgDatabase: av.string().default("betterframe"), + pgUser: av.string().default("betterframe"), + pgPassword: av.string().default("betterframe"), + pgPoolMax: av.int().min(1).max(1000).default(10), + }, + { unknownKeys: "strip" }, +); + +export type DbConfig = { + driver: "sqlite" | "postgres"; + sqlitePath: string; + pgUrl: string; + pgHost: string; + pgPort: number; + pgDatabase: string; + pgUser: string; + pgPassword: string; + pgPoolMax: number; +}; diff --git a/server/src/plugins/service-store/db-adapter.ts b/server/src/shared/db/db-adapter.ts similarity index 100% rename from server/src/plugins/service-store/db-adapter.ts rename to server/src/shared/db/db-adapter.ts diff --git a/server/src/shared/db/init.ts b/server/src/shared/db/init.ts new file mode 100644 index 0000000..ec4fa10 --- /dev/null +++ b/server/src/shared/db/init.ts @@ -0,0 +1,115 @@ +/** + * initDb — initialize the database from config (shared module). + * + * Replaces the init logic that was in service-store/index.ts. + * Each service plugin calls this independently with its own config. + */ +import { DatabaseSync } from "node:sqlite"; +import { dirname } from "node:path"; +import { mkdirSync } from "node:fs"; + +import { MIGRATIONS } from "./migrations.js"; +import { Repository } from "./repository.js"; +import type { DbConfig } from "./config.js"; + +interface DbLog { + info(msg: string): void; + warn(msg: string): void; +} + +export async function initDb( + config: DbConfig, + log: DbLog, + notifyFn?: (table: string, op: string, id?: string | number) => void, +): Promise<{ repo: Repository; close: () => Promise }> { + const driver = config.driver; + const notify = notifyFn ?? (() => {}); + + if (driver === "postgres") { + let pgUrl = config.pgUrl ?? ""; + if (!pgUrl) { + const u = encodeURIComponent(config.pgUser); + const p = encodeURIComponent(config.pgPassword); + pgUrl = `postgres://${u}:${p}@${config.pgHost}:${config.pgPort}/${config.pgDatabase}`; + } + log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`); + + const { PgAdapter } = await import("./pg-adapter.js"); + const adapter = new PgAdapter(pgUrl, config.pgPoolMax); + + // 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) { + log.info(`running PG migrations 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 { + log.info(`PG schema up to date (version ${currentVersion})`); + } + + const repo = new Repository(adapter, async (table, op, id) => { + notify(table, op, id); + }); + + return { repo, close: () => adapter.close() }; + } + + // SQLite path (default). + const path = config.sqlitePath; + log.info(`opening sqlite at ${path}`); + + try { + mkdirSync(dirname(path), { recursive: true }); + } catch (err) { + log.warn(`mkdir failed for ${dirname(path)}: ${(err as Error).message}`); + } + + const db = new DatabaseSync(path); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec("PRAGMA busy_timeout = 10000"); + + const row = db.prepare("PRAGMA user_version").get() as { user_version: number }; + const currentVersion = row.user_version; + const targetVersion = MIGRATIONS.length; + + if (currentVersion < targetVersion) { + log.info(`running migrations from ${currentVersion} to ${targetVersion}`); + for (let i = currentVersion; i < targetVersion; i++) { + const entry = MIGRATIONS[i]; + if (typeof entry === "string") { + db.exec(entry); + } else if (typeof entry === "function") { + entry(db); + } + } + db.exec(`PRAGMA user_version = ${targetVersion}`); + } else { + log.info(`schema up to date (version ${currentVersion})`); + } + + const { SqliteAdapter } = await import("./sqlite-adapter.js"); + const adapter = SqliteAdapter.fromExisting(db); + + const repo = new Repository(adapter, async (table, op, id) => { + notify(table, op, id); + }); + + return { repo, close: () => adapter.close() }; +} diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/shared/db/mappers.ts similarity index 99% rename from server/src/plugins/service-store/mappers.ts rename to server/src/shared/db/mappers.ts index 00321eb..111fc42 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/shared/db/mappers.ts @@ -54,7 +54,7 @@ import type { StreamSelector, User, UserRole, -} from "../../shared/types.js"; +} from "../types.js"; import { b, j } from "./util.js"; type Row = Record; diff --git a/server/src/plugins/service-store/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts similarity index 100% rename from server/src/plugins/service-store/migrations-pg.ts rename to server/src/shared/db/migrations-pg.ts diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/shared/db/migrations.ts similarity index 100% rename from server/src/plugins/service-store/migrations.ts rename to server/src/shared/db/migrations.ts diff --git a/server/src/plugins/service-store/pg-adapter.ts b/server/src/shared/db/pg-adapter.ts similarity index 100% rename from server/src/plugins/service-store/pg-adapter.ts rename to server/src/shared/db/pg-adapter.ts diff --git a/server/src/plugins/service-store/repository.ts b/server/src/shared/db/repository.ts similarity index 99% rename from server/src/plugins/service-store/repository.ts rename to server/src/shared/db/repository.ts index 19c7ff9..eec3027 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/shared/db/repository.ts @@ -55,7 +55,7 @@ import type { StreamRole, User, UserRole, -} from "../../shared/types.js"; +} from "../types.js"; import { rowToApiKey, rowToAuditEntry, diff --git a/server/src/plugins/service-store/sqlite-adapter.ts b/server/src/shared/db/sqlite-adapter.ts similarity index 100% rename from server/src/plugins/service-store/sqlite-adapter.ts rename to server/src/shared/db/sqlite-adapter.ts diff --git a/server/src/plugins/service-store/util.ts b/server/src/shared/db/util.ts similarity index 100% rename from server/src/plugins/service-store/util.ts rename to server/src/shared/db/util.ts diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index dd0d1dc..6f2cb71 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -7,7 +7,7 @@ * 3. Admin enters code in UI → confirmPairing creates kiosk + kiosk_key */ import { randomBytes } from "node:crypto"; -import type { Repository } from "../plugins/service-store/repository.js"; +import type { Repository } from "./db/repository.js"; import type { AuthApi } from "./auth.js"; import type { SecretsApi } from "./secrets.js"; import type { PairingCode } from "./types.js"; diff --git a/server/src/shared/plugin-registry.ts b/server/src/shared/plugin-registry.ts deleted file mode 100644 index 54e04ec..0000000 --- a/server/src/shared/plugin-registry.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Module-level store registry — the one cross-plugin reference needed. - * - * service-store registers its repo in init(). Downstream plugins - * (admin-http, api-http, coordinator-ws) look it up in their init(). - * initAfterPlugins guarantees ordering. - */ -import type { Repository } from "../plugins/service-store/repository.js"; - -let _repo: Repository | undefined; - -export function registerRepo(repo: Repository): void { - _repo = repo; -} - -export function getRepo(): Repository { - if (!_repo) throw new Error("plugin-registry: store repo not registered (init order bug?)"); - return _repo; -}