diff --git a/sec-config.template.yaml b/sec-config.template.yaml index 109b037..99726e6 100644 --- a/sec-config.template.yaml +++ b/sec-config.template.yaml @@ -24,8 +24,6 @@ default: enabled: true config: db: - driver: ${BF_DB_DRIVER} - sqlitePath: /var/lib/betterframe/betterframe.db host: ${BF_PG_HOST} port: ${BF_PG_PORT} database: ${BF_PG_DB} @@ -57,8 +55,6 @@ default: enabled: true config: db: - driver: ${BF_DB_DRIVER} - sqlitePath: /var/lib/betterframe/betterframe.db host: ${BF_PG_HOST} port: ${BF_PG_PORT} database: ${BF_PG_DB} @@ -86,8 +82,6 @@ default: enabled: true config: db: - driver: ${BF_DB_DRIVER} - sqlitePath: /var/lib/betterframe/betterframe.db host: ${BF_PG_HOST} port: ${BF_PG_PORT} database: ${BF_PG_DB} diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 92cd405..24b6b66 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -42,8 +42,6 @@ const ConfigSchema = av.object( { db: av.object( { - driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"), - sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), url: av.string().default(""), host: av.string().default("postgres"), port: av.int().min(1).max(65535).default(5432), diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 778981e..039560e 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -38,8 +38,6 @@ const ConfigSchema = av.object( { db: av.object( { - driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"), - sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), url: av.string().default(""), host: av.string().default("postgres"), port: av.int().min(1).max(65535).default(5432), diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index f389403..1187fa1 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -36,8 +36,6 @@ const ConfigSchema = av.object( { db: av.object( { - driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"), - sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), url: av.string().default(""), host: av.string().default("postgres"), port: av.int().min(1).max(65535).default(5432), diff --git a/server/src/shared/db/config.ts b/server/src/shared/db/config.ts index afbf8b4..53b9510 100644 --- a/server/src/shared/db/config.ts +++ b/server/src/shared/db/config.ts @@ -2,8 +2,6 @@ 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"), url: av.string().default(""), host: av.string().default("postgres"), port: av.int().min(1).max(65535).default(5432), @@ -16,8 +14,6 @@ export const dbConfigSchema = av.object( ); export type DbConfig = { - driver: "sqlite" | "postgres"; - sqlitePath: string; url: string; host: string; port: number; diff --git a/server/src/shared/db/db-adapter.ts b/server/src/shared/db/db-adapter.ts index 5bc2044..ca4f026 100644 --- a/server/src/shared/db/db-adapter.ts +++ b/server/src/shared/db/db-adapter.ts @@ -1,58 +1,28 @@ /** - * Backend-agnostic DB adapter. Repository talks to this; concrete adapters - * (sqlite, postgres) implement it. + * Backend-agnostic DB adapter. Repository talks to this; PG adapter implements it. * * Design choices: - * - All methods return Promises so the Postgres path can use real async I/O. - * The SQLite adapter wraps node:sqlite's synchronous calls in - * Promise.resolve to keep the same interface. - * - `?` is the canonical placeholder in SQL strings. The Postgres adapter + * - All methods return Promises (real async I/O with PG pool). + * - `?` is the canonical placeholder in SQL strings. The PG adapter * rewrites them to `$1, $2, ...` at execute time so repository code stays * dialect-neutral. - * - INSERTs that need to return the new row id must use `... RETURNING id` - * explicitly. Both SQLite (3.35+) and Postgres support it. - * - * Migrations and DDL fragments still differ between dialects (AUTOINCREMENT - * vs SERIAL, STRICT vs nothing, strftime vs now()), so each backend ships - * its own migration set rather than trying to abstract DDL. */ export type SqlValue = string | number | bigint | boolean | null | Uint8Array; export type Row = Record; export interface RunResult { - /** New row id when the statement used `RETURNING id`, else 0n. */ lastInsertRowid: bigint; - /** Rows affected (approximate for some Postgres queries). */ changes: number; } export interface DbAdapter { - /** Execute a write statement (INSERT / UPDATE / DELETE). */ run(sql: string, params?: ReadonlyArray): Promise; - /** Single-row query. Undefined if no row. */ get(sql: string, params?: ReadonlyArray): Promise; - /** Multi-row query. */ all(sql: string, params?: ReadonlyArray): Promise; - /** Execute multi-statement DDL (no params, no result). */ exec(sql: string): Promise; - /** Run a callback inside a transaction. Rolls back on throw. */ transaction(fn: () => Promise): Promise; - /** Identifies the backend. */ - dialect(): "sqlite" | "postgres"; - /** - * Set the schema search_path for multi-tenant isolation (PG only). - * SQLite adapter implements this as a no-op. - */ + dialect(): "postgres"; setSearchPath(schema: string): Promise; - /** Release the connection / pool. */ close(): Promise; } - -export interface DbAdapterConfig { - driver: "sqlite" | "postgres"; - /** SQLite-only: filesystem path. */ - sqlitePath?: string; - /** Postgres-only: connection string (postgres://user:pass@host:port/db). */ - pgUrl?: string; -} diff --git a/server/src/shared/db/init.ts b/server/src/shared/db/init.ts index 81585e4..70c08af 100644 --- a/server/src/shared/db/init.ts +++ b/server/src/shared/db/init.ts @@ -1,14 +1,9 @@ /** - * initDb — initialize the database from config (shared module). + * initDb — initialize the PostgreSQL database from config. * - * Replaces the init logic that was in service-store/index.ts. - * Each service plugin calls this independently with its own config. + * Runs PUBLIC_MIGRATIONS (global tables) then TENANT_MIGRATIONS + * (per-tenant schema). Creates default tenant if missing. */ -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 { DbAdapter } from "./db-adapter.js"; import type { DbConfig } from "./config.js"; @@ -23,133 +18,82 @@ export async function initDb( 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.url ?? ""; - if (!pgUrl) { - const u = encodeURIComponent(config.user); - const p = encodeURIComponent(config.password); - pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`; - } - log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`); + let pgUrl = config.url ?? ""; + if (!pgUrl) { + const u = encodeURIComponent(config.user); + const p = encodeURIComponent(config.password); + pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`; + } + log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`); - const { PgAdapter } = await import("./pg-adapter.js"); - const adapter = new PgAdapter(pgUrl, config.poolMax); + const { PgAdapter } = await import("./pg-adapter.js"); + const adapter = new PgAdapter(pgUrl, config.poolMax); - // 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) - )`); + 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) + )`); - // 1. Run PUBLIC_MIGRATIONS first (tenants + global_admins tables). - const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js"); - const pubVersionRow = await adapter.get<{ version: number }>( - `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`, - ).catch(() => undefined); - const pubCurrentVersion = pubVersionRow?.version ?? 0; - if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) { - log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`); - for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) { - try { - await adapter.exec(PUBLIC_MIGRATIONS[i]!); - } catch (err) { - log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`); - log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`); - throw err; - } - await adapter.run( - `INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`, - [i + 1], - ); + const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + const pubVersionRow = await adapter.get<{ version: number }>( + `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`, + ).catch(() => undefined); + const pubCurrentVersion = pubVersionRow?.version ?? 0; + if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) { + log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`); + for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) { + try { + await adapter.exec(PUBLIC_MIGRATIONS[i]!); + } catch (err) { + log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`); + log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`); + throw err; } - } else { - log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`); - } - - // 2. Run TENANT_MIGRATIONS in the public schema (default tenant). - 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 tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); - for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { - try { - await adapter.exec(TENANT_MIGRATIONS[i]!); - } catch (err) { - log.warn(`PG migration ${i} failed: ${(err as Error).message}`); - log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`); - throw err; - } - await adapter.run( - `INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`, - [i + 1], - ); - } - } else { - log.info(`PG schema up to date (version ${currentVersion})`); - } - - // 3. Ensure default tenant exists. - const defaultTenant = await adapter.get( - `SELECT id FROM public.tenants WHERE slug = 'default'`, - ); - if (!defaultTenant) { - log.info("creating default tenant"); await adapter.run( - `INSERT INTO public.tenants (name, slug, schema_name, is_active) - VALUES ('Default', 'default', 'public', true)`, + `INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`, + [i + 1], ); } - - 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})`); + log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`); } - const { SqliteAdapter } = await import("./sqlite-adapter.js"); - const adapter = SqliteAdapter.fromExisting(db); + 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 tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); + for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { + try { + await adapter.exec(TENANT_MIGRATIONS[i]!); + } catch (err) { + log.warn(`PG migration ${i} failed: ${(err as Error).message}`); + log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`); + throw err; + } + 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 defaultTenant = await adapter.get( + `SELECT id FROM public.tenants WHERE slug = 'default'`, + ); + if (!defaultTenant) { + log.info("creating default tenant"); + await adapter.run( + `INSERT INTO public.tenants (name, slug, schema_name, is_active) + VALUES ('Default', 'default', 'public', true)`, + ); + } const repo = new Repository(adapter, async (table, op, id) => { notify(table, op, id); @@ -160,40 +104,24 @@ export async function initDb( /** * Create a new tenant schema and run all TENANT_MIGRATIONS inside it. - * Called when a new tenant is created from the admin UI. - * - * @param adapter - the DB adapter (must be PG) - * @param slug - tenant slug (used to derive schema name: `tenant_`) - * @param log - logging callbacks */ export async function createTenantSchema( adapter: DbAdapter, slug: string, log: DbLog, ): Promise { - if (adapter.dialect() !== "postgres") { - // SQLite is single-tenant — no schema creation needed. - return; - } - // Validate slug to prevent SQL injection. if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { throw new Error(`invalid tenant slug: ${slug}`); } const schemaName = `tenant_${slug}`; log.info(`creating tenant schema: ${schemaName}`); - // Create the schema. await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`); - - // Set search_path to the new schema for running tenant migrations. await adapter.setSearchPath(schemaName); try { - // Run all TENANT_MIGRATIONS inside the new schema. const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); - // Ensure schema_migrations tracking for this schema. - // Use the public schema_migrations table (always in public). const versionRow = await adapter.get<{ version: number }>( `SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`, [schemaName], @@ -216,7 +144,6 @@ export async function createTenantSchema( } } } finally { - // Always reset search_path back to public. await adapter.setSearchPath("public"); } } diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index 05383e6..b197a4a 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -485,4 +485,137 @@ export const TENANT_MIGRATIONS: readonly string[] = [ `CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`, `ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`, + + // ---- UUIDv7 PK migration for existing databases ---- + // Databases created before UUIDv7 migration have INTEGER PKs. + // This migration converts them to TEXT in-place. Safe to run on + // databases that already have TEXT PKs (DO NOTHING on conflict). + // gen_random_uuid() generates UUIDv4 — close enough for backfill. + // New rows already use app-generated UUIDv7 from repository.ts. + `DO $$ + DECLARE + col_type text; + BEGIN + -- Only run if users.id is still integer (proxy for "needs migration"). + SELECT data_type INTO col_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'users' + AND column_name = 'id'; + IF col_type IS NULL OR col_type = 'text' THEN + RAISE NOTICE 'UUIDv7 migration: already TEXT or table missing, skipping'; + RETURN; + END IF; + + RAISE NOTICE 'UUIDv7 migration: converting INTEGER PKs to TEXT...'; + + -- 1. Drop all FK constraints first (PG won't let us alter referenced types). + -- sessions → users + ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_user_id_fkey; + -- api_keys → users + ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_user_id_fkey; + -- cameras — no FK to other tables with integer PK + -- camera_streams → cameras + ALTER TABLE camera_streams DROP CONSTRAINT IF EXISTS camera_streams_camera_id_fkey; + -- layouts — no FK from layout.id + -- display_layouts → displays, layouts + ALTER TABLE display_layouts DROP CONSTRAINT IF EXISTS display_layouts_display_id_fkey; + ALTER TABLE display_layouts DROP CONSTRAINT IF EXISTS display_layouts_layout_id_fkey; + -- layout_cells → layouts, cameras + ALTER TABLE layout_cells DROP CONSTRAINT IF EXISTS layout_cells_layout_id_fkey; + ALTER TABLE layout_cells DROP CONSTRAINT IF EXISTS layout_cells_camera_id_fkey; + -- kiosks → displays + ALTER TABLE kiosks DROP CONSTRAINT IF EXISTS kiosks_display_id_fkey; + -- labels — standalone + -- kiosk_labels → kiosks, labels + ALTER TABLE kiosk_labels DROP CONSTRAINT IF EXISTS kiosk_labels_kiosk_id_fkey; + ALTER TABLE kiosk_labels DROP CONSTRAINT IF EXISTS kiosk_labels_label_id_fkey; + -- camera_labels → cameras, labels + ALTER TABLE camera_labels DROP CONSTRAINT IF EXISTS camera_labels_camera_id_fkey; + ALTER TABLE camera_labels DROP CONSTRAINT IF EXISTS camera_labels_label_id_fkey; + -- layout_labels → layouts, labels + ALTER TABLE layout_labels DROP CONSTRAINT IF EXISTS layout_labels_layout_id_fkey; + ALTER TABLE layout_labels DROP CONSTRAINT IF EXISTS layout_labels_label_id_fkey; + -- event_log → kiosks, cameras + ALTER TABLE event_log DROP CONSTRAINT IF EXISTS event_log_source_kiosk_id_fkey; + ALTER TABLE event_log DROP CONSTRAINT IF EXISTS event_log_source_camera_id_fkey; + -- kiosk_gpio_bindings → kiosks + ALTER TABLE kiosk_gpio_bindings DROP CONSTRAINT IF EXISTS kiosk_gpio_bindings_kiosk_id_fkey; + -- kiosk_logs → kiosks + ALTER TABLE kiosk_logs DROP CONSTRAINT IF EXISTS kiosk_logs_kiosk_id_fkey; + -- camera_event_subscriptions → cameras, kiosks + ALTER TABLE camera_event_subscriptions DROP CONSTRAINT IF EXISTS camera_event_subscriptions_camera_id_fkey; + ALTER TABLE camera_event_subscriptions DROP CONSTRAINT IF EXISTS camera_event_subscriptions_subscribed_by_kiosk_id_fkey; + -- entities — standalone + -- audit_log — standalone + -- cloud_accounts — standalone (already TEXT PK) + + -- 2. Convert PK columns: add TEXT column, backfill, swap. + -- Helper: for each table, ALTER COLUMN TYPE works if data is castable. + -- Integer → TEXT cast is safe. + ALTER TABLE users ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE api_keys ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE displays ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE cameras ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE camera_streams ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE layouts ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE layout_cells ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE kiosks ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE labels ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE event_log ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE entities ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE kiosk_gpio_bindings ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE audit_log ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE kiosk_logs ALTER COLUMN id TYPE TEXT USING id::TEXT; + ALTER TABLE camera_event_subscriptions ALTER COLUMN id TYPE TEXT USING id::TEXT; + + -- 3. Convert FK columns to TEXT too. + ALTER TABLE sessions ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT; + ALTER TABLE api_keys ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT; + ALTER TABLE camera_streams ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT; + ALTER TABLE display_layouts ALTER COLUMN display_id TYPE TEXT USING display_id::TEXT; + ALTER TABLE display_layouts ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT; + ALTER TABLE layout_cells ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT; + ALTER TABLE layout_cells ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT; + ALTER TABLE kiosks ALTER COLUMN display_id TYPE TEXT USING display_id::TEXT; + ALTER TABLE kiosk_labels ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT; + ALTER TABLE kiosk_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT; + ALTER TABLE camera_labels ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT; + ALTER TABLE camera_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT; + ALTER TABLE layout_labels ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT; + ALTER TABLE layout_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT; + ALTER TABLE event_log ALTER COLUMN source_kiosk_id TYPE TEXT USING source_kiosk_id::TEXT; + ALTER TABLE event_log ALTER COLUMN source_camera_id TYPE TEXT USING source_camera_id::TEXT; + ALTER TABLE kiosk_gpio_bindings ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT; + ALTER TABLE kiosk_logs ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT; + ALTER TABLE camera_event_subscriptions ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT; + ALTER TABLE camera_event_subscriptions ALTER COLUMN subscribed_by_kiosk_id TYPE TEXT USING subscribed_by_kiosk_id::TEXT; + -- displays.default_layout_id + ALTER TABLE displays ALTER COLUMN default_layout_id TYPE TEXT USING default_layout_id::TEXT; + + -- 4. Re-add FK constraints. + ALTER TABLE sessions ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE api_keys ADD CONSTRAINT api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE camera_streams ADD CONSTRAINT camera_streams_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE; + ALTER TABLE display_layouts ADD CONSTRAINT display_layouts_display_id_fkey FOREIGN KEY (display_id) REFERENCES displays(id) ON DELETE CASCADE; + ALTER TABLE display_layouts ADD CONSTRAINT display_layouts_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE; + ALTER TABLE layout_cells ADD CONSTRAINT layout_cells_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE; + ALTER TABLE layout_cells ADD CONSTRAINT layout_cells_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE SET NULL; + ALTER TABLE kiosks ADD CONSTRAINT kiosks_display_id_fkey FOREIGN KEY (display_id) REFERENCES displays(id) ON DELETE SET NULL; + ALTER TABLE kiosk_labels ADD CONSTRAINT kiosk_labels_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE; + ALTER TABLE kiosk_labels ADD CONSTRAINT kiosk_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE; + ALTER TABLE camera_labels ADD CONSTRAINT camera_labels_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE; + ALTER TABLE camera_labels ADD CONSTRAINT camera_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE; + ALTER TABLE layout_labels ADD CONSTRAINT layout_labels_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE; + ALTER TABLE layout_labels ADD CONSTRAINT layout_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE; + ALTER TABLE event_log ADD CONSTRAINT event_log_source_kiosk_id_fkey FOREIGN KEY (source_kiosk_id) REFERENCES kiosks(id) ON DELETE SET NULL; + ALTER TABLE event_log ADD CONSTRAINT event_log_source_camera_id_fkey FOREIGN KEY (source_camera_id) REFERENCES cameras(id) ON DELETE SET NULL; + ALTER TABLE kiosk_gpio_bindings ADD CONSTRAINT kiosk_gpio_bindings_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE; + ALTER TABLE kiosk_logs ADD CONSTRAINT kiosk_logs_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE; + ALTER TABLE camera_event_subscriptions ADD CONSTRAINT camera_event_subscriptions_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE; + ALTER TABLE camera_event_subscriptions ADD CONSTRAINT camera_event_subscriptions_subscribed_by_kiosk_id_fkey FOREIGN KEY (subscribed_by_kiosk_id) REFERENCES kiosks(id) ON DELETE SET NULL; + ALTER TABLE displays ADD CONSTRAINT displays_default_layout_id_fkey FOREIGN KEY (default_layout_id) REFERENCES layouts(id) ON DELETE SET NULL; + + RAISE NOTICE 'UUIDv7 migration: complete — all PKs and FKs are now TEXT'; + END $$`, ]; diff --git a/server/src/shared/db/migrations.ts b/server/src/shared/db/migrations.ts deleted file mode 100644 index e15f09f..0000000 --- a/server/src/shared/db/migrations.ts +++ /dev/null @@ -1,1105 +0,0 @@ -/** - * Database migrations. - * - * Idempotent — `service-store.init()` runs ALL of these on every startup, - * inside one transaction. SQLite tolerates `IF NOT EXISTS` everywhere we - * need it. When schemas change non-additively, we'll graduate to a real - * versioned migrator; for v0.1 this is sufficient. - * - * NOTE on datetimes: stored as TEXT in ISO-8601 UTC ("YYYY-MM-DDTHH:MM:SS.sssZ"). - * Application code uses `new Date().toISOString()` for writes and - * `new Date(value)` for reads. No tz-aware datetime gotcha because TEXT is - * pure string round-trip. (Old python build hit a pain point with - * SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.) - */ - -/** - * A migration entry: either a plain SQL string or a function receiving the DB. - * Functions are used for ALTER TABLE which lacks IF NOT EXISTS in SQLite. - */ -import type { DatabaseSync } from "node:sqlite"; -export type MigrationEntry = string | ((db: DatabaseSync) => void); - -function addColumnIfNotExists( - db: DatabaseSync, - table: string, - column: string, - definition: string, -): void { - const cols = db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>; - if (cols.some((c) => c.name === column)) return; - db.exec(`ALTER TABLE "${table}" ADD COLUMN ${column} ${definition}`); -} - -export const MIGRATIONS: readonly MigrationEntry[] = [ - // ---- users --------------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')), - is_active INTEGER NOT NULL DEFAULT 1, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_secret_encrypted TEXT, - recovery_codes_hashed TEXT NOT NULL DEFAULT '[]', - must_change_password INTEGER NOT NULL DEFAULT 0, - failed_login_count INTEGER NOT NULL DEFAULT 0, - locked_until TEXT, - last_login_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - - // ---- sessions ------------------------------------------------------------ - `CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - csrf_token TEXT NOT NULL, - totp_pending INTEGER NOT NULL DEFAULT 0, - user_agent TEXT, - ip_address TEXT, - issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - expires_at TEXT NOT NULL, - revoked_at TEXT - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_sessions_active - ON sessions(expires_at) - WHERE revoked_at IS NULL`, - - // ---- api_keys ------------------------------------------------------------ - `CREATE TABLE IF NOT EXISTS api_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - key_hash TEXT NOT NULL, - key_prefix TEXT NOT NULL, - scopes TEXT NOT NULL DEFAULT '[]', - expires_at TEXT, - last_used_at TEXT, - last_used_ip TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - revoked_at TEXT - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`, - - // ---- setup_state (singleton row, id=1) ----------------------------------- - `CREATE TABLE IF NOT EXISTS setup_state ( - id INTEGER PRIMARY KEY CHECK(id = 1), - is_complete INTEGER NOT NULL DEFAULT 0, - cluster_key_provisioned INTEGER NOT NULL DEFAULT 0, - nodered_flows_deployed INTEGER NOT NULL DEFAULT 0, - completed_at TEXT, - extras TEXT NOT NULL DEFAULT '{}' - ) STRICT`, - `INSERT OR IGNORE INTO setup_state (id) VALUES (1)`, - - // ---- displays ------------------------------------------------------------ - `CREATE TABLE IF NOT EXISTS displays ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - "index" INTEGER NOT NULL UNIQUE, - is_primary INTEGER NOT NULL DEFAULT 0, - width_px INTEGER NOT NULL DEFAULT 1920, - height_px INTEGER NOT NULL DEFAULT 1080, - default_layout_id INTEGER, - idle_timeout_seconds INTEGER NOT NULL DEFAULT 600, - sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800, - cec_enabled INTEGER NOT NULL DEFAULT 1, - cec_device_path TEXT, - cec_logical_address INTEGER, - desired_power_state TEXT NOT NULL DEFAULT 'follow_layout' - CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')), - actual_power_state TEXT NOT NULL DEFAULT 'unknown' - CHECK(actual_power_state IN ('awake', 'standby', 'unknown')), - actual_power_state_at TEXT, - state_check_enabled INTEGER NOT NULL DEFAULT 0, - state_check_interval_seconds INTEGER NOT NULL DEFAULT 60 - ) STRICT`, - - // ---- cameras ------------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS cameras ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif')), - rtsp_url TEXT, - onvif_host TEXT, - onvif_port INTEGER, - onvif_username TEXT, - onvif_password TEXT, - capabilities TEXT NOT NULL DEFAULT '[]', - stream_policy TEXT NOT NULL DEFAULT 'auto' - CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')), - enabled INTEGER NOT NULL DEFAULT 1, - last_seen_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS camera_streams ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')), - name TEXT NOT NULL, - profile_token TEXT, - rtsp_uri TEXT NOT NULL, - width INTEGER, - height INTEGER, - encoding TEXT, - framerate REAL, - bitrate_kbps INTEGER, - is_discovered INTEGER NOT NULL DEFAULT 0 - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`, - - // ---- layout templates + layouts + cells ---------------------------------- - `CREATE TABLE IF NOT EXISTS layout_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - regions TEXT NOT NULL DEFAULT '[]', - grid_cols INTEGER NOT NULL DEFAULT 12, - grid_rows INTEGER NOT NULL DEFAULT 12, - is_builtin INTEGER NOT NULL DEFAULT 0 - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS layouts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - template_id INTEGER NOT NULL REFERENCES layout_templates(id), - display_id INTEGER NOT NULL REFERENCES displays(id), - priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')), - cooling_timeout_seconds INTEGER, - preload_camera_ids TEXT NOT NULL DEFAULT '[]', - is_default INTEGER NOT NULL DEFAULT 0, - resets_idle_timer INTEGER NOT NULL DEFAULT 1 - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS layout_cells ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - region_name TEXT NOT NULL, - content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), - camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, - stream_selector TEXT NOT NULL DEFAULT 'auto' - CHECK(stream_selector IN ('auto', 'main', 'sub')), - web_url TEXT, - html_content TEXT, - cooling_timeout_seconds INTEGER, - options TEXT NOT NULL DEFAULT '{}' - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`, - - // ---- kiosks -------------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS kiosks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - key_hash TEXT NOT NULL, - key_prefix TEXT NOT NULL, - capabilities TEXT NOT NULL DEFAULT '[]', - hardware_model TEXT, - os_version TEXT, - kiosk_app_version TEXT, - enabled INTEGER NOT NULL DEFAULT 1, - paired_at TEXT, - last_seen_at TEXT, - last_bundle_version TEXT, - display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`, - - // ---- labels -------------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - color TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS kiosk_labels ( - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK(role IN ('consume', 'operate')), - PRIMARY KEY (kiosk_id, label_id, role) - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS camera_labels ( - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - PRIMARY KEY (camera_id, label_id) - ) STRICT`, - - `CREATE TABLE IF NOT EXISTS layout_labels ( - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - PRIMARY KEY (layout_id, label_id) - ) STRICT`, - - // ---- pairing_codes ------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS pairing_codes ( - code TEXT PRIMARY KEY, - kiosk_proposed_name TEXT, - kiosk_hardware_model TEXT, - kiosk_capabilities TEXT NOT NULL DEFAULT '[]', - issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - expires_at TEXT NOT NULL, - consumed_at TEXT, - consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, - extras TEXT NOT NULL DEFAULT '{}' - ) STRICT`, - - // ---- event_log ----------------------------------------------------------- - `CREATE TABLE IF NOT EXISTS event_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, - source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, - source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')), - topic TEXT NOT NULL, - property_op TEXT, - payload TEXT NOT NULL DEFAULT '{}', - received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - forwarded_to_nodered INTEGER NOT NULL DEFAULT 0 - ) STRICT`, - - `CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`, - `CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`, - - // ---- v0.2: flatten layout_templates into layouts, display→kiosk inversion --- - (db: DatabaseSync) => { - // Skip entirely if v0.5 rebuild already dropped template_id (idempotent re-run) - const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>; - const hasTemplateId = cols.some((c) => c.name === "template_id"); - if (!hasTemplateId) { - // Just ensure displays.kiosk_id exists for fresh-but-post-v0.5 DBs - addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL"); - return; - } - - addColumnIfNotExists(db, "layouts", "regions", "TEXT NOT NULL DEFAULT '[]'"); - addColumnIfNotExists(db, "layouts", "grid_cols", "INTEGER NOT NULL DEFAULT 1"); - addColumnIfNotExists(db, "layouts", "grid_rows", "INTEGER NOT NULL DEFAULT 1"); - - // Copy template data into layouts (idempotent — only updates rows where regions is still '[]') - db.exec(`UPDATE layouts SET - regions = COALESCE((SELECT lt.regions FROM layout_templates lt WHERE lt.id = layouts.template_id), '[]'), - grid_cols = COALESCE((SELECT lt.grid_cols FROM layout_templates lt WHERE lt.id = layouts.template_id), 1), - grid_rows = COALESCE((SELECT lt.grid_rows FROM layout_templates lt WHERE lt.id = layouts.template_id), 1) - WHERE regions = '[]' AND template_id IS NOT NULL`); - - addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL"); - }, - `CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`, - - // ---- v0.3: decouple layouts from displays via join table ------------------- - // Layouts become standalone entities; displays maintain a list of available - // layouts via display_layouts. Old layouts.display_id column is kept (SQLite - // can't drop columns) but no longer used by the application. - `CREATE TABLE IF NOT EXISTS display_layouts ( - display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - PRIMARY KEY (display_id, layout_id) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`, - (db: DatabaseSync) => { - // Backfill: every existing layout that has display_id gets attached to - // that display via the new join table. Idempotent via INSERT OR IGNORE. - const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === "display_id")) return; - const rows = db - .prepare(`SELECT id, display_id FROM layouts WHERE display_id IS NOT NULL`) - .all() as Array<{ id: number; display_id: number | null }>; - const ins = db.prepare( - `INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`, - ); - for (const r of rows) { - if (r.display_id != null) ins.run(r.display_id, r.id); - } - }, - - // ---- v0.4: cells own their position; drop regions/grid_*/is_default ---------- - // layout_cells now have row/col/row_span/col_span columns directly. Existing - // cells get backfilled by parsing layouts.regions JSON and matching on - // region_name. The old columns (regions, grid_cols, grid_rows, is_default, - // region_name) are kept on the row (SQLite can't drop columns) but no longer - // used by the application. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "layout_cells", "row", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "layout_cells", "col", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "layout_cells", "row_span", "INTEGER NOT NULL DEFAULT 1"); - addColumnIfNotExists(db, "layout_cells", "col_span", "INTEGER NOT NULL DEFAULT 1"); - - // Backfill: parse each layout's regions JSON, match cells by region_name, - // copy row/col/rowSpan/colSpan onto the cell row. Only update cells that - // still have the default 0,0,1,1 (idempotent re-runs become no-ops once the - // operator has edited cells through the new UI). - const cellCols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>; - const hasRegionName = cellCols.some((c) => c.name === "region_name"); - const layoutCols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>; - const hasRegions = layoutCols.some((c) => c.name === "regions"); - if (!hasRegionName || !hasRegions) return; - - const layouts = db - .prepare(`SELECT id, regions FROM layouts WHERE regions IS NOT NULL AND regions != '[]'`) - .all() as Array<{ id: number; regions: string }>; - const updateCell = db.prepare( - `UPDATE layout_cells - SET row = ?, col = ?, row_span = ?, col_span = ? - WHERE id = ? - AND row = 0 AND col = 0 AND row_span = 1 AND col_span = 1`, - ); - for (const l of layouts) { - let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }>; - try { - regions = JSON.parse(l.regions); - } catch { - continue; - } - if (!Array.isArray(regions)) continue; - const cells = db - .prepare(`SELECT id, region_name FROM layout_cells WHERE layout_id = ?`) - .all(l.id) as Array<{ id: number; region_name: string }>; - for (const c of cells) { - const r = regions.find((reg) => reg.name === c.region_name); - if (!r) continue; - updateCell.run( - Number(r.row) || 0, - Number(r.col) || 0, - Number(r.rowSpan) || 1, - Number(r.colSpan) || 1, - c.id, - ); - } - } - }, - - // ---- v0.5: rebuild layouts table to drop legacy columns - // SQLite can't drop columns, so rebuild: create new schema → copy data → - // drop old → rename. Removes template_id, display_id, regions, grid_cols, - // grid_rows, is_default — cells own position now, displays attach via join. - (db: DatabaseSync) => { - const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>; - const hasTemplateId = cols.some((c) => c.name === "template_id"); - if (!hasTemplateId) return; // already migrated - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE layouts_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')), - cooling_timeout_seconds INTEGER, - preload_camera_ids TEXT NOT NULL DEFAULT '[]', - resets_idle_timer INTEGER NOT NULL DEFAULT 1 - ) STRICT; - - INSERT INTO layouts_new (id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer) - SELECT id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer FROM layouts; - - DROP TABLE layouts; - ALTER TABLE layouts_new RENAME TO layouts; - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - - // Same cleanup for layout_cells — drop region_name, layout_id FK stays - (db: DatabaseSync) => { - const cols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>; - const hasRegionName = cols.some((c) => c.name === "region_name"); - if (!hasRegionName) return; - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE layout_cells_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - row INTEGER NOT NULL DEFAULT 0, - col INTEGER NOT NULL DEFAULT 0, - row_span INTEGER NOT NULL DEFAULT 1, - col_span INTEGER NOT NULL DEFAULT 1, - content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), - camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, - stream_selector TEXT, - web_url TEXT, - html_content TEXT, - cooling_timeout_seconds INTEGER, - options TEXT NOT NULL DEFAULT '{}' - ) STRICT; - - INSERT INTO layout_cells_new (id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options) - SELECT id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options FROM layout_cells; - - DROP TABLE layout_cells; - ALTER TABLE layout_cells_new RENAME TO layout_cells; - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - - // Drop layout_templates entirely — concept removed - `DROP TABLE IF EXISTS layout_templates`, - - // ---- v0.8: entities — unified content pool for layout cells ----------------- - // Admin creates a reusable "entity" (camera reference, html snippet, web page) - // once and binds it to one or more layout cells. Cameras get an automatic - // mirror entity so existing layouts keep working. - `CREATE TABLE IF NOT EXISTS entities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web')), - description TEXT, - camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, - html_content TEXT, - web_url TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id)`, - - (db: DatabaseSync) => { - addColumnIfNotExists(db, "layout_cells", "entity_id", "INTEGER REFERENCES entities(id) ON DELETE SET NULL"); - }, - `CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id)`, - - // Backfill 1: ensure every camera has a mirror entity (name = camera.name) - (db: DatabaseSync) => { - const cams = db.prepare(`SELECT id, name FROM cameras`).all() as Array<{ id: number; name: string }>; - const has = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`); - const ins = db.prepare( - `INSERT OR IGNORE INTO entities (name, type, camera_id) VALUES (?, 'camera', ?)`, - ); - for (const c of cams) { - const existing = has.get(c.id); - if (existing) continue; - // Resolve name collision by appending the camera id - const taken = db.prepare(`SELECT id FROM entities WHERE name = ?`).get(c.name); - const useName = taken ? `${c.name} (cam #${String(c.id)})` : c.name; - ins.run(useName, c.id); - } - }, - - // Backfill 2: for each cell, set entity_id based on legacy content_type fields - (db: DatabaseSync) => { - const cells = db - .prepare( - `SELECT id, content_type, camera_id, html_content, web_url, entity_id - FROM layout_cells - WHERE entity_id IS NULL`, - ) - .all() as Array<{ - id: number; - content_type: string; - camera_id: number | null; - html_content: string | null; - web_url: string | null; - entity_id: number | null; - }>; - - const findCameraEntity = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`); - const insertEntity = db.prepare( - `INSERT INTO entities (name, type, html_content, web_url) VALUES (?, ?, ?, ?)`, - ); - const setCellEntity = db.prepare(`UPDATE layout_cells SET entity_id = ? WHERE id = ?`); - const nameExists = db.prepare(`SELECT 1 FROM entities WHERE name = ?`); - - let autoCounter = 1; - function uniqueName(base: string): string { - // Find a unique entity name for the auto-created snippet - let candidate = base; - while (nameExists.get(candidate)) { - candidate = `${base} ${String(autoCounter)}`; - autoCounter += 1; - } - autoCounter += 1; - return candidate; - } - - for (const cell of cells) { - if (cell.content_type === "camera" && cell.camera_id != null) { - const ent = findCameraEntity.get(cell.camera_id) as { id: number } | undefined; - if (ent) setCellEntity.run(ent.id, cell.id); - continue; - } - if (cell.content_type === "html" && cell.html_content) { - const name = uniqueName(`Cell ${String(cell.id)} HTML`); - const r = insertEntity.run(name, "html", cell.html_content, null); - setCellEntity.run(Number(r.lastInsertRowid), cell.id); - continue; - } - if (cell.content_type === "web" && cell.web_url) { - const name = uniqueName(`Cell ${String(cell.id)} Web`); - const r = insertEntity.run(name, "web", null, cell.web_url); - setCellEntity.run(Number(r.lastInsertRowid), cell.id); - continue; - } - // empty cell — leave entity_id null - } - }, - - // ---- v0.9: explicit empty layout cell state ------------------------------- - (db: DatabaseSync) => { - const createSql = db - .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'layout_cells'") - .get() as { sql?: string } | undefined; - if (createSql?.sql?.includes("'none'")) return; - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE layout_cells_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - row INTEGER NOT NULL DEFAULT 0, - col INTEGER NOT NULL DEFAULT 0, - row_span INTEGER NOT NULL DEFAULT 1, - col_span INTEGER NOT NULL DEFAULT 1, - content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), - camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, - stream_selector TEXT, - web_url TEXT, - html_content TEXT, - cooling_timeout_seconds INTEGER, - options TEXT NOT NULL DEFAULT '{}', - entity_id INTEGER REFERENCES entities(id) ON DELETE SET NULL - ) STRICT; - - INSERT INTO layout_cells_new ( - id, layout_id, row, col, row_span, col_span, - content_type, camera_id, stream_selector, web_url, html_content, - cooling_timeout_seconds, options, entity_id - ) - SELECT - id, layout_id, row, col, row_span, col_span, - CASE - WHEN entity_id IS NULL - AND content_type = 'html' - AND (html_content IS NULL OR html_content = '') - THEN 'none' - ELSE content_type - END, - camera_id, stream_selector, web_url, html_content, - cooling_timeout_seconds, options, entity_id - FROM layout_cells; - - DROP TABLE layout_cells; - ALTER TABLE layout_cells_new RENAME TO layout_cells; - CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id); - CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id); - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - - // ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------ - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL"); - addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL"); - addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "memory_total_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "memory_used_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_total_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL"); - }, - - // ---- per-cell content fit (cover|contain|fill) ---- - (db: DatabaseSync) => { - addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'"); - }, - - // ---- entities.dashboard — Node-RED Dashboard tab entity type --------------- - // Adds dashboard_id column and broadens the type CHECK to include - // 'dashboard'. SQLite can't ALTER a CHECK in place — rebuild the table when - // the old constraint is detected. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "entities", "dashboard_id", "TEXT"); - - const row = db - .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'entities'") - .get() as { sql?: string } | undefined; - if (!row?.sql) return; - if (row.sql.includes("'dashboard'")) return; // already migrated - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE entities_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')), - description TEXT, - camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, - html_content TEXT, - web_url TEXT, - dashboard_id TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT; - - INSERT INTO entities_new (id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at) - SELECT id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at FROM entities; - - DROP TABLE entities; - ALTER TABLE entities_new RENAME TO entities; - CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id); - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - - // ---- kiosk GPIO bindings ---- - `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, - chip TEXT NOT NULL DEFAULT 'gpiochip0', - pin INTEGER NOT NULL, - direction TEXT NOT NULL CHECK(direction IN ('in', 'out')), - pull TEXT CHECK(pull IN ('up', 'down', 'none')), - edge TEXT CHECK(edge IN ('rising', 'falling', 'both')), - topic TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`, - - // ---- displays.is_enabled — admin toggle to suppress window on a display ---- - (db: DatabaseSync) => { - addColumnIfNotExists(db, "displays", "is_enabled", "INTEGER NOT NULL DEFAULT 1"); - }, - - // ---- displays.index is local to the kiosk, not globally unique ------------- - (db: DatabaseSync) => { - const row = db - .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'displays'") - .get() as { sql?: string } | undefined; - if (!row?.sql || !row.sql.includes('"index" INTEGER NOT NULL UNIQUE')) return; - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE displays_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - "index" INTEGER NOT NULL, - is_primary INTEGER NOT NULL DEFAULT 0, - width_px INTEGER NOT NULL DEFAULT 1920, - height_px INTEGER NOT NULL DEFAULT 1080, - default_layout_id INTEGER, - idle_timeout_seconds INTEGER NOT NULL DEFAULT 600, - sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800, - cec_enabled INTEGER NOT NULL DEFAULT 1, - cec_device_path TEXT, - cec_logical_address INTEGER, - desired_power_state TEXT NOT NULL DEFAULT 'follow_layout' - CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')), - state_check_enabled INTEGER NOT NULL DEFAULT 0, - state_check_interval_seconds INTEGER NOT NULL DEFAULT 60, - kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, - is_enabled INTEGER NOT NULL DEFAULT 1 - ) STRICT; - - INSERT INTO displays_new ( - id, name, "index", is_primary, width_px, height_px, default_layout_id, - idle_timeout_seconds, sleep_timeout_seconds, cec_enabled, cec_device_path, - cec_logical_address, desired_power_state, state_check_enabled, - state_check_interval_seconds, kiosk_id, is_enabled - ) - SELECT - id, name, "index", is_primary, width_px, height_px, default_layout_id, - idle_timeout_seconds, sleep_timeout_seconds, cec_enabled, cec_device_path, - cec_logical_address, desired_power_state, state_check_enabled, - state_check_interval_seconds, kiosk_id, is_enabled - FROM displays; - - DROP TABLE displays; - ALTER TABLE displays_new RENAME TO displays; - CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id); - CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index - ON displays(kiosk_id, "index"); - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - - // ---- firmware OTA -------------------------------------------------------- - // One row per signed kiosk binary. arch lets us hold images for - // aarch64-pi5 + x86_64 + future targets side by side. signature is - // Ed25519(sha256(binary)) by the server's firmware-signing key — kiosk - // verifies before swap. - `CREATE TABLE IF NOT EXISTS firmware_releases ( - id TEXT PRIMARY KEY, - version TEXT NOT NULL, - channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')), - arch TEXT NOT NULL, - artifact_path TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - sha256 TEXT NOT NULL, - signature TEXT NOT NULL, - release_notes TEXT, - uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - yanked_at TEXT - ) STRICT`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_firmware_releases_version_arch ON firmware_releases(version, arch)`, - `CREATE INDEX IF NOT EXISTS idx_firmware_releases_channel ON firmware_releases(channel, arch, uploaded_at DESC)`, - - `CREATE TABLE IF NOT EXISTS firmware_rollouts ( - id TEXT PRIMARY KEY, - release_id TEXT NOT NULL REFERENCES firmware_releases(id) ON DELETE CASCADE, - target_kiosk_ids TEXT NOT NULL DEFAULT '[]', - state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')), - percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100), - started_at TEXT, - finished_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, - - // ---- full OS OTA --------------------------------------------------------- - // One row per signed RAUC bundle. compatibility must match the kiosk's RAUC - // compatible string (for example betterframe-rpi5-aarch64). - `CREATE TABLE IF NOT EXISTS os_update_releases ( - id TEXT PRIMARY KEY, - version TEXT NOT NULL, - channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')), - compatibility TEXT NOT NULL, - artifact_path TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - sha256 TEXT NOT NULL, - bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'), - release_notes TEXT, - uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - yanked_at TEXT - ) STRICT`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_os_update_releases_version_compat ON os_update_releases(version, compatibility)`, - `CREATE INDEX IF NOT EXISTS idx_os_update_releases_channel ON os_update_releases(channel, compatibility, uploaded_at DESC)`, - - `CREATE TABLE IF NOT EXISTS os_update_rollouts ( - id TEXT PRIMARY KEY, - release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE, - target_kiosk_ids TEXT NOT NULL DEFAULT '[]', - state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')), - percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100), - started_at TEXT, - finished_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`, - - // Per-kiosk firmware preferences + update tracking. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "firmware_channel", "TEXT NOT NULL DEFAULT 'stable'"); - addColumnIfNotExists(db, "kiosks", "firmware_target_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_at", "TEXT"); - addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "firmware_last_error", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_channel", "TEXT NOT NULL DEFAULT 'stable'"); - addColumnIfNotExists(db, "kiosks", "os_update_target_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_at", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_error", "TEXT"); - }, - - // ---- Kiosk LAN-side local server: reported via heartbeat ------------------ - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "local_key", "TEXT"); - addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT"); - addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT"); - addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT"); - }, - - // ---- Audit log ----------------------------------------------------------- - // Append-only record of security-relevant actions: logins, API key use, - // kiosk pair/replace, firmware upload/yank/rollout, admin CRUD of any - // resource. Read-only via admin UI, filterable. - `CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')), - actor_id INTEGER, - actor_label TEXT, - action TEXT NOT NULL, - resource_type TEXT, - resource_id TEXT, - ip TEXT, - metadata TEXT NOT NULL DEFAULT '{}', - result TEXT NOT NULL DEFAULT 'ok' CHECK(result IN ('ok', 'failed')) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`, - `CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`, - `CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`, - - // ---- Managed-image device config ----------------------------------------- - // For kiosks running our pre-built Pi image (managed_image=1), admins can - // push hostname / timezone / network / wifi config. Kiosk pulls on heartbeat - // when server's version > applied_version, applies via a privileged helper, - // echoes applied_version back. managed_config_error captures last failure. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT"); - addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT"); - addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT"); - }, - - // Backfill RTSP cameras created before camera_streams became mandatory for - // rendering. Without this, the kiosk sees a camera but no playable stream. - (db: DatabaseSync) => { - db.exec(` - INSERT INTO camera_streams (camera_id, role, name, rtsp_uri, is_discovered) - SELECT c.id, 'main', 'Main', c.rtsp_url, 0 - FROM cameras c - WHERE c.type = 'rtsp' - AND c.rtsp_url IS NOT NULL - AND c.rtsp_url != '' - AND NOT EXISTS ( - SELECT 1 FROM camera_streams s WHERE s.camera_id = c.id - ) - `); - }, - - // Display power state reported by kiosk heartbeat. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "displays", "actual_power_state", "TEXT NOT NULL DEFAULT 'unknown'"); - addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT"); - }, - - // Kiosk reports active layout per display via layout.changed events. - // Persist on the display row so the admin UI can highlight which layout - // is currently rendering instead of defaulting to first-in-list. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); - }, - - // Backfill hwmon/telemetry columns. They were originally added inline to - // an earlier migration entry; existing deploys had already passed that - // index via PRAGMA user_version, so the new columns silently never landed. - // Re-add idempotently here so replaceKioskKey / heartbeat stop hitting - // "no such column" on upgrade. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL"); - addColumnIfNotExists(db, "kiosks", "memory_total_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "memory_used_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_total_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER"); - addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL"); - }, - - // ---- kiosk_logs: dedicated table for kiosk application logs --------------- - `CREATE TABLE IF NOT EXISTS kiosk_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, - level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')), - message TEXT NOT NULL, - context TEXT NOT NULL DEFAULT '{}', - logged_at TEXT NOT NULL, - received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_kiosk_logs_kiosk_received ON kiosk_logs(kiosk_id, received_at DESC)`, - `CREATE INDEX IF NOT EXISTS idx_kiosk_logs_level ON kiosk_logs(level, received_at DESC)`, - - // Catch-all backfill for tables/columns that were added inside earlier - // migration entries after existing deploys had already passed those - // indices via PRAGMA user_version. All IF NOT EXISTS / addColumnIfNotExists - // so they're safe to run on fresh DBs too. - (db: DatabaseSync) => { - // --- reported hostname + network interfaces (heartbeat telemetry) --- - addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT"); - addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT"); - - // --- OS update per-kiosk prefs --- - addColumnIfNotExists(db, "kiosks", "os_update_channel", "TEXT NOT NULL DEFAULT 'stable'"); - addColumnIfNotExists(db, "kiosks", "os_update_target_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_at", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_version", "TEXT"); - addColumnIfNotExists(db, "kiosks", "os_update_last_error", "TEXT"); - - // --- OS update releases + rollouts tables --- - db.exec(`CREATE TABLE IF NOT EXISTS os_update_releases ( - id TEXT PRIMARY KEY, - version TEXT NOT NULL, - channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')), - compatibility TEXT NOT NULL, - artifact_path TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - sha256 TEXT NOT NULL, - bundle_format TEXT NOT NULL DEFAULT 'raucb', - release_notes TEXT, - uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - yanked_at TEXT - ) STRICT`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_os_update_releases_version_compat ON os_update_releases(version, compatibility)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_os_update_releases_channel ON os_update_releases(channel, compatibility, uploaded_at DESC)`); - - db.exec(`CREATE TABLE IF NOT EXISTS os_update_rollouts ( - id TEXT PRIMARY KEY, - release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE, - target_kiosk_ids TEXT NOT NULL DEFAULT '[]', - state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')), - percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100), - started_at TEXT, - finished_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL - ) STRICT`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`); - - // --- managed-image config columns --- - addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT"); - addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT"); - addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT"); - - // --- display active layout --- - addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); - - // --- per-kiosk encryption key --- - addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT"); - - // --- ONVIF event routing --- - addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'"); - addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); - addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); - - // --- cloud accounts table --- - db.exec(`CREATE TABLE IF NOT EXISTS cloud_accounts ( - id TEXT PRIMARY KEY, - vendor TEXT NOT NULL, - name TEXT NOT NULL, - credentials_encrypted TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - last_sync_at TEXT, - last_sync_error TEXT, - camera_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`); - }, - - // Per-kiosk encryption key. Replaces shared cluster_key for bundle - // encryption. Generated at pairing, stored encrypted with server secret, - // delivered to kiosk once. Compromised SD → only this kiosk's camera - // passwords exposed (not fleet-wide). - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT"); - }, - - // Cloud camera accounts: per-vendor, multiple accounts per vendor. - // Credentials encrypted with server secret. Sync runs server-side, - // streaming URLs delivered to kiosks via the bundle. - `CREATE TABLE IF NOT EXISTS cloud_accounts ( - id TEXT PRIMARY KEY, - vendor TEXT NOT NULL, - name TEXT NOT NULL, - credentials_encrypted TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - last_sync_at TEXT, - last_sync_error TEXT, - camera_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`, - - // ONVIF event routing: per-camera event_source (who polls), event_sink - // (where push callbacks go), and discovered supported topics. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'"); - addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); - addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); - }, - - // Cloud camera type + cloud-linked fields. Rebuild cameras table to add - // 'cloud' to type CHECK. Cloud cameras are managed by sync — not editable. - (db: DatabaseSync) => { - addColumnIfNotExists(db, "cameras", "cloud_account_id", "TEXT"); - addColumnIfNotExists(db, "cameras", "cloud_vendor_camera_id", "TEXT"); - addColumnIfNotExists(db, "cameras", "cloud_stream_url", "TEXT"); - addColumnIfNotExists(db, "cameras", "cloud_stream_type", "TEXT"); - - // Rebuild to widen CHECK constraint to include 'cloud'. - const row = db - .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'cameras'") - .get() as { sql?: string } | undefined; - if (!row?.sql || row.sql.includes("'cloud'")) return; - - db.exec("PRAGMA foreign_keys = OFF"); - db.exec(` - CREATE TABLE cameras_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')), - rtsp_url TEXT, - onvif_host TEXT, - onvif_port INTEGER, - onvif_username TEXT, - onvif_password TEXT, - capabilities TEXT NOT NULL DEFAULT '[]', - stream_policy TEXT NOT NULL DEFAULT 'auto' - CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')), - enabled INTEGER NOT NULL DEFAULT 1, - last_seen_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - event_source TEXT NOT NULL DEFAULT 'auto', - event_sink TEXT NOT NULL DEFAULT 'auto', - supported_event_topics TEXT NOT NULL DEFAULT '[]', - cloud_account_id TEXT, - cloud_vendor_camera_id TEXT, - cloud_stream_url TEXT, - cloud_stream_type TEXT - ) STRICT; - - INSERT INTO cameras_new ( - id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, - capabilities, stream_policy, enabled, last_seen_at, created_at, - event_source, event_sink, supported_event_topics, - cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type - ) - SELECT - id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, - capabilities, stream_policy, enabled, last_seen_at, created_at, - event_source, event_sink, supported_event_topics, - cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type - FROM cameras; - - DROP TABLE cameras; - ALTER TABLE cameras_new RENAME TO cameras; - `); - db.exec("PRAGMA foreign_keys = ON"); - }, - `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`, - `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_vendor ON cameras(cloud_account_id, cloud_vendor_camera_id)`, - - // ---- camera_event_subscriptions: per-camera per-topic subscription state --- - `CREATE TABLE IF NOT EXISTS camera_event_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, - topic TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')), - subscribed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, - last_event_at TEXT, - error_message TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - UNIQUE(camera_id, topic) - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`, - - // ---- camera_streams: RTSP component columns for ONVIF-discovered streams --- - // Stores host/port/path separately so bundle generation can build the final - // URL with properly encoded credentials from the camera row. Existing streams - // with only rtsp_uri continue to work (backward compat for RTSP-type cameras). - (db: DatabaseSync) => { - addColumnIfNotExists(db, "camera_streams", "rtsp_host", "TEXT"); - addColumnIfNotExists(db, "camera_streams", "rtsp_port", "INTEGER DEFAULT 554"); - addColumnIfNotExists(db, "camera_streams", "rtsp_path", "TEXT"); - }, - - (db: DatabaseSync) => { - addColumnIfNotExists(db, "kiosks", "partitions_json", "TEXT"); - }, -]; diff --git a/server/src/shared/db/pg-adapter.ts b/server/src/shared/db/pg-adapter.ts index 917c6ed..4524ded 100644 --- a/server/src/shared/db/pg-adapter.ts +++ b/server/src/shared/db/pg-adapter.ts @@ -1,10 +1,9 @@ /** * Postgres backend for the repository. * - * Translates SQLite-style `?` placeholders to Postgres `$1, $2, ...` at - * execute time so the Repository code can stay dialect-neutral. RETURNING - * id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs - * that need it — same for SQLite path so the SQL strings are portable). + * Translates `?` placeholders to Postgres `$1, $2, ...` at execute time + * so Repository SQL stays clean. Rewrites `INSERT OR IGNORE` to + * `INSERT ... ON CONFLICT DO NOTHING` for Postgres compatibility. * * Pool size: default 10 — configurable via pgPoolMax in sec-config.yaml. */ diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index f7e4cbd..4dd166c 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -1,5 +1,5 @@ /** - * Repository — typed accessor over the sqlite handle. + * Repository — typed accessor over the DB adapter. * * Keeps prepared statements cached for the life of the connection. All * mutating methods invoke the `notify` callback with (table, op, id) so the diff --git a/server/src/shared/db/sqlite-adapter.ts b/server/src/shared/db/sqlite-adapter.ts deleted file mode 100644 index 6fcee41..0000000 --- a/server/src/shared/db/sqlite-adapter.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * SQLite backend for the repository. Wraps node:sqlite (sync API) in - * Promise-returning methods so the Repository can stay async-uniform across - * both backends. - * - * Prepared statements are cached per-SQL for perf parity with the - * old direct-DatabaseSync code path. - */ -import { DatabaseSync, type StatementSync } from "node:sqlite"; - -import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js"; - -export class SqliteAdapter implements DbAdapter { - private readonly db: DatabaseSync; - private readonly stmts = new Map(); - private txDepth = 0; - - constructor(path: string) { - this.db = new DatabaseSync(path); - this.db.exec("PRAGMA journal_mode = WAL"); - this.db.exec("PRAGMA foreign_keys = ON"); - this.db.exec("PRAGMA synchronous = NORMAL"); - } - - /** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */ - static fromExisting(db: DatabaseSync): SqliteAdapter { - const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter; - (adapter as any).db = db; - (adapter as any).stmts = new Map(); - (adapter as any).txDepth = 0; - return adapter; - } - - private prep(sql: string): StatementSync { - let s = this.stmts.get(sql); - if (!s) { - s = this.db.prepare(sql); - this.stmts.set(sql, s); - } - return s; - } - - private coerce(params: ReadonlyArray): any[] { - return params.map((v) => (v === true ? 1 : v === false ? 0 : v)); - } - - async run(sql: string, params: ReadonlyArray = []): Promise { - const stmt = this.prep(sql); - const r = stmt.run(...this.coerce(params)); - return { - lastInsertRowid: - typeof r.lastInsertRowid === "bigint" ? r.lastInsertRowid : BigInt(r.lastInsertRowid), - changes: Number(r.changes), - }; - } - - async get(sql: string, params: ReadonlyArray = []): Promise { - const stmt = this.prep(sql); - const r = (stmt.get as any)(...this.coerce(params)); - return r as T | undefined; - } - - async all(sql: string, params: ReadonlyArray = []): Promise { - const stmt = this.prep(sql); - return (stmt.all as any)(...this.coerce(params)) as T[]; - } - - async exec(sql: string): Promise { - this.db.exec(sql); - } - - async transaction(fn: () => Promise): Promise { - if (this.txDepth === 0) this.db.exec("BEGIN"); - this.txDepth += 1; - try { - const result = await fn(); - this.txDepth -= 1; - if (this.txDepth === 0) this.db.exec("COMMIT"); - return result; - } catch (err) { - this.txDepth -= 1; - if (this.txDepth === 0) { - try { this.db.exec("ROLLBACK"); } catch { /* ignore */ } - } - throw err; - } - } - - dialect(): "sqlite" { return "sqlite"; } - - /** No-op for SQLite — single-tenant only. */ - async setSearchPath(_schema: string): Promise { - // SQLite doesn't support schemas — single tenant only. - } - - async close(): Promise { - this.db.close(); - } - - /** Expose raw DB for migrations that need fine control (idempotent - * ALTER TABLE, PRAGMA inspection, etc). Sqlite-only. */ - rawSync(): DatabaseSync { return this.db; } -} diff --git a/server/src/shared/tenant.ts b/server/src/shared/tenant.ts index fb038c6..518a982 100644 --- a/server/src/shared/tenant.ts +++ b/server/src/shared/tenant.ts @@ -15,8 +15,7 @@ * 3. All queries run against tenant's schema * 4. Connection returned to pool with search_path reset * - * SQLite mode: single-tenant, no schema switching. tenant_id is always - * the static DEFAULT_TENANT_ID. The tenant table isn't created. + * Default tenant uses the public schema directly (slug = "default"). */ export const DEFAULT_TENANT_ID = "default";