fix(db): rewrite PG migrations to match final SQLite schema

PG migrations still had the original table structure (layouts with
template_id/display_id, layout_cells with region_name) that SQLite
dropped in v0.5. PG deploy would fail because repo code expects the
final schema.

Fixes: layouts table (removed template_id/display_id/is_default),
layout_cells (removed region_name), added display_layouts join table,
kiosks.encrypt_key_encrypted, entities.name UNIQUE, all missing
indexes (sessions active, event_log received, audit_log actor,
firmware version/arch unique), foreign keys on pairing_codes/
event_log/firmware/rollouts, kiosk_gpio_bindings.created_at +
CHECK constraints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-23 03:03:44 +02:00
parent 851274d05d
commit 48a9e99eb2
No known key found for this signature in database

View file

@ -13,6 +13,9 @@
* - JSON stored as JSONB for indexing * - JSON stored as JSONB for indexing
* - Boolean is native BOOLEAN, not INTEGER * - Boolean is native BOOLEAN, not INTEGER
* *
* IMPORTANT: These create the FINAL schema (matching SQLite after all
* migrations have run). Do not include legacy columns that were dropped.
*
* Migration tracking: schema_migrations table in each schema records * Migration tracking: schema_migrations table in each schema records
* which version has been applied. Same PRAGMA user_version concept * which version has been applied. Same PRAGMA user_version concept
* but in a table since PG has no per-schema PRAGMA. * but in a table since PG has no per-schema PRAGMA.
@ -52,10 +55,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
* Per-tenant schema: full BetterFrame table set. * Per-tenant schema: full BetterFrame table set.
* These run inside SET search_path = tenant_<id>. * These run inside SET search_path = tenant_<id>.
* *
* Mirrors the SQLite migrations but with PG-native types. * Mirrors the FINAL SQLite schema (after all migrations) with PG-native types.
* Each entry is a single SQL statement (no functions like SQLite's
* addColumnIfNotExists PG has IF NOT EXISTS on ALTER TABLE ADD COLUMN
* natively via DO $$ blocks).
*/ */
export const TENANT_MIGRATIONS: readonly string[] = [ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- users --------------------------------------------------------------- // ---- users ---------------------------------------------------------------
@ -89,6 +89,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
revoked_at TIMESTAMPTZ revoked_at TIMESTAMPTZ
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, `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 ------------------------------------------------------------ // ---- api_keys ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS api_keys ( `CREATE TABLE IF NOT EXISTS api_keys (
@ -116,11 +117,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
)`, )`,
`INSERT INTO setup_state (id) VALUES (1) ON CONFLICT DO NOTHING`, `INSERT INTO setup_state (id) VALUES (1) ON CONFLICT DO NOTHING`,
// ---- displays ------------------------------------------------------------ // ---- displays (final schema — no UNIQUE on index, has kiosk_id) ----------
`CREATE TABLE IF NOT EXISTS displays ( `CREATE TABLE IF NOT EXISTS displays (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
"index" INTEGER NOT NULL UNIQUE, "index" INTEGER NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT false, is_primary BOOLEAN NOT NULL DEFAULT false,
kiosk_id INTEGER, kiosk_id INTEGER,
width_px INTEGER NOT NULL DEFAULT 1920, width_px INTEGER NOT NULL DEFAULT 1920,
@ -131,14 +132,18 @@ export const TENANT_MIGRATIONS: readonly string[] = [
cec_enabled BOOLEAN NOT NULL DEFAULT true, cec_enabled BOOLEAN NOT NULL DEFAULT true,
cec_device_path TEXT, cec_device_path TEXT,
cec_logical_address INTEGER, cec_logical_address INTEGER,
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout', desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
actual_power_state TEXT NOT NULL DEFAULT 'unknown', 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 TIMESTAMPTZ, actual_power_state_at TIMESTAMPTZ,
state_check_enabled BOOLEAN NOT NULL DEFAULT false, state_check_enabled BOOLEAN NOT NULL DEFAULT false,
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60, state_check_interval_seconds INTEGER NOT NULL DEFAULT 60,
is_enabled BOOLEAN NOT NULL DEFAULT true, is_enabled BOOLEAN NOT NULL DEFAULT true,
active_layout_id INTEGER active_layout_id INTEGER
)`, )`,
`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")`,
// ---- cameras ------------------------------------------------------------- // ---- cameras -------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS cameras ( `CREATE TABLE IF NOT EXISTS cameras (
@ -151,7 +156,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
onvif_username TEXT, onvif_username TEXT,
onvif_password TEXT, onvif_password TEXT,
capabilities JSONB NOT NULL DEFAULT '[]', capabilities JSONB NOT NULL DEFAULT '[]',
stream_policy TEXT NOT NULL DEFAULT 'auto' CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')), stream_policy TEXT NOT NULL DEFAULT 'auto'
CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')),
enabled BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true,
last_seen_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -176,7 +182,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`, `CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`,
// ---- kiosks -------------------------------------------------------------- // ---- kiosks (final schema — all telemetry + update columns) --------------
`CREATE TABLE IF NOT EXISTS kiosks ( `CREATE TABLE IF NOT EXISTS kiosks (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@ -192,6 +198,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
last_seen_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ,
last_bundle_version TEXT, last_bundle_version TEXT,
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL, display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL,
encrypt_key_encrypted TEXT,
cpu_temp_c REAL, cpu_temp_c REAL,
cpu_load_percent REAL, cpu_load_percent REAL,
fan_rpm INTEGER, fan_rpm INTEGER,
@ -226,41 +233,36 @@ export const TENANT_MIGRATIONS: readonly string[] = [
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`, `CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`,
// ---- layouts + templates + cells ----------------------------------------- // ---- layouts (final schema — no template_id, no display_id) --------------
`CREATE TABLE IF NOT EXISTS layout_templates (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
regions JSONB NOT NULL DEFAULT '[]',
grid_cols INTEGER NOT NULL DEFAULT 12,
grid_rows INTEGER NOT NULL DEFAULT 12,
is_builtin BOOLEAN NOT NULL DEFAULT false
)`,
`CREATE TABLE IF NOT EXISTS layouts ( `CREATE TABLE IF NOT EXISTS layouts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
template_id INTEGER NOT NULL REFERENCES layout_templates(id), priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
display_id INTEGER NOT NULL REFERENCES displays(id),
priority TEXT NOT NULL DEFAULT 'normal',
cooling_timeout_seconds INTEGER, cooling_timeout_seconds INTEGER,
preload_camera_ids JSONB NOT NULL DEFAULT '[]', preload_camera_ids JSONB NOT NULL DEFAULT '[]',
is_default BOOLEAN NOT NULL DEFAULT false,
resets_idle_timer BOOLEAN NOT NULL DEFAULT true resets_idle_timer BOOLEAN NOT NULL DEFAULT true
)`, )`,
// ---- display_layouts (join table) ----------------------------------------
`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)
)`,
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
// ---- layout_cells (final schema — no region_name) ------------------------
`CREATE TABLE IF NOT EXISTS layout_cells ( `CREATE TABLE IF NOT EXISTS layout_cells (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
region_name TEXT NOT NULL,
"row" INTEGER NOT NULL DEFAULT 0, "row" INTEGER NOT NULL DEFAULT 0,
col INTEGER NOT NULL DEFAULT 0, col INTEGER NOT NULL DEFAULT 0,
row_span INTEGER NOT NULL DEFAULT 1, row_span INTEGER NOT NULL DEFAULT 1,
col_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')), content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT NOT NULL DEFAULT 'auto', stream_selector TEXT,
web_url TEXT, web_url TEXT,
html_content TEXT, html_content TEXT,
cooling_timeout_seconds INTEGER, cooling_timeout_seconds INTEGER,
@ -269,6 +271,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
fit TEXT NOT NULL DEFAULT 'cover' fit TEXT NOT NULL DEFAULT 'cover'
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`, `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)`,
// ---- labels -------------------------------------------------------------- // ---- labels --------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS labels ( `CREATE TABLE IF NOT EXISTS labels (
@ -304,24 +307,38 @@ export const TENANT_MIGRATIONS: readonly string[] = [
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ, consumed_at TIMESTAMPTZ,
consumed_by_kiosk_id INTEGER, consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
extras JSONB NOT NULL DEFAULT '{}' extras JSONB NOT NULL DEFAULT '{}'
)`, )`,
// ---- event_log ----------------------------------------------------------- // ---- event_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS event_log ( `CREATE TABLE IF NOT EXISTS event_log (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
source_kiosk_id INTEGER, source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
source_camera_id INTEGER, source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
source_type TEXT NOT NULL DEFAULT 'system', source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
topic TEXT NOT NULL, topic TEXT NOT NULL,
property_op TEXT, property_op TEXT,
payload JSONB NOT NULL DEFAULT '{}', payload JSONB NOT NULL DEFAULT '{}',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(), received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
forwarded_to_nodered BOOLEAN NOT NULL DEFAULT false forwarded_to_nodered BOOLEAN NOT NULL DEFAULT false
)`, )`,
`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)`, `CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_camera ON event_log(source_camera_id, received_at DESC)`,
// ---- entities ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS entities (
id SERIAL PRIMARY KEY,
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 TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id)`,
// ---- firmware releases + rollouts ---------------------------------------- // ---- firmware releases + rollouts ----------------------------------------
`CREATE TABLE IF NOT EXISTS firmware_releases ( `CREATE TABLE IF NOT EXISTS firmware_releases (
@ -335,21 +352,24 @@ export const TENANT_MIGRATIONS: readonly string[] = [
signature TEXT NOT NULL, signature TEXT NOT NULL,
release_notes TEXT, release_notes TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploaded_by INTEGER, uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
yanked_at TIMESTAMPTZ yanked_at TIMESTAMPTZ,
UNIQUE(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 ( `CREATE TABLE IF NOT EXISTS firmware_rollouts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
release_id TEXT NOT NULL REFERENCES firmware_releases(id) ON DELETE CASCADE, release_id TEXT NOT NULL REFERENCES firmware_releases(id) ON DELETE CASCADE,
target_kiosk_ids JSONB NOT NULL DEFAULT '[]', target_kiosk_ids JSONB NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'queued', state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')),
percentage INTEGER NOT NULL DEFAULT 100, percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100),
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ, finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by INTEGER created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`,
// ---- OS update releases + rollouts -------------------------------------- // ---- OS update releases + rollouts --------------------------------------
`CREATE TABLE IF NOT EXISTS os_update_releases ( `CREATE TABLE IF NOT EXISTS os_update_releases (
@ -360,25 +380,27 @@ export const TENANT_MIGRATIONS: readonly string[] = [
artifact_path TEXT NOT NULL, artifact_path TEXT NOT NULL,
size_bytes BIGINT NOT NULL, size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
bundle_format TEXT NOT NULL DEFAULT 'raucb', bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
release_notes TEXT, release_notes TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploaded_by INTEGER, uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
yanked_at TIMESTAMPTZ, yanked_at TIMESTAMPTZ,
UNIQUE(version, compatibility) UNIQUE(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 ( `CREATE TABLE IF NOT EXISTS os_update_rollouts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE, release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE,
target_kiosk_ids JSONB NOT NULL DEFAULT '[]', target_kiosk_ids JSONB NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'queued', state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')),
percentage INTEGER NOT NULL DEFAULT 100, percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100),
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ, finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by INTEGER created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`,
// ---- audit_log ----------------------------------------------------------- // ---- audit_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS audit_log ( `CREATE TABLE IF NOT EXISTS audit_log (
@ -396,32 +418,22 @@ export const TENANT_MIGRATIONS: readonly string[] = [
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`, `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_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)`,
// ---- entities ------------------------------------------------------------ // ---- kiosk GPIO bindings -------------------------------------------------
`CREATE TABLE IF NOT EXISTS entities (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
description TEXT,
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
html_content TEXT,
web_url TEXT,
dashboard_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
// ---- GPIO bindings -------------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
chip TEXT NOT NULL DEFAULT 'gpiochip4', chip TEXT NOT NULL DEFAULT 'gpiochip4',
pin INTEGER NOT NULL, pin INTEGER NOT NULL,
direction TEXT NOT NULL DEFAULT 'in', direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')),
pull TEXT, pull TEXT CHECK(pull IN ('up', 'down', 'none')),
edge TEXT, edge TEXT CHECK(edge IN ('rising', 'falling', 'both')),
topic TEXT NOT NULL, topic TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(kiosk_id, chip, pin) UNIQUE(kiosk_id, chip, pin)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`,
// ---- kiosk_logs ---------------------------------------------------------- // ---- kiosk_logs ----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_logs ( `CREATE TABLE IF NOT EXISTS kiosk_logs (