@remnic/coding-graph 9.3.759

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Coding-graph SQLite schema — versioned meta + tables + FTS5 virtual table.
3
+ *
4
+ * Issue #1552 PR1 (Track B, Phase 1). The schema + write pipeline only;
5
+ * traversal, search, dead-code and the openCypher subset land in PR2/PR3.
6
+ *
7
+ * PR2 additive table (`node_attributes`): tracks per-node exclusion flags
8
+ * consumed by `deadCode()` — `is_exported`, `is_route_handler`. Added in
9
+ * PR2 (issue #1552 step 5) as a SEPARATE table rather than ALTER TABLE on
10
+ * `nodes`, so existing v1 databases gain the table via the same
11
+ * `CREATE TABLE IF NOT EXISTS` pass without a schema-version bump or a
12
+ * migration (rule 23 — additive, characterized before moving).
13
+ *
14
+ * Design anchors:
15
+ * - `packages/remnic-core/src/lcm/schema.ts` (versioning + pragmas) —
16
+ * copied VERBATIM for the WAL / busy_timeout / synchronous pragmas and
17
+ * the meta-table version row. Do not invent a new pattern (rule 23/38).
18
+ * - `packages/remnic-core/src/runtime/better-sqlite.ts` (`openBetterSqlite3`)
19
+ * — the shared opener; native-binding lifecycle is paid for once there.
20
+ * - issue://1552 "Design" — node ids hash sorted key material; file
21
+ * contents are NEVER stored (only spans + content hashes); FTS5 covers
22
+ * node names; provenance CHECK enforces the four-value enum.
23
+ *
24
+ * Schema versioning:
25
+ * The schema_version row lives in `meta` (a generic key/value table, not
26
+ * lcm_meta — independent store, separate version namespace). Fresh DB →
27
+ * schema_version=1. Upgrade stub: meta v0 → v1 (rule 23, characterize
28
+ * before moving).
29
+ *
30
+ * Dangling-edge policy (PR1 decision):
31
+ * When `upsertFileBatch` deletes a file's prior nodes + edges, cross-file
32
+ * edges whose `dst` was a node owned by the deleted file become dangling.
33
+ * We DROP them. They are counted in `UpsertResult.droppedDanglingEdges`
34
+ * so callers can surface the loss. Keeping them with a `dst_unresolved`
35
+ * marker would leak orphans and bias `traverse()` results — drop is the
36
+ * conservative choice for a write pipeline whose caller knows the
37
+ * canonical file set on each batch (rule 11, 40).
38
+ */
39
+ import type { BetterSqlite3Database } from "@remnic/core/runtime/better-sqlite";
40
+
41
+ import { expectRow, expectRows } from "./row-types.js";
42
+
43
+ export const CODING_GRAPH_SCHEMA_VERSION = 1;
44
+
45
+ /**
46
+ * FTS5 rowid derived from a deterministic node id. FTS5 rowids are
47
+ * signed 64-bit integers; we slice the leading 16 hex chars (= 64 bits)
48
+ * of the sha256 id and mask to the int64 positive range so SQLite
49
+ * accepts it. The full 64-bit space is large enough that collisions
50
+ * across distinct node ids are negligible. Contentless FTS5
51
+ * (`content=''`) does NOT store UNINDEXED column values, so the only
52
+ * reliable key into the virtual table is the rowid.
53
+ *
54
+ * Lives in graph-schema (not graph-store) so the schema migration path
55
+ * can rebuild FTS rows from existing `nodes` without importing the
56
+ * store module (which would create a circular dependency — graph-store
57
+ * imports graph-schema).
58
+ */
59
+ export function ftsRowidForNodeId(nodeId: string): bigint {
60
+ return BigInt(`0x${nodeId.slice(0, 16)}`) & BigInt("0x7fffffffffffffff");
61
+ }
62
+
63
+ /**
64
+ * Provenance enum for edges — mirrors #1552's `heuristic|lsp|trace|semantic`
65
+ * whitelist. The CHECK constraint rejects writes outside this set so a
66
+ * buggy resolver can't sneak in unknown values (rule 23).
67
+ */
68
+ export const EDGE_PROVENANCE_VALUES = [
69
+ "heuristic",
70
+ "lsp",
71
+ "trace",
72
+ "semantic",
73
+ ] as const;
74
+
75
+ export type EdgeProvenance = (typeof EDGE_PROVENANCE_VALUES)[number];
76
+
77
+ export function isEdgeProvenance(value: unknown): value is EdgeProvenance {
78
+ return (
79
+ typeof value === "string" &&
80
+ (EDGE_PROVENANCE_VALUES as readonly string[]).includes(value)
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Apply (or upgrade) the coding-graph schema on an already-open SQLite
86
+ * handle. Public so test seams and migration tools can bootstrap an
87
+ * in-memory database without going through {@link openCodingGraphDatabase}.
88
+ *
89
+ * Mirrors `applyLcmSchema` in `packages/remnic-core/src/lcm/schema.ts` —
90
+ * distinct function, same shape, separate version namespace.
91
+ */
92
+ export function applyCodingGraphSchema(db: BetterSqlite3Database): void {
93
+ const versionRow = expectRow<{ name: string }>(
94
+ db
95
+ .prepare(
96
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'",
97
+ )
98
+ .get(),
99
+ ["name"],
100
+ );
101
+
102
+ if (!versionRow) {
103
+ // Fresh DB — create every table and stamp the current version.
104
+ createTables(db);
105
+ writeSchemaVersion(db, CODING_GRAPH_SCHEMA_VERSION);
106
+ return;
107
+ }
108
+
109
+ const meta = expectRow<{ value: string }>(
110
+ db
111
+ .prepare("SELECT value FROM meta WHERE key = 'schema_version'")
112
+ .get(),
113
+ ["value"],
114
+ );
115
+ const currentVersion = meta ? parseInt(meta.value, 10) : 0;
116
+
117
+ // Only run createTables when the on-disk version is at or below this
118
+ // code's version. For an AT-OR-BELOW DB (fresh-ish v0/v1) the pass is
119
+ // additive: every core statement is CREATE TABLE IF NOT EXISTS, so the
120
+ // PR2 `node_attributes` table appears on existing v1 databases without
121
+ // a version bump (chatgpt-codex-connector P1: 'Create node_attributes
122
+ // for existing v1 stores'), and a v0 DB is upgraded.
123
+ //
124
+ // A NEWER DB (currentVersion > CODING_GRAPH_SCHEMA_VERSION — older
125
+ // code opening a future-version DB after a downgrade, or a parallel
126
+ // install) must be left UNTOUCHED: createTables is NOT purely
127
+ // additive because its FTS migration drops + recreates `nodes_fts`
128
+ // when the stored CREATE SQL lacks `contentless_delete=1`. Running
129
+ // that against a future schema that legitimately changed or removed
130
+ // that table would mutate the newer schema while preserving its
131
+ // version marker — silent corruption. Skip createTables AND the
132
+ // version write for newer DBs (chatgpt-codex-connector P2: 'Skip
133
+ // destructive DDL for future schema versions').
134
+ if (currentVersion > CODING_GRAPH_SCHEMA_VERSION) {
135
+ return;
136
+ }
137
+ createTables(db);
138
+ writeSchemaVersion(db, CODING_GRAPH_SCHEMA_VERSION);
139
+ }
140
+
141
+ function createTables(db: BetterSqlite3Database): void {
142
+ // Provenance whitelist must match EDGE_PROVENANCE_VALUES. SQLite CHECK
143
+ // constraints are re-checked against every INSERT/UPDATE; bypassing this
144
+ // gate would require raw exec, which we never do (rule 51).
145
+ const provenanceList = EDGE_PROVENANCE_VALUES.map((v) => `'${v}'`).join(", ");
146
+
147
+ db.exec(`
148
+ CREATE TABLE IF NOT EXISTS meta (
149
+ key TEXT PRIMARY KEY,
150
+ value TEXT NOT NULL
151
+ );
152
+
153
+ CREATE TABLE IF NOT EXISTS files (
154
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
155
+ path TEXT NOT NULL UNIQUE,
156
+ lang TEXT NOT NULL,
157
+ content_hash TEXT NOT NULL
158
+ );
159
+ CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
160
+
161
+ CREATE TABLE IF NOT EXISTS nodes (
162
+ id TEXT PRIMARY KEY,
163
+ label TEXT NOT NULL,
164
+ name TEXT NOT NULL,
165
+ qualified_name TEXT NOT NULL,
166
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
167
+ span_start INTEGER NOT NULL,
168
+ span_end INTEGER NOT NULL,
169
+ lang TEXT NOT NULL
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
172
+ CREATE INDEX IF NOT EXISTS idx_nodes_qname ON nodes(qualified_name);
173
+ CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);
174
+
175
+ CREATE TABLE IF NOT EXISTS edges (
176
+ src TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
177
+ dst TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
178
+ type TEXT NOT NULL,
179
+ confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
180
+ provenance TEXT NOT NULL CHECK (provenance IN (${provenanceList})),
181
+ UNIQUE (src, dst, type)
182
+ );
183
+ -- Destination-leading index. The UNIQUE(src,dst,type) key is
184
+ -- src-leading, so pruneFileNodes()'s 'WHERE dst IN (...)' count and
185
+ -- the ON DELETE CASCADE that follows a node delete (SQLite must
186
+ -- locate child edges by 'dst') would otherwise scan the whole edges
187
+ -- table. A dst-leading index turns ordinary symbol deletion into an
188
+ -- index lookup instead of a full scan
189
+ -- (chatgpt-codex-connector P2: 'Add an index for edge destination
190
+ -- lookups').
191
+ CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
192
+ `);
193
+ // PR2 (issue #1552 step 5): per-node exclusion flags consumed by
194
+ // `GraphStore.deadCode()`. Kept in a SEPARATE table rather than an
195
+ // ALTER TABLE on `nodes` so existing v1 databases gain the table via
196
+ // the same `CREATE TABLE IF NOT EXISTS` pass without a schema-version
197
+ // bump or a data migration (rule 23 — additive, characterized before
198
+ // moving). ON DELETE CASCADE on `nodes(id)` keeps the table in lockstep
199
+ // with node lifetimes; the foreign_keys=ON pragma set in
200
+ // `GraphStore.open()` enforces it.
201
+ //
202
+ // `is_exported`: 1 when the symbol's `name` matches an entry in the
203
+ // FileIR's `exports` list (matched per-file at write time). The
204
+ // dead-code query treats exported symbols as not-dead even with
205
+ // zero inbound CALLS/USES_TYPE edges — they form the package's
206
+ // public surface and may be called by external consumers the graph
207
+ // cannot see.
208
+ // `is_route_handler`: 1 when the symbol's `qualified_name` matches a
209
+ // route's `handlerQualifiedName` in the FileIR's `routes` list.
210
+ // Route handlers are reachable from HTTP requests regardless of
211
+ // whether any other node CALLS them inside the indexed codebase.
212
+ //
213
+ // Both columns are NOT NULL with CHECK IN (0,1): a missing row means
214
+ // "neither flag set" (the LEFT JOIN in deadCode() COALESCEs to 0).
215
+ db.exec(`
216
+ CREATE TABLE IF NOT EXISTS node_attributes (
217
+ node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
218
+ is_exported INTEGER NOT NULL CHECK (is_exported IN (0, 1)),
219
+ is_route_handler INTEGER NOT NULL CHECK (is_route_handler IN (0, 1))
220
+ );
221
+ CREATE INDEX IF NOT EXISTS idx_node_attributes_exported
222
+ ON node_attributes(is_exported) WHERE is_exported = 1;
223
+ CREATE INDEX IF NOT EXISTS idx_node_attributes_route
224
+ ON node_attributes(is_route_handler) WHERE is_route_handler = 1;
225
+ `);
226
+ // PR3 (issue #1553): co-change edges — file-level relationships mined
227
+ // from git history. Stored separately from the symbol-level `edges`
228
+ // table because co-change is a file-to-file concern, not symbol-to-
229
+ // symbol. Additive to v1 (CREATE TABLE IF NOT EXISTS — same pattern
230
+ // as `node_attributes` in PR2, rule 23).
231
+ //
232
+ // UNIQUE(file_a, file_b) ensures idempotent upserts; the mining
233
+ // pipeline clears + repopulates each run so stale edges from history
234
+ // changes are pruned automatically.
235
+ db.exec(`
236
+ CREATE TABLE IF NOT EXISTS co_changes (
237
+ file_a TEXT NOT NULL,
238
+ file_b TEXT NOT NULL,
239
+ support INTEGER NOT NULL CHECK (support >= 0),
240
+ confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
241
+ UNIQUE (file_a, file_b)
242
+ );
243
+ CREATE INDEX IF NOT EXISTS idx_co_changes_a ON co_changes(file_a);
244
+ CREATE INDEX IF NOT EXISTS idx_co_changes_b ON co_changes(file_b);
245
+ `);
246
+ // Semantic layer (issue #1556): symbol embedding vectors. Additive to v1
247
+ // (CREATE TABLE IF NOT EXISTS — same rule-23 pattern as node_attributes).
248
+ // One row per (node_id, model_id): a node re-embedded under a different
249
+ // provider/model gets a distinct row so a provider swap does not
250
+ // overwrite the prior vectors (the cache invalidation test covers this).
251
+ // content_hash is the canonical-text hash (rule 37) — a re-index compares
252
+ // it to decide whether to re-embed. CASCADE on nodes(id) keeps the table
253
+ // in lockstep with node lifetimes (foreign_keys=ON is set in GraphStore.open).
254
+ db.exec(`
255
+ CREATE TABLE IF NOT EXISTS symbol_vectors (
256
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
257
+ model_id TEXT NOT NULL,
258
+ content_hash TEXT NOT NULL,
259
+ dims INTEGER NOT NULL CHECK (dims > 0),
260
+ vector BLOB NOT NULL,
261
+ PRIMARY KEY (node_id, model_id)
262
+ );
263
+ CREATE INDEX IF NOT EXISTS idx_symbol_vectors_model
264
+ ON symbol_vectors(model_id);
265
+ `);
266
+ // repopulate both tables in lockstep without ordering concerns.
267
+ db.exec(`
268
+ CREATE TABLE IF NOT EXISTS fts_index (
269
+ fts_rowid INTEGER PRIMARY KEY,
270
+ node_id TEXT NOT NULL UNIQUE
271
+ );
272
+ CREATE INDEX IF NOT EXISTS idx_fts_index_node ON fts_index(node_id);
273
+ `);
274
+ // FTS5 virtual table over node names + qualified_names for PR2's
275
+ // name search. Contentless-delete mode (`content=''` plus
276
+ // `contentless_delete=1`, SQLite 3.43+) lets us run standard
277
+ // `DELETE` and `INSERT OR REPLACE` statements against the virtual
278
+ // table — the write pipeline's only requirement. The `id UNINDEXED`
279
+ // column mirrors the deterministic node id for human-readable
280
+ // inspection; the write pipeline's actual key is the rowid derived
281
+ // from the node-id hash (see `ftsRowidForNodeId` below).
282
+ //
283
+ // Migration: databases created before the contentless-FTS5 fix have
284
+ // an OLD `nodes_fts` table whose CREATE SQL used `content=nodes`
285
+ // (external-content) and lacks `contentless_delete=1`. The write
286
+ // pipeline's `DELETE FROM nodes_fts WHERE rowid = ?` fails on such
287
+ // a table while the source `nodes` row still exists, throwing and
288
+ // aborting the whole batch (kilo WARNING: 'Contentless FTS5 assumes
289
+ // fresh schema — old databases are not migrated'). We detect this
290
+ // by inspecting sqlite_master for the `contentless_delete=1` token;
291
+ // if absent, we DROP + RECREATE the virtual table and rebuild its
292
+ // rows from the surviving `nodes` table so the index is immediately
293
+ // usable after migration (rule 23: characterize before moving).
294
+ const ftsCreateSql = expectRow<{ sql: string }>(
295
+ db
296
+ .prepare(
297
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes_fts'",
298
+ )
299
+ .get(),
300
+ ["sql"],
301
+ );
302
+ const needsFtsRecreate =
303
+ !ftsCreateSql || !ftsCreateSql.sql.includes("contentless_delete=1");
304
+ if (needsFtsRecreate) {
305
+ db.exec("DROP TABLE IF EXISTS nodes_fts;");
306
+ db.exec(`
307
+ CREATE VIRTUAL TABLE nodes_fts USING fts5(
308
+ name,
309
+ qualified_name,
310
+ id UNINDEXED,
311
+ content='',
312
+ contentless_delete=1,
313
+ tokenize='unicode61 remove_diacritics 2'
314
+ );
315
+ `);
316
+ // Rebuild FTS + fts_index from surviving nodes so both are populated
317
+ // after migration. Fresh databases have zero rows so this is a no-op.
318
+ const survivingNodes = expectRows<{
319
+ id: string;
320
+ name: string;
321
+ qualified_name: string;
322
+ }>(
323
+ db.prepare("SELECT id, name, qualified_name FROM nodes").all(),
324
+ ["id", "name", "qualified_name"],
325
+ );
326
+ if (survivingNodes.length > 0) {
327
+ const insertFts = db.prepare(
328
+ "INSERT INTO nodes_fts (rowid, name, qualified_name) VALUES (?, ?, ?)",
329
+ );
330
+ const upsertFtsIndex = db.prepare(
331
+ "INSERT OR REPLACE INTO fts_index (fts_rowid, node_id) VALUES (?, ?)",
332
+ );
333
+ for (const n of survivingNodes) {
334
+ const rowid = ftsRowidForNodeId(n.id);
335
+ insertFts.run(rowid, n.name, n.qualified_name);
336
+ upsertFtsIndex.run(rowid, n.id);
337
+ }
338
+ }
339
+ }
340
+ // NOTE: the schema_version marker is written by the caller
341
+ // (applyCodingGraphSchema / writeSchemaVersion), NOT here. createTables
342
+ // only owns additive DDL; the version-write concern (never downgrade a
343
+ // newer DB) lives at the apply layer where currentVersion is known.
344
+ }
345
+
346
+ /**
347
+ * Stamp the schema_version meta row. INSERT OR REPLACE so the upgrade
348
+ * path can rewrite the row in place (rule 23 — one canonical form).
349
+ * Callers MUST gate this on `currentVersion <= CODING_GRAPH_SCHEMA_VERSION`
350
+ * to avoid downgrading a newer DB.
351
+ */
352
+ function writeSchemaVersion(
353
+ db: BetterSqlite3Database,
354
+ version: number,
355
+ ): void {
356
+ db.prepare(
357
+ "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
358
+ ).run(String(version));
359
+ }
360
+
361
+ /**
362
+ * Read the schema_version row. Returns 0 when the table is missing or the
363
+ * row has not been written yet — used by the upgrade stub test.
364
+ *
365
+ * Mirrors the LCM helper's tolerance: a missing `meta` table is not an
366
+ * error condition here, it just means the schema has never been applied.
367
+ */
368
+ export function readSchemaVersion(db: BetterSqlite3Database): number {
369
+ const metaTable = expectRow<{ name: string }>(
370
+ db
371
+ .prepare(
372
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'",
373
+ )
374
+ .get(),
375
+ ["name"],
376
+ );
377
+ if (!metaTable) return 0;
378
+ const meta = expectRow<{ value: string }>(
379
+ db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get(),
380
+ ["value"],
381
+ );
382
+ return meta ? parseInt(meta.value, 10) : 0;
383
+ }