/* RFC-0382: SQLite-backed cache layer using better-sqlite3. Provides persistent, file-backed caching with WAL mode and busy timeout for concurrent access. Self-healing: corrupt DB is deleted and recreated on open error. Do not implement RFC-specific cache helpers — that lives in rfc-cache.ts. Do not handle cache invalidation logic — callers check mtime + contentHash. SQLite is the persistent backend — it survives process restarts unlike the in-memory layer. ADR-0023: add close() method for explicit SQLite connection cleanup after pipeline completion. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1133: cache direct executeKernelCommand executions with flag-keyed results RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-0382 */ import { dirname } from "node:path"; import { createRequire } from "node:module"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import type { CacheEntry, CacheEntryInfo, CacheLayer, CacheListFilter, CacheNamespaceStatus, CacheStatus, } from "./cache-layer.ts"; import { parseCommandResultCacheKey } from "./command-result-cache.ts"; const require_ = createRequire(import.meta.url); const SCHEMA_DDL = ` CREATE TABLE IF NOT EXISTS cache_entries ( namespace TEXT NOT NULL, key TEXT NOT NULL, data TEXT NOT NULL, mtime INTEGER NOT NULL, content_hash TEXT NOT NULL, schema_version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL, PRIMARY KEY (namespace, key) ); CREATE TABLE IF NOT EXISTS cache_stats ( namespace TEXT NOT NULL, key TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, misses INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (namespace, key) ); `; interface CacheRow { data: string; mtime: number; content_hash: string; schema_version: number; updated_at: number; } interface NamespaceCountRow { namespace: string; entries: number; } interface StatsAggregateRow { total_hits: number | null; total_misses: number | null; } export class SqliteCacheLayer implements CacheLayer { readonly available = true; readonly unavailableReason: undefined; private readonly dbPath: string; private readonly io: WorkspaceIO; private db!: import("better-sqlite3").Database; private constructor(dbPath: string, io: WorkspaceIO) { this.dbPath = dbPath; this.io = io; } private openDb(): void { const BetterSqlite3 = require_("better-sqlite3") as typeof import("better-sqlite3"); this.db = new BetterSqlite3(this.dbPath); this.db.pragma("journal_mode = WAL"); this.db.pragma("busy_timeout = 5000"); this.db.exec(SCHEMA_DDL); } static async create( dbPath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const layer = new SqliteCacheLayer(dbPath, io); try { await io.mkdir(dirname(dbPath)); layer.openDb(); } catch (err) { // Self-healing: delete corrupt DB and retry once if (await io.exists(dbPath)) { try { await io.rm(dbPath); } catch { // ignore unlink errors } } await io.mkdir(dirname(dbPath)); layer.openDb(); if (err instanceof Error) { // pipeline-log-ok: self-healing corrupt cache DB — operational runtime warning, not a pipeline diagnostic console.warn(`cache DB was corrupt and has been recreated: ${err.message}`); } } return layer; } async get(namespace: string, key: string): Promise { const row = this.db .prepare<[string, string]>( "SELECT data, mtime, content_hash, schema_version, updated_at FROM cache_entries WHERE namespace = ? AND key = ?", ) .get(namespace, key) as CacheRow | undefined; if (!row) { this.incrementMiss(namespace, key); return null; } this.incrementHit(namespace, key); return { key, data: JSON.parse(row.data), mtime: row.mtime, contentHash: row.content_hash, updatedAt: row.updated_at, }; } async set( namespace: string, key: string, data: unknown, mtime: number, contentHash: string, ): Promise { const dataJson = JSON.stringify(data); const now = Date.now(); this.db .prepare<[string, string, string, number, string, number]>( `INSERT INTO cache_entries (namespace, key, data, mtime, content_hash, schema_version, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?) ON CONFLICT(namespace, key) DO UPDATE SET data = excluded.data, mtime = excluded.mtime, content_hash = excluded.content_hash, schema_version = excluded.schema_version, updated_at = excluded.updated_at`, ) .run(namespace, key, dataJson, mtime, contentHash, now); } async clear(namespace?: string): Promise { if (namespace) { this.db.prepare("DELETE FROM cache_entries WHERE namespace = ?").run(namespace); this.db.prepare("DELETE FROM cache_stats WHERE namespace = ?").run(namespace); } else { this.db.exec("DELETE FROM cache_entries"); this.db.exec("DELETE FROM cache_stats"); } } async status(): Promise { let dbSizeBytes = 0; try { dbSizeBytes = (await this.io.stat(this.dbPath)).size; } catch { dbSizeBytes = 0; } const namespaceRows = this.db .prepare<[]>("SELECT namespace, COUNT(*) as entries FROM cache_entries GROUP BY namespace") .all() as NamespaceCountRow[]; const namespaces: CacheNamespaceStatus[] = []; for (const ns of namespaceRows) { const statsRow = this.db .prepare<[string]>( "SELECT COALESCE(SUM(hits), 0) as total_hits, COALESCE(SUM(misses), 0) as total_misses FROM cache_stats WHERE namespace = ?", ) .get(ns.namespace) as StatsAggregateRow; const totalHits = statsRow?.total_hits ?? 0; const totalMisses = statsRow?.total_misses ?? 0; const total = totalHits + totalMisses; const hitRatio = total > 0 ? totalHits / total : 0; namespaces.push({ name: ns.namespace, entries: ns.entries, hitRatio, }); } return { available: true, dbPath: this.dbPath, dbSizeBytes, namespaces, }; } private incrementHit(namespace: string, key: string): void { this.db .prepare<[string, string]>( `INSERT INTO cache_stats (namespace, key, hits, misses) VALUES (?, ?, 1, 0) ON CONFLICT(namespace, key) DO UPDATE SET hits = hits + 1`, ) .run(namespace, key); } private incrementMiss(namespace: string, key: string): void { this.db .prepare<[string, string]>( `INSERT INTO cache_stats (namespace, key, hits, misses) VALUES (?, ?, 0, 1) ON CONFLICT(namespace, key) DO UPDATE SET misses = misses + 1`, ) .run(namespace, key); } async close(): Promise { this.db.close(); } async list(filter?: CacheListFilter): Promise { let sql = "SELECT namespace, key, updated_at FROM cache_entries"; const conditions: string[] = []; const params: string[] = []; if (filter?.namespace) { conditions.push("namespace = ?"); params.push(filter.namespace); } if (filter?.commandName) { conditions.push("key LIKE ?"); params.push(`%:${filter.commandName}:%`); } if (conditions.length > 0) { sql += " WHERE " + conditions.join(" AND "); } sql += " ORDER BY updated_at DESC"; const rows = this.db.prepare(sql).all(...params) as { namespace: string; key: string; updated_at: number; }[]; const results: CacheEntryInfo[] = []; for (const row of rows) { const parsed = parseCommandResultCacheKey(row.namespace, row.key); if (!parsed) continue; const statsRow = this.db .prepare<[string, string]>("SELECT hits FROM cache_stats WHERE namespace = ? AND key = ?") .get(row.namespace, row.key) as { hits: number } | undefined; results.push({ namespace: row.namespace, key: row.key, commandName: parsed.commandName, siteName: parsed.siteName, inputsHash: parsed.inputsHash, moduleHash: parsed.moduleHash, flagsHash: parsed.flagsHash, cachedAt: row.updated_at, hitCount: statsRow?.hits ?? 0, }); } return results; } }