import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; const STATE_SCHEMA = 'stem-card-backend-state.v1'; export class CardStateDatabase { constructor(file) { this.file = path.resolve(file); mkdirSync(path.dirname(this.file), { recursive: true }); this.database = new DatabaseSync(this.file); this.database.exec(` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; CREATE TABLE IF NOT EXISTS card_state ( key TEXT PRIMARY KEY, schema_name TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL, value_json TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS card_event ( id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, actor TEXT NOT NULL, task_id TEXT, block_id TEXT, phase_id TEXT, step_id TEXT, snapshot_ref TEXT, evidence_ref TEXT, payload_json TEXT NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS card_event_occurred_at ON card_event(occurred_at, id); `); this.readStatement = this.database.prepare(` SELECT value_json FROM card_state WHERE key = ? `); this.writeStatement = this.database.prepare(` INSERT INTO card_state (key, schema_name, revision, updated_at, value_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET schema_name = excluded.schema_name, revision = excluded.revision, updated_at = excluded.updated_at, value_json = excluded.value_json `); this.appendEventStatement = this.database.prepare(` INSERT INTO card_event ( occurred_at, event_type, actor, task_id, block_id, phase_id, step_id, snapshot_ref, evidence_ref, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id `); this.listEventsStatement = this.database.prepare(` SELECT id, occurred_at, event_type, actor, task_id, block_id, phase_id, step_id, snapshot_ref, evidence_ref, payload_json FROM card_event WHERE id > ? ORDER BY id ASC LIMIT ? `); } read(key) { const row = this.readStatement.get(key); if (!row) return null; try { return JSON.parse(row.value_json); } catch { return null; } } write(key, value) { const revision = Number.isSafeInteger(Number(value?.revision)) ? Number(value.revision) : 0; const updatedAt = String(value?.updated_at ?? value?.selected_at ?? new Date().toISOString()); this.writeStatement.run( key, STATE_SCHEMA, revision, updatedAt, JSON.stringify(value) ); return value; } appendEvent({ eventType, actor = 'system', navigation = null, evidenceRef = null, payload = {}, occurredAt = new Date().toISOString() }) { const row = this.appendEventStatement.get( String(occurredAt), String(eventType), String(actor), navigation?.task?.id ?? null, navigation?.block?.id ?? null, navigation?.phase?.id ?? null, navigation?.step?.id ?? null, navigation?.snapshot_ref ?? null, evidenceRef == null ? null : String(evidenceRef), JSON.stringify(payload ?? {}) ); return { id: Number(row.id), occurred_at: String(occurredAt), event_type: String(eventType), actor: String(actor), task_id: navigation?.task?.id ?? null, block_id: navigation?.block?.id ?? null, phase_id: navigation?.phase?.id ?? null, step_id: navigation?.step?.id ?? null, snapshot_ref: navigation?.snapshot_ref ?? null, evidence_ref: evidenceRef == null ? null : String(evidenceRef), payload: payload ?? {} }; } listEvents({ afterId = 0, limit = 200 } = {}) { return this.listEventsStatement.all(Number(afterId), Number(limit)).map(row => { let payload = {}; try { payload = JSON.parse(row.payload_json); } catch { // Preserve the audit row even if an old payload cannot be decoded. } return { id: Number(row.id), occurred_at: row.occurred_at, event_type: row.event_type, actor: row.actor, task_id: row.task_id, block_id: row.block_id, phase_id: row.phase_id, step_id: row.step_id, snapshot_ref: row.snapshot_ref, evidence_ref: row.evidence_ref, payload }; }); } close() { this.database.close(); } } export { STATE_SCHEMA };