import { DatabaseSync } from 'node:sqlite' /** * Forward-only, idempotent, bounded migrations (spec: no data rollback, * previous revision must keep working, heavy work staged as jobs). */ export interface Migration { version: number name: string up(db: DatabaseSync): void } export const MIGRATIONS: Migration[] = [ { version: 1, name: 'initial schema', up: (db) => { db.exec('PRAGMA auto_vacuum = INCREMENTAL') db.exec(` CREATE TABLE audit_events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, ts TEXT NOT NULL, session_id TEXT, revision TEXT, agent_digest TEXT, worktree_head TEXT, worktree_dirty INTEGER NOT NULL DEFAULT 0, kind TEXT NOT NULL, payload TEXT NOT NULL ); CREATE INDEX audit_ts ON audit_events(ts); CREATE INDEX audit_kind_ts ON audit_events(kind, ts); CREATE TABLE chat_messages ( id TEXT PRIMARY KEY, update_id INTEGER UNIQUE, chat_id INTEGER NOT NULL, message_id INTEGER NOT NULL UNIQUE, direction TEXT NOT NULL CHECK(direction IN ('in','out')), ts TEXT NOT NULL, sender_id TEXT, sender_name TEXT, reply_to_message_id INTEGER, trigger_reason TEXT, text TEXT, media_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX chat_ts ON chat_messages(ts); CREATE TABLE tombstones ( message_id INTEGER PRIMARY KEY, requester_id TEXT NOT NULL, authority TEXT NOT NULL CHECK(authority IN ('sender','oliver')), reason TEXT, ts TEXT NOT NULL ); CREATE TABLE deployment_events ( id TEXT PRIMARY KEY, ts TEXT NOT NULL, kind TEXT NOT NULL, revision TEXT, detail TEXT NOT NULL, received_at TEXT NOT NULL ); CREATE INDEX deploy_ts ON deployment_events(ts); CREATE TABLE media ( digest TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL, original_name TEXT, telegram_file_id TEXT, created_at TEXT NOT NULL ); CREATE TABLE media_analyses ( digest TEXT NOT NULL, capability TEXT NOT NULL, model TEXT NOT NULL, schema_version INTEGER NOT NULL, result TEXT NOT NULL, usage_json TEXT, created_at TEXT NOT NULL, PRIMARY KEY (digest, capability, model, schema_version) ); CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL CHECK(status IN ('running','done','failed')), detail TEXT ); CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `) }, }, ] export function openDatabase(path: string, options: { readonly?: boolean } = {}): DatabaseSync { const db = new DatabaseSync(path, { readOnly: options.readonly }) db.exec('PRAGMA journal_mode = WAL') db.exec('PRAGMA synchronous = FULL') db.exec('PRAGMA foreign_keys = ON') db.exec('PRAGMA busy_timeout = 5000') return db } export function migrate(db: DatabaseSync): { from: number; to: number } { const before = db.prepare('PRAGMA user_version').get() as { user_version: number } let current = before.user_version for (const m of MIGRATIONS) { if (m.version <= current) continue db.exec('BEGIN') try { m.up(db) db.exec(`PRAGMA user_version = ${m.version}`) db.exec('COMMIT') current = m.version } catch (err) { db.exec('ROLLBACK') throw err } } return { from: before.user_version, to: current } } export function schemaVersion(db: DatabaseSync): number { const row = db.prepare('PRAGMA user_version').get() as { user_version: number } return row.user_version }