import type { SqliteDatabase } from './database.js'; import { createHash } from 'node:crypto'; export interface Migration { readonly id: number; readonly name: string; readonly statements: readonly string[]; } export const MIGRATIONS: readonly Migration[] = [ { id: 1, name: 'initial-control-plane-schema', statements: [ `CREATE TABLE instances ( id TEXT PRIMARY KEY, name TEXT NOT NULL, base_url TEXT NOT NULL UNIQUE, auth_mode TEXT NOT NULL DEFAULT 'password', enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), config_revision INTEGER NOT NULL DEFAULT 1 CHECK (config_revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE instance_tags ( instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, tag TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (instance_id, tag) )`, `CREATE TABLE capabilities ( instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, operation_id TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')), upstream_version TEXT, detail_code TEXT, observed_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (instance_id, operation_id) )`, `CREATE TABLE status_snapshots ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, category TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('fresh', 'stale', 'expired', 'unknown')), payload_json TEXT NOT NULL, observed_at TEXT NOT NULL, expires_at TEXT, created_at TEXT NOT NULL )`, `CREATE TABLE jobs ( id TEXT PRIMARY KEY, parent_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT, operation_id TEXT NOT NULL, risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')), status TEXT NOT NULL, requested_by TEXT NOT NULL, request_id TEXT NOT NULL, parameters_digest TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL )`, `CREATE TABLE job_items ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT, attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0), status TEXT NOT NULL, result_code TEXT, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL, UNIQUE (job_id, instance_id, attempt_number) )`, `CREATE TABLE audit_events ( id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL, job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL, actor TEXT NOT NULL, operation_id TEXT NOT NULL, risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')), request_id TEXT NOT NULL, parameters_summary_json TEXT NOT NULL, body_digest TEXT, result_code TEXT NOT NULL, duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), created_at TEXT NOT NULL )`, `CREATE TABLE app_settings ( key TEXT PRIMARY KEY, value_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE secret_references ( id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, purpose TEXT NOT NULL, provider TEXT NOT NULL, external_reference TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (provider, external_reference) )`, 'CREATE INDEX idx_status_snapshots_instance_observed_at ON status_snapshots(instance_id, observed_at DESC)', 'CREATE INDEX idx_jobs_status_created_at ON jobs(status, created_at DESC)', 'CREATE INDEX idx_audit_events_created_at ON audit_events(created_at DESC)', 'CREATE INDEX idx_capabilities_state ON capabilities(state)', 'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)', ], }, { id: 2, name: 'secret-cleanup-task-outbox', statements: [ `CREATE TABLE secret_cleanup_tasks ( reference TEXT PRIMARY KEY, instance_id TEXT NOT NULL, purpose TEXT NOT NULL, provider TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0), queued_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at FROM ( SELECT external_reference,instance_id,purpose,provider,updated_at, ROW_NUMBER() OVER ( PARTITION BY instance_id,purpose ORDER BY updated_at DESC,created_at DESC,id DESC ) AS row_number FROM secret_references WHERE instance_id IS NOT NULL ) ranked WHERE row_number > 1`, `DELETE FROM secret_references WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY instance_id,purpose ORDER BY updated_at DESC,created_at DESC,id DESC ) AS row_number FROM secret_references WHERE instance_id IS NOT NULL ) ranked WHERE row_number > 1 )`, 'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL', 'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)', ], }, { id: 3, name: 'current-status-snapshot-lifecycle', statements: [ `DELETE FROM status_snapshots WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY instance_id, category ORDER BY observed_at DESC, created_at DESC, id DESC ) AS row_number FROM status_snapshots ) ranked WHERE row_number > 1 )`, 'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)', ], }, { id: 4, name: 'r3-preparations-and-durable-job-lineage', statements: [ 'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT', 'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT', 'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL', `CREATE TABLE job_items_v4 ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, instance_id TEXT NOT NULL, attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0), status TEXT NOT NULL, result_code TEXT, error_json TEXT, source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL, UNIQUE (job_id, instance_id, attempt_number) )`, `INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at) SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`, 'DROP TABLE job_items', 'ALTER TABLE job_items_v4 RENAME TO job_items', 'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)', `CREATE TABLE job_attempts ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')), started_at TEXT NOT NULL, finished_at TEXT, created_at TEXT NOT NULL )`, 'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)', `CREATE TABLE operation_preparations ( id TEXT PRIMARY KEY, operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'), risk_level TEXT NOT NULL CHECK (risk_level = 'R3'), status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')), target_instance_id TEXT NOT NULL, target_revision INTEGER NOT NULL CHECK (target_revision > 0), parameter_schema_id TEXT NOT NULL, parameters_digest TEXT NOT NULL, token_digest TEXT NOT NULL CHECK (length(token_digest) = 64), requested_by TEXT NOT NULL, request_id TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, 'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)', ], }, { id: 5, name: 'generic-secure-operation-preparation-binding', statements: [ `CREATE TABLE operation_preparations_v5 ( id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')), status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')), target_instance_id TEXT NOT NULL, target_revision INTEGER NOT NULL CHECK (target_revision > 0), target_origin TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, canonical_query TEXT NOT NULL, body_digest TEXT NOT NULL CHECK (length(body_digest) = 64), content_type TEXT NOT NULL, parameter_schema_id TEXT NOT NULL, parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64), nonce TEXT NOT NULL UNIQUE, token_digest TEXT NOT NULL CHECK (length(token_digest) = 64), requested_by TEXT NOT NULL, request_id TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `INSERT INTO operation_preparations_v5 (id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path, canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest, requested_by,request_id,expires_at,consumed_at,created_at,updated_at) SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision, COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'', '${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest, 'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`, 'DROP TABLE operation_preparations', 'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations', 'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)', ], }, { id: 6, name: 'durable-event-journal', statements: [ `CREATE TABLE event_journal ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, public_id TEXT NOT NULL UNIQUE, envelope_json TEXT NOT NULL )`, 'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)', ], }, { id: 7, name: 'aggregate-console-password-protection', statements: [ `CREATE TABLE console_auth_config ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), protection_enabled INTEGER NOT NULL DEFAULT 0 CHECK (protection_enabled IN (0, 1)), password_salt TEXT, password_hash TEXT, password_revision INTEGER NOT NULL DEFAULT 0 CHECK (password_revision >= 0), updated_at TEXT NOT NULL, CHECK ((password_salt IS NULL) = (password_hash IS NULL)) )`, `CREATE TABLE console_auth_sessions ( session_hash TEXT PRIMARY KEY CHECK (length(session_hash) = 64), password_revision INTEGER NOT NULL CHECK (password_revision > 0), created_at TEXT NOT NULL, expires_at TEXT NOT NULL )`, 'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)', ], }, { id: 8, name: 'scheduled-automation', statements: [ `CREATE TABLE scheduled_tasks ( id TEXT PRIMARY KEY, name TEXT NOT NULL, operation_type TEXT NOT NULL CHECK (operation_type IN ('restart-service','reboot-system','send-sms')), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), cron_expression TEXT NOT NULL, timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'), target_selector_json TEXT NOT NULL, sms_secret_reference TEXT, sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0), effective_start_at TEXT, effective_end_at TEXT, misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')), overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')), retry_policy_json TEXT NOT NULL, next_due_at TEXT, last_evaluated_at TEXT, created_by TEXT NOT NULL, updated_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)), CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL)) )`, `CREATE TABLE scheduled_runs ( id TEXT PRIMARY KEY, scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT, schedule_version INTEGER NOT NULL CHECK (schedule_version > 0), task_snapshot_json TEXT NOT NULL, due_at TEXT NOT NULL, claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, target_snapshot_json TEXT NOT NULL, outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')), reason TEXT, job_ids_json TEXT NOT NULL DEFAULT '[]', trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')), attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0), UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source) )`, 'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL', 'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)', 'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)', ], }, { id: 9, name: 'central-notifications', statements: [ `CREATE TABLE notification_channels ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ( 'webhook','bark','pushplus','wecom_app','wecom_robot','dingtalk_robot','dingtalk_app', 'feishu_robot','telegram','email','serverchan' )), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), config_json TEXT NOT NULL, secret_reference TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE notification_rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, event_type TEXT NOT NULL CHECK (event_type IN ( 'sms','ddns','version','system','device','automation' )), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), condition_json TEXT NOT NULL, scope_json TEXT NOT NULL, channel_ids_json TEXT NOT NULL, templates_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE notification_deliveries ( id TEXT PRIMARY KEY, rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, instance_id TEXT, event_type TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ( 'success','failed','pending','sending','retrying','unmatched','no_available_channel','quiet_hours' )), detail TEXT, created_at TEXT NOT NULL, sent_at TEXT )`, 'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)', 'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)', ], }, { id: 10, name: 'hub-message-and-notification-queue-persistence', statements: [ `CREATE TABLE sms_messages ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, upstream_id TEXT NOT NULL, direction TEXT NOT NULL CHECK (direction IN ('incoming','outgoing','received','sent','unknown')), phone_number TEXT NOT NULL, content TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, transport TEXT NOT NULL, synced_at TEXT NOT NULL, UNIQUE (instance_id, upstream_id) )`, 'CREATE INDEX idx_sms_messages_instance_timestamp ON sms_messages(instance_id, timestamp DESC)', 'CREATE INDEX idx_sms_messages_timestamp ON sms_messages(timestamp DESC)', 'CREATE INDEX idx_sms_messages_phone_timestamp ON sms_messages(phone_number, timestamp DESC)', `CREATE TABLE notification_queue ( id TEXT PRIMARY KEY, instance_id TEXT, event_type TEXT NOT NULL, rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ( 'pending','sending','succeeded','failed','cancelled' )), attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0), payload_json TEXT NOT NULL, last_error TEXT, available_at TEXT NOT NULL, delivered_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, 'CREATE INDEX idx_notification_queue_status_available_at ON notification_queue(status, available_at)', 'CREATE INDEX idx_notification_queue_created_at ON notification_queue(created_at DESC)', ], }, { id: 11, name: 'hub-groups-tags-and-automation-triggers', statements: [ `CREATE TABLE device_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, 'ALTER TABLE instances ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL', 'CREATE INDEX idx_instances_group_id ON instances(group_id)', `CREATE TABLE tag_registry ( tag TEXT PRIMARY KEY, color TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `INSERT INTO tag_registry (tag, created_at, updated_at) SELECT tag, MIN(created_at), MAX(created_at) FROM instance_tags GROUP BY tag`, // scheduled_tasks carries an operation_type CHECK that has to widen, so both the // task table and its only child are rebuilt; renaming the child first keeps the // RESTRICT edge from firing while the parent is swapped out. 'ALTER TABLE scheduled_runs RENAME TO scheduled_runs_old', 'ALTER TABLE scheduled_tasks RENAME TO scheduled_tasks_old', `CREATE TABLE scheduled_tasks ( id TEXT PRIMARY KEY, name TEXT NOT NULL, operation_type TEXT NOT NULL CHECK (operation_type IN ( 'restart-service','reboot-system','send-sms','restart-baseband','backup-data' )), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), cron_expression TEXT NOT NULL, timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'), target_selector_json TEXT NOT NULL, sms_secret_reference TEXT, sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0), effective_start_at TEXT, effective_end_at TEXT, misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')), overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')), retry_policy_json TEXT NOT NULL, next_due_at TEXT, last_evaluated_at TEXT, created_by TEXT NOT NULL, updated_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, trigger_json TEXT, CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)), CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL)) )`, `CREATE TABLE scheduled_runs ( id TEXT PRIMARY KEY, scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT, schedule_version INTEGER NOT NULL CHECK (schedule_version > 0), task_snapshot_json TEXT NOT NULL, due_at TEXT NOT NULL, claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, target_snapshot_json TEXT NOT NULL, outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')), reason TEXT, job_ids_json TEXT NOT NULL DEFAULT '[]', trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')), attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0), UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source) )`, `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone, target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at, effective_end_at,misfire_policy,overlap_policy,retry_policy_json,next_due_at, last_evaluated_at,created_by,updated_by,created_at,updated_at,deleted_at,trigger_json) SELECT id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy, overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by, created_at,updated_at,deleted_at,NULL FROM scheduled_tasks_old`, `INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at, claimed_at,started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json, trigger_source,attempt) SELECT id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at, finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt FROM scheduled_runs_old`, 'DROP TABLE scheduled_runs_old', 'DROP TABLE scheduled_tasks_old', 'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL', 'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)', 'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)', ], }, { id: 12, name: 'schedule-action-delay', statements: [ `ALTER TABLE scheduled_tasks ADD COLUMN delay_seconds INTEGER CHECK (delay_seconds IS NULL OR (delay_seconds >= 0 AND delay_seconds <= 3600))`, ], }, { id: 13, name: 'notification-rule-rate-limit-and-quiet-hours', statements: [ `ALTER TABLE notification_rules ADD COLUMN rate_limit_json TEXT NOT NULL DEFAULT '{"enabled":false,"maxMessages":20,"windowSeconds":60}'`, `ALTER TABLE notification_rules ADD COLUMN quiet_hours_json TEXT NOT NULL DEFAULT '[]'`, // Suppressions (quiet hours, rate limit) are attributed to a rule rather than a channel, // so channel_id becomes optional. The status CHECK also has to widen, which means the // table is rebuilt; nothing references notification_deliveries, so the swap is contained. `CREATE TABLE notification_deliveries_new ( id TEXT PRIMARY KEY, rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE, instance_id TEXT, event_type TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ( 'success','failed','pending','sending','retrying','unmatched','no_available_channel', 'quiet_hours','rate_limited' )), detail TEXT, created_at TEXT NOT NULL, sent_at TEXT )`, `INSERT INTO notification_deliveries_new (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) SELECT id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at FROM notification_deliveries`, 'DROP TABLE notification_deliveries', 'ALTER TABLE notification_deliveries_new RENAME TO notification_deliveries', 'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)', 'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)', ], }, { id: 14, name: 'connection-log-history', statements: [ // The Hub keeps a rolling connection history per device; the snapshot table only holds // the latest probe, so outcomes need their own bounded journal. `CREATE TABLE connection_logs ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, outcome TEXT NOT NULL CHECK (outcome IN ('success','stale','failed','unsupported')), state TEXT NOT NULL CHECK (state IN ('fresh','stale','expired','unknown')), error_code TEXT, http_status INTEGER, duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), observed_at TEXT NOT NULL )`, 'CREATE INDEX idx_connection_logs_observed_at ON connection_logs(observed_at DESC)', 'CREATE INDEX idx_connection_logs_instance_observed_at ON connection_logs(instance_id, observed_at DESC)', 'CREATE INDEX idx_connection_logs_outcome_observed_at ON connection_logs(outcome, observed_at DESC)', ], }, { id: 15, name: 'sms-offline-outbox', statements: [ // The Hub keeps sending while a device is offline and delivers when it returns; the // central archive alone cannot hold a write, so outbound SMS need their own queue. `CREATE TABLE sms_outbox ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, phone_number TEXT NOT NULL, content TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ( 'queued','sending','sent','failed','cancelled' )), attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), max_attempts INTEGER NOT NULL DEFAULT 24 CHECK (max_attempts > 0), last_error TEXT, available_at TEXT NOT NULL, sent_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, 'CREATE INDEX idx_sms_outbox_status_available_at ON sms_outbox(status, available_at)', 'CREATE INDEX idx_sms_outbox_instance_status ON sms_outbox(instance_id, status)', 'CREATE INDEX idx_sms_outbox_created_at ON sms_outbox(created_at DESC)', ], }, { id: 16, name: 'notification-channel-secret-fields', statements: [ // Hub channels keep several credentials (DingTalk needs an access token *and* a signing // key), so the secret store holds a JSON map and the row records which keys it covers. "ALTER TABLE notification_channels ADD COLUMN secret_fields TEXT NOT NULL DEFAULT ''", ], }, ]; const createMigrationsTable = `CREATE TABLE schema_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, checksum TEXT NOT NULL, applied_at TEXT NOT NULL )`; function checksum(migration: Migration): string { return createHash('sha256').update(JSON.stringify(migration.statements)).digest('hex'); } function validateCatalog(migrations: readonly Migration[]): void { const ids = new Set(); const names = new Set(); let previous = -Infinity; for (const migration of migrations) { if (!Number.isSafeInteger(migration.id) || migration.id < 0) throw new Error(`Migration id ${migration.id} is invalid`); if (ids.has(migration.id)) throw new Error(`Duplicate migration id ${migration.id}`); if (names.has(migration.name)) throw new Error(`Duplicate migration name ${migration.name}`); if (migration.id <= previous) throw new Error('Migration ids must be strictly increasing'); ids.add(migration.id); names.add(migration.name); previous = migration.id; } } export function migrateDatabase( database: SqliteDatabase, migrations: readonly Migration[] = MIGRATIONS, ): void { validateCatalog(migrations); database.transaction(() => { database.exec( 'CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)', ); const maximum = database.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as { id: number | null; }; const applied = database.prepare('SELECT name, checksum FROM schema_migrations WHERE id = ?'); const appliedByName = database.prepare('SELECT id FROM schema_migrations WHERE name = ?'); const record = database.prepare( 'INSERT INTO schema_migrations (id, name, checksum, applied_at) VALUES (?, ?, ?, ?)', ); for (const migration of migrations) { const digest = checksum(migration); const existing = applied.get(migration.id) as { name: string; checksum: string } | undefined; if (existing) { if (existing.name !== migration.name) throw new Error(`Migration ${migration.id} name mismatch`); if (existing.checksum !== digest) throw new Error(`Migration ${migration.id} checksum drift detected`); continue; } const existingName = appliedByName.get(migration.name) as { id: number } | undefined; if (existingName) throw new Error( `Migration ${migration.name} drift detected: applied as id ${existingName.id}`, ); if (maximum.id !== null && migration.id < maximum.id) throw new Error( `Migration ${migration.id} is an unapplied backfill below applied id ${maximum.id}`, ); for (const statement of migration.statements) database.exec(statement); record.run(migration.id, migration.name, digest, new Date().toISOString()); } })(); } // Kept separate so a failed first migration transaction removes metadata too. export const migrationMetadataSql = createMigrationsTable;