582 lines
25 KiB
TypeScript
582 lines
25 KiB
TypeScript
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 '{}'");
|
|
});
|
|
|
|
migrateMcpTables(db);
|
|
migrateSshTables(db);
|
|
migrateNotesTables(db);
|
|
migrateDashboardWidgets(db);
|
|
migrateGatewayVirtualKeys(db);
|
|
migratePushNotificationsTables(db);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
`);
|
|
}
|