/** * 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)`, ];