import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import Database from 'better-sqlite3'; import type { KtxScanEnrichmentCompletedStage, KtxScanEnrichmentFailedStage, KtxScanEnrichmentStageLookup, KtxScanEnrichmentStageRecord, KtxScanEnrichmentStateStore, } from './enrichment-state.js'; import type { KtxScanEnrichmentStage, KtxScanMode } from './types.js'; export interface SqliteLocalScanEnrichmentStateStoreOptions { dbPath: string; } interface StageRow { run_id: string; connection_id: string; sync_id: string; mode: KtxScanMode; stage: KtxScanEnrichmentStage; input_hash: string; status: 'completed' | 'failed'; output_json: string | null; error_message: string | null; updated_at: string; } function parseStageRow(row: StageRow): KtxScanEnrichmentStageRecord { if (row.status === 'completed') { return { runId: row.run_id, connectionId: row.connection_id, syncId: row.sync_id, mode: row.mode, stage: row.stage, inputHash: row.input_hash, status: 'completed', output: JSON.parse(row.output_json ?? 'null') as TOutput, errorMessage: null, updatedAt: row.updated_at, }; } return { runId: row.run_id, connectionId: row.connection_id, syncId: row.sync_id, mode: row.mode, stage: row.stage, inputHash: row.input_hash, status: 'failed', output: null, errorMessage: row.error_message ?? 'Unknown enrichment stage failure', updatedAt: row.updated_at, }; } function isSafeRunId(runId: string): boolean { return /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(runId); } export class SqliteLocalScanEnrichmentStateStore implements KtxScanEnrichmentStateStore { private readonly db: Database.Database; constructor(options: SqliteLocalScanEnrichmentStateStoreOptions) { mkdirSync(dirname(options.dbPath), { recursive: true }); this.db = new Database(options.dbPath); this.db.pragma('journal_mode = WAL'); this.db.exec(` CREATE TABLE IF NOT EXISTS local_scan_enrichment_stages ( run_id TEXT NOT NULL, stage TEXT NOT NULL, input_hash TEXT NOT NULL, connection_id TEXT NOT NULL, sync_id TEXT NOT NULL, mode TEXT NOT NULL, status TEXT NOT NULL, output_json TEXT, error_message TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (run_id, stage) ); CREATE INDEX IF NOT EXISTS local_scan_enrichment_stages_run_idx ON local_scan_enrichment_stages (run_id, updated_at, stage); `); } async findCompletedStage( input: KtxScanEnrichmentStageLookup, ): Promise | null> { if (!isSafeRunId(input.runId)) { return null; } const row = this.db .prepare( ` SELECT * FROM local_scan_enrichment_stages WHERE run_id = ? AND stage = ? AND input_hash = ? AND status = 'completed' `, ) .get(input.runId, input.stage, input.inputHash) as StageRow | undefined; if (!row) { return null; } const parsed = parseStageRow(row); return parsed.status === 'completed' ? parsed : null; } async saveCompletedStage( input: Omit, 'status' | 'errorMessage'>, ): Promise { this.db .prepare( ` INSERT INTO local_scan_enrichment_stages ( run_id, stage, input_hash, connection_id, sync_id, mode, status, output_json, error_message, updated_at ) VALUES ( @runId, @stage, @inputHash, @connectionId, @syncId, @mode, 'completed', @outputJson, NULL, @updatedAt ) ON CONFLICT(run_id, stage) DO UPDATE SET input_hash = excluded.input_hash, connection_id = excluded.connection_id, sync_id = excluded.sync_id, mode = excluded.mode, status = excluded.status, output_json = excluded.output_json, error_message = excluded.error_message, updated_at = excluded.updated_at `, ) .run({ runId: input.runId, stage: input.stage, inputHash: input.inputHash, connectionId: input.connectionId, syncId: input.syncId, mode: input.mode, outputJson: JSON.stringify(input.output), updatedAt: input.updatedAt, }); } async saveFailedStage(input: Omit): Promise { this.db .prepare( ` INSERT INTO local_scan_enrichment_stages ( run_id, stage, input_hash, connection_id, sync_id, mode, status, output_json, error_message, updated_at ) VALUES ( @runId, @stage, @inputHash, @connectionId, @syncId, @mode, 'failed', NULL, @errorMessage, @updatedAt ) ON CONFLICT(run_id, stage) DO UPDATE SET input_hash = excluded.input_hash, connection_id = excluded.connection_id, sync_id = excluded.sync_id, mode = excluded.mode, status = excluded.status, output_json = excluded.output_json, error_message = excluded.error_message, updated_at = excluded.updated_at `, ) .run({ runId: input.runId, stage: input.stage, inputHash: input.inputHash, connectionId: input.connectionId, syncId: input.syncId, mode: input.mode, errorMessage: input.errorMessage, updatedAt: input.updatedAt, }); } async listRunStages(runId: string): Promise { if (!isSafeRunId(runId)) { return []; } const rows = this.db .prepare( ` SELECT * FROM local_scan_enrichment_stages WHERE run_id = ? ORDER BY updated_at ASC, stage ASC `, ) .all(runId) as StageRow[]; return rows.map((row) => parseStageRow(row)); } }