diff --git a/src/analyze/framework.ts b/src/analyze/framework.ts index c34e749..46c4419 100644 --- a/src/analyze/framework.ts +++ b/src/analyze/framework.ts @@ -84,6 +84,7 @@ import { upsertAnalyzerDef, upsertAnalyzerVersion, } from "../db/analysis-queries.js"; +import { prep } from "../db/prepared.js"; import { materializeProposalsFromNode, applyValidationFromNode } from "./proposal-materializer.js"; import { mapWithConcurrency } from "./concurrency.js"; @@ -731,8 +732,8 @@ function isDuplicateInputKey(err: unknown): boolean { } function db_loadMessages(db: Database.Database, sessionId: string): MessageRow[] { - return db - .prepare( + // Static SQL (two adjacent string literals) — stable text, so safe to cache. + return prep(db, "SELECT id, session_id, parent_id, timestamp, role, content_text, content_thinking, tool_calls, tool_results " + "FROM messages WHERE session_id = ? ORDER BY rowid ASC", ) diff --git a/src/db/analysis-queries.ts b/src/db/analysis-queries.ts index bfa57ab..b9fc244 100644 --- a/src/db/analysis-queries.ts +++ b/src/db/analysis-queries.ts @@ -8,6 +8,7 @@ */ import type Database from "better-sqlite3"; +import { prep } from "./prepared.js"; import type { AnalysisEdgeRow, AnalysisNodeRow, @@ -25,7 +26,7 @@ import { EDGE_KINDS, REF_KINDS } from "../analyze/edge-kinds.js"; // ───────────────────────── analyzer registry ───────────────────────── export function upsertAnalyzerDef(db: Database.Database, def: AnalyzerDef): void { - db.prepare(` + prep(db, ` INSERT INTO analyzer_defs (id, label, description, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -44,7 +45,7 @@ export function upsertAnalyzerDef(db: Database.Database, def: AnalyzerDef): void } export function upsertAnalyzerVersion(db: Database.Database, version: AnalyzerVersion): void { - db.prepare(` + prep(db, ` INSERT INTO analyzer_versions (analyzer_id, version_id, implementation_kind, code_ref, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(analyzer_id, version_id) DO NOTHING @@ -58,7 +59,7 @@ export function upsertAnalyzerVersion(db: Database.Database, version: AnalyzerVe } export function registerPrompt(db: Database.Database, prompt: PromptVersion): void { - db.prepare(` + prep(db, ` INSERT INTO prompt_registry (hash, content, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(hash) DO NOTHING @@ -74,8 +75,7 @@ export function resolveConfig( params: { analyzerId: string; configJson: Record; label?: string }, ): AnalyzerConfig { const configHash = computeConfigHash(params.configJson); - const existing = db - .prepare("SELECT id, analyzer_id, config_hash, config_json, label FROM analyzer_configs WHERE config_hash = ?") + const existing = prep(db, "SELECT id, analyzer_id, config_hash, config_json, label FROM analyzer_configs WHERE config_hash = ?") .get(configHash) as | { id: string; analyzer_id: string; config_hash: string; config_json: string; label: string | null } | undefined; @@ -91,7 +91,7 @@ export function resolveConfig( } const id = uuidv7(); - db.prepare(` + prep(db, ` INSERT INTO analyzer_configs (id, analyzer_id, config_hash, config_json, label, created_at) VALUES (?, ?, ?, ?, ?, ?) `).run(id, params.analyzerId, configHash, JSON.stringify(params.configJson), params.label ?? null, new Date().toISOString()); @@ -120,7 +120,7 @@ export function createRun( modelSpec?: string; }, ): void { - db.prepare(` + prep(db, ` INSERT INTO analysis_runs (id, analyzer_id, analyzer_version_id, config_id, session_id, mode, status, prompt_bundle_hash, model_spec, started_at) VALUES (?, ?, ?, ?, ?, ?, 'ok', ?, ?, ?) @@ -149,7 +149,7 @@ export function finishRun( errorMessage?: string | null; }, ): void { - db.prepare(` + prep(db, ` UPDATE analysis_runs SET status = ?, finished_at = ?, nodes_produced = ?, nodes_skipped = ?, cost_usd = ?, tokens_used = ?, error_message = ? @@ -167,7 +167,7 @@ export function finishRun( } export function getRun(db: Database.Database, runId: string): AnalysisRunRow | undefined { - return db.prepare("SELECT * FROM analysis_runs WHERE id = ?").get(runId) as AnalysisRunRow | undefined; + return prep(db, "SELECT * FROM analysis_runs WHERE id = ?").get(runId) as AnalysisRunRow | undefined; } // ───────────────────────── nodes ───────────────────────── @@ -194,7 +194,7 @@ export function insertNode( createdAt: string; }, ): void { - db.prepare(` + prep(db, ` INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_key, output_key, config_fingerprint, model_used, cost_usd, tokens_used, duration_ms, created_at) @@ -221,7 +221,7 @@ export function insertNode( } export function getNode(db: Database.Database, id: string): AnalysisNodeRow | undefined { - return db.prepare("SELECT * FROM analysis_nodes WHERE id = ?").get(id) as AnalysisNodeRow | undefined; + return prep(db, "SELECT * FROM analysis_nodes WHERE id = ?").get(id) as AnalysisNodeRow | undefined; } /** @@ -233,12 +233,12 @@ export function getNode(db: Database.Database, id: string): AnalysisNodeRow | un */ export function getNodeByOutputKey(db: Database.Database, outputKey: string): AnalysisNodeRow | undefined { if (!outputKey) return undefined; - return db.prepare("SELECT * FROM analysis_nodes WHERE output_key = ? LIMIT 1").get(outputKey) as AnalysisNodeRow | undefined; + return prep(db, "SELECT * FROM analysis_nodes WHERE output_key = ? LIMIT 1").get(outputKey) as AnalysisNodeRow | undefined; } /** Idempotency lookup: a node produced by an exact recipe over an exact source set. */ export function findNodeByInputKey(db: Database.Database, inputKey: string): AnalysisNodeRow | undefined { - return db.prepare("SELECT * FROM analysis_nodes WHERE input_key = ?").get(inputKey) as AnalysisNodeRow | undefined; + return prep(db, "SELECT * FROM analysis_nodes WHERE input_key = ?").get(inputKey) as AnalysisNodeRow | undefined; } /** @@ -251,26 +251,24 @@ export function findLatestNodeBySourceSet( analyzerId: string, sourceSetHash: string, ): AnalysisNodeRow | undefined { - return db - .prepare( + return prep(db, "SELECT * FROM analysis_nodes WHERE analyzer_id = ? AND source_set_hash = ? AND node_kind != 'error' ORDER BY created_at DESC, rowid DESC LIMIT 1", ) .get(analyzerId, sourceSetHash) as AnalysisNodeRow | undefined; } export function getSessionNodes(db: Database.Database, sessionId: string): AnalysisNodeRow[] { - return db.prepare("SELECT * FROM analysis_nodes WHERE session_id = ? ORDER BY created_at ASC, rowid ASC").all(sessionId) as AnalysisNodeRow[]; + return prep(db, "SELECT * FROM analysis_nodes WHERE session_id = ? ORDER BY created_at ASC, rowid ASC").all(sessionId) as AnalysisNodeRow[]; } /** Every analysis node, for integrity verification. */ export function getAllAnalysisNodes(db: Database.Database): AnalysisNodeRow[] { - return db.prepare("SELECT * FROM analysis_nodes ORDER BY created_at ASC, rowid ASC").all() as AnalysisNodeRow[]; + return prep(db, "SELECT * FROM analysis_nodes ORDER BY created_at ASC, rowid ASC").all() as AnalysisNodeRow[]; } /** A session's messages in stream order — for reconstructing turns verbatim. */ export function getSessionMessageRows(db: Database.Database, sessionId: string): MessageRow[] { - return db - .prepare( + return prep(db, "SELECT id, session_id, parent_id, timestamp, role, content_text, content_thinking, tool_calls, tool_results " + "FROM messages WHERE session_id = ? ORDER BY rowid ASC", ) @@ -278,8 +276,7 @@ export function getSessionMessageRows(db: Database.Database, sessionId: string): } export function getNodesByAnalyzer(db: Database.Database, analyzerId: string, sessionId: string): AnalysisNodeRow[] { - return db - .prepare("SELECT * FROM analysis_nodes WHERE analyzer_id = ? AND session_id = ? ORDER BY created_at ASC, rowid ASC") + return prep(db, "SELECT * FROM analysis_nodes WHERE analyzer_id = ? AND session_id = ? ORDER BY created_at ASC, rowid ASC") .all(analyzerId, sessionId) as AnalysisNodeRow[]; } @@ -295,8 +292,7 @@ export function getNodesByAnalyzer(db: Database.Database, analyzerId: string, se * `source_set_hash`, newest first, errors excluded. */ export function getLatestNodesByAnalyzerAcrossSessions(db: Database.Database, analyzerId: string): AnalysisNodeRow[] { - return db - .prepare( + return prep(db, `SELECT * FROM analysis_nodes n WHERE n.analyzer_id = ? AND n.node_kind != 'error' @@ -319,36 +315,33 @@ export function insertEdge( db: Database.Database, edge: { fromNodeId: string; toRefKind: string; toRefId: string; edgeKind: string; ordinal: number }, ): void { - db.prepare(` + prep(db, ` INSERT INTO analysis_edges (id, from_node_id, to_ref_kind, to_ref_id, edge_kind, ordinal) VALUES (?, ?, ?, ?, ?, ?) `).run(uuidv7(), edge.fromNodeId, edge.toRefKind, edge.toRefId, edge.edgeKind, edge.ordinal); } export function getEdgesFrom(db: Database.Database, nodeId: string): AnalysisEdgeRow[] { - return db.prepare("SELECT * FROM analysis_edges WHERE from_node_id = ? ORDER BY ordinal ASC").all(nodeId) as AnalysisEdgeRow[]; + return prep(db, "SELECT * FROM analysis_edges WHERE from_node_id = ? ORDER BY ordinal ASC").all(nodeId) as AnalysisEdgeRow[]; } export function getEdgesTo(db: Database.Database, toRefId: string, edgeKind?: string): AnalysisEdgeRow[] { if (edgeKind) { - return db - .prepare("SELECT * FROM analysis_edges WHERE to_ref_id = ? AND edge_kind = ?") + return prep(db, "SELECT * FROM analysis_edges WHERE to_ref_id = ? AND edge_kind = ?") .all(toRefId, edgeKind) as AnalysisEdgeRow[]; } - return db.prepare("SELECT * FROM analysis_edges WHERE to_ref_id = ?").all(toRefId) as AnalysisEdgeRow[]; + return prep(db, "SELECT * FROM analysis_edges WHERE to_ref_id = ?").all(toRefId) as AnalysisEdgeRow[]; } /** Message ids that a node anchors to (via `anchors` edges with message targets). */ export function getAnchoredMessageIds(db: Database.Database, nodeId: string): string[] { - const rows = db - .prepare("SELECT to_ref_id FROM analysis_edges WHERE from_node_id = ? AND edge_kind = ? AND to_ref_kind = ?") + const rows = prep(db, "SELECT to_ref_id FROM analysis_edges WHERE from_node_id = ? AND edge_kind = ? AND to_ref_kind = ?") .all(nodeId, EDGE_KINDS.ANCHORS, REF_KINDS.MESSAGE) as Array<{ to_ref_id: string }>; return rows.map((r) => r.to_ref_id); } export function getMessage(db: Database.Database, id: string): MessageRow | undefined { - return db - .prepare( + return prep(db, "SELECT id, session_id, parent_id, timestamp, role, content_text, content_thinking, tool_calls, tool_results FROM messages WHERE id = ?", ) .get(id) as MessageRow | undefined; @@ -366,8 +359,7 @@ export function getNodeVersions( analyzerId: string, sourceSetHash: string, ): AnalysisNodeRow[] { - return db - .prepare( + return prep(db, "SELECT * FROM analysis_nodes WHERE analyzer_id = ? AND source_set_hash = ? ORDER BY created_at ASC, rowid ASC", ) .all(analyzerId, sourceSetHash) as AnalysisNodeRow[]; @@ -375,8 +367,7 @@ export function getNodeVersions( /** The node that `nodeId` revises (its immediate older-version predecessor), if any. */ export function getRevisedNode(db: Database.Database, nodeId: string): AnalysisNodeRow | undefined { - const edge = db - .prepare("SELECT to_ref_id FROM analysis_edges WHERE from_node_id = ? AND edge_kind = ? LIMIT 1") + const edge = prep(db, "SELECT to_ref_id FROM analysis_edges WHERE from_node_id = ? AND edge_kind = ? LIMIT 1") .get(nodeId, EDGE_KINDS.REVISES) as { to_ref_id: string } | undefined; if (!edge) return undefined; // `revises` edges reference the predecessor's content-addressed output_key. @@ -388,8 +379,7 @@ export function getRevisions(db: Database.Database, nodeId: string): AnalysisNod // `revises` edges point at the predecessor's output_key, so match on that. const node = getNode(db, nodeId); if (!node || !node.output_key) return []; - const edges = db - .prepare("SELECT from_node_id FROM analysis_edges WHERE to_ref_id = ? AND edge_kind = ?") + const edges = prep(db, "SELECT from_node_id FROM analysis_edges WHERE to_ref_id = ? AND edge_kind = ?") .all(node.output_key, EDGE_KINDS.REVISES) as Array<{ from_node_id: string }>; const out: AnalysisNodeRow[] = []; for (const e of edges) { @@ -409,10 +399,10 @@ export interface AnalysisStats { } export function getAnalysisStats(db: Database.Database): AnalysisStats { - const nodes = (db.prepare("SELECT COUNT(*) AS c FROM analysis_nodes").get() as { c: number }).c; - const edges = (db.prepare("SELECT COUNT(*) AS c FROM analysis_edges").get() as { c: number }).c; - const runs = (db.prepare("SELECT COUNT(*) AS c FROM analysis_runs").get() as { c: number }).c; - const kindRows = db.prepare("SELECT node_kind, COUNT(*) AS c FROM analysis_nodes GROUP BY node_kind").all() as Array<{ + const nodes = (prep(db, "SELECT COUNT(*) AS c FROM analysis_nodes").get() as { c: number }).c; + const edges = (prep(db, "SELECT COUNT(*) AS c FROM analysis_edges").get() as { c: number }).c; + const runs = (prep(db, "SELECT COUNT(*) AS c FROM analysis_runs").get() as { c: number }).c; + const kindRows = prep(db, "SELECT node_kind, COUNT(*) AS c FROM analysis_nodes GROUP BY node_kind").all() as Array<{ node_kind: string; c: number; }>; diff --git a/src/db/prepared.ts b/src/db/prepared.ts new file mode 100644 index 0000000..a83db69 --- /dev/null +++ b/src/db/prepared.ts @@ -0,0 +1,36 @@ +import type Database from "better-sqlite3"; +import type { Statement } from "better-sqlite3"; + +/** + * Prepared-statement cache, keyed per `Database` connection. + * + * better-sqlite3 `Statement`s are bound to the connection they were prepared + * on, so the cache must be per-instance rather than module-global: a statement + * prepared on a closed database must never be handed to a later connection + * (tests create a fresh temp DB per case, so a module-level `Map` + * would leak across cases). We key a WeakMap by the `Database` object, and each + * connection lazily grows its own `Map` on first use. + * + * Population is lazy and every caller runs after `migrate()`, so the cache is + * only ever filled once the schema is final — a cached statement can never + * capture a pre-migration query plan. There is deliberately no + * `initializeStatementCache(db)` hook to call after migration: because the map + * fills on first use, there is simply nothing for a new connection path to + * forget, and no init call that could be omitted. + */ +const statementCache = new WeakMap>(); + +/** Return a cached, connection-bound prepared statement, preparing on miss. */ +export function prep(db: Database.Database, sql: string): Statement { + let cache = statementCache.get(db); + if (!cache) { + cache = new Map(); + statementCache.set(db, cache); + } + let stmt = cache.get(sql); + if (!stmt) { + stmt = db.prepare(sql); + cache.set(sql, stmt); + } + return stmt; +} diff --git a/src/db/queries.ts b/src/db/queries.ts index ebda883..0c14021 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import { prep } from "./prepared.js"; import type { Proposal, ProposalStatus, @@ -30,7 +31,7 @@ export interface SessionInsert { } export function upsertSession(db: Database.Database, s: SessionInsert): void { - db.prepare(` + prep(db, ` INSERT INTO sessions (id, file_path, project, source, cwd, parent_session, started_at, last_line, last_modified, analyzed_at, message_count, branch_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -42,33 +43,33 @@ export function upsertSession(db: Database.Database, s: SessionInsert): void { } export function getCursor(db: Database.Database, filePath: string): { last_line: number; last_modified: number } | undefined { - return db.prepare("SELECT last_line, last_modified FROM sessions WHERE file_path = ?").get(filePath) as { last_line: number; last_modified: number } | undefined; + return prep(db, "SELECT last_line, last_modified FROM sessions WHERE file_path = ?").get(filePath) as { last_line: number; last_modified: number } | undefined; } export function updateCursor(db: Database.Database, sessionId: string, lastLine: number, lastModified: number): void { - db.prepare("UPDATE sessions SET last_line = ?, last_modified = ? WHERE id = ?").run(lastLine, lastModified, sessionId); + prep(db, "UPDATE sessions SET last_line = ?, last_modified = ? WHERE id = ?").run(lastLine, lastModified, sessionId); } export function updateMessageCount(db: Database.Database, sessionId: string, count: number): void { - db.prepare("UPDATE sessions SET message_count = ? WHERE id = ?").run(count, sessionId); + prep(db, "UPDATE sessions SET message_count = ? WHERE id = ?").run(count, sessionId); } export function markAnalyzed(db: Database.Database, sessionId: string): void { - db.prepare("UPDATE sessions SET analyzed_at = ? WHERE id = ?").run(new Date().toISOString(), sessionId); + prep(db, "UPDATE sessions SET analyzed_at = ? WHERE id = ?").run(new Date().toISOString(), sessionId); } export function getUnanalyzedSessions(db: Database.Database, limit?: number): Array<{ id: string; file_path: string; started_at: string }> { const sql = limit ? "SELECT id, file_path, started_at FROM sessions WHERE analyzed_at IS NULL ORDER BY started_at ASC LIMIT ?" : "SELECT id, file_path, started_at FROM sessions WHERE analyzed_at IS NULL ORDER BY started_at ASC"; - return (limit ? db.prepare(sql).all(limit) : db.prepare(sql).all()) as Array<{ id: string; file_path: string; started_at: string }>; + return (limit ? prep(db, sql).all(limit) : prep(db, sql).all()) as Array<{ id: string; file_path: string; started_at: string }>; } export function getAllSessions(db: Database.Database, limit?: number): Array<{ id: string; file_path: string; started_at: string }> { const sql = limit ? "SELECT id, file_path, started_at FROM sessions ORDER BY started_at ASC LIMIT ?" : "SELECT id, file_path, started_at FROM sessions ORDER BY started_at ASC"; - return (limit ? db.prepare(sql).all(limit) : db.prepare(sql).all()) as Array<{ id: string; file_path: string; started_at: string }>; + return (limit ? prep(db, sql).all(limit) : prep(db, sql).all()) as Array<{ id: string; file_path: string; started_at: string }>; } export interface SessionLabel { @@ -80,7 +81,7 @@ export interface SessionLabel { /** Lightweight labels (project/cwd/message_count) for every session, for display. */ export function getSessionLabels(db: Database.Database): SessionLabel[] { - return db.prepare("SELECT id, project, cwd, message_count FROM sessions").all() as SessionLabel[]; + return prep(db, "SELECT id, project, cwd, message_count FROM sessions").all() as SessionLabel[]; } // ── Messages ── @@ -100,18 +101,18 @@ export interface MessageInsert { } export function insertMessage(db: Database.Database, m: MessageInsert): void { - db.prepare(` + prep(db, ` INSERT OR IGNORE INTO messages (id, session_id, source, parent_id, timestamp, role, content_text, content_thinking, tool_calls, tool_results, usage, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(m.id, m.session_id, m.source, m.parent_id, m.timestamp, m.role, m.content_text, m.content_thinking, m.tool_calls, m.tool_results, m.usage, null); } export function countMessages(db: Database.Database, sessionId: string): number { - return (db.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ?").get(sessionId) as { c: number }).c; + return (prep(db, "SELECT COUNT(*) as c FROM messages WHERE session_id = ?").get(sessionId) as { c: number }).c; } export function getSessionMessages(db: Database.Database, sessionId: string): Array<{ role: string; content_text: string | null; content_thinking: string | null; tool_calls: string | null; timestamp: string | null }> { - return db.prepare("SELECT role, content_text, content_thinking, tool_calls, timestamp FROM messages WHERE session_id = ? ORDER BY rowid ASC").all(sessionId) as any[]; + return prep(db, "SELECT role, content_text, content_thinking, tool_calls, timestamp FROM messages WHERE session_id = ? ORDER BY rowid ASC").all(sessionId) as any[]; } // ── Proposals (v2) ── @@ -137,11 +138,11 @@ export function listProposals(db: Database.Database, status?: string, severity?: params.push(offset); } } - return db.prepare(sql).all(...params) as Proposal[]; + return prep(db, sql).all(...params) as Proposal[]; } export function getProposal(db: Database.Database, id: string): Proposal | undefined { - return db.prepare("SELECT * FROM proposals WHERE id = ?").get(id) as Proposal | undefined; + return prep(db, "SELECT * FROM proposals WHERE id = ?").get(id) as Proposal | undefined; } /** Optional human feedback recorded alongside an accept/reject. */ @@ -167,14 +168,14 @@ function decideProposal( input?: DecisionInput, remediationId?: string | null, ): boolean { - const row = db.prepare("SELECT input_key, status FROM proposals WHERE id = ?").get(id) as + const row = prep(db, "SELECT input_key, status FROM proposals WHERE id = ?").get(id) as | { input_key: string; status: string } | undefined; if (!row || row.status !== "open") return false; const now = new Date().toISOString(); const tx = db.transaction(() => { - db.prepare("UPDATE proposals SET status = ?, updated_at = ? WHERE id = ?").run(newStatus, now, id); - db.prepare( + prep(db, "UPDATE proposals SET status = ?, updated_at = ? WHERE id = ?").run(newStatus, now, id); + prep(db, "INSERT INTO proposal_decisions " + "(id, proposal_input_key, decision, disposition, rationale, actual_change, harness_ref, remediation_id, decided_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -293,13 +294,13 @@ export function acceptProposalsWithRemediation( const tx = db.transaction(() => { const open = new Set( proposalIds.filter((id) => { - const row = db.prepare("SELECT status FROM proposals WHERE id = ?").get(id) as { status: string } | undefined; + const row = prep(db, "SELECT status FROM proposals WHERE id = ?").get(id) as { status: string } | undefined; return row?.status === "open"; }), ); if (open.size > 0) { remediationId = uuidv7(); - db.prepare("INSERT INTO remediations (id, description, actual_change, created_at) VALUES (?, ?, ?, ?)").run( + prep(db, "INSERT INTO remediations (id, description, actual_change, created_at) VALUES (?, ?, ?, ?)").run( remediationId, remediation.description, remediation.actual_change ?? null, @@ -319,13 +320,12 @@ export function acceptProposalsWithRemediation( } export function getRemediation(db: Database.Database, id: string): Remediation | undefined { - return db.prepare("SELECT * FROM remediations WHERE id = ?").get(id) as Remediation | undefined; + return prep(db, "SELECT * FROM remediations WHERE id = ?").get(id) as Remediation | undefined; } /** Every decision made under one remediation, oldest first. */ export function getDecisionsForRemediation(db: Database.Database, remediationId: string): ProposalDecision[] { - return db - .prepare("SELECT * FROM proposal_decisions WHERE remediation_id = ? ORDER BY decided_at ASC, rowid ASC") + return prep(db, "SELECT * FROM proposal_decisions WHERE remediation_id = ? ORDER BY decided_at ASC, rowid ASC") .all(remediationId) as ProposalDecision[]; } @@ -333,36 +333,32 @@ export function getDecisionsForRemediation(db: Database.Database, remediationId: /** The latest (authoritative) decision for a proposal's input_key, if any. */ export function getLatestDecision(db: Database.Database, proposalInputKey: string): ProposalDecision | undefined { - return db - .prepare("SELECT * FROM proposal_decisions WHERE proposal_input_key = ? ORDER BY decided_at DESC, rowid DESC LIMIT 1") + return prep(db, "SELECT * FROM proposal_decisions WHERE proposal_input_key = ? ORDER BY decided_at DESC, rowid DESC LIMIT 1") .get(proposalInputKey) as ProposalDecision | undefined; } /** Full decision history for one proposal, oldest first. */ export function getDecisionsForProposal(db: Database.Database, proposalInputKey: string): ProposalDecision[] { - return db - .prepare("SELECT * FROM proposal_decisions WHERE proposal_input_key = ? ORDER BY decided_at ASC, rowid ASC") + return prep(db, "SELECT * FROM proposal_decisions WHERE proposal_input_key = ? ORDER BY decided_at ASC, rowid ASC") .all(proposalInputKey) as ProposalDecision[]; } /** Every decision, newest first — the corpus the future meta-analyzer consumes. */ export function getAllDecisions(db: Database.Database): ProposalDecision[] { - return db.prepare("SELECT * FROM proposal_decisions ORDER BY decided_at DESC, rowid DESC").all() as ProposalDecision[]; + return prep(db, "SELECT * FROM proposal_decisions ORDER BY decided_at DESC, rowid DESC").all() as ProposalDecision[]; } // ── Proposal validation (issue #6) ── /** Open proposals for a session, in stable order — the input to proposal-validate. */ export function listOpenProposalsForSession(db: Database.Database, sessionId: string): Proposal[] { - return db - .prepare("SELECT * FROM proposals WHERE session_id = ? AND status = 'open' ORDER BY created_at ASC, rowid ASC") + return prep(db, "SELECT * FROM proposals WHERE session_id = ? AND status = 'open' ORDER BY created_at ASC, rowid ASC") .all(sessionId) as Proposal[]; } /** Distinct session ids that currently have at least one open proposal to validate. */ export function listSessionIdsWithOpenProposals(db: Database.Database, limit?: number): string[] { - const rows = db - .prepare("SELECT DISTINCT session_id FROM proposals WHERE status = 'open' ORDER BY session_id") + const rows = prep(db, "SELECT DISTINCT session_id FROM proposals WHERE status = 'open' ORDER BY session_id") .all() as Array<{ session_id: string }>; const ids = rows.map((r) => r.session_id); return typeof limit === "number" ? ids.slice(0, limit) : ids; @@ -370,8 +366,7 @@ export function listSessionIdsWithOpenProposals(db: Database.Database, limit?: n /** Count open proposals grouped by validation status, for a run summary. */ export function countOpenProposalsByValidationStatus(db: Database.Database): Record { - const rows = db - .prepare("SELECT validation_status AS s, COUNT(*) AS c FROM proposals WHERE status = 'open' GROUP BY validation_status") + const rows = prep(db, "SELECT validation_status AS s, COUNT(*) AS c FROM proposals WHERE status = 'open' GROUP BY validation_status") .all() as Array<{ s: string; c: number }>; const out: Record = {}; for (const r of rows) out[r.s] = r.c; @@ -381,16 +376,16 @@ export function countOpenProposalsByValidationStatus(db: Database.Database): Rec // ── Stats ── export function getStats(db: Database.Database): Stats { - const totalSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions").get() as { c: number }).c; - const piSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source = 'pi'").get() as { c: number }).c; - const claudeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source = 'claude'").get() as { c: number }).c; - const totalMessages = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant')").get() as { c: number }).c; - const piMessages = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant') AND source = 'pi'").get() as { c: number }).c; - const claudeMessages = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant') AND source = 'claude'").get() as { c: number }).c; - const totalToolResults = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role = 'toolResult'").get() as { c: number }).c; - const sessionsAnalyzed = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE analyzed_at IS NOT NULL").get() as { c: number }).c; - - const statusRows = db.prepare("SELECT status, COUNT(*) as c FROM proposals GROUP BY status").all() as Array<{ status: string; c: number }>; + const totalSessions = (prep(db, "SELECT COUNT(*) as c FROM sessions").get() as { c: number }).c; + const piSessions = (prep(db, "SELECT COUNT(*) as c FROM sessions WHERE source = 'pi'").get() as { c: number }).c; + const claudeSessions = (prep(db, "SELECT COUNT(*) as c FROM sessions WHERE source = 'claude'").get() as { c: number }).c; + const totalMessages = (prep(db, "SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant')").get() as { c: number }).c; + const piMessages = (prep(db, "SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant') AND source = 'pi'").get() as { c: number }).c; + const claudeMessages = (prep(db, "SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant') AND source = 'claude'").get() as { c: number }).c; + const totalToolResults = (prep(db, "SELECT COUNT(*) as c FROM messages WHERE role = 'toolResult'").get() as { c: number }).c; + const sessionsAnalyzed = (prep(db, "SELECT COUNT(*) as c FROM sessions WHERE analyzed_at IS NOT NULL").get() as { c: number }).c; + + const statusRows = prep(db, "SELECT status, COUNT(*) as c FROM proposals GROUP BY status").all() as Array<{ status: string; c: number }>; const proposalsByStatus: Record = { open: 0, applied: 0, rejected: 0, duplicate: 0 }; for (const r of statusRows) { if (r.status === "open" || r.status === "applied" || r.status === "rejected" || r.status === "duplicate") { @@ -427,7 +422,7 @@ export function getTokenStats(db: Database.Database): SourceTokenStats { const params: unknown[] = source ? [source] : []; // Count turns and tool calls for assistant messages that have usage - const row = db.prepare(` + const row = prep(db, ` SELECT COUNT(*) as turnCount, COALESCE(SUM(json_extract(usage, '$.input')), 0) as totalInput, @@ -450,7 +445,7 @@ export function getTokenStats(db: Database.Database): SourceTokenStats { }; // Count tool calls from tool_calls JSON array - const tcRow = db.prepare(` + const tcRow = prep(db, ` SELECT COALESCE(SUM(CASE WHEN tool_calls IS NOT NULL AND tool_calls != '[]' diff --git a/tests/component/prepared.test.ts b/tests/component/prepared.test.ts new file mode 100644 index 0000000..7152153 --- /dev/null +++ b/tests/component/prepared.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { prep } from "../../src/db/prepared.js"; + +describe("prepared statement cache (prep)", () => { + it("reuses the same Statement for identical SQL on the same connection", () => { + const db = new Database(":memory:"); + const a = prep(db, "SELECT 1"); + const b = prep(db, "SELECT 1"); + assert.equal(a, b, "cache should return the identical Statement object"); + db.close(); + }); + + it("keeps distinct caches per connection (no cross-DB statement leakage)", () => { + const sql = "SELECT 1"; + const db1 = new Database(":memory:"); + const db2 = new Database(":memory:"); + const s1 = prep(db1, sql); + const s2 = prep(db2, sql); + assert.notEqual(s1, s2, "each connection must have its own prepared Statement"); + db1.close(); + db2.close(); + }); + + it("a fresh connection never receives a statement prepared on a closed one", () => { + const closed = new Database(":memory:"); + prep(closed, "SELECT 1"); + closed.close(); + + // A new connection sharing the identical SQL must still work — it must + // not be handed the closed connection's statement. + const fresh = new Database(":memory:"); + const stmt = prep(fresh, "SELECT 1"); + assert.deepEqual(stmt.get(), { "1": 1 }); + fresh.close(); + }); + + it("prepared statements run correctly through the cache", () => { + const db = new Database(":memory:"); + const stmt = prep(db, "SELECT ? AS val"); + assert.deepEqual(stmt.get(42), { val: 42 }); + db.close(); + }); +});