/** * Schema migrations and one-shot backfills for the API DB. * * Kept separate from db/index.ts so tests can apply migrations to an in-memory * SQLite handle without triggering the singleton DB connection at import time. */ import type { Database as BetterSqlite3Database } from 'better-sqlite3'; export function runMigrations(handle: BetterSqlite3Database) { handle.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, image TEXT, google_id TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'user', consent_given INTEGER NOT NULL DEFAULT 0, consent_at TEXT, created_at TEXT NOT NULL, deleted_at TEXT ); CREATE TABLE IF NOT EXISTS integration_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), provider TEXT NOT NULL, access_token TEXT NOT NULL, refresh_token TEXT, expires_at TEXT, connected_at TEXT NOT NULL, UNIQUE(user_id, provider) ); CREATE TABLE IF NOT EXISTS tip_feedback ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), tip_id TEXT NOT NULL, action TEXT NOT NULL, source_id TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tip_views ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), tip_id TEXT NOT NULL, served_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS push_subscriptions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), expires_at TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS admin_actions ( id TEXT PRIMARY KEY, admin_id TEXT NOT NULL REFERENCES users(id), action TEXT NOT NULL, target_type TEXT, target_id TEXT, detail TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tip_scores ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), tip_id TEXT NOT NULL, policy TEXT NOT NULL, ml_score INTEGER, features_json TEXT, candidate_count INTEGER, latency_ms INTEGER, served_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS saved_queries ( id TEXT PRIMARY KEY, admin_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, sql TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_profile_features ( user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, value REAL, value_text TEXT, updated_at TEXT NOT NULL, ttl_sec INTEGER NOT NULL, PRIMARY KEY (user_id, name) ); CREATE TABLE IF NOT EXISTS sim_runs ( id TEXT PRIMARY KEY, policy_a TEXT NOT NULL, policy_b TEXT NOT NULL, n_users INTEGER NOT NULL, n_rounds INTEGER NOT NULL, tasks_per_round INTEGER NOT NULL DEFAULT 8, use_llm INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', summary_json TEXT, winner TEXT, persona_breakdown_json TEXT, created_at TEXT NOT NULL, finished_at TEXT ); CREATE TABLE IF NOT EXISTS sim_events ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES sim_runs(id), round INTEGER NOT NULL, user_id TEXT NOT NULL, persona TEXT NOT NULL, policy TEXT NOT NULL, tip_content TEXT NOT NULL, priority INTEGER NOT NULL, is_overdue INTEGER NOT NULL, action TEXT NOT NULL, dwell_ms INTEGER, reward_milli INTEGER NOT NULL, hour INTEGER NOT NULL, day_of_week INTEGER NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS agent_outputs ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), agent_id TEXT NOT NULL, prompt_text TEXT NOT NULL, signals_snapshot TEXT, computed_at TEXT NOT NULL, expires_at TEXT NOT NULL, agent_version TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_agent_outputs_user_agent_exp ON agent_outputs(user_id, agent_id, expires_at DESC); CREATE TABLE IF NOT EXISTS user_preferences ( user_id TEXT NOT NULL REFERENCES users(id), scope TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'user', updated_at TEXT NOT NULL, PRIMARY KEY (user_id, scope, key) ); CREATE TABLE IF NOT EXISTS user_consents ( user_id TEXT NOT NULL REFERENCES users(id), consent_key TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT, PRIMARY KEY (user_id, consent_key) ); CREATE TABLE IF NOT EXISTS user_contexts ( user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0, schedule_json TEXT, created_at TEXT NOT NULL, PRIMARY KEY (user_id, name) ); `); // Additive column migrations — safe to run on existing DBs. // SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present. for (const stmt of [ `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`, `ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, `ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`, `ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`, `ALTER TABLE integration_tokens ADD COLUMN token_status TEXT NOT NULL DEFAULT 'active'`, `ALTER TABLE tip_scores ADD COLUMN prompt_version TEXT`, `ALTER TABLE tip_scores ADD COLUMN llm_model TEXT`, `ALTER TABLE tip_scores ADD COLUMN tip_kind TEXT`, `ALTER TABLE sim_runs ADD COLUMN mlflow_run_id TEXT`, `ALTER TABLE sim_runs ADD COLUMN judge_mode TEXT NOT NULL DEFAULT 'rule'`, `ALTER TABLE sim_runs ADD COLUMN n_policies INTEGER NOT NULL DEFAULT 2`, `ALTER TABLE users ADD COLUMN tone TEXT`, `ALTER TABLE users ADD COLUMN tip_kinds_json TEXT`, ]) { try { handle.exec(stmt); } catch { /* column already exists */ } } // Backfill: ADR-0014 collapses users.consent_given into user_consents // (consent_key='data:core'). Idempotent — INSERT OR IGNORE on the // composite PK skips users already migrated. Stays in place until the // column is dropped (PR 6 of the migration plan). handle.exec(` INSERT OR IGNORE INTO user_consents (user_id, consent_key, granted_at) SELECT id, 'data:core', COALESCE(consent_at, created_at) FROM users WHERE consent_given = 1 `); // Seed first admin from env (ADMIN_SEED_EMAIL). const seedEmail = process.env.ADMIN_SEED_EMAIL; if (seedEmail) { handle.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail); } }