Adds user_preferences, user_consents, user_contexts and the tone / tip_kinds_json columns on users. Backfills consent_given=1 rows into user_consents as data:core; INSERT OR IGNORE keeps it idempotent and respects later revocations. Migration body moves to db/migrations.ts so tests can apply it to a fresh in-memory handle without opening the prod DB on import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
6.9 KiB
TypeScript
219 lines
6.9 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|