import type Database from 'better-sqlite3'; /** * Run database migrations. Safe to call on a fresh DB or on an existing production DB * (idempotent throughout). On fresh DBs, prerequisite tables are bootstrapped by the * individual migrate* functions as needed. */ export function runMigrations(db: Database.Database): void { // Helper: check if a table exists in this DB. const tableExists = (name: string): boolean => !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)); // Add owner_id to jobs (if not exists) // Guard: table may not exist when runMigrations is called on a fresh DB // (schema.sql hasn't been applied yet); the ALTER is a no-op in that case. const jobsCols = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>; if (tableExists('jobs') && !jobsCols.some(c => c.name === 'owner_id')) { db.exec("ALTER TABLE jobs ADD COLUMN owner_id TEXT REFERENCES users(id)"); } // Add owner_id to local_tasks (if not exists) const tasksCols = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>; if (tableExists('local_tasks') && !tasksCols.some(c => c.name === 'owner_id')) { db.exec("ALTER TABLE local_tasks ADD COLUMN owner_id TEXT REFERENCES users(id)"); } // Audit 2026-06-08 fix: user_gitea_orgs was created ONLY in // Repository.initSchema(), so a DB built via the migration path alone lacked // it and the task-list display SELECT (correlated subquery on org_name in // repository.ts) crashed with "no such table". Create it here too so the // fresh path (schema.sql) and the migration path stay in sync. db.exec(` CREATE TABLE IF NOT EXISTS user_gitea_orgs ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, org_id TEXT NOT NULL, org_name TEXT NOT NULL, fetched_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, org_id) ); CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); `); // Add context tracking columns to jobs (if not exists) // re-fetch after the owner_id ALTER above to reflect the updated schema const jobsColsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>; const existingCols = new Set(jobsColsAfter.map(c => c.name)); if (tableExists('jobs') && !existingCols.has('context_prompt_tokens')) { db.exec("ALTER TABLE jobs ADD COLUMN context_prompt_tokens INTEGER"); } if (tableExists('jobs') && !existingCols.has('context_limit_tokens')) { db.exec("ALTER TABLE jobs ADD COLUMN context_limit_tokens INTEGER"); } if (tableExists('jobs') && !existingCols.has('context_updated_at')) { db.exec("ALTER TABLE jobs ADD COLUMN context_updated_at TEXT"); } // Phase: piece handoff (Continue-with-another-piece feature). // continued_from_job_id links a continuation job to its predecessor on the // same local_task. NULL for normal jobs. SQLite's REFERENCES clause in // ALTER TABLE is informational only — integrity is enforced at the API // layer (POST /api/local/tasks/:id/continue). if (tableExists('jobs') && !existingCols.has('continued_from_job_id')) { db.exec("ALTER TABLE jobs ADD COLUMN continued_from_job_id TEXT REFERENCES jobs(id)"); } // Phase A: multi-team GPU pool + node status // last_backend_id records the physical backend (LiteLLM deployment id) // that handled this job's LLM calls. NULL for direct workers; set to // the value of the proxy's x-litellm-model-id header on the FIRST LLM // call of the job and never overwritten (sticky-backend policy per // design Open Question #3). if (tableExists('jobs') && !existingCols.has('last_backend_id')) { db.exec("ALTER TABLE jobs ADD COLUMN last_backend_id TEXT"); } // Mission Brief: per-task pinned memo (JSON blob). const tasksColsAfter = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>; if (tableExists('local_tasks') && !tasksColsAfter.some(c => c.name === 'mission_brief')) { db.exec("ALTER TABLE local_tasks ADD COLUMN mission_brief TEXT"); } // Add injected_at to local_task_comments (interjection feature: tracks // when a user comment was injected into the running agent's conversation). addColumnIfMissing(db, 'local_task_comments', 'injected_at', () => { db.exec("ALTER TABLE local_task_comments ADD COLUMN injected_at TEXT"); }); // Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled. addColumnIfMissing(db, 'local_tasks', 'options', () => { db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'"); }); // Title provenance: 'auto' (creation fallback) / 'agent' (derived from // Mission Brief goal) / 'user' (manual edit, never auto-overwritten). addColumnIfMissing(db, 'local_tasks', 'title_source', () => { db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'"); }); migrateMcpTables(db); migrateSshTables(db); migrateNotesTables(db); migrateDashboardWidgets(db); migrateGatewayVirtualKeys(db); migratePushNotificationsTables(db); migrateLlmUsageDaily(db); migrateLlmUsageHourly(db); } /** * Per-user daily LLM usage aggregation (gateway + direct). Idempotent. * Mirrors schema.sql + Repository.initSchema (dual-path rule: * project_db_migration_dual_path). Additive table, no mixed-version risk. * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md. */ function migrateLlmUsageDaily(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS llm_usage_daily ( day TEXT NOT NULL, user_id TEXT NOT NULL, source TEXT NOT NULL, model TEXT NOT NULL, route TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (day, user_id, source, model, route) ); CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day ON llm_usage_daily (user_id, day); `); } /** * Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the * write target). Idempotent; mirrors schema.sql + Repository.initSchema. * Backfills the daily archive into the hourly table once (hour = day||'T00') * via INSERT OR IGNORE so re-running the migration never double-counts. * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md. */ function migrateLlmUsageHourly(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS llm_usage_hourly ( hour TEXT NOT NULL, user_id TEXT NOT NULL, source TEXT NOT NULL, model TEXT NOT NULL, route TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (hour, user_id, source, model, route) ); CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour ON llm_usage_hourly (user_id, hour); INSERT OR IGNORE INTO llm_usage_hourly (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) SELECT day || 'T00', user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at FROM llm_usage_daily; `); } /** * Idempotent column addition helper. Checks PRAGMA table_info and runs the * callback only when the column is missing. */ function addColumnIfMissing( db: Database.Database, table: string, column: string, apply: () => void, ): void { const tableExists = !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table)); if (!tableExists) return; const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>; if (!cols.some(c => c.name === column)) { apply(); } } /** * Ensure MCP (Model Context Protocol) tables exist. * Idempotent (uses CREATE TABLE IF NOT EXISTS). FK clauses are omitted here * vs. schema.sql to keep this migration safe for in-flight DBs where FK * enforcement may already be ON before the referenced tables have been * migrated to their final shape. */ function migrateMcpTables(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS mcp_servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, oauth_client_id TEXT NOT NULL, oauth_client_secret_enc BLOB NOT NULL, oauth_scopes TEXT, issuer TEXT, authorization_endpoint TEXT, token_endpoint TEXT, discovery_fingerprint TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_by TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS user_mcp_tokens ( user_id TEXT NOT NULL, server_id TEXT NOT NULL, access_token_enc BLOB NOT NULL, refresh_token_enc BLOB, expires_at TEXT, scope TEXT, scope_type TEXT NOT NULL DEFAULT 'user' CHECK(scope_type IN ('user', 'org')), scope_id TEXT, connected_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, server_id) ); CREATE TABLE IF NOT EXISTS mcp_server_tools ( server_id TEXT NOT NULL, tool_name TEXT NOT NULL, description TEXT, input_schema TEXT, refreshed_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (server_id, tool_name) ); CREATE TABLE IF NOT EXISTS mcp_oauth_pending ( state TEXT PRIMARY KEY, user_id TEXT NOT NULL, server_id TEXT NOT NULL, code_verifier TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_created ON mcp_oauth_pending(created_at); `); // Phase 8: API key auth + user-owned servers // ALTER TABLE additions are idempotent via PRAGMA table_info check. const mcpServerCols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>; const mcpServerColNames = new Set(mcpServerCols.map(c => c.name)); if (!mcpServerColNames.has('auth_kind')) { db.exec("ALTER TABLE mcp_servers ADD COLUMN auth_kind TEXT NOT NULL DEFAULT 'oauth'"); } if (!mcpServerColNames.has('static_token_enc')) { // Nullable BLOB: present for api_key servers, NULL for oauth servers. db.exec('ALTER TABLE mcp_servers ADD COLUMN static_token_enc BLOB'); } if (!mcpServerColNames.has('owner_id')) { // NULL = global/admin-managed; NOT NULL = user-owned. db.exec('ALTER TABLE mcp_servers ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE'); } if (!mcpServerColNames.has('auth_header_name')) { // Custom auth header name for api_key servers (e.g. 'xc-mcp-token'). // NULL = default Authorization: Bearer. db.exec('ALTER TABLE mcp_servers ADD COLUMN auth_header_name TEXT'); } db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_servers_owner ON mcp_servers(owner_id);'); } /** * Ensure SSH tables exist. * Idempotent (uses CREATE TABLE IF NOT EXISTS). * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1). * * FK clauses are omitted vs schema.sql to keep the migration safe when applied * to in-flight DBs where FK enforcement may already be ON. */ function migrateSshTables(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS system_deks ( id INTEGER PRIMARY KEY CHECK (id = 1), encrypted_dek BLOB NOT NULL, key_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS ssh_user_deks ( user_id TEXT PRIMARY KEY, encrypted_dek BLOB NOT NULL, key_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS ssh_connections ( id TEXT PRIMARY KEY, owner_id TEXT, label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22, username TEXT NOT NULL, private_key_enc BLOB NOT NULL, passphrase_enc BLOB, key_version INTEGER NOT NULL DEFAULT 1, key_fingerprint TEXT, host_key_type TEXT, host_key_b64 TEXT, host_key_fingerprint TEXT, host_key_recorded_at TEXT, host_key_verified_at TEXT, host_key_pending INTEGER NOT NULL DEFAULT 0, host_key_pending_b64 TEXT, host_key_pending_fingerprint TEXT, host_key_pending_token TEXT, host_key_pending_source TEXT, command_deny_patterns TEXT, command_allow_patterns TEXT, remote_path_prefix TEXT NOT NULL CHECK (LENGTH(remote_path_prefix) > 0), allow_remote_unrestricted INTEGER NOT NULL DEFAULT 0, allow_private_addresses INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, disabled_by_admin INTEGER NOT NULL DEFAULT 0, disabled_by_admin_reason TEXT, disabled_by_admin_at TEXT, disabled_by_admin_user_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_ssh_connections_owner ON ssh_connections(owner_id); CREATE INDEX IF NOT EXISTS idx_ssh_connections_enabled ON ssh_connections(enabled, disabled_by_admin); CREATE TABLE IF NOT EXISTS ssh_connection_grants ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, subject_type TEXT NOT NULL CHECK (subject_type IN ('user','org')), subject_id TEXT NOT NULL, piece_name TEXT, applies_to_all_pieces INTEGER NOT NULL DEFAULT 0, granted_by_user_id TEXT NOT NULL, reason TEXT NOT NULL CHECK (LENGTH(reason) >= 8), expires_at TEXT, created_at TEXT NOT NULL, CHECK ( (applies_to_all_pieces = 1 AND piece_name IS NULL) OR (applies_to_all_pieces = 0 AND piece_name IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS idx_ssh_grants_connection ON ssh_connection_grants(connection_id); CREATE INDEX IF NOT EXISTS idx_ssh_grants_subject ON ssh_connection_grants(subject_type, subject_id); CREATE TABLE IF NOT EXISTS ssh_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, entity_type TEXT, entity_id TEXT, connection_id TEXT, owner_id TEXT, acting_user_id TEXT, job_id TEXT, piece_name TEXT, outcome TEXT NOT NULL CHECK (outcome IN ('pending','success','failed','denied','aborted')), reason TEXT, detail TEXT, started_at TEXT NOT NULL, completed_at TEXT ); CREATE INDEX IF NOT EXISTS idx_ssh_audit_action ON ssh_audit_log(action, started_at); CREATE INDEX IF NOT EXISTS idx_ssh_audit_connection ON ssh_audit_log(connection_id, started_at); CREATE INDEX IF NOT EXISTS idx_ssh_audit_owner ON ssh_audit_log(owner_id, started_at); CREATE INDEX IF NOT EXISTS idx_ssh_audit_outcome ON ssh_audit_log(outcome, started_at); CREATE INDEX IF NOT EXISTS idx_ssh_audit_pending ON ssh_audit_log(outcome) WHERE outcome = 'pending'; CREATE TABLE IF NOT EXISTS ssh_abuse_counters ( scope_key TEXT PRIMARY KEY, scope_kind TEXT NOT NULL CHECK (scope_kind IN ('conn','userhost','globalhost')), enforce_lock INTEGER NOT NULL DEFAULT 1, failure_count INTEGER NOT NULL DEFAULT 0, failure_window_start TEXT, lock_until TEXT, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_ssh_abuse_kind ON ssh_abuse_counters(scope_kind); CREATE INDEX IF NOT EXISTS idx_ssh_abuse_locked ON ssh_abuse_counters(lock_until) WHERE lock_until IS NOT NULL; `); // Future ALTERs: add new columns to ssh_connections via PRAGMA table_info pattern, // matching the MCP migrations above. } /** * Ensure Shared Knowledge Notes tables exist. * Idempotent (uses CREATE TABLE IF NOT EXISTS / CREATE VIRTUAL TABLE IF NOT EXISTS). * Plan: docs/superpowers/plans/2026-05-15-shared-knowledge-notes.md (Task 1). * * FK clauses match schema.sql. Note that this function also creates the users * table as a prerequisite so it is safe to call on a fresh DB (e.g. in tests) * before schema.sql has been applied. */ function migrateNotesTables(db: Database.Database): void { // Ensure the users prerequisite table exists (no-op on production DBs where // schema.sql was already applied). db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT, avatar_url TEXT, role TEXT NOT NULL DEFAULT 'user', status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); // Enable FK enforcement so CASCADE DELETE works (also set by Repository in production; // this covers test DBs that don't go through the Repository constructor). db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS note_index ( owner_id TEXT NOT NULL, folder TEXT NOT NULL, file_name TEXT NOT NULL, title TEXT, visibility TEXT NOT NULL CHECK (visibility IN ('private','org','public')), visibility_scope_org_id TEXT, mode_hint TEXT CHECK (mode_hint IS NULL OR mode_hint IN ('search','inject')), tags_json TEXT, content_size INTEGER NOT NULL DEFAULT 0, content_hash TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL, PRIMARY KEY (owner_id, folder, file_name), FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_note_index_visibility ON note_index(visibility, visibility_scope_org_id); CREATE INDEX IF NOT EXISTS idx_note_index_owner_folder ON note_index(owner_id, folder); CREATE TABLE IF NOT EXISTS note_subscriptions ( consumer_user_id TEXT NOT NULL, publisher_user_id TEXT NOT NULL, folder TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('search','inject')), enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, PRIMARY KEY (consumer_user_id, publisher_user_id, folder), FOREIGN KEY (consumer_user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (publisher_user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_note_subscriptions_consumer_mode ON note_subscriptions(consumer_user_id, mode, enabled); CREATE TABLE IF NOT EXISTS pending_reindex ( owner_id TEXT NOT NULL, folder TEXT NOT NULL, file_name TEXT NOT NULL, reason TEXT, created_at INTEGER NOT NULL, PRIMARY KEY (owner_id, folder, file_name) ); CREATE VIRTUAL TABLE IF NOT EXISTS note_index_fts USING fts5( owner_id UNINDEXED, folder UNINDEXED, file_name UNINDEXED, title, tags, body ); CREATE TRIGGER IF NOT EXISTS note_index_ai AFTER INSERT ON note_index BEGIN INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body) VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body); END; CREATE TRIGGER IF NOT EXISTS note_index_ad AFTER DELETE ON note_index BEGIN DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name; END; CREATE TRIGGER IF NOT EXISTS note_index_au AFTER UPDATE ON note_index BEGIN DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name; INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body) VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body); END; `); } /** * Ensure user_dashboard_widgets table exists for the Side Info Panel feature. * Idempotent (CREATE TABLE IF NOT EXISTS). */ function migrateDashboardWidgets(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS user_dashboard_widgets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'markdown', markdown_content TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, slug) ); CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user ON user_dashboard_widgets (user_id, sort_order); `); // Phase B (2026-05): existing deployments created the table without the // kind column. ALTER is idempotent because we check column existence first. const columns = db.prepare(`PRAGMA table_info(user_dashboard_widgets)`).all() as Array<{ name: string }>; if (!columns.some(c => c.name === 'kind')) { db.exec(`ALTER TABLE user_dashboard_widgets ADD COLUMN kind TEXT NOT NULL DEFAULT 'markdown'`); } } /** * Ensure gateway_virtual_keys table exists for AAO Gateway Phase 2a. * Idempotent (CREATE TABLE IF NOT EXISTS + partial / non-unique CREATE * INDEX). Mirrors the shape in src/db/schema.sql; both paths must stay in * sync (see memory: project_db_migration_dual_path). * * Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2a). */ function migrateGatewayVirtualKeys(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS gateway_virtual_keys ( id TEXT PRIMARY KEY, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, team TEXT NOT NULL, allowed_models TEXT, source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')), created_at TEXT NOT NULL, created_by TEXT, revoked_at TEXT, revoked_by TEXT, last_used_at TEXT ); CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active ON gateway_virtual_keys (key_hash) WHERE revoked_at IS NULL; CREATE INDEX IF NOT EXISTS idx_gateway_keys_team ON gateway_virtual_keys (team); `); // Phase 2b: per-key budget + rate limit columns. Idempotent via // PRAGMA table_info — repeated calls are safe and no-op once present. const cols = db.prepare("PRAGMA table_info('gateway_virtual_keys')").all() as Array<{ name: string }>; const colNames = new Set(cols.map(c => c.name)); if (!colNames.has('tokens_budget')) { db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN tokens_budget INTEGER'); } if (!colNames.has('rate_limit_rpm')) { db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN rate_limit_rpm INTEGER'); } // Phase 2b: monthly usage tracker. CREATE TABLE IF NOT EXISTS is // idempotent. ON DELETE CASCADE ensures a hard-delete of a virtual key // wipes its usage rows too — but we still write the FK clause here // (matching schema.sql) because the gateway boot enables foreign_keys // pragma. Composite PK doubles as the lookup index for the hot-path // budget check (`getGatewayKeyUsage`). db.exec(` CREATE TABLE IF NOT EXISTS gateway_key_usage ( key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE, period_start TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (key_id, period_start) ); CREATE INDEX IF NOT EXISTS idx_gateway_usage_key ON gateway_key_usage (key_id); `); } /** * Browser Notifications V2 (Web Push) tables. Idempotent. * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. * Mirrors schema.sql; both paths must stay in sync (memory: * project_db_migration_dual_path). */ function migratePushNotificationsTables(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS push_subscriptions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, vapid_key_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_success_at TEXT, last_failure_at TEXT, failure_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id); CREATE TABLE IF NOT EXISTS user_notification_prefs ( user_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1, event_running INTEGER NOT NULL DEFAULT 1, event_succeeded INTEGER NOT NULL DEFAULT 1, event_failed INTEGER NOT NULL DEFAULT 1, event_waiting_human INTEGER NOT NULL DEFAULT 1, include_details INTEGER NOT NULL DEFAULT 0, v1_migrated INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Local-auth credentials (email + password accounts). Idempotent add so -- an existing no-auth / OAuth-only deployment gains local login on upgrade. CREATE TABLE IF NOT EXISTS local_credentials ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, salt TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Local organizations + membership (provider-agnostic 'org' visibility for -- local accounts). Idempotent add. CREATE TABLE IF NOT EXISTS local_orgs ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS local_org_members ( org_id TEXT NOT NULL REFERENCES local_orgs(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', added_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (org_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_local_org_members_user ON local_org_members(user_id); `); }