@henols/vice-mcp 0.2.1 → 0.2.3

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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/anno-store.ts ADDED
@@ -0,0 +1,4002 @@
1
+ #!/usr/bin/env node
2
+ // anno-store.ts
3
+ //
4
+ // The ONE module in this repo that names `node:sqlite`. Nothing else may open,
5
+ // query or write an annotation store file; every other module reaches the
6
+ // store through the functions below (STORE-07).
7
+ //
8
+ // ---------------------------------------------------------------------------
9
+ // WHY THIS FILE EXISTS
10
+ // ---------------------------------------------------------------------------
11
+ // `node:sqlite` is still marked *active development* on the Node 22 line: its
12
+ // surface can change under a patch release, and it emits an
13
+ // `ExperimentalWarning` on first load. A dependency with that profile earns a
14
+ // blast radius of exactly one file -- and, more to the point, a CONFINEMENT
15
+ // THAT IS ASSERTED rather than promised. `anno-seam.test.ts` scans the shipped
16
+ // module set and fails if any second module names the specifier, through any of
17
+ // its four working access routes.
18
+ //
19
+ // Three measured facts shaped the code below, and each one is here because the
20
+ // obvious reading of SQLite's behaviour is wrong:
21
+ //
22
+ // * A ZERO-LENGTH FILE OPENS. Measured on this host: `new DatabaseSync()`
23
+ // over an empty file succeeds, `pragma integrity_check` reports `ok`,
24
+ // `sqlite_master` comes back empty and `user_version` reads 0. So SQLite
25
+ // cannot tell "your annotations are gone" from "there are no annotations"
26
+ // -- and the difference is the difference between a bug report and a
27
+ // shrug. The refusal is therefore the store's OWN job: the `anno_meta`
28
+ // row, the `schema_version` match, and `pragma integrity_check`
29
+ // (0.73 ms measured on a 100 KB / 5,000-row store). Stated residual, not
30
+ // a closed claim: a truncation small enough to leave the last page
31
+ // internally consistent would still open.
32
+ //
33
+ // * THE DEFAULT `delete` JOURNAL MODE IS THE RIGHT ONE, and it is not set
34
+ // here. Measured: `delete` and `wal` survive `SIGKILL` identically.
35
+ // `delete` is single-file at rest and leaves only a transient
36
+ // `<db>-journal` after an unclean kill, which the next open rolls back and
37
+ // removes; `wal` is the only mode with PERSISTENT `-wal`/`-shm` sidecars.
38
+ // And `journal_mode` is a PERSISTENT DATABASE PROPERTY -- `pragma`
39
+ // statements are not transactional -- so one stray `pragma journal_mode`
40
+ // anywhere would be inherited by every later connection to that file, by
41
+ // any process. Setting nothing is the decision; a test pins the mode so
42
+ // the decision cannot be undone silently.
43
+ //
44
+ // * `node:sqlite` DOES expose a session surface. `DatabaseSync.prototype`
45
+ // carries `createSession` and `applyChangeset`, `ENABLE_SESSION` is
46
+ // compiled in, and a changeset was replayed between two databases during
47
+ // research. Only the changeset INVERSION primitive is missing -- which is
48
+ // why revert is a whole-store snapshot restore rather than an inverted
49
+ // changeset. No comment in this repo may claim the session surface is
50
+ // absent, because that is false.
51
+ //
52
+ // THIS MODULE MUST BE LISTED IN `package.json`'s `files[]`, and the reason is
53
+ // NOT the reachability reason `prg-image.ts:30-36` gives for itself. This
54
+ // module is not yet reachable from the published entry point's import closure,
55
+ // and `scripts/check-npm-packages.mjs` asserts only one direction -- every
56
+ // REACHABLE module must be listed -- never the converse. The real reason to
57
+ // list it: `STORE-07`'s assertion scans `shippedTsModules()`, which is derived
58
+ // from `files[]`, so an unlisted module makes that assertion VACUOUS. It would
59
+ // pass by scanning a set this file is not in. Copying the reachability sentence
60
+ // here would plant a false claim in a brand-new seam header, which is the
61
+ // defect `block-class.ts`'s own stale rationale demonstrates.
62
+ //
63
+ // ---------------------------------------------------------------------------
64
+ // WHAT NOT TO DO -- each entry names a specific, named trap
65
+ // ---------------------------------------------------------------------------
66
+ // 1. NEVER load a SQLite extension: not through the two extension-loading
67
+ // methods on `DatabaseSync.prototype`, and not through the constructor
68
+ // option that permits them. Both exist, and either one turns this
69
+ // module's caller-supplied FILE ARGUMENT into arbitrary code loading.
70
+ // `anno-seam.test.ts` asserts all three names are absent from this
71
+ // module's code.
72
+ // 2. NEVER write a double-quoted SQL string literal. `node:sqlite` disables
73
+ // the double-quoted-string misfeature by default, so
74
+ // `insert into t values ("a")` throws `no such column: "a"` rather than
75
+ // inserting the letter a. Single quotes for literals, or bound parameters.
76
+ // 3. NEVER interpolate a value into `exec()`. `exec()` takes NO parameters,
77
+ // which is exactly why everything else goes through `prepare().run()`.
78
+ // There is one unavoidable exception -- `vacuum into '<path>'` cannot be
79
+ // parameterised -- and that path is validated before it arrives and
80
+ // single-quote-escaped by doubling at the one site that builds it.
81
+ // 4. NEVER set `journal_mode`, and never set `synchronous`. See the second
82
+ // measured fact above: the mode is persistent in the FILE, so this is not
83
+ // a per-connection preference that a later caller could override.
84
+ // 5. NEVER create an FTS5 virtual table. Measured: an indexed
85
+ // `LIKE 'prefix%'` is 2.02 ms against FTS5 `MATCH`'s 2.99 ms, with a
86
+ // 121.8 ms index rebuild, over 20,000 rows. Adding FTS5 later is
87
+ // ADDITIVE; removing it is a schema migration. The search surface belongs
88
+ // to `STORE-06` and this module must simply not foreclose it.
89
+ // 6. NEVER add an explicit save or flush verb. Durability is this module's
90
+ // responsibility, not the caller's: every accepted write commits before it
91
+ // returns. A save verb is a way for a caller to lose data by forgetting.
92
+ // 7. NEVER import either host/container path-translation seam. The store file
93
+ // is container-side; a host-translated path would let a store write land
94
+ // on the HOST filesystem, silently, outside the workspace. That is
95
+ // precisely the failure the closed consumer set in
96
+ // `hostpath-consumers.test.ts` exists to prevent, and this module's
97
+ // absence from it is asserted there rather than merely stated here.
98
+ // 8. NEVER cache a derived index, census or xref on disk. A cached
99
+ // derivation is a second truth that can disagree with the rows; see
100
+ // `anno-index.ts`'s trap 2.
101
+ // 9. NEVER turn the contradicted-comment report into an error or a refusal,
102
+ // and never widen the rule to "any comment at the address". Both changes
103
+ // look like tightening and are the opposite. A REFUSAL would push a caller
104
+ // toward deleting the comment to get the retype through, converting a
105
+ // reported loss into a silent one -- the exact outcome the report exists to
106
+ // prevent (`STORE-03`). A WIDENED rule would fire on every retype of a
107
+ // commented range, and a report that fires every time is a report nobody
108
+ // reads, so the one case that matters stops being noticed (`STORE-01`).
109
+ // 10. NEVER prune the snapshot ring INSIDE the write transaction, and never
110
+ // let a revert fall back to the nearest retained revision. A filesystem
111
+ // unlink is not part of the transaction, so pruning inside it means a
112
+ // rollback leaves a POINTER ROW AIMED AT A FILE THAT IS ALREADY GONE --
113
+ // the one failure direction the revert path cannot survive. Pruning after
114
+ // the commit inverts that failure deliberately: a kill in the window
115
+ // between the commit and the prune leaves EXTRA files, which are harmless
116
+ // and reconcilable by revision number. INSIDE the prune loop the SAME
117
+ // premise decides the SAME way: the POINTER ROW is deleted first and the
118
+ // file second, because a kill landing between those two adjacent
119
+ // statements is what chooses between the two half-states, and
120
+ // row-then-file is the arrangement that produces the harmless one.
121
+ // RECORDED RATHER THAN QUIETLY DELETED, because a rationale that became
122
+ // false is evidence: this paragraph previously concluded the reverse --
123
+ // that the file is deleted before its pointer row -- which contradicted
124
+ // its own premise, and the loop was written to match the inverted
125
+ // conclusion. And a revert that SUBSTITUTES the nearest
126
+ // retained revision for the one asked for changes the caller's intent with
127
+ // nothing recording that it happened, so a revert past the bound is
128
+ // refused BY NAME instead (`STORE-04`).
129
+ import { randomUUID } from "node:crypto";
130
+ import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
131
+ import { basename, dirname, join, resolve } from "node:path";
132
+ import { DatabaseSync } from "node:sqlite";
133
+
134
+ import { buildPaintIndex, type PaintIndex } from "./anno-index.ts";
135
+ import {
136
+ assertAccessKind,
137
+ assertCommentText,
138
+ assertCommentType,
139
+ assertDataType,
140
+ assertEnumName,
141
+ assertEvidSourceBank,
142
+ assertLabelKind,
143
+ assertLegalLabel,
144
+ assertRangeShape,
145
+ assertRunIdentityDigest,
146
+ assertRunIdentitySeed,
147
+ isSplitDataType,
148
+ splitEntryAddressPairs,
149
+ ADDRESS_MAX,
150
+ ADDRESS_MIN,
151
+ AnnoCommentError,
152
+ AnnoCommentGradeError,
153
+ AnnoLabelError,
154
+ AnnoRangeShapeError,
155
+ AnnoRevisionArgumentError,
156
+ AnnoSplitRemainderError,
157
+ AnnoStoreCorruptError,
158
+ AnnoStoreError,
159
+ AnnoStorePathError,
160
+ AnnoStoreStaleRevisionError,
161
+ AnnoTypeError,
162
+ MAX_SNAPSHOT_REVISIONS,
163
+ parseStoreAddress,
164
+ parseVariantKey,
165
+ storePathWithinWorkspace,
166
+ SCHEMA_VERSION,
167
+ type CommentRow,
168
+ type CommentType,
169
+ type ContradictedComment,
170
+ type DataType,
171
+ type EvidExecRow,
172
+ type EvidSourceBank,
173
+ type ExcludedRangeRow,
174
+ type LabelKind,
175
+ type LabelRow,
176
+ type EnumUsageRow,
177
+ type ObservedRunRow,
178
+ type ProjectEnumRow,
179
+ type RangeRow,
180
+ type ScopeRow,
181
+ type SplitDataType,
182
+ type SplitTableReinterpretation,
183
+ type SplitTableSurvivor,
184
+ type XrefAccessKind,
185
+ type XrefRow,
186
+ } from "./anno-types.ts";
187
+ import { CONFIDENCE_GRADES, parseConfidencePrefix, AnnoConfidenceGradeError } from "./anno-confidence.ts";
188
+ // Imported for ONE purpose: the family predicate the guarded regions below use to
189
+ // decide "rethrow unchanged" versus "wrap". Every `Anno*Error` in `anno-types.ts`
190
+ // already extends it, so nothing new enters the module graph -- `anno-types.ts`
191
+ // imports the same class from the same file.
192
+ import { ViceError } from "./vice-errors.ts";
193
+
194
+ /**
195
+ * What every write entry point in this module returns.
196
+ *
197
+ * `changed` is the ONLY signal that distinguishes a no-op from a real edit. The
198
+ * revision is NOT that signal: every accepted write advances it by exactly one,
199
+ * including a write that turned out to be identical to what was already stored.
200
+ * That is deliberate -- a repeated identical write is accepted rather than
201
+ * refused (an agent re-running an annotation pass must not have to diff first),
202
+ * and the revision has to advance for the snapshot ring to stay meaningful.
203
+ */
204
+ export interface AnnoWriteResult {
205
+ revision: number;
206
+ changed: boolean;
207
+ }
208
+
209
+ /**
210
+ * The complete on-disk schema, created in full at first open.
211
+ *
212
+ * THE REVERSAL THIS RECORDS, kept rather than deleted because a rationale that
213
+ * became false is evidence. This paragraph used to read "created in full at
214
+ * first open so `SCHEMA_VERSION` stays 1 and no later work alters an on-disk
215
+ * shape". The FIRST half is still true and is why every table below exists from
216
+ * the very first write. The SECOND half became false: `anno_snapshot` carried a
217
+ * `path text not null` column holding the snapshot's ABSOLUTE location, and two
218
+ * destructive consequences were reproduced against committed code -- two stores
219
+ * in one directory sharing one ring (CR-01) and a directory rename plus one
220
+ * write destroying the whole revert history (CR-03). The column is DROPPED at
221
+ * `SCHEMA_VERSION` 2 and the location is computed from the handle by
222
+ * `snapshotDirFor()` at every read and every delete, so there is no persisted
223
+ * absolute string left for a second namespace -- a bind mount seen from the
224
+ * host and from a container is this repo's own everyday case -- to disagree
225
+ * with. `anno-types.ts`'s `SCHEMA_VERSION` doc comment carries the whole
226
+ * argument and the reason a version-1 store is refused rather than migrated.
227
+ *
228
+ * THE VERSION 2 DDL CHANGE TOUCHED ONLY `anno_snapshot`. Every other table's
229
+ * column list below, including the reserved and uninterpreted `bank` columns,
230
+ * was byte-identical to version 1's.
231
+ *
232
+ * THAT SENTENCE IS KEPT AND SCOPED RATHER THAN DELETED, because at
233
+ * `SCHEMA_VERSION` 3 it stopped being the whole truth: D-15 (2026-08-29) ADDS
234
+ * one table, `anno_enum_usage`, and its index. It changes no existing table's
235
+ * column list, so the scoped claim above still holds of every table version 2
236
+ * had. The version 3 table associates ONE address with ONE `anno_enum` row by
237
+ * enum **id** -- see `anno-types.ts`'s `SCHEMA_VERSION` doc comment for what
238
+ * the bump buys, why no migration arm was written, and the basis measured on
239
+ * the day that cost was accepted.
240
+ *
241
+ * `anno_xref` and its `access_kind` column exist from the very first write.
242
+ * Two requirement texts look like they conflict here and do not: `STORE-05`
243
+ * requires the column, while the cross-reference criterion forbids CACHING a
244
+ * DERIVED cross-reference on disk. Both hold at once -- the table exists, and
245
+ * only non-derivable references (hand-asserted, or resolved from something
246
+ * outside the bytes) are ever stored in it. Derivation stays on the query
247
+ * side. This paragraph exists so a later reader does not have to rediscover
248
+ * that the two criteria appeared to disagree.
249
+ *
250
+ * Every range, label, comment and xref row carries a nullable `bank` column
251
+ * that NOTHING in this module interprets and no code path reads except the row
252
+ * mapper in `listRanges()`. It is reserved, and every row written today has it
253
+ * null.
254
+ *
255
+ * AT `SCHEMA_VERSION` 4 (EVID-01/EVID-02), ONE MORE TABLE IS ADDED:
256
+ * `anno_evid_exec`, the durable runtime-execution evidence table. See
257
+ * `anno-types.ts`'s `SCHEMA_VERSION` doc comment for what the bump buys and
258
+ * the decided, dated fate of an existing version-3 store (`reaffirm-refusal`
259
+ * -- no migration arm). `anno_evid_exec` carries NO `bank` column and NO
260
+ * nullable column at all: unlike the annotation tables above, every field on
261
+ * a row here is a fact the runtime evidence layer is licensed to assert, or
262
+ * the row does not exist. Its run-identity key is the bare triple
263
+ * `(image_sha256, argv_digest, seed)`, selected by plan 43-01's live A/B
264
+ * (`docs/phase43-instrumentation-perturbation-ab.md`, verdict
265
+ * `no-perturbation`) -- there is deliberately no `run_class` column.
266
+ *
267
+ * AT `SCHEMA_VERSION` 5 (`BUILD-07`), ONE MORE TABLE IS ADDED:
268
+ * `anno_excluded_range`, the durable record of a user-requested exclusion --
269
+ * its extent and the reason the user gave. See `anno-types.ts`'s
270
+ * `SCHEMA_VERSION` doc comment for what the bump buys, why a table was chosen
271
+ * over a column on `anno_range`, and the decided, dated fate of an existing
272
+ * version-4 store (`reaffirm-refusal` -- no migration arm). `reason` is `not
273
+ * null`: an exclusion with no reason is a hole with a row in front of it.
274
+ * `anno_excluded_range` carries NO `bank` column, for the same reason
275
+ * `ExcludedRangeRow`'s own doc comment gives -- an exclusion is a statement
276
+ * about the subject program, not a memory view.
277
+ */
278
+ export const DDL = `
279
+ create table anno_meta (
280
+ id integer primary key check(id = 1),
281
+ schema_version integer not null,
282
+ revision integer not null
283
+ );
284
+
285
+ create table anno_range (
286
+ id integer primary key autoincrement,
287
+ start integer not null,
288
+ end_inclusive integer not null,
289
+ data_type text not null,
290
+ bank integer
291
+ );
292
+
293
+ create table anno_label (
294
+ id integer primary key autoincrement,
295
+ address integer not null,
296
+ name text not null unique,
297
+ kind text not null,
298
+ bank integer
299
+ );
300
+
301
+ create table anno_comment (
302
+ id integer primary key autoincrement,
303
+ address integer not null,
304
+ comment_type text not null,
305
+ text text not null,
306
+ bank integer,
307
+ unique(address, comment_type)
308
+ );
309
+
310
+ create table anno_scope (
311
+ id integer primary key autoincrement,
312
+ start integer not null,
313
+ end_inclusive integer not null
314
+ );
315
+
316
+ create table anno_enum (
317
+ id integer primary key autoincrement,
318
+ name text not null unique,
319
+ variants text not null,
320
+ description text
321
+ );
322
+
323
+ create table anno_enum_usage (
324
+ id integer primary key autoincrement,
325
+ address integer not null,
326
+ enum_id integer not null references anno_enum(id),
327
+ bank integer,
328
+ unique(address, bank)
329
+ );
330
+
331
+ create table anno_xref (
332
+ id integer primary key autoincrement,
333
+ from_address integer not null,
334
+ to_address integer not null,
335
+ access_kind text not null,
336
+ bank integer
337
+ );
338
+
339
+ create table anno_snapshot (
340
+ revision integer primary key
341
+ );
342
+
343
+ create table anno_evid_exec (
344
+ id integer primary key autoincrement,
345
+ image_sha256 text not null,
346
+ argv_digest text not null,
347
+ seed text not null,
348
+ address integer not null,
349
+ source_bank text not null,
350
+ unique(image_sha256, argv_digest, seed, address, source_bank)
351
+ );
352
+
353
+ create table anno_excluded_range (
354
+ id integer primary key autoincrement,
355
+ start integer not null,
356
+ end_inclusive integer not null,
357
+ reason text not null,
358
+ unique(start, end_inclusive)
359
+ );
360
+
361
+ create index anno_range_end_start on anno_range(end_inclusive, start);
362
+ create index anno_label_address on anno_label(address);
363
+ create index anno_comment_address on anno_comment(address);
364
+ create index anno_enum_usage_address on anno_enum_usage(address);
365
+ create index anno_xref_to on anno_xref(to_address);
366
+ create index anno_evid_exec_address on anno_evid_exec(address);
367
+ create index anno_excluded_range_start on anno_excluded_range(start);
368
+ `;
369
+
370
+ /** An open store: the connection, the resolved store path, and the directory
371
+ * the snapshots sibling lives in. The handle is owned by its CALLER -- this
372
+ * module holds no connection of its own, so two concurrent callers cannot
373
+ * observe each other's connection state. */
374
+ export interface AnnoStoreHandle {
375
+ db: DatabaseSync;
376
+ path: string;
377
+ dir: string;
378
+ /**
379
+ * THIS CONNECTION'S TRANSACTION STATE IS UNKNOWN: a housekeeping sweep run on
380
+ * it reported that its own `rollback` threw, so it may still hold an open
381
+ * transaction and the store's write lock (WR-18).
382
+ *
383
+ * THE REMEDY IS THE ONE THE COMMIT HANDLER ALREADY PRINTS, in the same words:
384
+ * CLOSE IT AND REOPEN rather than reusing it. Node 22's `DatabaseSync` exposes
385
+ * no transaction-state accessor -- the surface is `open, close, prepare, exec,
386
+ * function, location, aggregate, createSession, applyChangeset,
387
+ * enableLoadExtension, loadExtension`, measured on this host -- so this field
388
+ * is the only thing that can carry the fact from the call that produced it to
389
+ * the call that must act on it.
390
+ *
391
+ * WHY THE FACT LIVES ON THE HANDLE AND NOT ON THE WRITE'S RESULT. The write
392
+ * that produced it COMMITTED; reporting it as that write's failure is exactly
393
+ * what prohibition 28-11 P5 forbids, and would send a caller to retry an
394
+ * additive verb. So the accepted write returns its revision unchanged and it
395
+ * is the NEXT call on this connection that refuses BY NAME -- which is what
396
+ * turns CR-07's bare `cannot start a transaction within a transaction` into a
397
+ * diagnosis.
398
+ *
399
+ * `false` on every freshly opened handle, set in `openStore` at the one place
400
+ * the handle object is built.
401
+ */
402
+ transactionStateUnknown: boolean;
403
+ }
404
+
405
+ /** The ONE `commit` statement in this module. Both the first-open schema
406
+ * creation and the write sequence route through here, so the single site a
407
+ * durability proof plants its violation against is unique and unambiguous. */
408
+ function commitTransaction(db: DatabaseSync): void {
409
+ db.exec("commit");
410
+ }
411
+
412
+ /** The one place a filesystem path is spliced into SQL text, because
413
+ * `vacuum into` cannot be parameterised and `exec()` takes no parameters
414
+ * (trap 3). Refuses a path carrying a NUL or a newline outright rather than
415
+ * escaping it, then doubles single quotes. */
416
+ function sqlQuotedPath(path: string): string {
417
+ if (/[\0\n\r]/.test(path)) {
418
+ throw new AnnoStorePathError(`store path ${JSON.stringify(path)} contains a control character -- refusing to splice it into SQL text`, {
419
+ path,
420
+ });
421
+ }
422
+ return `'${path.replace(/'/g, "''")}'`;
423
+ }
424
+
425
+ /** `fsync` a file or a directory by path. Directory fsync is what makes a
426
+ * `rename` durable, not just visible. */
427
+ function fsyncPath(path: string): void {
428
+ const fd = openSync(path, "r");
429
+ try {
430
+ fsyncSync(fd);
431
+ } finally {
432
+ closeSync(fd);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Opens the store at `path`, creating and initialising it when it does not
438
+ * exist yet, and REFUSING it when it exists but is not a store this build can
439
+ * speak to.
440
+ *
441
+ * A `workspaceRoot` IS REQUIRED unless the caller explicitly asks for the
442
+ * unconfined path with `unconfinedModuleDerivedPath: true`, and the inversion is
443
+ * deliberate (WR-25). Confinement used to be opt-IN, which made the mitigation
444
+ * for the one unvalidated input this module's own header calls out the one a
445
+ * caller could forget -- and two of this store's recorded blockers were confinement
446
+ * escapes. The escape exists for exactly one shape: a path THIS MODULE derived
447
+ * itself (a snapshot image path, a staging path, or the live store path
448
+ * `revertTo` already resolved), where there is no caller argument left to
449
+ * confine. Every such site below carries a one-line comment naming the
450
+ * module-derived value that produced its path, and `anno-seam.test.ts` pins
451
+ * that no other shipped module names the option at all.
452
+ *
453
+ * When `workspaceRoot` is supplied the path is confined to it first. The
454
+ * fresh-versus-existing decision is made with `existsSync` BEFORE the
455
+ * connection is constructed, because constructing `DatabaseSync` creates the
456
+ * file -- after that point there is no way left to ask the question.
457
+ *
458
+ * `timeout` is set so a genuinely concurrent writer WAITS for the lock rather
459
+ * than failing `SQLITE_BUSY` on contact. No other connection option is passed:
460
+ * see traps 1 and 4.
461
+ *
462
+ * `mustExist` EXISTS FOR EXACTLY ONE PURPOSE: JUDGING A FILE THE CALLER IS
463
+ * ABOUT TO INSTALL, and its two halves are inseparable. This function's default
464
+ * behaviour is to CREATE and initialise an absent store -- which is the right
465
+ * default for opening a project's store and precisely the wrong one for asking
466
+ * "is this snapshot image a store I can speak to", because a judge that can
467
+ * create or modify the thing it judges is not a judge: it would manufacture the
468
+ * very empty store it was asked to detect and then report it healthy. So with
469
+ * `mustExist` set, an absent path is REFUSED BY NAME before `new DatabaseSync`
470
+ * is constructed, which makes the create-and-initialise branch below
471
+ * unreachable, and the connection is opened `readOnly`.
472
+ *
473
+ * READ-ONLY IS NOT BELT-AND-BRACES ON THE EXISTENCE TEST -- it closes the
474
+ * residual window the existence test leaves. Between the `existsSync` above and
475
+ * the constructor below the file can be unlinked; a writable open would then
476
+ * create it, and the judgement would be about a file this call had just made
477
+ * up. Measured on this host at plan time: a `readOnly` open of an absent path
478
+ * REFUSES with `unable to open database file` rather than creating it. Nothing
479
+ * downstream is duplicated for this option -- the `anno_meta` read, the
480
+ * `schema_version` comparison and `pragma integrity_check` are REUSED
481
+ * UNCHANGED, because those four checks together ARE the definition of "an
482
+ * annotation store this build can speak to" and a second list of them would be
483
+ * a second answer to the one question this option exists to answer once.
484
+ */
485
+ export function openStore(
486
+ path: string,
487
+ opts: { workspaceRoot?: string; mustExist?: boolean; unconfinedModuleDerivedPath?: boolean } = {},
488
+ ): AnnoStoreHandle {
489
+ // CONFINEMENT IS THE DEFAULT, AND THE ESCAPE IS A WORD A GREP CAN FIND
490
+ // (WR-25). `anno-types.ts`'s header names the three things nothing upstream
491
+ // validates -- "an address of 65536, a misspelled data type, and a store path
492
+ // pointing outside the workspace all look identical to the transport" -- and
493
+ // this was the only one of the three whose mitigation a caller could simply
494
+ // forget. Two of this phase's blockers (CR-03, CR-04) were confinement
495
+ // escapes.
496
+ //
497
+ // REFUSED BEFORE THE PATH IS RESOLVED AND LONG BEFORE `new DatabaseSync`, for
498
+ // the same reason `mustExist` is refused where it is and recorded in its own
499
+ // comment: this function's default behaviour is to CREATE the file, so after
500
+ // the constructor there is no longer a question to ask -- the store would
501
+ // already exist wherever the argument pointed.
502
+ if (opts.workspaceRoot === undefined && opts.unconfinedModuleDerivedPath !== true) {
503
+ throw new AnnoStorePathError(
504
+ `${path}: refusing to open an annotation store without a workspace root. The MCP transport validates NOTHING -- ` +
505
+ `\`vice-proxy.ts\`'s raw-schema validator is \`validate: (value) => ({ value })\` -- so an unconfined store path is a store file ` +
506
+ `created wherever the caller's argument pointed. Pass { workspaceRoot } to confine the path, or ` +
507
+ `{ unconfinedModuleDerivedPath: true } if and only if THIS MODULE derived the path itself.`,
508
+ { path },
509
+ );
510
+ }
511
+
512
+ const resolved = opts.workspaceRoot === undefined ? resolve(path) : storePathWithinWorkspace(path, opts.workspaceRoot);
513
+ const fresh = !existsSync(resolved);
514
+
515
+ // REFUSED BEFORE THE CONNECTION IS CONSTRUCTED, and the position is the whole
516
+ // point: `new DatabaseSync` on an absent path CREATES the file, so after that
517
+ // line there is no way left to ask the question -- and the answer would be
518
+ // "yes, a healthy empty store", about a file this call invented.
519
+ if (opts.mustExist === true && fresh) {
520
+ throw new AnnoStoreError(
521
+ `${resolved}: cannot open an annotation store here -- the file does not exist, and this open was asked to JUDGE an existing image ` +
522
+ `rather than create one. An absent image is refused rather than initialised, because a judge that creates the thing it judges ` +
523
+ `would report the empty store it just made as healthy.`,
524
+ { data: { path: resolved } },
525
+ );
526
+ }
527
+
528
+ // WRAPPED, AND THE CLASS IS DELIBERATE. Two reproduced inputs -- a path that
529
+ // IS a directory, and a path whose parent directory does not exist -- both
530
+ // throw a bare `unable to open database file` here, with no path in the
531
+ // message and outside the `ViceError` family that every other refusal in this
532
+ // module belongs to. `AnnoStorePathError` rather than a new class, because
533
+ // both cases say the same thing `storePathWithinWorkspace` already says:
534
+ // this is not a place a store can live.
535
+ let db: DatabaseSync;
536
+ try {
537
+ db = opts.mustExist === true ? new DatabaseSync(resolved, { readOnly: true, timeout: 5_000 }) : new DatabaseSync(resolved, { timeout: 5_000 });
538
+ } catch (e) {
539
+ throw new AnnoStorePathError(`${resolved}: cannot open an annotation store here (${(e as Error).message})`, { path: resolved });
540
+ }
541
+ const handle: AnnoStoreHandle = { db, path: resolved, dir: dirname(resolved), transactionStateUnknown: false };
542
+
543
+ if (fresh) {
544
+ // WRAPPED FOR THE CONNECTION, NOT ONLY FOR THE MESSAGE. The most plausible
545
+ // failure in this block is a SECOND process that also saw
546
+ // the file absent, giving `table anno_meta already exists` -- and
547
+ // unwrapped that left BOTH the connection and the transaction open, so the
548
+ // caller lost the file handle and the lock with no way to reach either.
549
+ // The rollback is attempted inside its own swallowing `try` for the same
550
+ // reason the write sequence does it: there is nothing useful to do with a
551
+ // second error, and reporting it would replace the real one.
552
+ try {
553
+ db.exec("begin immediate");
554
+ db.exec(DDL);
555
+ db.prepare("insert into anno_meta(id, schema_version, revision) values (1, ?, 0)").run(SCHEMA_VERSION);
556
+ commitTransaction(db);
557
+ } catch (e) {
558
+ try {
559
+ db.exec("rollback");
560
+ } catch {
561
+ // deliberately ignored -- see above
562
+ }
563
+ db.close();
564
+ throw new AnnoStoreError(`${resolved}: failed to initialise a fresh annotation store (${(e as Error).message})`);
565
+ }
566
+ return handle;
567
+ }
568
+
569
+ let meta: { schema_version: number; revision: number } | undefined;
570
+ try {
571
+ meta = db.prepare("select schema_version, revision from anno_meta where id = 1").get() as
572
+ | { schema_version: number; revision: number }
573
+ | undefined;
574
+ } catch (e) {
575
+ db.close();
576
+ throw new AnnoStoreCorruptError(
577
+ `${resolved}: not an annotation store (${(e as Error).message}) -- refusing to treat a truncated, empty or foreign file as an empty store, ` +
578
+ `because "the annotations are gone" and "there are no annotations" must not read the same. This is the branch a ZERO-LENGTH file takes: ` +
579
+ `SQLite opens it, reports integrity_check ok and returns an empty sqlite_master, so the refusal has to be the store's own.`,
580
+ { path: resolved },
581
+ );
582
+ }
583
+
584
+ if (!meta) {
585
+ db.close();
586
+ throw new AnnoStoreCorruptError(
587
+ `${resolved}: annotation store has no meta row -- refusing to treat a truncated, empty or foreign file as an empty store, ` +
588
+ `because "the annotations are gone" and "there are no annotations" must not read the same`,
589
+ { path: resolved },
590
+ );
591
+ }
592
+
593
+ if (meta.schema_version !== SCHEMA_VERSION) {
594
+ db.close();
595
+ // NAMES THE REMEDY AND DENIES NOTHING IS LOST (EVID-02's checkpoint,
596
+ // condition 2). This build refuses rather than upgrades -- see
597
+ // `anno-types.ts`'s `SCHEMA_VERSION` doc comment for the decided,
598
+ // dated reason -- and the refusal happens BEFORE any write, so the
599
+ // file on disk is exactly what it was a moment ago: its labels,
600
+ // comments and enums are not lost, only unreadable by this build.
601
+ throw new AnnoStoreCorruptError(
602
+ `${resolved}: schema_version ${meta.schema_version}, expected ${SCHEMA_VERSION} -- refusing to open rather than upgrade. This file is ` +
603
+ `left exactly as it was: nothing on it is read, rewritten or deleted by this refusal. Open it with a build whose SCHEMA_VERSION is ` +
604
+ `${meta.schema_version} to read it (see anno-types.ts's SCHEMA_VERSION doc comment for what changed at each version), or hand-copy ` +
605
+ `its rows into a fresh store at this build's version.`,
606
+ { path: resolved },
607
+ );
608
+ }
609
+
610
+ // WR-04, THE LAST KNOWN FAMILY ESCAPE IN THIS FUNCTION. The two blocks either
611
+ // side of this one are already wrapped, and for the same two reasons: an
612
+ // unwrapped failure here leaks the CONNECTION as well as escaping the
613
+ // `ViceError` family, so the caller loses the file handle with no way to
614
+ // reach it. The shape deliberately matches those two -- close, then refuse
615
+ // with `AnnoStoreCorruptError` naming the path -- because a store whose
616
+ // integrity check cannot even RUN is not a store this build can speak to,
617
+ // which is the same fact the non-`ok` branch below reports.
618
+ let check: { integrity_check: string }[];
619
+ try {
620
+ check = db.prepare("pragma integrity_check").all() as { integrity_check: string }[];
621
+ } catch (e) {
622
+ db.close();
623
+ throw new AnnoStoreCorruptError(`${resolved}: integrity_check could not be run at all (${(e as Error).message})`, { path: resolved });
624
+ }
625
+ if (check.length !== 1 || check[0].integrity_check !== "ok") {
626
+ db.close();
627
+ throw new AnnoStoreCorruptError(`${resolved}: integrity_check reported ${JSON.stringify(check)}`, { path: resolved });
628
+ }
629
+
630
+ return handle;
631
+ }
632
+
633
+ /** Closes the connection. Safe to call once per handle. */
634
+ export function closeStore(handle: AnnoStoreHandle): void {
635
+ handle.db.close();
636
+ }
637
+
638
+ /** The store's current revision, read from `anno_meta`. */
639
+ export function currentRevision(handle: AnnoStoreHandle): number {
640
+ const row = handle.db.prepare("select revision from anno_meta where id = 1").get() as { revision: number } | undefined;
641
+ if (!row) {
642
+ throw new AnnoStoreCorruptError(`${handle.path}: annotation store has no meta row`, { path: handle.path });
643
+ }
644
+ return row.revision;
645
+ }
646
+
647
+ /**
648
+ * The suffix appended to the store FILENAME to name its snapshot ring
649
+ * directory. Appended to the FILENAME rather than being a fixed directory name
650
+ * (`<dir>/snapshots`, which is what this was), and the distinction is the whole
651
+ * of CR-01's fix: two distinct store files in one directory have distinct
652
+ * basenames by definition of a filesystem, so distinct basenames give distinct
653
+ * rings BY CONSTRUCTION rather than by an ownership predicate layered over a
654
+ * shared location.
655
+ *
656
+ * THE PREDICATE ROUTE WAS ALREADY TRIED AND COULD NOT SEE THE DEFECT. Plan
657
+ * 28-07 added a per-revision ownership check over the shared `<dir>/snapshots`
658
+ * ring; it was structurally blind to CR-01 because revision numbers are not
659
+ * unique ACROSS stores -- two stores in one directory both write `r1.db`, and
660
+ * every per-revision predicate says "yes, revision 1 is mine" to both of them.
661
+ * A location that cannot collide has no such blind spot to test for.
662
+ */
663
+ const SNAPSHOT_DIR_SUFFIX = ".snapshots";
664
+
665
+ /**
666
+ * THE one authority on where a store's snapshot ring lives: a sibling
667
+ * directory of the store file, named after the store FILE plus
668
+ * `SNAPSHOT_DIR_SUFFIX`. For a store at `<dir>/proj.annostore` that is
669
+ * `<dir>/proj.annostore.snapshots`.
670
+ *
671
+ * THE RESIDUAL, STATED RATHER THAN CLAIMED CLOSED -- AND RESTATED AFTER THIS
672
+ * PARAGRAPH'S EARLIER VERSION WAS FALSIFIED BY DRIVING THE CODE (CR-05). What
673
+ * it got RIGHT and keeps: the location is a pure function of the handle, the
674
+ * sweep only ever reads `snapshotDirFor(handle)` so it cannot see a ring it
675
+ * does not name, and renaming the containing DIRECTORY is not a residual at all
676
+ * -- the ring moves with the directory, so nothing is lost (pinned by the CR-03
677
+ * rename test). What became FALSE: it claimed the old ring was never deleted at
678
+ * all and that `retainedRevisions()` reporting an empty list was therefore a
679
+ * truthful under-claim. That was true of the FILES and false of the ROWS -- so
680
+ * the claim is not repeated here even to disown it, because the next reader
681
+ * greps this file for the guarantee, not for its refutation. The verifier drove
682
+ * it in round 3: the next
683
+ * write's sweep classified every pointer row as an orphan and deleted them
684
+ * irreversibly, and restoring the original name recovered nothing.
685
+ *
686
+ * WHAT THE CODE ACTUALLY DOES NOW. A second spelling of the same store file --
687
+ * a SYMLINK ALIAS, or a store-file rename (`mv proj.annostore
688
+ * other.annostore`) -- names a DIFFERENT ring, so a handle opened under it
689
+ * publishes into a SECOND ring. The first ring's files are never deleted, and
690
+ * since CR-05 its pointer rows are never deleted BY THE SWEEP -- but
691
+ * `pruneSnapshots`' doomed loop still deletes every row below
692
+ * `currentRevision() - MAX_SNAPSHOT_REVISIONS`, so restoring the original name
693
+ * restores the floor ONLY while the wrong-spelling handle has not advanced past
694
+ * `MAX_SNAPSHOT_REVISIONS` further revisions. The bound is stated in the same
695
+ * sentence as the claim on purpose: an unqualified "the rows survive, so
696
+ * renaming back recovers the floor" with the qualifier in a later sentence is a
697
+ * paragraph a reader takes the unqualified half of, which is the 28-07 P3
698
+ * failure this correction exists to remove, reproduced by the correction.
699
+ *
700
+ * TWO RESIDUALS SURVIVE, BOTH ACCEPTED ON THE RECORD.
701
+ * * Each spelling accretes its OWN ring, so `MAX_SNAPSHOT_REVISIONS` bounds
702
+ * each ring but not the on-disk footprint across spellings.
703
+ * * Prohibition 28-07 P2 remains VIOLATED in the UNDER-CLAIM direction under
704
+ * a second spelling: opened that way the store reports
705
+ * `retainedRevisions() == []` and `oldestRetainedRevision() ==
706
+ * NO_RETAINED_REVISION` while the first ring's files sit on disk. Abandoning
707
+ * the row sweep removed the DESTRUCTION that under-claim used to drive; it
708
+ * did not remove the under-claim. Closing it would mean teaching
709
+ * `retainedRevisions` to read a ring whose ownership this handle cannot
710
+ * establish, which is prohibition 28-10 P3 / 28-11 P4 -- the guess the whole
711
+ * decision exists to refuse.
712
+ */
713
+ export function snapshotDirFor(handle: AnnoStoreHandle): string {
714
+ return join(handle.dir, basename(handle.path) + SNAPSHOT_DIR_SUFFIX);
715
+ }
716
+
717
+ /** Where the pre-mutation snapshot of `revision` lives: inside the ring
718
+ * `snapshotDirFor()` names -- a sibling directory named after the store FILE --
719
+ * holding one file per revision. The extension and the layout are decided here
720
+ * on purpose -- changing either later is a user-visible file rename. */
721
+ export function snapshotPathFor(handle: AnnoStoreHandle, revision: number): string {
722
+ return join(snapshotDirFor(handle), `r${revision}.db`);
723
+ }
724
+
725
+ /** What `oldestRetainedRevision()` reports when the ring holds NO snapshot at
726
+ * all -- a freshly created store, or one restored from its very first
727
+ * snapshot. Named rather than left as a bare `-1` for the same reason
728
+ * `anno-index.ts`'s `NO_ROW` is named: a sentinel a caller has to recognise
729
+ * from its VALUE is a sentinel a caller gets wrong. Reporting
730
+ * `currentRevision()` in that state instead would be a LIE -- `revertTo`
731
+ * refuses the current revision too, because no snapshot records it. */
732
+ export const NO_RETAINED_REVISION = -1;
733
+
734
+ /**
735
+ * The anchored filename of one snapshot inside the ring directory
736
+ * `snapshotDirFor()` names, and the source of the revision number the
737
+ * reconciliation below derives from a filename alone.
738
+ *
739
+ * ANCHORED ON PURPOSE, and the anchoring is load-bearing rather than tidy:
740
+ * plan 28-08 introduces per-attempt STAGING files in this same directory under
741
+ * a different suffix, and a sweep that matched them would delete another
742
+ * writer's in-flight snapshot -- the exact loss this reconciliation exists to
743
+ * prevent, committed by the repair itself.
744
+ *
745
+ * A frozen `RegExp` literal is NOT module-level mutable state: the scan in
746
+ * `anno-seam.test.ts` matches `new Map|Set|WeakMap|WeakSet` and array/object
747
+ * initialisers, so this constant sits outside it by construction rather than
748
+ * by exemption.
749
+ */
750
+ const SNAPSHOT_FILE_PATTERN = /^r(\d+)\.db$/;
751
+
752
+ /**
753
+ * THE file-half witness, and the ONLY answer in this module to "can revision
754
+ * `r`'s snapshot image be opened as an annotation store this build can speak
755
+ * to". Returns `null` when it can, and the underlying refusal's MESSAGE when it
756
+ * cannot.
757
+ *
758
+ * IT REPLACED A PRESENCE TEST AT BOTH OF THE TWO SITES THAT CARRIED ONE -- the
759
+ * filter inside `retainedRevisions` and `revertTo`'s step-2 gate -- and the
760
+ * promotion is the whole of CR-08's supporting half. Presence was never a
761
+ * witness that a file is a store: this module's FIRST MEASURED FACT (header,
762
+ * `:22-31`) is that a ZERO-LENGTH FILE OPENS as a SQLite database and reports
763
+ * `integrity_check ok`. So the store advertised a revision whose image was not a
764
+ * database, and a caller following that published floor destroyed the live store
765
+ * irrecoverably. After this function there is no presence test on a snapshot
766
+ * path left anywhere in the module, and a test asserts that as an absence.
767
+ *
768
+ * IT RETURNS THE REASON RATHER THAN A BOOLEAN because two callers need two
769
+ * different things from one question: the filter needs only "did it open", and
770
+ * `revertTo`'s refusal QUOTES the reason so the caller can tell an absent image
771
+ * from a corrupt one without a second predicate to disagree with the first.
772
+ *
773
+ * `mustExist` IS WHAT MAKES THIS A JUDGEMENT RATHER THAN A CREATION.
774
+ * `openStore`'s default is to create and initialise an absent store, so a
775
+ * witness built without that option would manufacture the very empty store it
776
+ * was asked to detect, in the ring, and then report it healthy. See
777
+ * `openStore`'s doc comment for why the read-only half is inseparable from it.
778
+ */
779
+ function snapshotOpenFailure(handle: AnnoStoreHandle, revision: number): string | null {
780
+ try {
781
+ // MODULE-DERIVED PATH: `snapshotPathFor(handle, revision)` -- built from the
782
+ // handle's own already-confined store path, so there is no caller argument
783
+ // left to confine. `mustExist` is unchanged: this open JUDGES, never creates.
784
+ closeStore(openStore(snapshotPathFor(handle, revision), { mustExist: true, unconfinedModuleDerivedPath: true }));
785
+ return null;
786
+ } catch (e) {
787
+ return (e as Error).message;
788
+ }
789
+ }
790
+
791
+ /**
792
+ * The pointer-ROW question, and NOT a second answer to "what is retained":
793
+ * which revisions does `anno_snapshot` CLAIM, in ascending order, regardless of
794
+ * whether their images can be opened.
795
+ *
796
+ * NAMED DIFFERENTLY BECAUSE IT IS A DIFFERENT QUESTION, and the naming is the
797
+ * guard against it being mistaken for a fourth independent decision about
798
+ * "retained". Its one consumer is `reconcileSnapshotRing`'s keep-set: a sweep
799
+ * over FILES asks "is this file claimed by a row", which is not the same
800
+ * question as "can a caller revert to this revision". The two answers were
801
+ * identical for every input reachable before the promotion above -- the presence
802
+ * half of the old definition is trivially true of a file `readdirSync` just
803
+ * returned -- and they begin to diverge only now, in the safe direction: an
804
+ * image that fails to open but that a row still claims stays on disk as
805
+ * EVIDENCE instead of being unlinked by a sweep that would otherwise perform a
806
+ * second destruction while calling itself a repair.
807
+ */
808
+ function claimedRevisions(handle: AnnoStoreHandle): number[] {
809
+ const rows = handle.db.prepare("select revision from anno_snapshot order by revision").all() as { revision: number }[];
810
+ return rows.map((row) => row.revision);
811
+ }
812
+
813
+ /**
814
+ * THE definition of "revision `r` is retained", and the only one. Returns the
815
+ * retained revisions in ascending order.
816
+ *
817
+ * A revision is retained when its pointer row in `anno_snapshot` exists AND its
818
+ * image in the ring `snapshotDirFor()` names OPENS as an annotation store this
819
+ * build can speak to. The pointer row is the INDEX -- it is what a revision
820
+ * number is looked up in -- and the image is proved by `snapshotOpenFailure`,
821
+ * which opens it.
822
+ *
823
+ * THE FILE HALF WAS PROMOTED FROM PRESENCE TO OPENABILITY, and the reversal is
824
+ * recorded here because a rationale that became false is evidence. The
825
+ * previous witness was `existsSync` alone. It advertised a revision whose image
826
+ * was NOT a database -- a snapshot truncated to zero bytes by a crash between
827
+ * `vacuum into` and its fsync, a partial copy, bit rot, a file another tool
828
+ * wrote -- and `revertTo` gated on the same presence test, so following the
829
+ * store's OWN published floor took a 69,632-byte live store to 0 bytes with no
830
+ * handle returned and every later `openStore` refusing. The bytes destroyed
831
+ * were the only copy: the CURRENT revision has no snapshot, by design. Presence
832
+ * could never have been the witness, and the module knew why before it was
833
+ * written -- a ZERO-LENGTH FILE OPENS and reports `integrity_check ok`, so
834
+ * "the annotations are gone" and "there are no annotations" read the same and
835
+ * the refusal has to be the store's own. That reasoning was applied to
836
+ * `openStore` and not to the image `revertTo` installs.
837
+ *
838
+ * THE COST, IN THE SAME PARAGRAPH AS THE CLAIM. This function now OPENS up to
839
+ * `MAX_SNAPSHOT_REVISIONS` (32) SQLite databases per call, each running an
840
+ * `anno_meta` read, a `schema_version` comparison and `pragma integrity_check`
841
+ * (0.73 ms measured on a 100 KB store). That is affordable because every one of
842
+ * its call sites is QUERY-TIME, and it is affordable only because of that:
843
+ * `oldestRetainedRevision()` (which has no shipped caller at all today) and
844
+ * `revertTo()`'s step-2 gate and refusal list. NOTHING ON THE WRITE PATH READS
845
+ * IT -- `reconcileSnapshotRing`'s keep-set moved to `claimedRevisions` in the
846
+ * same change, which is what keeps the 32 opens out of every accepted write and
847
+ * out of the sweep's own write lock. If a caller ever needs "retained" on a
848
+ * per-write hot path, the answer has to be cached or narrowed and THAT becomes
849
+ * the single decision -- not a fourth one alongside this.
850
+ *
851
+ * THE WITNESS IS COMPUTED FROM THE HANDLE, not read from the row. Version 1
852
+ * persisted the snapshot's absolute path in `anno_snapshot.path` and tested THAT
853
+ * for existence, which is a SECOND TRUTH about one file -- and two truths about
854
+ * one file are two things that can disagree. They did, twice, both reproduced:
855
+ * a directory rename invalidated every persisted path at once, after which this
856
+ * function reported NO retained revisions while the files sat there on disk, and
857
+ * the next write's prune destroyed them (CR-03). The same shape covers every
858
+ * adjacent case rather than just that one repro -- a bind mount seen from two
859
+ * namespaces (this repo's entire architecture is built around that boundary), a
860
+ * symlinked ancestor, a container/host path pair, a case-insensitive filesystem,
861
+ * a `realpath` that changes between two opens. Dropping the column removes the
862
+ * PRIMITIVE: there is no persisted absolute string left, so there is nothing for
863
+ * a second namespace to disagree with.
864
+ *
865
+ * Neither half is sufficient on its own, and the reason is
866
+ * measured rather than theoretical: the snapshot image is a `vacuum into` of
867
+ * the WHOLE store, so it carries the `anno_snapshot` table with it, and
868
+ * restoring it reinstates pointer rows for revisions whose FILES an earlier
869
+ * prune already deleted. A row without a usable image is not a revision anyone
870
+ * can revert to, and reporting it as one steers the caller straight into a raw
871
+ * `ENOENT` out of `copyFileSync` -- or, once the image is present but not a
872
+ * database, into the destruction of the live store.
873
+ *
874
+ * ITS CONSUMERS ARE NAMED HERE so a reader can see the set is closed, and the
875
+ * set is SMALLER than it was: `oldestRetainedRevision()` (the published floor)
876
+ * and `revertTo()` (the step-2 gate, and the "available revisions" list inside
877
+ * its refusal). `reconcileSnapshotRing` IS NO LONGER ONE OF THEM -- it reads
878
+ * `claimedRevisions` instead, because a sweep over FILES asks a different
879
+ * question, and because reading this function from inside that sweep's own
880
+ * `begin immediate` would have opened up to 32 databases with the store's write
881
+ * lock held. Every remaining consumer reads this function rather than deciding
882
+ * for itself what "retained" means: three independent decisions is precisely how
883
+ * the three answers came to disagree, CR-08 was the gap between two of them, and
884
+ * a fourth would also hide the row-only regression from the proofs that exist to
885
+ * catch it.
886
+ */
887
+ export function retainedRevisions(handle: AnnoStoreHandle): number[] {
888
+ return claimedRevisions(handle).filter((revision) => snapshotOpenFailure(handle, revision) === null);
889
+ }
890
+
891
+ /**
892
+ * The smallest revision the snapshot ring still holds, or
893
+ * `NO_RETAINED_REVISION` when it holds none. This is the FLOOR of what
894
+ * `revertTo` can still honour.
895
+ *
896
+ * Read from the pointer ROWS rather than computed as
897
+ * `currentRevision() - MAX_SNAPSHOT_REVISIONS`. The two agree on a store that
898
+ * has only ever been written forward, and they DISAGREE after a revert -- the
899
+ * arithmetic would then name a revision no row records, and a floor naming an
900
+ * unrevertable revision is worse than no floor at all.
901
+ *
902
+ * AND THE ROW-ONLY READING PRODUCED EXACTLY THE FAILURE THAT PARAGRAPH WAS
903
+ * WRITTEN TO AVOID. Reading `min(revision)` off the pointer rows alone
904
+ * published `0` on a store whose `r0.db` the prune had already removed, and
905
+ * following that floor threw a bare `ENOENT` -- the argument above was right
906
+ * and its implementation was one existence check short. So the floor is now
907
+ * the first element of `retainedRevisions()` -- and that reading was then one
908
+ * step short a SECOND time, in the same direction: an existence check published
909
+ * `0` on a store whose `r0.db` was present but was not a database, and
910
+ * following THAT floor destroyed the live store (CR-08). The floor now requires
911
+ * the image to OPEN, not merely to exist, so the store still cannot publish a
912
+ * number it will then refuse -- in either direction.
913
+ */
914
+ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
915
+ const retained = retainedRevisions(handle);
916
+ return retained.length === 0 ? NO_RETAINED_REVISION : retained[0];
917
+ }
918
+
919
+ /**
920
+ * THE resolver for a snapshot-ring half-state, and the only one. Returns what
921
+ * it actually did, so a caller -- and a test -- can ASSERT the resolution
922
+ * rather than infer it from a later symptom.
923
+ *
924
+ * The ring has two truths that can disagree, and therefore two half-states:
925
+ *
926
+ * * AN ORPHAN ROW (a pointer row whose file is gone) IS NO LONGER SWEPT AT
927
+ * ALL, and the reversal is recorded here rather than left to be inferred
928
+ * from an absence. This function used to delete every such row. CR-05
929
+ * reproduced, twice, what that costs: the ring is named from
930
+ * `basename(handle.path)` -- a PATH SPELLING -- so a SYMLINK ALIAS of the
931
+ * store file, or a store-file rename (`mv proj.annostore
932
+ * other.annostore`), makes `retainedRevisions()` report every EXISTING
933
+ * pointer row as unretained, after which this sweep classified them as
934
+ * orphan rows and deleted them under its own committed transaction. A
935
+ * reachable revert history was destroyed irreversibly, and restoring the
936
+ * original name recovered nothing.
937
+ *
938
+ * THE ROW DIRECTION IS ABANDONED RATHER THAN GUARDED because this sweep
939
+ * cannot establish ownership of it in ANY spelling, and a repair that
940
+ * judges a state it cannot have produced is the guess prohibitions 28-10 P3
941
+ * / 28-11 P4 forbid. Trap 10 calls the orphan-ROW direction "the one the
942
+ * revert path cannot survive" and that was true when it was written; it is
943
+ * not true of this code. `retainedRevisions()` requires the image to OPEN
944
+ * as an annotation store, `oldestRetainedRevision()` routes through it, and
945
+ * `revertTo` step 2 refuses on the SAME witness -- `snapshotOpenFailure` --
946
+ * BEFORE anything is destroyed, in three arms rather than one: no pointer
947
+ * row, an image that will not open, and (step 3b) a staged copy that will
948
+ * not open. So the store never advertises an unusable revision and never
949
+ * follows one.
950
+ *
951
+ * AND THE BASIS OF "INERT" HAS CHANGED, so it is restated rather than left
952
+ * to be re-derived. The old basis was that every consumer of "retained"
953
+ * required the FILE, so a row nothing looked at was invisible to the whole
954
+ * ring. That is no longer true: the keep-set below reads
955
+ * `claimedRevisions`, so this sweep now looks at rows the advertisement
956
+ * ignores. THE NEW BASIS IS BETTER RATHER THAN WEAKER, and it is the
957
+ * conclusion CR-08 forced: a row the sweep KEEPS is precisely what makes a
958
+ * corrupt image survive on disk as EVIDENCE instead of being unlinked. A
959
+ * sweep that deleted the image of a failure would be destroying the only
960
+ * record of the failure that has to be diagnosed -- a second destruction
961
+ * dressed as a repair. The CR-05 conclusion is unchanged: the row direction
962
+ * stays abandoned, for the ownership reason above.
963
+ *
964
+ * THE KEEP-SET QUERY RUNS ON THE CONNECTION ALREADY IN HAND, and that is
965
+ * the second, independent reason it reads `claimedRevisions` rather than
966
+ * `retainedRevisions`. This function computes its keep-set INSIDE its own
967
+ * `begin immediate`, with the store's WRITE LOCK held. Under the promoted
968
+ * meaning of "retained", reading that function here would have opened up to
969
+ * `MAX_SNAPSHOT_REVISIONS` (32) SQLite databases while holding that lock, on
970
+ * every accepted write. `claimedRevisions` is one `select` on the connection
971
+ * this function already has, so the promoted witness is NEVER invoked while
972
+ * this sweep holds the store's write lock, and the 32-image cost stays
973
+ * confined to query time.
974
+ *
975
+ * Deleting an orphan row was hygiene, and hygiene that destroyed a
976
+ * reachable revert history is a worse failure than the state it tidied.
977
+ *
978
+ * ROWS STAY BOUNDED WITHOUT THIS SWEEP. `pruneSnapshots`' doomed loop
979
+ * deletes every row below `currentRevision() - MAX_SNAPSHOT_REVISIONS`, so
980
+ * an orphan row is reaped on the ordinary path once it ages out -- and
981
+ * under a wrong spelling that reaping is exactly what the correct spelling
982
+ * would also have done at the same revision, so the behaviour converges to
983
+ * correct instead of diverging into loss. The residual is extra ROWS, which
984
+ * is the direction this module's own trap-10 premise calls harmless.
985
+ * * AN ORPHAN FILE (a snapshot file no surviving pointer row claims) is the
986
+ * harmless direction, and it is harmless only until it is FORGOTTEN: the
987
+ * bound is computed over rows, so an unclaimed file is invisible to it
988
+ * forever. A single revert orphans up to `MAX_SNAPSHOT_REVISIONS` of them
989
+ * at once, which is how the directory bound stopped holding after a
990
+ * revert. Every such file is unlinked and its path reported in
991
+ * `droppedFiles`.
992
+ *
993
+ * Each unlink is `force: true` inside a SWALLOWING `try`, and only a unlink
994
+ * that actually happened is reported: an undeletable file must not make the
995
+ * store unwritable, and leaving it unreported means the NEXT reconciliation
996
+ * sees it again rather than the store believing it is gone.
997
+ *
998
+ * ITS CALL SITES ARE EXACTLY TWO, AND `openStore` IS DELIBERATELY NOT ONE OF
999
+ * THEM. It runs at the end of `revertTo` on the NEW handle before that handle
1000
+ * is returned (a restore is the one operation that manufactures orphan rows),
1001
+ * and as the FIRST statement of `pruneSnapshots` (so the bound is computed
1002
+ * over a ring with no half-states). It must NOT run from `openStore`:
1003
+ * `anno-durability.test.ts:291-347` asserts that an orphan snapshot file left
1004
+ * in the kill window SURVIVES a reopen and is identified by its revision, and
1005
+ * that is a verified truth of plan 28-06 -- merely LOOKING at a store must not
1006
+ * change it, and the orphan a kill window leaves is deliberately the harmless
1007
+ * direction. Reconciling on open would redden that test, and rightly. BOTH
1008
+ * SITES ARE OUTSIDE ANY OPEN TRANSACTION, which is now a REQUIREMENT rather
1009
+ * than an incidental fact: this function opens and closes a transaction of its
1010
+ * own, so calling it from inside one is not supported.
1011
+ *
1012
+ * IT NOW TAKES THE STORE'S WRITE LOCK BEFORE IT DECIDES ANYTHING, and the
1013
+ * reason is a reproduced defect (CR-02) rather than caution. A snapshot becomes
1014
+ * a FILESYSTEM fact (the `renameSync` inside `publishSnapshot`) before it
1015
+ * becomes a TRANSACTIONAL one (the pointer-row insert), so a sweep reading only
1016
+ * its own committed view sees a live writer's published file as unowned and
1017
+ * unlinks it -- after which the winning writer's own pointer row advertises a
1018
+ * revision whose file is gone. That is the orphan-ROW state trap 10 calls
1019
+ * unsurvivable, manufactured by the reconciliation written to prevent it, and
1020
+ * the destroyed revision is permanently unrevertible.
1021
+ *
1022
+ * THE LOCK IS THE EXACT INSTRUMENT, NOT A TIMING HEURISTIC, and the exactness
1023
+ * is derived rather than measured: publication is reachable only from behind a
1024
+ * WON compare-and-swap, and the compare-and-swap runs inside `begin immediate`,
1025
+ * so a writer that is published-but-uncommitted HOLDS this same write lock. The
1026
+ * publish-to-commit window and the write-lock hold are the same interval. A
1027
+ * grace bound over file mtimes was the offered alternative and is refused on the
1028
+ * record: it is a guess about how long a writer may sit between its rename and
1029
+ * its transaction's end, it is wrong for a writer that is paged out or stopped
1030
+ * at a debugger, and the next reproduction of this defect would arrive as a
1031
+ * request to raise the constant.
1032
+ *
1033
+ * `deferred` IS WHAT DECLINING LOOKS LIKE, AND DECLINING IS CORRECT RATHER THAN
1034
+ * BEST-EFFORT. When the lock cannot be taken within the connection's five-second
1035
+ * `busy_timeout`, this function changes NOTHING and returns
1036
+ * `{ droppedFiles: [], deferred: true, rollbackFailed: false }`. A sweep that pressed
1037
+ * on would be judging a state it cannot establish -- exactly the guess the lock
1038
+ * exists to remove -- so it abstains and REPORTS the abstention, which is what
1039
+ * lets a caller and a test assert it rather than infer it from an absence. The
1040
+ * cost is that the ring may temporarily exceed `MAX_SNAPSHOT_REVISIONS` files
1041
+ * until the next accepted write sweeps successfully: extra FILES, the direction
1042
+ * trap 10's own premise calls harmless and reconcilable by revision number.
1043
+ *
1044
+ * `deferred` IS NOW WIDENED, AND THE WIDENING IS STATED RATHER THAN LEFT TO BE
1045
+ * INFERRED FROM THE ONE NEW RETURN SITE. It reports "THIS SWEEP CHANGED
1046
+ * NOTHING -- which holds as stated while `rollbackFailed` is `false`, and which
1047
+ * becomes 'this sweep INTENDED to change nothing but cannot establish that its
1048
+ * own transaction closed' when `rollbackFailed` is `true`", and it covers two
1049
+ * causes: another writer holds the write lock (step
1050
+ * 1 above), and this sweep failed part-way and rolled back (the structural
1051
+ * handler around steps 2-4). The qualifier is in the SAME sentence as the claim
1052
+ * on purpose: an unqualified guarantee with its qualifier further down is the
1053
+ * 28-07 P3 shape this module has already had to correct once, and a reader who
1054
+ * stops at the first sentence must not stop at a claim that is sometimes false.
1055
+ *
1056
+ * `rollbackFailed` IS ALWAYS PRESENT AND IS NEVER OPTIONAL, for the reason the
1057
+ * neighbouring `droppedRows` assertion in `anno-store.test.ts` already records:
1058
+ * a field that can only ever answer one value is a claim the next reader has to
1059
+ * falsify by experiment. It is `false` at every ordinary return site including
1060
+ * the lock-contention deferral, and `true` only where this function's own
1061
+ * `rollback` threw -- which is the one state in which the sentence above cannot
1062
+ * be honoured, and the one this function has no other way to report, because
1063
+ * rethrowing is forbidden here (28-11 P5).
1064
+ *
1065
+ * NO SECOND DISCRIMINATOR WAS ADDED FOR `deferred`'s TWO CAUSES, and the reason
1066
+ * is not economy. Its only consumer, `pruneSnapshots`, returns early
1067
+ * identically in both cases, so a discriminator would have no reader -- and
1068
+ * CR-07's actual complaint, that a LEAKED transaction makes every later sweep
1069
+ * report `deferred` indistinguishably from contention, is removed AT ITS SOURCE
1070
+ * by the handler rather than papered over with a label. A field describing a
1071
+ * state this code can no longer reach would be exactly the kind of comment
1072
+ * prohibition 28-07 P3 forbids, in the shape of an enum.
1073
+ *
1074
+ * THE NEW CALLER-VISIBLE LATENCY, STATED HERE BECAUSE A READER OF THE OLD
1075
+ * COMMENT WOULD NOT EXPECT IT. Before this change the sweep took no write lock
1076
+ * and could not block at all. After it, under contention, a caller blocks HERE
1077
+ * for up to the connection's five-second `busy_timeout` before it proceeds --
1078
+ * at BOTH of the two call sites: every accepted write (through `pruneSnapshots`
1079
+ * at `runWriteSequence` step 9) and every `revertTo` (through its own step-6
1080
+ * sweep on the restored handle). Phase 29 puts both on an MCP tool path. Each is
1081
+ * bounded at ONE timeout and not two, because `pruneSnapshots` returns early
1082
+ * when this function reports `deferred` rather than running its own autocommit
1083
+ * deletes into the same contention. An honest cost stated at the seam is worth
1084
+ * more than a fast comment.
1085
+ *
1086
+ * AND THE TRANSACTION IS STILL CLOSED BEFORE ANY UNLINK, for exactly the reason
1087
+ * `pruneSnapshots`' own loop deletes the row first. This used to be stated as
1088
+ * "the row deletes are committed before any unlink"; there are no row deletes
1089
+ * left (see the ORPHAN ROW bullet above), so what the ordering now guarantees is
1090
+ * narrower and is stated narrowly: an interruption between the commit and the
1091
+ * unlinks leaves extra FILES, never a pointer row aimed at a deleted file.
1092
+ * Pinned by a source-order control in `anno-store.test.ts`, which since CR-05
1093
+ * asserts the ABSENCE of any pointer-row delete in this body as well as the
1094
+ * surviving commit-before-unlink order -- a presence assertion cannot see
1095
+ * either.
1096
+ */
1097
+ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles: string[]; deferred: boolean; rollbackFailed: boolean } {
1098
+ const droppedFiles: string[] = [];
1099
+
1100
+ // STEP 1. Take the store's write lock BEFORE reading anything, so no writer
1101
+ // can be mid-publication while this function decides. Every failure is
1102
+ // treated the same way and none is rethrown: the expected one is
1103
+ // `SQLITE_BUSY` after the connection's five-second `busy_timeout` (another
1104
+ // writer holds the lock), and any other failure equally means this function
1105
+ // cannot establish the moment it is required to judge from. Declining is the
1106
+ // whole contract -- a partial sweep is precisely what must not happen.
1107
+ try {
1108
+ handle.db.exec("begin immediate");
1109
+ } catch {
1110
+ // `rollbackFailed: false` and not omitted: nothing was begun, so there was
1111
+ // no transaction to close and the reported abstention is exact.
1112
+ return { droppedFiles, deferred: true, rollbackFailed: false };
1113
+ }
1114
+
1115
+ // DECLARED OUTSIDE THE HANDLER BELOW so step 5 can still read it after the
1116
+ // handler closes. The transaction's lifetime is structural; the drop set's
1117
+ // scope is not, and conflating the two would put the unlink loop inside the
1118
+ // transaction, which is exactly what trap 10 forbids.
1119
+ const orphanFiles: string[] = [];
1120
+
1121
+ // EVERYTHING FROM HERE TO THE COMMIT IS BRACKETED, AND THE BRACKET IS THE
1122
+ // FIX (CR-07). `begin immediate` above has already opened a transaction on
1123
+ // the CALLER's connection. Before this handler existed, any throw between
1124
+ // that statement and the commit -- `readdirSync` on a ring directory that
1125
+ // became unreadable, a failure of the keep-set `select`, anything --
1126
+ // propagated out with the transaction still
1127
+ // OPEN. Step 9's WR-02 wrap then swallowed it, so an ordinary `setDataType`
1128
+ // reported SUCCESS while leaving the handle permanently inside a transaction:
1129
+ // every later write failed with "cannot start a transaction within a
1130
+ // transaction", and every later sweep reported `deferred` indistinguishably
1131
+ // from ordinary contention. The lifetime is now structural rather than
1132
+ // path-dependent: there is no route out of this block that does not either
1133
+ // commit or roll back.
1134
+ try {
1135
+ // STEP 2. With the lock held, compute the drop set -- which since CR-05 has
1136
+ // exactly ONE direction, the FILE direction -- before changing anything.
1137
+ //
1138
+ // THE KEEP-SET IS THE POINTER-ROW SET, AND THAT IS A DIFFERENT QUESTION
1139
+ // rather than a fourth answer to "what is retained". This resolver still
1140
+ // does NOT re-decide anything with a predicate of its own -- it asks
1141
+ // `claimedRevisions`, the ONE answer to "which revisions does
1142
+ // `anno_snapshot` claim", exactly as it used to ask the ONE answer to "what
1143
+ // is retained".
1144
+ //
1145
+ // WHY THE QUESTION IS GENUINELY DIFFERENT, AND WHY THE ANSWERS ONLY DIVERGE
1146
+ // NOW. A sweep over FILES asks "is this file claimed by a pointer row";
1147
+ // `retainedRevisions` asks "can a caller revert to this revision". Before
1148
+ // CR-08's promotion those two were identical for every reachable input,
1149
+ // because the presence half of the old definition is trivially true of a
1150
+ // file `readdirSync` just returned. The promotion is what separates them,
1151
+ // and the separation runs in the SAFE direction: an image that fails to open
1152
+ // but that a row still claims is kept on disk as EVIDENCE. Leaving this
1153
+ // keep-set on `retainedRevisions` would have made the CR-08 fix its own
1154
+ // second destroyer -- the sweep would unlink exactly the corrupt image whose
1155
+ // refusal has to be diagnosed, one ordinary write after the refusal.
1156
+ //
1157
+ // AND IT IS ONE `select` ON THE CONNECTION ALREADY IN HAND, INSIDE THE WRITE
1158
+ // LOCK. `begin immediate` above is held for the whole of this block, so
1159
+ // reading the promoted `retainedRevisions` here would open up to
1160
+ // `MAX_SNAPSHOT_REVISIONS` (32) SQLite databases with the store's write lock
1161
+ // held, on every accepted write. See the ORPHAN ROW bullet above.
1162
+ const claimed = new Set(claimedRevisions(handle));
1163
+
1164
+ // A store that has never been written has no ring directory at all, and
1165
+ // `readdirSync` throws on an absent one. That is not a half-state -- and the
1166
+ // check lives HERE, inside the drop-set computation, rather than returning
1167
+ // early: an early return from this point would leave the sweep's own
1168
+ // transaction OPEN on the caller's connection. With no directory there are
1169
+ // simply no orphan files, and the function falls through to close its
1170
+ // transaction like any other run.
1171
+ //
1172
+ // NAMED THROUGH `snapshotDirFor` AND ONLY THROUGH IT, which is what confines
1173
+ // this sweep to a ring this store can be the owner of. It cannot see -- and
1174
+ // therefore cannot delete -- a legacy `<dir>/snapshots` ring, or a ring
1175
+ // belonging to a neighbouring store file in the same directory.
1176
+ const snapshotDir = snapshotDirFor(handle);
1177
+ if (existsSync(snapshotDir)) {
1178
+ for (const name of readdirSync(snapshotDir).sort()) {
1179
+ const match = SNAPSHOT_FILE_PATTERN.exec(name);
1180
+ if (!match) continue;
1181
+ if (claimed.has(Number(match[1]))) continue;
1182
+ orphanFiles.push(join(snapshotDir, name));
1183
+ }
1184
+ }
1185
+
1186
+ // STEP 3 IS GONE ON PURPOSE, and its absence is the fix for CR-05. It
1187
+ // deleted every pointer row this handle's spelling of the ring could not
1188
+ // vouch for; under a second spelling of the same store file that was every
1189
+ // row it had. The whole argument is in the ORPHAN ROW bullet above.
1190
+
1191
+ // STEP 4. Close the sweep's own transaction through THE module's single
1192
+ // commit site. It must be `commitTransaction` and never a second
1193
+ // `handle.db.exec` of the bare word: `anno-seam.test.ts` asserts this module
1194
+ // contains exactly ONE such statement, because the durability proof's planted
1195
+ // violation must have a single site -- a second literal would split that
1196
+ // planting and let half of it survive.
1197
+ commitTransaction(handle.db);
1198
+ } catch {
1199
+ // ROLLED BACK INSIDE ITS OWN SWALLOWING `try`: there is nothing useful to
1200
+ // do with a second error here, and reporting it would replace the first.
1201
+ // WHAT IS NEW IS THAT THE OUTCOME IS RECORDED RATHER THAN ASSUMED (WR-16).
1202
+ // Node 22's `DatabaseSync` exposes no transaction-state accessor, so this
1203
+ // boolean is the only thing that can tell a caller which of the two
1204
+ // happened -- and this function cannot tell it by throwing, because
1205
+ // rethrowing here would convert a COMMITTED write into a caller-visible
1206
+ // failure on `revertTo`'s step-6 call site (28-11 P5).
1207
+ let rolledBack = true;
1208
+ try {
1209
+ handle.db.exec("rollback");
1210
+ } catch {
1211
+ // deliberately ignored -- see above; only the FACT is kept.
1212
+ rolledBack = false;
1213
+ }
1214
+ // AND DELIBERATELY NOT RETHROWN. A throw from here is swallowed by step 9's
1215
+ // WR-02 wrap anyway, so rethrowing would buy nothing on the write path --
1216
+ // and on `revertTo`'s own step-6 call site it would convert a COMMITTED
1217
+ // write into a caller-visible failure, which prohibition 28-11 P5 forbids.
1218
+ // The sweep changed nothing, which is precisely what `deferred` reports --
1219
+ // qualified by `rollbackFailed`, which is the one case in which "changed
1220
+ // nothing" is an intention this function cannot establish.
1221
+ return { droppedFiles: [], deferred: true, rollbackFailed: !rolledBack };
1222
+ }
1223
+
1224
+ // STEP 5, AND ITS POSITION IS THE POINT: only now, with the sweep's
1225
+ // transaction durably closed, unlink the orphan files. An interruption
1226
+ // between step 4 and here leaves extra FILES, which trap 10's premise calls
1227
+ // harmless and reconcilable by revision number, and never a pointer row aimed
1228
+ // at a deleted file.
1229
+ //
1230
+ // Each unlink is `force: true` inside a SWALLOWING `try`, and only a unlink
1231
+ // that actually happened is reported: an undeletable file must not make the
1232
+ // store unwritable, and leaving it unreported means the NEXT reconciliation
1233
+ // sees it again rather than the store believing it is gone.
1234
+ for (const orphan of orphanFiles) {
1235
+ try {
1236
+ rmSync(orphan, { force: true });
1237
+ droppedFiles.push(orphan);
1238
+ } catch {
1239
+ // Deliberately ignored, and deliberately NOT reported as dropped: an
1240
+ // undeletable file must not make the store unwritable, and the next
1241
+ // reconciliation has to see it again rather than believe it is gone.
1242
+ }
1243
+ }
1244
+
1245
+ // `rollbackFailed: false` on the ordinary path: control only reaches here
1246
+ // through the commit above, so no rollback was attempted at all.
1247
+ return { droppedFiles, deferred: false, rollbackFailed: false };
1248
+ }
1249
+
1250
+ /**
1251
+ * Bounds the store's own snapshot ring directory (`snapshotDirFor()`) at
1252
+ * `MAX_SNAPSHOT_REVISIONS` by
1253
+ * deleting every snapshot older than the newest `MAX_SNAPSHOT_REVISIONS`
1254
+ * revisions -- ITS POINTER ROW FIRST, THE FILE SECOND.
1255
+ *
1256
+ * MUST BE CALLED AFTER THE COMMIT AND OUTSIDE THE TRANSACTION. Trap 10 in the
1257
+ * module header carries the whole argument; the short form is that an unlink is
1258
+ * not transactional, so the ordering around the commit CHOOSES which failure a
1259
+ * kill in the window produces -- and the choice made here is "extra files"
1260
+ * over "a pointer row aimed at a deleted file".
1261
+ *
1262
+ * THE SAME CHOICE IS MADE AGAIN INSIDE THE LOOP, between its two statements,
1263
+ * and for the same reason. A kill landing there leaves an orphan FILE -- which
1264
+ * trap 10's premise already calls harmless and reconcilable by revision
1265
+ * number -- and never an orphan ROW. The ordering is pinned by a source-order
1266
+ * control in `anno-store.test.ts`, because both statements are present in
1267
+ * either arrangement and a presence assertion cannot see the difference.
1268
+ *
1269
+ * The bound itself lives in `anno-types.ts` and is imported, never copied: a
1270
+ * second literal would drift the moment the first one is edited, silently, and
1271
+ * a store whose pruning bound disagrees with its declared bound has a revert
1272
+ * history shorter than it says it has.
1273
+ *
1274
+ * ITS FIRST STATEMENT NOW TAKES AND RELEASES A TRANSACTION, so `pruneSnapshots`
1275
+ * itself must not be called from inside one -- and under contention that first
1276
+ * statement can block for the connection's five-second `busy_timeout`. See
1277
+ * `reconcileSnapshotRing` for the whole argument and for the latency this adds
1278
+ * at both of its call sites.
1279
+ */
1280
+ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
1281
+ // FIRST, BEFORE THE BOUND IS COMPUTED: resolve any half-state, so the bound
1282
+ // is computed over a ring whose rows and files agree. This is what makes the
1283
+ // directory bound hold AFTER A REVERT as well as after a forward-only run --
1284
+ // a restore leaves up to `MAX_SNAPSHOT_REVISIONS` files claimed by no row,
1285
+ // and a prune that iterates rows alone can never see them.
1286
+ //
1287
+ // AND ITS REPORT IS CONSUMED RATHER THAN DISCARDED. When the sweep DECLINED
1288
+ // to judge -- it could not take the write lock inside the connection's
1289
+ // five-second `busy_timeout`, so another writer is mid-publication -- this
1290
+ // function returns here, before the doomed-set `select`. Two reasons, and the
1291
+ // second is the load-bearing one:
1292
+ //
1293
+ // 1. It bounds the added latency at ONE `busy_timeout` rather than two. The
1294
+ // doomed-set deletes below are autocommit WRITES, so under the same
1295
+ // contention they would block a second five seconds and then fail
1296
+ // `SQLITE_BUSY` -- which the step-9 call site's wrap swallows, making the
1297
+ // worst case roughly ten seconds of silent added latency for work that is
1298
+ // guaranteed to be redone.
1299
+ // 2. The doomed set would be computed over a ring the sweep just declined to
1300
+ // reconcile. Acting on it is the same guess the sweep abstained from,
1301
+ // with an extra step: a prune that presses on where its own sweep
1302
+ // abstained publishes a bound the store cannot support.
1303
+ //
1304
+ // The cost is one more accepted write's worth of un-pruned ring -- extra
1305
+ // FILES, the direction trap 10's premise calls harmless and reconcilable by
1306
+ // revision number, and the same direction a deferred sweep already accepts.
1307
+ //
1308
+ // AND ITS `rollbackFailed` IS CONSUMED FOR THE SAME REASON, RETURNED RATHER
1309
+ // THAN DISCARDED (WR-18). The argument recorded above for consuming
1310
+ // `.deferred` is the argument for consuming this one, so it is extended here
1311
+ // rather than restated: a fact this function throws away is a fact its caller
1312
+ // cannot act on, and `rollbackFailed` reports the ONE state the sweep's own
1313
+ // handler cannot fix -- its `rollback` threw, so the connection may still hold
1314
+ // an open transaction and the store's write lock. Until this return existed
1315
+ // the field had no production reader anywhere, which is what a field whose
1316
+ // only reader is a test asserting it is always `false` amounts to.
1317
+ //
1318
+ // IT IS RETURNED, NOT THROWN, and the distinction is prohibition 28-11 P5:
1319
+ // both call sites reach this function AFTER a write or a revert has already
1320
+ // landed, so a throw here would convert a committed write into a
1321
+ // caller-visible failure.
1322
+ const swept = reconcileSnapshotRing(handle);
1323
+ if (swept.deferred) return swept.rollbackFailed;
1324
+
1325
+ const floor = currentRevision(handle) - MAX_SNAPSHOT_REVISIONS;
1326
+ const doomed = handle.db.prepare("select revision from anno_snapshot where revision < ? order by revision").all(floor) as {
1327
+ revision: number;
1328
+ }[];
1329
+
1330
+ for (const row of doomed) {
1331
+ // THE POINTER ROW GOES FIRST, AND THE ORDER IS THE GUARANTEE. The prune
1332
+ // runs outside any transaction (correctly -- see above), so a kill BETWEEN
1333
+ // these two statements decides which half-state survives. Row-then-file
1334
+ // leaves an orphan FILE, which trap 10's own premise calls harmless and
1335
+ // reconcilable by revision number. File-then-row -- the arrangement this
1336
+ // loop used to be written in -- leaves a POINTER ROW AIMED AT A DELETED
1337
+ // FILE, which is the one failure direction the revert path cannot survive.
1338
+ // Deliberately NOT swallowed: the row delete is the half that must be loud.
1339
+ handle.db.prepare("delete from anno_snapshot where revision = ?").run(row.revision);
1340
+
1341
+ // Swallowed on purpose, and ONLY here: an interrupted earlier prune may
1342
+ // already have removed this file, and a prune that threw on an
1343
+ // already-absent file would make the store unwritable after a single kill
1344
+ // in the window. With the row already gone, a file this fails to unlink is
1345
+ // an orphan FILE -- which the reconciliation at the top of the NEXT prune
1346
+ // can still see and retry. Under the earlier arrangement the row was
1347
+ // deleted unconditionally after a swallowed failure, so the file became
1348
+ // invisible to the bound forever (WR-01's secondary point).
1349
+ try {
1350
+ // THE PATH IS COMPUTED HERE, at the delete, from the handle -- never read
1351
+ // from the row. A persisted absolute path is environment-controlled input
1352
+ // to an `rmSync`; the only path this delete can name is
1353
+ // `join(snapshotDirFor(handle), "r<digits>.db")`.
1354
+ rmSync(snapshotPathFor(handle, row.revision), { force: true });
1355
+ } catch {
1356
+ // deliberately ignored -- see above
1357
+ }
1358
+ }
1359
+
1360
+ // Control only reaches here through a sweep that returned `deferred: false`,
1361
+ // which is only produced after its own commit -- so no rollback was attempted
1362
+ // at all and the answer is exact rather than a default.
1363
+ return false;
1364
+ }
1365
+
1366
+ /**
1367
+ * Vacuums the store's CURRENT contents into a snapshot file staged under a name
1368
+ * unique to THIS ATTEMPT, and returns that path. It never reads, writes,
1369
+ * removes or renames the published `r<revision>.db` -- publication is
1370
+ * `publishSnapshot`'s job and happens only after the compare-and-swap below has
1371
+ * been won.
1372
+ *
1373
+ * EXPORTED FOR EXACTLY ONE REASON, and no other: the snapshot-ownership proof
1374
+ * has to drive the IDENTICAL staging code the production writer uses. A
1375
+ * hand-copied variant inside a test can drift out of agreement with the real
1376
+ * one, and a proof that agrees with a copy proves nothing about the original.
1377
+ * `anno-seam.test.ts` asserts that no shipped module other than this one so
1378
+ * much as names it -- the same bound `applyWriteWithoutCommit` carries, by the
1379
+ * same mechanism rather than a second one.
1380
+ *
1381
+ * THE STAGING SUFFIX IS DELIBERATELY OUTSIDE `SNAPSHOT_FILE_PATTERN`. That
1382
+ * pattern is anchored on `r<digits>.db`, and `reconcileSnapshotRing`'s
1383
+ * directory sweep matches only what it matches -- so a concurrent writer's
1384
+ * in-flight staging file is invisible to the sweep and can never be deleted out
1385
+ * from under it. That anchoring is the contract between this function and the
1386
+ * reconciliation; neither side may drift from it.
1387
+ *
1388
+ * THE UNIQUENESS IS PER ATTEMPT, NOT PER REVISION, and the distinction is
1389
+ * load-bearing: a revision number can recur after a revert, and two attempts at
1390
+ * the same revision -- in this process or another -- must not share a path,
1391
+ * because `vacuum into` refuses an existing target and because two writers
1392
+ * filling one file is the very collision this staging exists to remove.
1393
+ *
1394
+ * AND THE IMAGE IS FSYNCED BEFORE THIS FUNCTION RETURNS (WR-13). The pointer row
1395
+ * that names this file is inserted inside the write transaction and committed by
1396
+ * SQLite, WHICH DOES FSYNC -- so without the `fsyncPath` below the ROW is
1397
+ * durable and the FILE it names is not. Trap 10's durability premise covers a
1398
+ * `SIGKILL`, where the page cache survives the dead process and the bytes land
1399
+ * anyway; it does NOT cover a host crash, which loses the cache. The consequence
1400
+ * is not an extra file: it is a PRESENT, PARTIAL snapshot that
1401
+ * `retainedRevisions()` would advertise as revertible, which is exactly the input
1402
+ * CR-08 was reproduced with. 28-16's step-2 and step-3b gates make that input a
1403
+ * REFUSAL rather than a destruction; this call removes the input at its source
1404
+ * rather than relying on the refusal, because a refusal on the only route back
1405
+ * is still a lost history.
1406
+ */
1407
+ export function stageSnapshot(handle: AnnoStoreHandle, revision: number): string {
1408
+ const snapPath = snapshotPathFor(handle, revision);
1409
+ mkdirSync(dirname(snapPath), { recursive: true });
1410
+ const staging = join(dirname(snapPath), `r${revision}.${process.pid}.${randomUUID()}.tmp`);
1411
+ handle.db.exec(`vacuum into ${sqlQuotedPath(staging)}`);
1412
+ fsyncPath(staging);
1413
+ return staging;
1414
+ }
1415
+
1416
+ /**
1417
+ * Publishes a staged snapshot onto its revision's user-visible path. One
1418
+ * `renameSync`, called from exactly one place: between the WON compare-and-swap
1419
+ * and the pointer-row insert, so the only writer that can publish is the writer
1420
+ * that will own the row.
1421
+ *
1422
+ * THE REVERSAL THIS RECORDS. The code here used to be an unconditional
1423
+ * `rmSync(snapPath, { force: true })` followed by a `vacuum into` of the
1424
+ * published path, both BEFORE `begin immediate`, justified by a comment reading
1425
+ * "`vacuum into` refuses an existing target, and a revision number can recur
1426
+ * after a revert, so the stale file is removed rather than colliding". THE
1427
+ * PREMISE IS TRUE AND IS KEPT: `vacuum into` does refuse an existing target, and
1428
+ * a revision number does recur after a revert. THE REMEDY WAS WRONG. Removing
1429
+ * the published path is an unowned write, performed by a writer that may be
1430
+ * about to be refused, onto a file a COMMITTED pointer row already claims -- so
1431
+ * a losing writer replaced a winner's bytes and left that winner's row
1432
+ * describing a different revision, which `revertTo` then restored without a
1433
+ * word. The recurrence is handled HERE instead: a rename overwrites without a
1434
+ * prior removal, so the stale file is replaced by the writer that OWNS the new
1435
+ * pointer row and by no one else.
1436
+ *
1437
+ * AND A RENAME HERE CANNOT DESTROY A CLAIMED SNAPSHOT, which is proved rather
1438
+ * than hoped. Pointer rows are inserted for the PRE-mutation revision, and the
1439
+ * vacuum image is taken before that insert -- so every row in any surviving
1440
+ * pointer table names a revision STRICTLY BELOW the revision the store is at.
1441
+ * The only path this rename targets is the CURRENT revision's, which no
1442
+ * surviving row can name. `anno-store.test.ts` asserts that property over a
1443
+ * store that has been reverted and written forward again, rather than leaving
1444
+ * it as an argument.
1445
+ *
1446
+ * AND THE RING DIRECTORY IS FSYNCED AFTER THE RENAME (WR-13). A rename is
1447
+ * VISIBLE immediately and DURABLE only after the directory is fsynced -- the
1448
+ * distinction `fsyncPath`'s own doc sentence records. The pointer row that names
1449
+ * this file is inserted inside the write transaction a few statements below and
1450
+ * committed by SQLite, WHICH DOES FSYNC, so without this call the two halves of
1451
+ * one revision's record have different durability: the ROW survives a host crash
1452
+ * and the DIRECTORY ENTRY naming its image may not. Trap 10's premise -- that a
1453
+ * kill in the window leaves EXTRA files, which are harmless -- is true of a
1454
+ * `SIGKILL` and NOT of a host crash, which loses the page cache; the surviving
1455
+ * half-state there is a durable row naming a file whose bytes never reached
1456
+ * disk, i.e. the PRESENT, PARTIAL snapshot `retainedRevisions()` would advertise
1457
+ * and the exact input CR-08 was reproduced with. 28-16 made that input a refusal
1458
+ * rather than a destruction; this call removes the input at its source instead of
1459
+ * relying on that refusal. The order is the same as `revertTo`'s steps 3 and 5
1460
+ * and uses the same helper, deliberately -- a second durability idiom in one
1461
+ * module is a second thing to keep true.
1462
+ *
1463
+ * AND THE DIRECTORY FSYNC IS BEST EFFORT WHILE THE IMAGE FSYNC IS NOT, which is
1464
+ * a difference in WHICH half-state each one removes and not a difference in
1465
+ * rigour. `stageSnapshot`'s `fsyncPath` is unguarded because it removes the
1466
+ * DESTRUCTIVE outcome: a durable row naming a file whose BYTES never reached
1467
+ * disk, i.e. a present, partial image `retainedRevisions()` advertises. A failure
1468
+ * there refuses before `begin immediate`, so nothing is published and nothing is
1469
+ * committed. THIS call removes only the OTHER outcome -- a crash losing the
1470
+ * directory entry, which leaves an orphan ROW naming a file that is not there,
1471
+ * the direction trap 10's premise already calls harmless, 28-13 made inert and
1472
+ * 28-16 refuses BY NAME. Refusing an ordinary write because that harmless
1473
+ * direction could not be closed would trade a bounded, non-destructive
1474
+ * half-state for a store that cannot be written at all: `openSync(dir, "r")`
1475
+ * needs the ring directory READABLE, so a writable-but-unreadable ring (mode
1476
+ * 0300 -- measured) would make every `setDataType` throw, and that is also the
1477
+ * precondition CR-07's only behavioural control is built from. So the two
1478
+ * reachable outcomes after an interruption stay exactly the two this module
1479
+ * bounds them to -- a missing entry, or an entry whose contents ARE durable --
1480
+ * and the failure of this call moves the outcome from the second to the first
1481
+ * rather than out of the pair.
1482
+ */
1483
+ function publishSnapshot(stagingPath: string, snapPath: string): void {
1484
+ renameSync(stagingPath, snapPath);
1485
+ try {
1486
+ fsyncPath(dirname(snapPath));
1487
+ } catch {
1488
+ // deliberately ignored -- see above. Swallowed rather than reported for the
1489
+ // same reason `discardSnapshot` swallows: replacing the caller's ACTUAL
1490
+ // outcome with a second error about a durability step whose failure leaves
1491
+ // an already-bounded half-state is a worse answer than the one it replaces.
1492
+ }
1493
+ }
1494
+
1495
+ /**
1496
+ * Removes a staged snapshot that will never be published, on every refusal and
1497
+ * every rollback exit of the write sequence. A safe no-op after a successful
1498
+ * publication, because the staged file no longer exists under that name -- which
1499
+ * is why the call sites do not have to know which side of the publication they
1500
+ * are on.
1501
+ *
1502
+ * AND IT IS THE ONE PLACE A STAGING FILE IS REMOVED, which is why `revertTo`'s
1503
+ * three cleanup exits route through it too (WR-24 / WR-11). Those three used to
1504
+ * be bare `rmSync(staging, { force: true })` calls, so the module had two
1505
+ * answers to "where does a staging file get removed" and a later reader looking
1506
+ * for the one place found only half of them. The swallowing semantics below are
1507
+ * what all three of those sites want as well: each is already refusing with a
1508
+ * named error the caller needs to read, and a second error about a temporary
1509
+ * file would replace it.
1510
+ *
1511
+ * Swallowing on purpose. A staging file this fails to remove is an orphan
1512
+ * `.tmp`, and an orphan `.tmp` is harmless: nothing addresses it, no pointer row
1513
+ * can name it and the reconciliation sweep does not match it. Reporting a second
1514
+ * error here would replace the caller's ACTUAL refusal -- the one it needs to
1515
+ * read -- with a confusing one about a temporary file.
1516
+ */
1517
+ function discardSnapshot(stagingPath: string): void {
1518
+ try {
1519
+ rmSync(stagingPath, { force: true });
1520
+ } catch {
1521
+ // deliberately ignored -- see above
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * The write sequence. THE ORDER BELOW IS LOAD-BEARING and is not a style
1527
+ * choice:
1528
+ *
1529
+ * 1. read the current revision;
1530
+ * 2. refuse immediately if the caller based its edit on a different one;
1531
+ * 3. STAGE the pre-mutation snapshot with `vacuum into`, under a name unique
1532
+ * to this attempt and outside the published naming;
1533
+ * 4. `begin immediate`;
1534
+ * 5. compare-and-swap the revision, requiring exactly one changed row;
1535
+ * 6. PUBLISH the staged snapshot onto the revision's path by rename, then
1536
+ * insert the snapshot pointer row for the PRE-mutation revision;
1537
+ * 7. run the caller's mutation;
1538
+ * 8. commit -- once, through the module's one commit site;
1539
+ * 9. prune the snapshot ring -- AFTER the commit and OUTSIDE the
1540
+ * transaction, because an unlink is not transactional (trap 10).
1541
+ *
1542
+ * WHY THE SNAPSHOT PRECEDES THE MUTATION: reverse the two and a kill inside
1543
+ * the window leaves a DURABLE MUTATION WITH NO SNAPSHOT -- an edit that can
1544
+ * never be undone. In the present order a kill inside the window leaves an
1545
+ * orphan snapshot FILE, which is harmless and reconcilable by revision number.
1546
+ *
1547
+ * WHY THE SNAPSHOT IS STAGED AND ONLY PUBLISHED AFTER THE COMPARE-AND-SWAP IS
1548
+ * WON: a published snapshot must have EXACTLY ONE writer -- the writer that
1549
+ * goes on to commit that revision's pointer row. The earlier arrangement did
1550
+ * the filesystem work before the lock, on the published path, with no check
1551
+ * that a committed pointer row already owned it, so a writer that was about to
1552
+ * be REFUSED could still replace a winner's bytes:
1553
+ *
1554
+ * B reads rev 5; A reads rev 5, snapshots r5.db, wins the CAS, commits the
1555
+ * row (5, r5.db); B removes r5.db and re-creates it from the CURRENT
1556
+ * (revision 6) state; B's CAS then fails and B is refused -- leaving A's
1557
+ * committed row describing revision 6, and `revertTo(5)` silently restoring
1558
+ * the wrong state.
1559
+ *
1560
+ * A refusal must be indistinguishable, from every other process's point of
1561
+ * view, from the write never having been attempted -- ON DISK INCLUDED, in
1562
+ * bytes nobody reads until a revert. Staging under a per-attempt name and
1563
+ * publishing by rename only after the CAS is won is what makes that true: a
1564
+ * loser touches nothing but its own staging file, and discards even that.
1565
+ *
1566
+ * WHY THE POINTER ROW IS INSERTED INSIDE THE SAME TRANSACTION AS THE MUTATION:
1567
+ * that is the mechanism that makes the durability claim and the revert claim
1568
+ * fail TOGETHER from one planted violation instead of separately. Removing the
1569
+ * commit at step 8 loses the mutation and the pointer row at once, so a single
1570
+ * combined test reddens in both halves.
1571
+ */
1572
+ function runWriteSequence<T>(
1573
+ handle: AnnoStoreHandle,
1574
+ mutate: (db: DatabaseSync) => T,
1575
+ doCommit: boolean,
1576
+ baseRevision?: number,
1577
+ ): { revision: number; result: T } {
1578
+ // BEFORE `begin immediate`, AND BEFORE ANYTHING IS READ (WR-18). A previous
1579
+ // write's housekeeping sweep ran on THIS connection and reported that its own
1580
+ // `rollback` threw, so the connection may still hold an open transaction and
1581
+ // the store's write lock. Without this refusal the next `begin immediate`
1582
+ // surfaces SQLite's bare `cannot start a transaction within a transaction` --
1583
+ // CR-07's exact reported symptom, outside the `ViceError` family, with nothing
1584
+ // naming the cause or the remedy.
1585
+ //
1586
+ // AN EXISTING IN-FAMILY CLASS, NOT A NEW ONE: this is the same fact the commit
1587
+ // handler and the publish handler already report in prose, so it is reported
1588
+ // in the same words -- CLOSE IT AND REOPEN -- rather than given a second
1589
+ // vocabulary a caller would have to learn.
1590
+ if (handle.transactionStateUnknown) {
1591
+ throw new AnnoStoreError(
1592
+ `${handle.path}: refusing the write -- a previous write's housekeeping sweep on this connection could not roll back its own ` +
1593
+ `transaction, so this connection may still hold an open transaction and the store's write lock. Its transaction state cannot ` +
1594
+ `be established from this process (Node's DatabaseSync exposes no transaction-state accessor), so it is not reused: CLOSE IT ` +
1595
+ `AND REOPEN rather than reusing it. The store on disk is unharmed -- the write that produced this state COMMITTED -- and a ` +
1596
+ `freshly opened handle on the same path writes normally.`,
1597
+ { data: { path: handle.path, step: "refuse a handle whose transaction state is unknown" } },
1598
+ );
1599
+ }
1600
+
1601
+ const rev = currentRevision(handle);
1602
+
1603
+ if (baseRevision !== undefined && baseRevision !== rev) {
1604
+ throw new AnnoStoreStaleRevisionError(
1605
+ `refusing the write: base revision ${baseRevision} is not the current on-disk revision ${rev}`,
1606
+ { baseRevision, currentRevision: rev },
1607
+ );
1608
+ }
1609
+
1610
+ // WR-01, THE PRE-LOCK ARM. `stageSnapshot` runs BEFORE `begin immediate`, so
1611
+ // it gets its own handler rather than sharing the outer one below: there is
1612
+ // no transaction to roll back yet and no staged file to discard, so the two
1613
+ // arms genuinely differ in what they have to undo. The reachable input is a
1614
+ // regular FILE sitting where the ring directory should be, which makes
1615
+ // `mkdirSync` throw `EEXIST` with no privilege and no race involved -- and
1616
+ // unwrapped that escaped as a bare `Error`.
1617
+ let staging: string;
1618
+ try {
1619
+ staging = stageSnapshot(handle, rev);
1620
+ } catch (e) {
1621
+ if (e instanceof ViceError) throw e;
1622
+ throw new AnnoStoreError(
1623
+ `${handle.path}: the write sequence failed while staging the pre-mutation snapshot for revision ${rev} ` +
1624
+ `(${(e as Error).message}). Nothing has been changed -- the transaction was not opened.`,
1625
+ { data: { path: handle.path, revision: rev, step: "stage the pre-mutation snapshot" } },
1626
+ );
1627
+ }
1628
+
1629
+ // WR-01, THE MAIN WINDOW: `begin immediate`, the compare-and-swap, the
1630
+ // publication and the pointer-row insert, wrapped as ONE region. Its catch
1631
+ // undoes both kinds of state this region can leave behind -- an open
1632
+ // transaction with the compare-and-swap applied, and a staged `.tmp` -- and
1633
+ // only then rethrows. A `ViceError` goes through UNCHANGED so no existing
1634
+ // refusal's class or message moves; anything else is wrapped, which is what
1635
+ // keeps the family closed.
1636
+ //
1637
+ // THE INNER ROLLBACK AND DISCARD IN THE CAS-FAILURE BRANCH BELOW ARE NOT
1638
+ // REDUNDANT AND MUST NOT BE "SIMPLIFIED" AWAY. `anno-store.test.ts`'s WR-11
1639
+ // control extracts the slice between `cas.changes` and `publishSnapshot` and
1640
+ // asserts a `rollback` is present inside it, positioned after the
1641
+ // `select revision from anno_meta` read -- that positioning is a VERIFIED
1642
+ // behaviour, because the second number is only visible while that transaction
1643
+ // still sees it. What this handler's own second attempt does on a connection
1644
+ // that branch already rolled back is throw "no transaction is active", which
1645
+ // its own swallowing `try` absorbs; and its second `discardSnapshot` is a
1646
+ // `force: true` no-op.
1647
+ try {
1648
+ handle.db.exec("begin immediate");
1649
+
1650
+ const cas = handle.db.prepare("update anno_meta set revision = revision + 1 where id = 1 and revision = ?").run(rev);
1651
+ if (Number(cas.changes) !== 1) {
1652
+ // THE SECOND NUMBER IS READ BEFORE THE ROLLBACK, and the order is the
1653
+ // point: this is the one refusal path on which a CONCURRENT writer moved
1654
+ // the revision, so it is the path on which the second number is most
1655
+ // informative -- and it is only visible while this transaction still sees
1656
+ // it. Reporting "the revision moved" with one number is the word
1657
+ // "conflict" with extra steps: the caller cannot tell a lost race from a
1658
+ // mistyped base, and cannot say which two values disagreed.
1659
+ const moved = handle.db.prepare("select revision from anno_meta where id = 1").get() as { revision: number } | undefined;
1660
+ handle.db.exec("rollback");
1661
+ discardSnapshot(staging);
1662
+ throw new AnnoStoreStaleRevisionError(
1663
+ `refusing the write: the revision moved under us (expected ${rev}, found ${moved === undefined ? "no meta row" : moved.revision})`,
1664
+ { baseRevision: rev, currentRevision: moved?.revision },
1665
+ );
1666
+ }
1667
+
1668
+ // ONLY THE WINNER REACHES HERE, which is the whole ownership discipline: the
1669
+ // publication sits between the won compare-and-swap and the pointer-row
1670
+ // insert, so the writer that puts the bytes at the revision's path is
1671
+ // exactly the writer whose row will claim them. A loser never names this
1672
+ // path at all.
1673
+ const snapPath = snapshotPathFor(handle, rev);
1674
+ publishSnapshot(staging, snapPath);
1675
+
1676
+ // THE ROW CARRIES A REVISION NUMBER AND NOTHING ELSE. Its location is not
1677
+ // persisted: `snapshotPathFor(handle, revision)` recomputes it at every read
1678
+ // and every delete, so the row cannot come to disagree with the file it
1679
+ // claims (see `retainedRevisions`).
1680
+ handle.db.prepare("insert into anno_snapshot(revision) values (?)").run(rev);
1681
+ } catch (e) {
1682
+ // THE ROLLBACK'S OUTCOME IS RECORDED, NOT ASSUMED (WR-16). The message below
1683
+ // used to state the rollback as a fact after this `catch` had swallowed that
1684
+ // rollback's own failure, so the one case in which the claim is false is
1685
+ // exactly the case in which it was printed. Node 22's `DatabaseSync` exposes
1686
+ // no transaction-state accessor -- the surface is `open, close, prepare,
1687
+ // exec, function, location, aggregate, createSession, applyChangeset,
1688
+ // enableLoadExtension, loadExtension`, measured on this host -- so this local
1689
+ // is the only thing that can keep the message honest.
1690
+ let rolledBack = true;
1691
+ try {
1692
+ handle.db.exec("rollback");
1693
+ } catch {
1694
+ // Deliberately ignored. On the CAS-failure path this connection has
1695
+ // already been rolled back, so this second attempt reports "no
1696
+ // transaction is active" -- and there is nothing useful to do with a
1697
+ // second error anyway: reporting it would replace the caller's actual
1698
+ // refusal. Only the FACT is kept.
1699
+ rolledBack = false;
1700
+ }
1701
+ // Unconditional and safe unconditionally: `force: true` on a name the
1702
+ // publication may already have renamed away is a no-op, so this call site
1703
+ // does not have to know which side of the publication the failure landed on.
1704
+ // What it removes is the case that matters -- a failure BEFORE the rename,
1705
+ // which would otherwise leave a `.tmp` nothing addresses and that the
1706
+ // reconciliation sweep is deliberately anchored NOT to match, so nothing
1707
+ // would ever clean it up.
1708
+ discardSnapshot(staging);
1709
+ if (e instanceof ViceError) throw e;
1710
+ throw new AnnoStoreError(
1711
+ `${handle.path}: the write sequence failed between the staged snapshot and the pointer-row insert for revision ${rev} ` +
1712
+ `(${(e as Error).message}). ` +
1713
+ (rolledBack
1714
+ ? `The transaction has been rolled back and the staged snapshot discarded, so the revision is unchanged.`
1715
+ : `The staged snapshot has been discarded, but the rollback ALSO failed: this connection may still hold an open transaction ` +
1716
+ `and the store's write lock, so CLOSE IT AND REOPEN rather than reusing it. Nothing was written -- the store on disk is ` +
1717
+ `still at revision ${rev} -- but this connection's own view of the revision cannot be trusted until it is reopened.`),
1718
+ { data: { path: handle.path, revision: rev, rolledBack, step: "publish the snapshot and insert its pointer row" } },
1719
+ );
1720
+ }
1721
+
1722
+ // A REFUSAL RAISED INSIDE THE MUTATION MUST ROLL THE WHOLE SEQUENCE BACK.
1723
+ // Several entry points below refuse from inside their mutation on purpose,
1724
+ // because the refusal needs to read rows -- a label name already bound to a
1725
+ // different address is the load-bearing case, and reading it outside the
1726
+ // transaction would open a window in which a concurrent writer binds the
1727
+ // name between the read and the insert. Without this rollback the thrown
1728
+ // refusal would leave the transaction OPEN with the revision compare-and-swap
1729
+ // already applied, so `currentRevision()` on this same connection would report
1730
+ // an advanced revision for a write that was refused, and every later statement
1731
+ // would run inside a transaction nobody meant to start.
1732
+ //
1733
+ // The inner catch is deliberately silent: if the rollback itself fails there
1734
+ // is nothing useful to do with that second error, and reporting it would
1735
+ // replace the caller's actual refusal with a confusing one.
1736
+ let result: T;
1737
+ try {
1738
+ result = mutate(handle.db);
1739
+ } catch (mutationError) {
1740
+ try {
1741
+ handle.db.exec("rollback");
1742
+ } catch {
1743
+ // deliberately ignored -- see above
1744
+ }
1745
+ // Unconditional, and safe unconditionally: by this point the staged file has
1746
+ // already been renamed onto the revision's path, so this is a no-op -- the
1747
+ // call site does not have to know which side of the publication it is on.
1748
+ // What the rollback DOES leave behind is a published FILE no pointer row
1749
+ // claims, which is the harmless direction: `reconcileSnapshotRing` sweeps
1750
+ // exactly that.
1751
+ discardSnapshot(staging);
1752
+ throw mutationError;
1753
+ }
1754
+
1755
+ if (doCommit) {
1756
+ // CR-06, THE COMMIT ARM. This is the ONE statement in the sequence whose
1757
+ // failure leaves the transaction OPEN with everything already applied -- the
1758
+ // compare-and-swap, the caller's mutation and the pointer-row insert -- and
1759
+ // it was outside every handler until this arm was added. A concurrent READER
1760
+ // is enough to trigger it: `COMMIT` of a write transaction needs SQLite's
1761
+ // EXCLUSIVE lock, and step 4's `begin immediate` never excluded readers. It
1762
+ // was reproduced with a genuinely separate OS process holding a read
1763
+ // transaction: a bare `Error: database is locked` after the connection's
1764
+ // 5000 ms `busy_timeout`, outside the `ViceError` family, with the write
1765
+ // lock still held and `currentRevision()` on this connection reporting the
1766
+ // ADVANCED revision for a write that never landed. On the Phase 29 tool
1767
+ // path a handle lives as long as the session, so the leaked write lock
1768
+ // locks every other connection out for that long.
1769
+ //
1770
+ // THE ROLLBACK IS THE REPAIR, not the refusal: it releases the store's write
1771
+ // lock and undoes the compare-and-swap, the caller's mutation and the
1772
+ // pointer-row insert TOGETHER, so `currentRevision()` on this connection
1773
+ // goes back to `rev` and the handle is immediately usable again. Its inner
1774
+ // `catch` swallows for the same stated reason as the two handlers above --
1775
+ // there is nothing useful to do with a second error and reporting it would
1776
+ // replace the caller's actual refusal.
1777
+ //
1778
+ // `discardSnapshot(staging)` is a NO-OP by the time control reaches here:
1779
+ // `publishSnapshot` has already renamed the staged file onto the revision's
1780
+ // published path, so nothing exists under the staging name and `force: true`
1781
+ // returns quietly. It is called anyway so this arm matches the other two and
1782
+ // no future reader has to prove which side of the publication it is on. The
1783
+ // PUBLISHED file is deliberately left behind: with the pointer row rolled
1784
+ // back it is an orphan FILE -- the harmless direction, which the next
1785
+ // accepted write's sweep reclaims.
1786
+ //
1787
+ // The refusal carries `code` from the underlying error when it has one
1788
+ // (IN-05's cheap half, on this wrap only), so a caller can ask whether the
1789
+ // failure was lock contention without substring-matching the message.
1790
+ // `cause` is deliberately NOT added: that needs a new field on
1791
+ // `ViceErrorOptions` in `vice.ts`, a shared module outside this phase.
1792
+ try {
1793
+ commitTransaction(handle.db);
1794
+ } catch (e) {
1795
+ // RECORDED, NOT ASSERTED (WR-16). "the transaction has been rolled back"
1796
+ // was stated as a fact directly under a `catch` that swallowed the
1797
+ // rollback's own failure -- so on the one path where the claim is false it
1798
+ // was still printed, and a refusal that reports the CR-06 state as its own
1799
+ // repair sends the caller straight back into reusing a connection that may
1800
+ // still hold the store's write lock. There is no cheap check available:
1801
+ // Node 22's `DatabaseSync` exposes no transaction-state accessor (surface
1802
+ // measured on this host: `open, close, prepare, exec, function, location,
1803
+ // aggregate, createSession, applyChangeset, enableLoadExtension,
1804
+ // loadExtension`), which is a reason not to ASSERT the outcome, and this
1805
+ // local is what replaces the assertion.
1806
+ let rolledBack = true;
1807
+ try {
1808
+ handle.db.exec("rollback");
1809
+ } catch {
1810
+ // deliberately ignored -- see above; only the FACT is kept.
1811
+ rolledBack = false;
1812
+ }
1813
+ discardSnapshot(staging);
1814
+ if (e instanceof ViceError) throw e;
1815
+ throw new AnnoStoreError(
1816
+ `${handle.path}: the write for revision ${rev + 1} could not be committed (${(e as Error).message}). ` +
1817
+ (rolledBack
1818
+ ? `Nothing was written and the transaction has been rolled back, so the store is still at revision ${rev}.`
1819
+ : `Nothing was written, but the rollback ALSO failed: this connection may still hold an open transaction and the store's ` +
1820
+ `write lock, so CLOSE IT AND REOPEN rather than reusing it. The store on disk is still at revision ${rev}, while this ` +
1821
+ `connection may report ${rev + 1} for a write that never landed.`),
1822
+ {
1823
+ code: (e as { code?: number | string }).code,
1824
+ // `rolledBack` is carried in `data` as well as in the prose so a caller
1825
+ // can branch on the fact instead of substring-matching a message.
1826
+ // The wording here is FREE. It used to be constrained: the
1827
+ // single-commit-site control in `anno-seam.test.ts` counted the WORD
1828
+ // `commit` over this module's stripped source, so a `step` value
1829
+ // reading "commit ..." reddened a control in a different file. WR-15
1830
+ // replaced that count with a match on `exec()` calls carrying a bare
1831
+ // statement literal, which no error message can satisfy, and the
1832
+ // constraint went with it -- this value is unchanged only because
1833
+ // changing it would be a gratuitous behaviour change.
1834
+ data: { path: handle.path, revision: rev, rolledBack, step: "committing the write transaction" },
1835
+ },
1836
+ );
1837
+ }
1838
+ // STEP 9, AND ITS POSITION IS THE POINT: the prune runs AFTER the commit
1839
+ // and OUTSIDE the transaction (trap 10). It sits inside the `doCommit`
1840
+ // branch because a sequence that never commits has no accepted write to
1841
+ // bound.
1842
+ //
1843
+ // WR-02: WRAPPED, AND DELIBERATELY NOT RETHROWN. By this line the
1844
+ // transaction has already returned, so THE WRITE HAPPENED -- the mutation
1845
+ // and the pointer row are durable. A housekeeping failure that threw from
1846
+ // here would report a write that succeeded as a failure, and the caller
1847
+ // would retry an ADDITIVE verb and produce a second row. That is WR-02's
1848
+ // exact complaint, and it became more likely rather than less once the
1849
+ // sweep started taking the write lock.
1850
+ //
1851
+ // The consequence of swallowing is an UN-PRUNED RING -- extra files, the
1852
+ // direction trap 10's own premise calls harmless and reconcilable by
1853
+ // revision number -- and the next accepted write's sweep resolves it. There
1854
+ // is deliberately NO logging channel: this module has none, and introducing
1855
+ // one here would be new surface with its own stdio hazards on an MCP
1856
+ // transport.
1857
+ //
1858
+ // AND ITS REPORT IS CONSUMED (WR-18). `pruneSnapshots` returns the sweep's
1859
+ // `rollbackFailed` -- the one state the sweep's own handler cannot fix --
1860
+ // and it is RECORDED ON THE HANDLE rather than thrown or logged. Not thrown,
1861
+ // because by this line the write is committed and 28-11 P5 forbids reporting
1862
+ // a committed write as a failure; not logged, because this module has no
1863
+ // logging channel and introducing one here would be new surface with stdio
1864
+ // hazards on an MCP transport (see the paragraph above).
1865
+ //
1866
+ // THE WRITE'S OWN RESULT IS STILL A SUCCESS WITH ITS REVISION. The fact is
1867
+ // carried on the HANDLE precisely so the write that succeeded is not the
1868
+ // call that reports it: the NEXT call on this connection refuses by name at
1869
+ // the head of this function, with the close-and-reopen remedy.
1870
+ try {
1871
+ if (pruneSnapshots(handle)) handle.transactionStateUnknown = true;
1872
+ } catch {
1873
+ // deliberately ignored -- see above
1874
+ }
1875
+ }
1876
+
1877
+ return { revision: rev + 1, result };
1878
+ }
1879
+
1880
+ /** Runs `mutate` as one durable, revision-advancing write. */
1881
+ export function applyWrite<T>(
1882
+ handle: AnnoStoreHandle,
1883
+ mutate: (db: DatabaseSync) => T,
1884
+ opts: { baseRevision?: number } = {},
1885
+ ): { revision: number; result: T } {
1886
+ return runWriteSequence(handle, mutate, true, opts.baseRevision);
1887
+ }
1888
+
1889
+ /**
1890
+ * The same sequence WITHOUT the commit. This exists for exactly one reason and
1891
+ * no other: the durability-and-revert proof's planted violation is "remove the
1892
+ * commit", and a planting that drives the IDENTICAL code path is stronger
1893
+ * evidence than a hand-copied variant that can drift out of agreement with the
1894
+ * real one.
1895
+ *
1896
+ * Its only caller is a spawned, test-only helper that is deliberately absent
1897
+ * from `package.json`'s `files[]`, and `anno-seam.test.ts` asserts that no
1898
+ * shipped module other than this one so much as names it.
1899
+ */
1900
+ export function applyWriteWithoutCommit<T>(
1901
+ handle: AnnoStoreHandle,
1902
+ mutate: (db: DatabaseSync) => T,
1903
+ opts: { baseRevision?: number } = {},
1904
+ ): { revision: number; result: T } {
1905
+ return runWriteSequence(handle, mutate, false, opts.baseRevision);
1906
+ }
1907
+
1908
+ /**
1909
+ * The module's ONE range insert. `bank` is a parameter rather than a hardcoded
1910
+ * `null` (IN-06): a remainder re-inserted by split-and-preserve carries the
1911
+ * overlapped row's own `bank` forward, and a newly typed range carries `null`.
1912
+ * `bank` is reserved and interpreted by nothing today, which is exactly why a
1913
+ * write path that silently dropped it would be an unobservable loss a future
1914
+ * banked-memory model inherits.
1915
+ */
1916
+ function insertRange(db: DatabaseSync, start: number, endInclusive: number, dataType: string, bank: number | null): void {
1917
+ db.prepare("insert into anno_range(start, end_inclusive, data_type, bank) values (?, ?, ?, ?)").run(start, endInclusive, dataType, bank);
1918
+ }
1919
+
1920
+ /** One overlapped row as `retype()` reads it. `bank` is selected because the
1921
+ * remainders re-inserted from this row must carry it forward. */
1922
+ interface OverlappedRangeRow {
1923
+ id: number;
1924
+ start: number;
1925
+ end_inclusive: number;
1926
+ data_type: string;
1927
+ bank: number | null;
1928
+ }
1929
+
1930
+ /**
1931
+ * Would preserving `remainderStart..remainderEndInclusive` as `row`'s own type
1932
+ * write a row the store would REFUSE at its own entry point? Returns the
1933
+ * refusal to throw, or `null` when the remainder is legal.
1934
+ *
1935
+ * THE QUESTION IS ASKED THROUGH `assertRangeShape` ITSELF, never through a
1936
+ * re-implemented even-count test. That is the whole point: there is then exactly
1937
+ * ONE definition of a legal range shape in the repo, and a rule added to it
1938
+ * later applies to the store's own writer for free. A second copy of the rule
1939
+ * here would drift the moment the first one is edited, and the drift is silent.
1940
+ *
1941
+ * It is a separate named function rather than inline code so the round-trip
1942
+ * invariant in `anno-overlap.test.ts` has a named thing to point at.
1943
+ */
1944
+ function remainderRefusal(
1945
+ row: OverlappedRangeRow,
1946
+ remainderStart: number,
1947
+ remainderEndInclusive: number,
1948
+ side: "head" | "tail",
1949
+ callerStart: number,
1950
+ callerEndInclusive: number,
1951
+ ): AnnoSplitRemainderError | null {
1952
+ try {
1953
+ assertRangeShape(remainderStart, remainderEndInclusive, row.data_type as DataType);
1954
+ return null;
1955
+ } catch (e) {
1956
+ if (!(e instanceof AnnoRangeShapeError)) throw e;
1957
+ // The caller's own boundary on the OFFENDING side. Moving it by one flips
1958
+ // the remainder's parity, so the two nearest legal values are one either
1959
+ // way -- reported as numbers so the caller does not have to work out which
1960
+ // end to move or by how much.
1961
+ const boundaryName = side === "head" ? "start" : "endInclusive";
1962
+ const boundary = side === "head" ? callerStart : callerEndInclusive;
1963
+ const span = remainderEndInclusive - remainderStart + 1;
1964
+ const message =
1965
+ `typing ${callerStart}..${callerEndInclusive} (${hexRange(callerStart, callerEndInclusive)}) would split range id ${row.id} ` +
1966
+ `(${row.start}..${row.end_inclusive}, ${hexRange(row.start, row.end_inclusive)}, ${row.data_type}) and leave a ${side} remainder ` +
1967
+ `${remainderStart}..${remainderEndInclusive} (${hexRange(remainderStart, remainderEndInclusive)}) of ${span} byte(s), which is not a ` +
1968
+ `shape this store accepts: ${e.message}. The whole retype is refused, so nothing was written. The nearest ${boundaryName} values ` +
1969
+ `that would leave an even ${side} are ${boundary - 1} and ${boundary + 1}; alternatively extend the retype to one of the table's ` +
1970
+ `own entry boundaries, or retype the whole table to the type you want first.`;
1971
+ return new AnnoSplitRemainderError(message, {
1972
+ start: remainderStart,
1973
+ endInclusive: remainderEndInclusive,
1974
+ rowId: row.id,
1975
+ rowStart: row.start,
1976
+ rowEndInclusive: row.end_inclusive,
1977
+ dataType: row.data_type as DataType,
1978
+ remainderStart,
1979
+ remainderEndInclusive,
1980
+ side,
1981
+ });
1982
+ }
1983
+ }
1984
+
1985
+ /** `$xxxx-$xxxx`, the spelling the rest of this module's messages use. */
1986
+ function hexRange(start: number, endInclusive: number): string {
1987
+ return `$${start.toString(16).padStart(4, "0")}-$${endInclusive.toString(16).padStart(4, "0")}`;
1988
+ }
1989
+
1990
+ /** A canonical key for one entry-address couple, so the intersection below is
1991
+ * exact set arithmetic rather than an `includes()` over arrays that compares
1992
+ * tuple IDENTITY and would report every pair lost. */
1993
+ function entryPairKey(pair: readonly [number, number]): string {
1994
+ return `${pair[0]}:${pair[1]}`;
1995
+ }
1996
+
1997
+ /**
1998
+ * What fragmenting `row` at the caller's range COSTS, or `null` when it costs
1999
+ * nothing this record could describe (CR-10).
2000
+ *
2001
+ * `null` in exactly two cases, both of them honest:
2002
+ * * the row is not a split-table layout -- asked through `isSplitDataType`,
2003
+ * never through a hand-written list of the four names (`anno-types.ts` trap
2004
+ * 2). A non-split row's meaning does not depend on its extent, so there is
2005
+ * nothing to disclose;
2006
+ * * the caller's range leaves NO remainder of this row. Nothing survives, so
2007
+ * no preservation is claimed and none is owed. A full cover is a deletion,
2008
+ * and a deletion is already visible in the row set.
2009
+ *
2010
+ * Otherwise it consults the SPLIT LAYOUT'S OWN PAIRING RULE through
2011
+ * `splitEntryAddressPairs()` -- the same single definition `resolveSplitTargets()`
2012
+ * consumes -- once over the ROW's span and once over each remainder's, and
2013
+ * reports both sets plus their intersection.
2014
+ *
2015
+ * IT MUST RUN AFTER `remainderRefusal()` FOR THE SAME ROW AND BEFORE THE FIRST
2016
+ * `delete`. After, because a remainder that fails the parity gate is not a
2017
+ * remainder this store will ever write and `splitEntryAddressPairs()` would
2018
+ * refuse it; before, because a refusal must still cost nothing and an acceptance
2019
+ * must never be half-applied.
2020
+ *
2021
+ * PURE: no I/O, no SQL, no state. It reads the row shape it is handed.
2022
+ */
2023
+ function splitReinterpretation(
2024
+ row: OverlappedRangeRow,
2025
+ callerStart: number,
2026
+ callerEndInclusive: number,
2027
+ ): SplitTableReinterpretation | null {
2028
+ const dataType = row.data_type as DataType;
2029
+ if (!isSplitDataType(dataType)) return null;
2030
+
2031
+ const hasHead = row.start < callerStart;
2032
+ const hasTail = row.end_inclusive > callerEndInclusive;
2033
+ if (!hasHead && !hasTail) return null;
2034
+
2035
+ const layout = dataType as SplitDataType;
2036
+ const before = splitEntryAddressPairs(row.start, row.end_inclusive, layout);
2037
+
2038
+ // HEAD THEN TAIL, and the order is part of the contract: within one record the
2039
+ // survivors read in ascending address order, which is the order the mutation
2040
+ // loop below re-inserts them in.
2041
+ const survivors: SplitTableSurvivor[] = [];
2042
+ const addSurvivor = (start: number, endInclusive: number): void => {
2043
+ const pairs = splitEntryAddressPairs(start, endInclusive, layout);
2044
+ survivors.push({ start, endInclusive, entryCount: pairs.entryCount, entryPairs: pairs.pairs });
2045
+ };
2046
+ if (hasHead) addSurvivor(row.start, callerStart - 1);
2047
+ if (hasTail) addSurvivor(callerEndInclusive + 1, row.end_inclusive);
2048
+
2049
+ // THE INTERSECTION IS COMPUTED, NOT ASSUMED. It is empty today for every
2050
+ // proper fragment -- that is the arithmetic in `retype()`'s DECISION 1 -- but
2051
+ // hardcoding the emptiness would make this field a restatement of the layout
2052
+ // rather than a measurement of it, and it would stop being true the moment a
2053
+ // layout with a different pairing is added.
2054
+ const survivingKeys = new Set(survivors.flatMap((s) => s.entryPairs.map(entryPairKey)));
2055
+ const preservedEntryPairs = before.pairs.filter((pair) => survivingKeys.has(entryPairKey(pair)));
2056
+
2057
+ const survivorText = survivors
2058
+ .map((s) => `${s.start}..${s.endInclusive} (${hexRange(s.start, s.endInclusive)}, ${s.entryCount} entries)`)
2059
+ .join(" and ");
2060
+ const summary =
2061
+ `typing ${callerStart}..${callerEndInclusive} (${hexRange(callerStart, callerEndInclusive)}) fragments range id ${row.id} ` +
2062
+ `(${row.start}..${row.end_inclusive}, ${hexRange(row.start, row.end_inclusive)}, ${dataType}, ${before.entryCount} entries), ` +
2063
+ `leaving ${survivorText}. A split table pairs byte i with byte n + i, so changing either end re-pairs every entry: ` +
2064
+ `${preservedEntryPairs.length} of ${before.entryCount} entry-address pairs are preserved. The surviving row(s) are still legal ` +
2065
+ `and still decode -- they decode to DIFFERENT 16-bit values than the ones recorded here.`;
2066
+
2067
+ return {
2068
+ rowId: row.id,
2069
+ rowStart: row.start,
2070
+ rowEndInclusive: row.end_inclusive,
2071
+ dataType: layout,
2072
+ entryCountBefore: before.entryCount,
2073
+ entryPairsBefore: before.pairs,
2074
+ survivors,
2075
+ preservedEntryPairs,
2076
+ summary,
2077
+ };
2078
+ }
2079
+
2080
+ /**
2081
+ * SPLIT-AND-PRESERVE. Every row overlapping the new range is deleted, and the
2082
+ * parts of it that fall OUTSIDE the new range are re-inserted with their
2083
+ * original type -- the head when the old row started earlier, the tail when it
2084
+ * ended later.
2085
+ *
2086
+ * Why not the obvious `filter()`-and-insert (delete every overlapping row,
2087
+ * insert the new one): it is accidentally RIGHT on the total-typed-bytes
2088
+ * metric for four of the five overlap cases, and it LOSES BYTES in the
2089
+ * fully-contained case -- an old row strictly wider than the new one on both
2090
+ * sides has both its head and its tail discarded. That case is therefore the
2091
+ * detector, and a proof that exercises only the other four proves nothing.
2092
+ *
2093
+ * Returns whether the mutation CHANGED anything. Typing a range that is already
2094
+ * exactly that range with exactly that type is a no-op: it leaves the single
2095
+ * existing row alone and reports `false`. The revision still advances, because
2096
+ * every accepted write advances it by exactly one -- so `changed` is the ONLY
2097
+ * signal that distinguishes a no-op, and the revision is never that signal.
2098
+ *
2099
+ * ---------------------------------------------------------------------------
2100
+ * DECISION 1: THE SPLIT-ROW REMAINDER RULE -- TWO OUTCOMES, NEITHER SILENT
2101
+ * (CR-09 and CR-10).
2102
+ * ---------------------------------------------------------------------------
2103
+ * A split-table row has TWO things that can go wrong when a caller's range
2104
+ * fragments it, and this function answers them differently on purpose. The
2105
+ * comment and the code below state ONE rule, and both halves of it are here.
2106
+ *
2107
+ * (1) THE ODD REMAINDER IS REFUSED, and the whole retype is refused with it
2108
+ * (CR-09). A remainder that is not a legal shape for its OWN type -- the
2109
+ * odd-byte-count tail of a fragmented split table is the reachable case -- is a
2110
+ * row `setDataType` would decline to create and `resolveSplitTargets()` cannot
2111
+ * decode. The store never persists a range row it would refuse at its own entry
2112
+ * point, by any writer. DEMOTING the illegal remainder to the vocabulary's
2113
+ * `undefined` member was considered and REJECTED, because it destroys the
2114
+ * recorded split ORIENTATION, which this module's own header calls the one
2115
+ * irreversible decision in this area with no field to migrate -- a one-way data
2116
+ * decision taken silently on the caller's behalf. Rounding the caller's range
2117
+ * outward to an entry boundary is forbidden outright by `anno-types.ts` trap 7.
2118
+ *
2119
+ * (2) THE EVEN REMAINDER IS ACCEPTED **WITH A REPORT** -- never accepted
2120
+ * silently (CR-10). Parity is not the only thing a fragment can break. A split
2121
+ * table pairs byte `i` with byte `n + i`, so an entry's partner is a function of
2122
+ * the row's START and its LENGTH, and changing either end re-pairs EVERY entry.
2123
+ *
2124
+ * THE ARITHMETIC, because it settles the design rather than merely describing
2125
+ * it: a surviving fragment of `m` entries pairs its own byte `j` with its own
2126
+ * byte `m + j`, which matches an original pair only when `m == n` -- only when
2127
+ * the fragment IS the whole row. **No proper fragment preserves a single entry
2128
+ * pair, at any boundary, THE MIDPOINT INCLUDED.** So preservation is not
2129
+ * something a cleverer boundary rule can recover, and the surviving rows are the
2130
+ * dangerous kind of wrong: legal, re-acceptable, decodable, and decoding to
2131
+ * different 16-bit values than the ones a human recorded.
2132
+ *
2133
+ * ANSWER (a) -- REFUSE ANY PARTIAL OVERLAP OF A SPLIT ROW -- WAS WEIGHED AND NOT
2134
+ * TAKEN. It is defensible and it is implementable, but it makes split tables
2135
+ * editable only WHOLESALE, and the inputs it would refuse are ordinary
2136
+ * annotation work: correcting a few bytes inside a table, or trimming a table
2137
+ * typed one entry too wide. Split-and-preserve exists for exactly that.
2138
+ *
2139
+ * WHAT IS TAKEN is answer (b): `splitReinterpretation()` builds one record per
2140
+ * fragmented split row, carrying the pairs the row read BEFORE and the pairs
2141
+ * each survivor reads AFTER, and `setDataType()` returns it as DATA on a
2142
+ * SUCCESSFUL result beside `contradictedComments`. The failing clause was never
2143
+ * "a partial overwrite must preserve"; it was "silently un-documenting a
2144
+ * previously annotated region is the exact failure the store exists to prevent".
2145
+ * Disclosure removes the silence, which is the clause that was actually false.
2146
+ *
2147
+ * RECORDING THE TABLE'S ORIGINAL EXTENT ON DISK -- so the pairing could be
2148
+ * reconstructed later -- WAS REJECTED. It is a new column, therefore a
2149
+ * `SCHEMA_VERSION` bump, therefore a one-way decision requiring the older
2150
+ * on-disk shape to refuse by name (28-10 P4); and this milestone's one
2151
+ * irreversible decision is already spent on the twelve-member vocabulary. The
2152
+ * return channel gives the caller the same fact at the only moment it can still
2153
+ * act on it, and costs nothing that cannot be reverted.
2154
+ *
2155
+ * BOTH CHECKS RUN OVER EVERY REMAINDER OF EVERY OVERLAPPING ROW BEFORE THE FIRST
2156
+ * `delete`, and the ordering is the guarantee, not a tidiness preference: a
2157
+ * refusal must cost nothing observable, and leaning on the transaction's
2158
+ * rollback to undo a half-applied mutation would make that depend on a rollback
2159
+ * that the CR-06 arm's own `rollbackFailed` handling shows can itself fail.
2160
+ * Compute, refuse, then mutate. The parity check runs FIRST and is untouched by
2161
+ * the disclosure: a refusing retype returns no report because it returns nothing
2162
+ * at all.
2163
+ *
2164
+ * ---------------------------------------------------------------------------
2165
+ * DECISION 2: THE UNION COLLAPSE IS INTENDED (STORE-02, round-3 WR-08).
2166
+ * ---------------------------------------------------------------------------
2167
+ * A caller range that SPANS several existing rows deletes all of them and
2168
+ * inserts one row. That is intended, and it does not contradict STORE-02:
2169
+ * STORE-02 forbids the store joining adjacent ranges OF ITS OWN ACCORD, and
2170
+ * here the caller asked for exactly one range and got exactly one range. The
2171
+ * store still never joins two rows nobody asked about -- see the behavioural
2172
+ * and structural adjacency controls.
2173
+ *
2174
+ * `changed: true` is CORRECT for that call even when every address resolves to
2175
+ * the same type afterwards, because `changed` reports the ROW SET and the row
2176
+ * identities really did change: both original ids are gone and a new one exists.
2177
+ * Both shapes -- the union retype and the same-type subrange, which fragments
2178
+ * one row into three with every id churned -- are pinned BY VALUE in
2179
+ * `anno-overlap.test.ts`, so "does not join" can be told apart from "was never
2180
+ * asked to".
2181
+ */
2182
+ function retype(
2183
+ db: DatabaseSync,
2184
+ start: number,
2185
+ endInclusive: number,
2186
+ dataType: DataType,
2187
+ ): { changed: boolean; reinterpretedSplitTables: readonly SplitTableReinterpretation[] } {
2188
+ const overlapping = db
2189
+ .prepare("select id, start, end_inclusive, data_type, bank from anno_range where end_inclusive >= ? and start <= ? order by id")
2190
+ // The cast names the shape INLINE rather than through `OverlappedRangeRow`
2191
+ // for one mechanical reason: `node:sqlite` types `all()` as
2192
+ // `Record<string, SQLOutputValue>[]`, and TypeScript refuses a direct
2193
+ // assertion to a named interface as insufficiently overlapping while
2194
+ // accepting the identical anonymous shape. The result is structurally the
2195
+ // interface, which is what the helper below takes.
2196
+ .all(start, endInclusive) as { id: number; start: number; end_inclusive: number; data_type: string; bank: number | null }[];
2197
+
2198
+ if (
2199
+ overlapping.length === 1 &&
2200
+ overlapping[0].start === start &&
2201
+ overlapping[0].end_inclusive === endInclusive &&
2202
+ overlapping[0].data_type === dataType
2203
+ ) {
2204
+ // AN IDENTICAL REPEAT REPORTS NOTHING. Nothing is fragmented, so a second
2205
+ // disclosure of a fragmentation that already happened would be a false
2206
+ // report -- and the field is empty rather than absent, so the caller still
2207
+ // reads it unconditionally.
2208
+ return { changed: false, reinterpretedSplitTables: [] };
2209
+ }
2210
+
2211
+ // THE GATE. Every remainder the loop below would write, asked the entry
2212
+ // point's own shape question, BEFORE anything is deleted or inserted -- and,
2213
+ // for a split row that survives that question, what the fragmentation COSTS.
2214
+ //
2215
+ // WHAT THIS GATE DOES NOT ASK, said here because "THE GATE" reads absolute
2216
+ // and a reader will otherwise take it for one (WR-31, 28-21 P1 / 28-07 P3):
2217
+ // the shape question is asked of REMAINDERS, never of the overlapped row
2218
+ // itself. A row the caller's range covers in full has no head and no tail,
2219
+ // so neither branch below runs and its shape is never examined -- correctly,
2220
+ // because the second loop DELETES it outright and a deletion owes no shape.
2221
+ // The consequence to hold on to: a malformed legacy row (one an already-
2222
+ // superseded build wrote) is refused only when something of it SURVIVES.
2223
+ // Do not read this gate as a validity check over `overlapping`; it is a
2224
+ // validity check over what `retype()` is about to write back.
2225
+ //
2226
+ // ORDER WITHIN THE LOOP IS LOAD-BEARING TWICE OVER: the two refusal checks run
2227
+ // before the reinterpretation for the SAME row, so a row whose remainder fails
2228
+ // parity never reaches a computation that would refuse it a second time with a
2229
+ // worse message; and the whole loop runs before the first `delete`, so a
2230
+ // refusal still costs nothing and an acceptance is never half-applied.
2231
+ //
2232
+ // The records are collected in the query's own `order by id` order, which is
2233
+ // what makes the report's array order ASCENDING OVERLAPPED-ROW ID rather than
2234
+ // an accident of iteration.
2235
+ const reinterpretedSplitTables: SplitTableReinterpretation[] = [];
2236
+ for (const row of overlapping) {
2237
+ if (row.start < start) {
2238
+ const refusal = remainderRefusal(row, row.start, start - 1, "head", start, endInclusive);
2239
+ if (refusal) throw refusal;
2240
+ }
2241
+ if (row.end_inclusive > endInclusive) {
2242
+ const refusal = remainderRefusal(row, endInclusive + 1, row.end_inclusive, "tail", start, endInclusive);
2243
+ if (refusal) throw refusal;
2244
+ }
2245
+ const reinterpretation = splitReinterpretation(row, start, endInclusive);
2246
+ if (reinterpretation !== null) reinterpretedSplitTables.push(reinterpretation);
2247
+ }
2248
+
2249
+ for (const row of overlapping) {
2250
+ db.prepare("delete from anno_range where id = ?").run(row.id);
2251
+ if (row.start < start) {
2252
+ insertRange(db, row.start, start - 1, row.data_type, row.bank);
2253
+ }
2254
+ if (row.end_inclusive > endInclusive) {
2255
+ insertRange(db, endInclusive + 1, row.end_inclusive, row.data_type, row.bank);
2256
+ }
2257
+ }
2258
+
2259
+ insertRange(db, start, endInclusive, dataType, null);
2260
+ return { changed: true, reinterpretedSplitTables };
2261
+ }
2262
+
2263
+ /** The two grade brackets that assert the addresses are CODE, and the two that
2264
+ * assert they are DATA -- derived from the five-grade vocabulary by their
2265
+ * token's suffix rather than restated here. The vocabulary has exactly one home
2266
+ * and this module is not it: a second copy of the four bracket strings would
2267
+ * drift the moment the first one is edited, and the drift is silent. */
2268
+ const CODE_GRADE_BRACKETS: readonly string[] = CONFIDENCE_GRADES.filter((grade) => grade.token.endsWith("-code")).map((grade) => grade.bracket);
2269
+ const DATA_GRADE_BRACKETS: readonly string[] = CONFIDENCE_GRADES.filter((grade) => grade.token.endsWith("-data")).map((grade) => grade.bracket);
2270
+
2271
+ /**
2272
+ * Does retyping a region to `dataType` make a comment graded `gradeBracket`
2273
+ * FALSE? The ONE definition of "contradicted" in this repo; the query path below
2274
+ * calls it, and so does its test, so the rule and its proof cannot drift.
2275
+ *
2276
+ * The rule:
2277
+ * * a code-asserting grade is contradicted by any data type other than
2278
+ * `code` -- the comment says the bytes execute and the retype says they do
2279
+ * not;
2280
+ * * a data-asserting grade is contradicted by `code`, and by nothing else --
2281
+ * every other member of the vocabulary is another way of saying data, so a
2282
+ * `byte` region retyped to `word` leaves such a comment true;
2283
+ * * the no-reliable-interpretation grade, and an ungraded comment
2284
+ * (`gradeBracket` null), are NEVER contradicted. Neither one asserted
2285
+ * anything a retype could falsify.
2286
+ *
2287
+ * THE DECISION, WITH THE ALTERNATIVE NOT TAKEN. "Contradicts" means the retype
2288
+ * makes the comment FALSE -- not merely that a comment happens to sit at a
2289
+ * retyped address. The broad reading -- any comment at all at a retyped address
2290
+ * is contradicted -- was considered and REJECTED, because it would make every
2291
+ * retype of a commented range report, and a report that fires every time is a
2292
+ * report nobody reads. The report exists so a human notices the one case that
2293
+ * matters. A later reader must NOT "simplify" this predicate back into the broad
2294
+ * form; that is a regression wearing the clothes of a cleanup.
2295
+ */
2296
+ export function contradictedCommentsFor(gradeBracket: string | null, dataType: DataType): boolean {
2297
+ if (gradeBracket === null) return false;
2298
+ if (CODE_GRADE_BRACKETS.includes(gradeBracket)) return dataType !== "code";
2299
+ if (DATA_GRADE_BRACKETS.includes(gradeBracket)) return dataType === "code";
2300
+ return false;
2301
+ }
2302
+
2303
+ /**
2304
+ * Every stored comment inside `start..endInclusive` that retyping to `dataType`
2305
+ * makes false, in ascending address order.
2306
+ *
2307
+ * MUST RUN INSIDE THE RETYPE'S OWN TRANSACTION. Run outside it, a comment
2308
+ * written by another connection between the query and the retype would be
2309
+ * missed, and the report would be silently short by one -- which is the failure
2310
+ * mode a report is supposed to close, not open. `begin immediate` serialises the
2311
+ * pair.
2312
+ *
2313
+ * A malformed bracket token is REFUSED here, never read as ungraded: swallowing
2314
+ * it would quietly exempt that comment from the report forever.
2315
+ */
2316
+ function collectContradictedComments(db: DatabaseSync, start: number, endInclusive: number, dataType: DataType): ContradictedComment[] {
2317
+ const rows = db
2318
+ .prepare("select address, comment_type, text from anno_comment where address >= ? and address <= ? order by address, comment_type")
2319
+ .all(start, endInclusive) as { address: number; comment_type: string; text: string }[];
2320
+
2321
+ const out: ContradictedComment[] = [];
2322
+ for (const row of rows) {
2323
+ let grade: string | null;
2324
+ try {
2325
+ const parsed = parseConfidencePrefix(row.text);
2326
+ grade = parsed.grade === null ? null : parsed.grade.bracket;
2327
+ } catch (e) {
2328
+ if (e instanceof AnnoConfidenceGradeError) {
2329
+ throw new AnnoCommentGradeError(
2330
+ `the comment at address ${row.address} ($${row.address.toString(16).padStart(4, "0")}) carries a bracket token the store cannot ` +
2331
+ `interpret, so it cannot say whether typing that address as ${dataType} makes the comment false: ${e.message}`,
2332
+ { comment: row.text, cause: e },
2333
+ );
2334
+ }
2335
+ throw e;
2336
+ }
2337
+ if (contradictedCommentsFor(grade, dataType)) {
2338
+ out.push({
2339
+ address: row.address,
2340
+ commentType: row.comment_type as CommentType,
2341
+ text: row.text,
2342
+ grade: grade as string,
2343
+ contradictedBy: dataType,
2344
+ });
2345
+ }
2346
+ }
2347
+ return out;
2348
+ }
2349
+
2350
+ /**
2351
+ * What `setDataType()` returns: `AnnoWriteResult` plus the two things this
2352
+ * retype has just cost that the row set alone does not show -- the comments it
2353
+ * made false, and the split tables it re-interpreted.
2354
+ *
2355
+ * BOTH REPORT FIELDS ARE ALWAYS PRESENT AND OFTEN EMPTY, never absent, so a
2356
+ * caller reads them unconditionally instead of guarding on them.
2357
+ *
2358
+ * BOTH ARE DATA ON A SUCCESSFUL RESULT -- never an error, never a refusal, and
2359
+ * there is no option to make either one. For `contradictedComments` see the
2360
+ * module header's trap 9: a refusal would push a caller toward deleting the
2361
+ * comment to get the retype through, which converts a REPORTED loss into a
2362
+ * SILENT one. `reinterpretedSplitTables` rides alongside it for the same reason
2363
+ * and by the same pattern.
2364
+ *
2365
+ * `reinterpretedSplitTables` carries one record per OVERLAPPED SPLIT ROW that
2366
+ * the accepted write fragmented -- the row's identity and span, the entry-address
2367
+ * pairs it read before, every surviving remainder's span and the pairs it reads
2368
+ * now, and the pairs PRESERVED (computed by comparing the two sets). It is empty
2369
+ * whenever the write fragmented no split row: a non-split overlap, a full cover,
2370
+ * an identical repeat. Its array order is ASCENDING OVERLAPPED-ROW ID, matching
2371
+ * the gate's own `order by id`; within a record the survivors are ordered HEAD
2372
+ * then TAIL. See `retype()`'s DECISION 1 for why this is a RETURN CHANNEL rather
2373
+ * than an on-disk column.
2374
+ */
2375
+ export interface SetDataTypeResult extends AnnoWriteResult {
2376
+ contradictedComments: readonly ContradictedComment[];
2377
+ reinterpretedSplitTables: readonly SplitTableReinterpretation[];
2378
+ }
2379
+
2380
+ /**
2381
+ * Types the inclusive range `start..endInclusive` as `dataType`, preserving
2382
+ * whatever the overlapping rows said about the addresses outside it.
2383
+ *
2384
+ * Every argument is validated before any SQL runs -- the transport validates
2385
+ * nothing (see `anno-types.ts`'s header).
2386
+ */
2387
+ export function setDataType(
2388
+ handle: AnnoStoreHandle,
2389
+ args: { start: number | string; endInclusive: number | string; dataType: unknown; baseRevision?: number },
2390
+ ): SetDataTypeResult {
2391
+ const dataType = assertDataType(args.dataType);
2392
+ // ORDERING IS LOAD-BEARING: `parseStoreAddress` owns the STRING forms only
2393
+ // -- what base is this text in, and is it a form the store accepts at all --
2394
+ // while `assertRangeShape` owns range-ness for both forms. A numeric
2395
+ // argument is therefore passed straight through, so an end of 65536 or -1 is
2396
+ // refused as a RANGE SHAPE rather than as an unparseable address. Collapsing
2397
+ // the two makes a caller unable to tell "that is not an address" from "those
2398
+ // two ends do not make a range".
2399
+ const start = typeof args.start === "string" ? parseStoreAddress(args.start, { what: "start" }) : args.start;
2400
+ const endInclusive = typeof args.endInclusive === "string" ? parseStoreAddress(args.endInclusive, { what: "endInclusive" }) : args.endInclusive;
2401
+ assertRangeShape(start, endInclusive, dataType);
2402
+
2403
+ const { revision, result } = applyWrite(
2404
+ handle,
2405
+ (db) => {
2406
+ // The query precedes the mutation and shares its transaction: the rows it
2407
+ // reads are the ones the retype is about to contradict, and no concurrent
2408
+ // writer can slip a comment in between the two.
2409
+ const contradictedComments = collectContradictedComments(db, start, endInclusive, dataType);
2410
+ const { changed, reinterpretedSplitTables } = retype(db, start, endInclusive, dataType);
2411
+ return { changed, contradictedComments, reinterpretedSplitTables };
2412
+ },
2413
+ { baseRevision: args.baseRevision },
2414
+ );
2415
+ return {
2416
+ revision,
2417
+ changed: result.changed,
2418
+ contradictedComments: result.contradictedComments,
2419
+ reinterpretedSplitTables: result.reinterpretedSplitTables,
2420
+ };
2421
+ }
2422
+
2423
+ /** Every typed range, in insertion order. The `bank` column is read HERE and
2424
+ * nowhere else in this module. */
2425
+ export function listRanges(handle: AnnoStoreHandle): RangeRow[] {
2426
+ const rows = handle.db.prepare("select id, start, end_inclusive, data_type, bank from anno_range order by id").all() as {
2427
+ id: number;
2428
+ start: number;
2429
+ end_inclusive: number;
2430
+ data_type: string;
2431
+ bank: number | null;
2432
+ }[];
2433
+ return rows.map((row) => ({
2434
+ id: row.id,
2435
+ start: row.start,
2436
+ endInclusive: row.end_inclusive,
2437
+ dataType: row.data_type as DataType,
2438
+ bank: row.bank,
2439
+ }));
2440
+ }
2441
+
2442
+ /**
2443
+ * Restores the whole store to the state it had at `revision`, by replacing the
2444
+ * store file with that revision's snapshot. Whole-store restore rather than an
2445
+ * inverted changeset because the changeset INVERSION primitive is the one part
2446
+ * of `node:sqlite`'s session surface that is missing (see the third measured
2447
+ * fact in the header).
2448
+ *
2449
+ * The replacement is atomic from a reader's point of view: the snapshot is
2450
+ * copied to a staging file beside the store, both the file and its directory
2451
+ * are fsynced, and only then is the staging file renamed over the store path.
2452
+ * Returns a NEW handle -- the old one is closed and must not be reused.
2453
+ *
2454
+ * ON THE REFUSAL PATH AND ON A STAGING FAILURE THE CALLER'S HANDLE IS STILL
2455
+ * OPEN. `revertTo` refuses an unretained revision before it touches the
2456
+ * filesystem, and it stages and fsyncs the copy before it closes anything. The
2457
+ * steps are numbered in the body and each number's POSITION is commented,
2458
+ * because the ordering is the guarantee.
2459
+ *
2460
+ * THE REFUSAL SET HAS THREE ARMS, and all three are `AnnoStoreError`:
2461
+ * * NO POINTER ROW claims the revision (step 2, first arm) -- "no snapshot is
2462
+ * retained for it", with the oldest retained revision, the bound and the
2463
+ * available list.
2464
+ * * A ROW CLAIMS IT BUT ITS IMAGE WILL NOT OPEN as an annotation store (step
2465
+ * 2, second arm) -- the arm CR-08 added, covering an absent image and a
2466
+ * present-but-unusable one alike, with the underlying reason quoted so the
2467
+ * caller can tell which. This is the arm the old presence-only gate did not
2468
+ * have, and its absence is what let the store be destroyed installing an
2469
+ * image that was not a database.
2470
+ * * THE STAGED COPY WILL NOT OPEN (step 3b) -- the same witness in its third
2471
+ * position, on the exact bytes step 5 renames.
2472
+ *
2473
+ * AND THE ORDERING RULE IS NOW STATED IN FULL, because "stages and fsyncs the
2474
+ * copy before it closes anything" was the whole of it and it was not enough.
2475
+ * NOTHING IS CLOSED AND NOTHING IS RENAMED UNTIL THE STAGED IMAGE HAS BEEN
2476
+ * OPENED AS AN ANNOTATION STORE THIS BUILD CAN SPEAK TO. Step 3b sits between
2477
+ * the staging and the close and does exactly that, and it is the step the whole
2478
+ * guarantee now rests on: presence was the only witness before it, and presence
2479
+ * proves nothing (a ZERO-LENGTH FILE OPENS -- the module header's first measured
2480
+ * fact). Reproduced before step 3b existed: a 0-byte retained snapshot took the
2481
+ * live 69,632-byte store to 0 bytes, returned no handle at all, and made every
2482
+ * later `openStore` refuse. The sentence above about the refusal path and the
2483
+ * staging failure stays exactly true; step 3b widens the set of failures it
2484
+ * covers rather than qualifying it.
2485
+ */
2486
+ /**
2487
+ * The one gate on a revision-shaped argument, and it exists because ONE
2488
+ * unvalidated value lands in TWO places that can then disagree (WR-22): a bound
2489
+ * SQL parameter, and a snapshot FILENAME.
2490
+ *
2491
+ * Accepts a non-negative safe integer and nothing else. A numeric STRING is
2492
+ * refused ON PURPOSE rather than coerced: SQLite applies the pointer column's
2493
+ * INTEGER affinity to a bound TEXT operand, so `"0001"` MATCHES revision 1's
2494
+ * row -- while `snapshotPathFor` builds `r0001.db` from the string. Coercing
2495
+ * would hide the caller's mistake; matching-then-failing reports it as a damaged
2496
+ * ring, which is the confusion `AnnoRevisionArgumentError`'s doc comment records
2497
+ * in full.
2498
+ *
2499
+ * DELIBERATELY NOT REUSED FOR `baseRevision`. The round-5 review's WR-22 sketch
2500
+ * suggests it; that is WR-06, which the round-5 verification does not route to
2501
+ * this round, so the declination is recorded here rather than left looking like
2502
+ * an omission -- `runWriteSequence`'s existing `baseRevision` staleness refusal
2503
+ * is this validator's SIBLING, not its client.
2504
+ */
2505
+ function assertRevisionArgument(value: unknown, parameter: string): number {
2506
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
2507
+ // `JSON.stringify` is the right rendering for every value EXCEPT the three
2508
+ // numbers JSON cannot represent: it turns `NaN` and both infinities into the
2509
+ // string `null`, which would name a value the caller never passed -- the
2510
+ // opposite of the verbatim naming this refusal exists to provide.
2511
+ const shown = typeof value === "number" && !Number.isFinite(value) ? String(value) : JSON.stringify(value);
2512
+ throw new AnnoRevisionArgumentError(
2513
+ `${parameter} ${shown} is not a revision -- expected a non-negative integer. A numeric STRING is refused on ` +
2514
+ `purpose rather than coerced: SQLite's column affinity would match the pointer row while the snapshot FILENAME is built from ` +
2515
+ `the string, so the two would disagree about which revision is being reverted to. Nothing has been read and nothing has been ` +
2516
+ `written.`,
2517
+ { value, parameter },
2518
+ );
2519
+ }
2520
+ return value;
2521
+ }
2522
+
2523
+ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHandle {
2524
+ // STEP 0, AND IT IS FIRST FOR THE REASON WR-22 RECORDS: this argument reaches
2525
+ // a bound SQL parameter AND a filename, so it is judged before either exists.
2526
+ // Before this line, `revertTo(handle, "1")` silently reverted the store and
2527
+ // `revertTo(handle, "0001")` refused with CR-08's CORRUPTION message.
2528
+ assertRevisionArgument(revision, "revision");
2529
+
2530
+ // STEP 1. The pointer row -- the INDEX half of "retained". An EXISTENCE check
2531
+ // and nothing more: the row carries only its revision number, and the FILE's
2532
+ // location is computed below from the handle rather than read from the row
2533
+ // (see `retainedRevisions` for why a persisted path was removed).
2534
+ const pointer = handle.db.prepare("select revision from anno_snapshot where revision = ?").get(revision) as
2535
+ | { revision: number }
2536
+ | undefined;
2537
+ const snapPath = snapshotPathFor(handle, revision);
2538
+
2539
+ // STEP 2, AND ITS POSITION IS THE WHOLE POINT: refuse BEFORE anything is
2540
+ // destroyed. It has TWO ARMS, and both of them are before any filesystem
2541
+ // mutation, so both leave the caller's handle open and the store untouched.
2542
+ //
2543
+ // THE FILE HALF NOW READS `snapshotOpenFailure` -- the same witness
2544
+ // `retainedRevisions` reads, in its other position. It used to read
2545
+ // `existsSync` and nothing more, which is CR-08: a present image that was not
2546
+ // a database passed this gate, and the store was destroyed installing it. The
2547
+ // "oldest retained" and "available revisions" figures are built from
2548
+ // `retainedRevisions()` and never from the raw rows, so a refusal cannot
2549
+ // steer the caller at a revision the very next call would also refuse.
2550
+ //
2551
+ // THE ARMS SPLIT ON THE ROW, NOT ON THE FILE'S PRESENCE, and that is
2552
+ // deliberate rather than an omission. Asking "is the image absent" separately
2553
+ // from "does the image open" would put a SECOND predicate back on a snapshot
2554
+ // path -- a second truth about one file, which is how CR-03 and CR-08 both
2555
+ // happened. The two file-half sub-cases are distinguished by the QUOTED
2556
+ // REASON instead: an absent image quotes "the file does not exist", a corrupt
2557
+ // one quotes what SQLite or `openStore` said. One witness, one message, and
2558
+ // the caller can still tell them apart.
2559
+ if (!pointer) {
2560
+ const available = retainedRevisions(handle);
2561
+ const oldest = available.length === 0 ? NO_RETAINED_REVISION : available[0];
2562
+ throw new AnnoStoreError(
2563
+ `cannot revert to revision ${revision}: no snapshot is retained for it. The oldest retained revision is ` +
2564
+ `${oldest === NO_RETAINED_REVISION ? "(none -- the ring is empty)" : oldest} and the current revision is ${currentRevision(handle)}; ` +
2565
+ `the ring holds at most ${MAX_SNAPSHOT_REVISIONS} revisions. The request is REFUSED rather than substituting the nearest retained ` +
2566
+ `revision, because returning a revision other than the one asked for changes the caller's intent with nothing recording that it ` +
2567
+ `happened. Available revisions: ${available.length === 0 ? "(none)" : available.join(", ")}`,
2568
+ { data: { path: handle.path, revision } },
2569
+ );
2570
+ }
2571
+
2572
+ const openFailure = snapshotOpenFailure(handle, revision);
2573
+ if (openFailure !== null) {
2574
+ const available = retainedRevisions(handle);
2575
+ throw new AnnoStoreError(
2576
+ `cannot revert to revision ${revision}: a pointer row claims it, but its snapshot ${snapPath} is not a readable annotation store ` +
2577
+ `(${openFailure}). Presence was never the witness -- a ZERO-LENGTH FILE OPENS as a SQLite database and reports integrity_check ` +
2578
+ `ok -- so the image is OPENED before anything is replaced, and this one did not open. NOTHING has been replaced: ${handle.path} is ` +
2579
+ `still at revision ${currentRevision(handle)} and this handle is still open and usable. The snapshot is left on disk for ` +
2580
+ `inspection rather than unlinked, because a corrupt image a pointer row still claims is EVIDENCE. Available revisions: ` +
2581
+ `${available.length === 0 ? "(none)" : available.join(", ")}`,
2582
+ { data: { path: handle.path, snapshotPath: snapPath, operation: "validate", revision } },
2583
+ );
2584
+ }
2585
+
2586
+ const storePath = handle.path;
2587
+ const dir = handle.dir;
2588
+ // THE STAGING NAME IS UNIQUE PER ATTEMPT, NOT PER (PID, REVISION) (WR-24),
2589
+ // and it is built from `randomUUID` -- the SAME primitive `stageSnapshot`
2590
+ // uses, so there is ONE answer in this module to "how is a staging name made
2591
+ // unique" rather than two that can drift. `stageSnapshot`'s own doc comment
2592
+ // states the rule in capitals and states why: a revision number can recur
2593
+ // after a revert, and two attempts at the same revision -- in this process or
2594
+ // another -- must not share a path. That sentence was never applied here.
2595
+ //
2596
+ // THE TWO FAILURE MODES THIS CLOSES, named rather than implied. (1) A second
2597
+ // attempt at the same revision copies over the first attempt's staged bytes
2598
+ // BETWEEN that attempt's step-3b validation and its step-5 rename, so the
2599
+ // image judged is not the image installed -- the exact window step 3b exists
2600
+ // to remove. (2) Any of the three cleanups below removes another attempt's
2601
+ // IN-FLIGHT file, because under the old name all attempts at one revision
2602
+ // addressed the same path.
2603
+ //
2604
+ // AND WHAT IT DOES NOT CLOSE, stated because a comment that implied otherwise
2605
+ // would be prohibition 28-07 P3's exact shape: THE LEAK HALF STAYS OPEN UNDER
2606
+ // WR-11. A process killed between the copy and any of the three cleanups
2607
+ // still leaves this file behind, and it sits beside the store rather than
2608
+ // inside the ring directory, so `reconcileSnapshotRing`'s sweep -- anchored on
2609
+ // `r<digits>.db` inside `snapshotDirFor()` -- does not and must not match it.
2610
+ // Nothing reclaims it. That is WR-11's other half and it is not closed here.
2611
+ //
2612
+ // THE PID IS KEPT deliberately: it is the diagnostic that lets a human finding
2613
+ // a leaked file say which process produced it, and the `.revert-` marker is
2614
+ // kept for the same reason -- uniqueness was the defect, not the labelling.
2615
+ const staging = `${storePath}.revert-${process.pid}.${randomUUID()}.tmp`;
2616
+
2617
+ // STEP 3. Stage and fsync the copy WITH THE CONNECTION STILL OPEN. Every
2618
+ // failure reachable here -- `ENOENT`, `ENOSPC`, `EACCES` -- therefore leaves
2619
+ // the caller a USABLE handle: nothing has been replaced yet, so
2620
+ // `currentRevision(handle)` and `listRanges(handle)` still answer and the
2621
+ // caller can decide what to do. That is WR-02's entire complaint, and it is
2622
+ // fixed by ordering rather than by a rescue path.
2623
+ try {
2624
+ copyFileSync(snapPath, staging);
2625
+ fsyncPath(staging);
2626
+ fsyncPath(dir);
2627
+ } catch (e) {
2628
+ discardSnapshot(staging);
2629
+ throw new AnnoStoreError(
2630
+ `cannot revert ${storePath} to revision ${revision}: staging the snapshot ${snapPath} failed during copy/fsync ` +
2631
+ `(${(e as Error).message}). Nothing has been replaced and the store connection is deliberately still OPEN and usable.`,
2632
+ { data: { path: storePath, snapshotPath: snapPath, operation: "copy/fsync", revision } },
2633
+ );
2634
+ }
2635
+
2636
+ // STEP 3b, AND ITS POSITION IS THE GUARANTEE (CR-08). OPEN THE STAGED IMAGE
2637
+ // BEFORE ANYTHING IS CLOSED AND BEFORE ANYTHING IS RENAMED. Until this step
2638
+ // existed the only witness that the image about to be installed was a store at
2639
+ // all was `existsSync` -- and this module's own FIRST MEASURED FACT (see the
2640
+ // header, `:22-31`) is that a ZERO-LENGTH FILE OPENS as a SQLite database and
2641
+ // reports `integrity_check ok`, so presence proves nothing and the refusal has
2642
+ // to be the store's OWN job. That reasoning was applied to `openStore` and
2643
+ // never applied to the image `revertTo` installs, which is exactly the gap: a
2644
+ // 0-byte retained snapshot took the live store from 69,632 bytes to 0, with no
2645
+ // handle returned and no route back, because the bytes destroyed are the only
2646
+ // copy of the CURRENT revision -- `revertTo` refuses the current revision by
2647
+ // design, precisely because no snapshot records it.
2648
+ //
2649
+ // THE STAGED COPY AND NOT ONLY THE SOURCE IMAGE, and the difference is a
2650
+ // window rather than a nicety: the staged file is the exact bytes step 5
2651
+ // renames over the store, so judging it is what leaves no interval in which
2652
+ // the judged bytes and the installed bytes can differ. Step 2's gate on the
2653
+ // SOURCE image is the same witness in its other position -- one witness, two
2654
+ // positions, not two witnesses.
2655
+ //
2656
+ // A FAILURE HERE COSTS THE CALLER NOTHING. The connection is still open, so
2657
+ // `currentRevision(handle)` and `listRanges(handle)` still answer; the live
2658
+ // store has not been touched; and the snapshot is left on disk for inspection
2659
+ // rather than unlinked, because a corrupt image a pointer row still claims is
2660
+ // EVIDENCE.
2661
+ try {
2662
+ // MODULE-DERIVED PATH: `staging`, the per-attempt staging name this function
2663
+ // built next to the store it is reverting. `mustExist` is unchanged.
2664
+ closeStore(openStore(staging, { mustExist: true, unconfinedModuleDerivedPath: true }));
2665
+ } catch (e) {
2666
+ discardSnapshot(staging);
2667
+ throw new AnnoStoreError(
2668
+ `cannot revert ${storePath} to revision ${revision}: the retained snapshot ${snapPath} is not a readable annotation store ` +
2669
+ `(${(e as Error).message}). NOTHING has been replaced -- the store is still at revision ${currentRevision(handle)} and this ` +
2670
+ `handle is still open and usable. The snapshot is left on disk for inspection.`,
2671
+ { data: { path: storePath, snapshotPath: snapPath, operation: "validate", revision } },
2672
+ );
2673
+ }
2674
+
2675
+ // STEP 4. Only now, with a durable staged image beside the store that has been
2676
+ // OPENED as an annotation store this build can speak to.
2677
+ closeStore(handle);
2678
+
2679
+ // STEP 5. The rename is the ONE step that cannot be done with the connection
2680
+ // open, so the residual is stated rather than claimed closed: a failure HERE
2681
+ // does leave the caller without a handle. The staging file is removed so a
2682
+ // retry is not blocked by its own leftovers, and the error names the
2683
+ // operation so the caller can tell this case from step 3's.
2684
+ try {
2685
+ renameSync(staging, storePath);
2686
+ fsyncPath(dir);
2687
+ } catch (e) {
2688
+ discardSnapshot(staging);
2689
+ throw new AnnoStoreError(
2690
+ `cannot revert ${storePath} to revision ${revision}: renaming the staged snapshot over the store failed ` +
2691
+ `(${(e as Error).message}). The store connection was already closed for the rename -- that is the one residual this path ` +
2692
+ `cannot remove, because the rename cannot be done with the connection open -- so reopen the store to inspect it.`,
2693
+ { data: { path: storePath, snapshotPath: snapPath, operation: "rename", revision } },
2694
+ );
2695
+ }
2696
+
2697
+ // STEP 6. Reconcile the RESTORED ring before the handle leaves this
2698
+ // function. The image just restored carries `anno_snapshot` rows for
2699
+ // revisions whose files an earlier prune removed, and it leaves every
2700
+ // snapshot taken AFTER `revision` unclaimed by any row. ONLY THE FILE HALF IS
2701
+ // RESOLVED HERE, and the ROW half is deliberately left: since CR-05 the sweep
2702
+ // abstains from the pointer-row direction entirely, because it cannot
2703
+ // establish ownership of a row under a second spelling of the store file, so
2704
+ // the restored image's stale rows are TOLERATED rather than deleted. This
2705
+ // sentence previously claimed BOTH halves were resolved here -- the reversal
2706
+ // is RECORDED RATHER THAN QUIETLY REWRITTEN, because a rationale that became
2707
+ // false is evidence; the falsified sentence itself is not repeated verbatim,
2708
+ // because the next reader greps this file for the guarantee it asserts, not
2709
+ // for its refutation.
2710
+ // The handle this function hands back still never advertises a revision it
2711
+ // cannot deliver, and that does not depend on the sweep at all: the published
2712
+ // floor routes through `retainedRevisions`, which requires BOTH halves of a
2713
+ // revision's record regardless of whether either half was ever swept.
2714
+ //
2715
+ // The directory bound is the OTHER clause of the original sentence, and it
2716
+ // was already qualified once for a different reason -- that qualification is
2717
+ // still exactly true and is extended, not replaced. It
2718
+ // previously ended "and the directory bound holds after a revert as well as
2719
+ // before one", which is now an over-claim: `reconcileSnapshotRing` takes the
2720
+ // store's write lock before it judges, and under contention it DECLINES and
2721
+ // reports `deferred`, handing back a ring it did not reconcile.
2722
+ //
2723
+ // * THE FIRST CLAUSE SURVIVES UNCHANGED, and is not weakened: the handle
2724
+ // still never advertises a revision it cannot deliver, because the
2725
+ // published floor routes through `retainedRevisions`, which requires BOTH
2726
+ // halves of a revision's record regardless of whether the sweep ran.
2727
+ // * THE SECOND CLAUSE IS CONDITIONAL. The directory bound holds after a
2728
+ // revert as well as before one unless the sweep deferred, in which case
2729
+ // the restored ring stays over-full until the next accepted write sweeps
2730
+ // it -- extra FILES, the harmless direction, reported rather than silent.
2731
+ //
2732
+ // AND THIS IS THE SECOND OF THE TWO BLOCKING SITES. Under contention the
2733
+ // sweep below can itself stall for up to the connection's five-second
2734
+ // `busy_timeout` before `revertTo` returns.
2735
+ //
2736
+ // CR-07's THIRD PROPERTY: THE SWEEP IS HOUSEKEEPING AND MUST NEVER COST THE
2737
+ // CALLER A HANDLE. By this line the revert has ALREADY SUCCEEDED ON DISK --
2738
+ // step 5's rename and directory fsync have returned, so the store file at
2739
+ // `storePath` IS the reverted image whatever happens next. Round 3 observed
2740
+ // this exact line throw a bare, non-family `Error: EACCES` from
2741
+ // `readdirSync` on an unreadable ring directory AFTER a successful
2742
+ // `rev 4 -> rev 2`: the caller got no handle at all for a revert that had
2743
+ // landed, and the connection opened one statement above was left with nothing
2744
+ // able to close it.
2745
+ //
2746
+ // THE HANDLER CLOSES AND REOPENS RATHER THAN RETURNING `restored`, and that
2747
+ // is deliberate, not defensive noise. If the sweep threw, that connection's
2748
+ // transaction state is UNKNOWN -- handing back a connection that may still
2749
+ // hold the store's write lock is the defect being closed, not a repair of it.
2750
+ // The reopen routes through `openStore`, which is already inside the
2751
+ // `ViceError` family, so a genuine failure to reopen refuses BY NAME rather
2752
+ // than escaping as a bare error. The inner `try` around `closeStore` swallows
2753
+ // for the same reason every other inner rollback in this module does: there
2754
+ // is nothing useful to do with a second error while unwinding the first.
2755
+ //
2756
+ // STATED HONESTLY: AFTER PLAN 28-13 THIS `catch` IS NOT REACHABLE FROM ANY
2757
+ // INPUT. That plan bracketed `reconcileSnapshotRing`'s whole body in a handler
2758
+ // that rolls back and returns `{ droppedFiles: [], deferred: true }` without
2759
+ // rethrowing, so no reachable input makes the sweep throw. This handler is
2760
+ // therefore DEFENCE IN DEPTH against a future edit that reintroduces a throw
2761
+ // -- not a currently reachable arm -- and `anno-store.test.ts` says the same
2762
+ // thing in both of its controls rather than letting a green test imply a
2763
+ // behavioural proof it does not carry.
2764
+ //
2765
+ // AND THE REOPEN IS NOW INSIDE THE SAME GUARANTEE (WR-17), WHICH IS WHERE IT
2766
+ // BELONGED. The sentence above -- "a housekeeping failure never costs the
2767
+ // caller a handle" -- used to hold only for the branch that CANNOT fire. The
2768
+ // sweep call was guarded and is unreachable; the two `openStore` calls were
2769
+ // NOT guarded and are by far the likelier to throw, because each one runs the
2770
+ // `anno_meta` read, the `schema_version` comparison and `pragma
2771
+ // integrity_check` against the image this function has just installed. Round
2772
+ // 4 reproduced exactly that: a bad snapshot made the reopen throw, the
2773
+ // caller's original handle had been closed at step 4, and `revertTo` returned
2774
+ // nothing at all.
2775
+ //
2776
+ // ITS INTERACTION WITH STEP 3b, STATED BECAUSE IT NARROWS THE CLAIM RATHER
2777
+ // THAN CLOSING IT. Step 3b now opens the staged image BEFORE the rename, so
2778
+ // the reopen's most likely failure -- the image is not a store -- cannot reach
2779
+ // this line at all. What is left is a store that became unopenable BETWEEN the
2780
+ // rename and the reopen: another process truncating it, a device error, a
2781
+ // permission change. This handler covers that remainder.
2782
+ //
2783
+ // AND IT MUST REPORT A LANDED REVERT, NEVER A FAILED ONE (prohibition
2784
+ // 28-11 P5). By this line step 5's rename and directory fsync have returned,
2785
+ // so the file at `storePath` IS the reverted image whatever happens next.
2786
+ // Presenting that as a failed revert would convert a committed write into a
2787
+ // caller-visible failure and send the caller looking for a revert that
2788
+ // already happened. A `ViceError` is rethrown UNCHANGED -- it is already
2789
+ // named, already carries the path, and re-wrapping it would bury the reason
2790
+ // one layer deeper; anything else is wrapped so no route out of step 6
2791
+ // reaches the caller as a bare OS error.
2792
+ let restored: AnnoStoreHandle;
2793
+ try {
2794
+ // MODULE-DERIVED PATH: `storePath` is `handle.path`, already confined by the
2795
+ // open that produced the caller's handle.
2796
+ restored = openStore(storePath, { unconfinedModuleDerivedPath: true });
2797
+ } catch (e) {
2798
+ if (e instanceof ViceError) throw e;
2799
+ throw new AnnoStoreError(
2800
+ `the revert of ${storePath} to revision ${revision} LANDED ON DISK, but reopening the store afterwards failed ` +
2801
+ `(${(e as Error).message}). The revert is NOT undone and must not be retried as though it had failed: the file at ${storePath} ` +
2802
+ `IS the restored image, so reopen it with openStore to inspect it.`,
2803
+ { data: { path: storePath, revision, step: "reopen after revert" } },
2804
+ );
2805
+ }
2806
+ //
2807
+ // AND THE SWEEP'S RESULT IS BOUND RATHER THAN DISCARDED (WR-18), WHICH IS THE
2808
+ // ARM THE `catch` ABOVE CANNOT SEE. `reconcileSnapshotRing` does not throw
2809
+ // when its own `rollback` fails -- rethrowing there is forbidden by 28-11 P5,
2810
+ // because on this very call site it would convert a LANDED revert into a
2811
+ // caller-visible failure -- so it REPORTS the fact in `rollbackFailed`
2812
+ // instead. Reaching that state WITHOUT a throw is exactly why the existing
2813
+ // catch arm alone was not enough: `revertTo` would hand back a connection that
2814
+ // may still hold the store's write lock, which is CR-07's reported symptom
2815
+ // re-created on the revert path.
2816
+ //
2817
+ // THE REMEDY IS THE SAME BLOCK, REUSED RATHER THAN COPIED: close the
2818
+ // connection whose transaction state is unknown and hand back a freshly opened
2819
+ // one. `revertTo` must not return a handle it cannot vouch for, and a second
2820
+ // copy of the remedy is a second place it can drift.
2821
+ let reopenNeeded: boolean;
2822
+ try {
2823
+ reopenNeeded = reconcileSnapshotRing(restored).rollbackFailed;
2824
+ } catch {
2825
+ reopenNeeded = true;
2826
+ }
2827
+ if (reopenNeeded) {
2828
+ try {
2829
+ closeStore(restored);
2830
+ } catch {
2831
+ // deliberately ignored -- see above
2832
+ }
2833
+ try {
2834
+ // MODULE-DERIVED PATH: `storePath` is `handle.path`, as above.
2835
+ return openStore(storePath, { unconfinedModuleDerivedPath: true });
2836
+ } catch (e) {
2837
+ if (e instanceof ViceError) throw e;
2838
+ throw new AnnoStoreError(
2839
+ `the revert of ${storePath} to revision ${revision} LANDED ON DISK, but reopening the store after a failed ring reconciliation ` +
2840
+ `failed too (${(e as Error).message}). The revert is NOT undone: the file at ${storePath} IS the restored image, so reopen it ` +
2841
+ `with openStore to inspect it.`,
2842
+ { data: { path: storePath, revision, step: "reopen after revert" } },
2843
+ );
2844
+ }
2845
+ }
2846
+ return restored;
2847
+ }
2848
+
2849
+ /** The paint index over this store's current rows, rebuilt from the rows every
2850
+ * time (`anno-index.ts` traps 2 and 4). A convenience over
2851
+ * `buildPaintIndex(listRanges(handle))` -- it holds nothing between calls. */
2852
+ export function paintIndexOf(handle: AnnoStoreHandle): PaintIndex {
2853
+ return buildPaintIndex(listRanges(handle));
2854
+ }
2855
+
2856
+ // ---------------------------------------------------------------------------
2857
+ // The five annotation kinds: labels, comments, scopes, project enums and
2858
+ // cross-references. Every entry point below VALIDATES FIRST and only then goes
2859
+ // through `applyWrite`, so no SQL runs on an unvalidated argument. Every
2860
+ // statement is `prepare().run()` with bound parameters -- `exec()` stays
2861
+ // restricted to the fixed `DDL`, the transaction keywords and the one escaped
2862
+ // `vacuum into` (trap 3).
2863
+ // ---------------------------------------------------------------------------
2864
+
2865
+ /**
2866
+ * Binds `name` to `address` with label kind `kind`.
2867
+ *
2868
+ * THE COLLISION IS REFUSED, NEVER RESOLVED. A name already bound to a
2869
+ * DIFFERENT address throws `AnnoLabelError` naming the name and both addresses.
2870
+ * It is not rebound, not suffixed and not sanitised: see `anno-types.ts` trap 7
2871
+ * for the hazard, which is that any of those silently merges or moves a name a
2872
+ * human deliberately chose, with nothing recording that it happened.
2873
+ *
2874
+ * The DDL's `unique(name)` constraint is a SECOND LINE OF DEFENCE and is
2875
+ * deliberately not the observable refusal. The named error is thrown first, from
2876
+ * inside the mutation's own transaction so a concurrent writer cannot bind the
2877
+ * name between the read and the insert; the constraint only catches a path that
2878
+ * bypassed this function entirely.
2879
+ *
2880
+ * The name-versus-name comparison is EXACT BYTE EQUALITY -- the SQL `=` on a
2881
+ * text column with the default (binary) collation, matching the definition
2882
+ * `assertLegalLabel()`'s doc comment states. No case folding, no Unicode
2883
+ * normalisation, no trimming.
2884
+ */
2885
+ export function setLabel(
2886
+ handle: AnnoStoreHandle,
2887
+ args: { address: number | string; name: unknown; kind: unknown; baseRevision?: number },
2888
+ ): AnnoWriteResult {
2889
+ const address = parseStoreAddress(args.address, { what: "address" });
2890
+ const name = assertLegalLabel(args.name);
2891
+ const kind: LabelKind = assertLabelKind(args.kind);
2892
+
2893
+ const { revision, result } = applyWrite(
2894
+ handle,
2895
+ (db) => {
2896
+ const existing = db.prepare("select id, address, kind from anno_label where name = ?").get(name) as
2897
+ | { id: number; address: number; kind: string }
2898
+ | undefined;
2899
+
2900
+ if (existing && existing.address !== address) {
2901
+ throw new AnnoLabelError(
2902
+ `label name ${JSON.stringify(name)} is already bound to address ${existing.address} ` +
2903
+ `($${existing.address.toString(16).padStart(4, "0")}) and cannot also name address ${address} ` +
2904
+ `($${address.toString(16).padStart(4, "0")}) -- the write is REFUSED rather than rebinding the name or inventing a variant of it, ` +
2905
+ `because either would silently merge or move a name somebody chose on purpose`,
2906
+ { identifier: name, reason: "name already bound to a different address", existingAddress: existing.address, requestedAddress: address },
2907
+ );
2908
+ }
2909
+
2910
+ if (existing) {
2911
+ if (existing.kind === kind) return false;
2912
+ db.prepare("update anno_label set kind = ? where id = ?").run(kind, existing.id);
2913
+ return true;
2914
+ }
2915
+
2916
+ db.prepare("insert into anno_label(address, name, kind, bank) values (?, ?, ?, ?)").run(address, name, kind, null);
2917
+ return true;
2918
+ },
2919
+ { baseRevision: args.baseRevision },
2920
+ );
2921
+ return { revision, changed: result };
2922
+ }
2923
+
2924
+ /** Every label, in ascending `id` order. One of the row mappers that read the
2925
+ * reserved `bank` column -- see `listRanges()` for why nothing else may. */
2926
+ export function listLabels(handle: AnnoStoreHandle): LabelRow[] {
2927
+ const rows = handle.db.prepare("select id, address, name, kind, bank from anno_label order by id").all() as {
2928
+ id: number;
2929
+ address: number;
2930
+ name: string;
2931
+ kind: string;
2932
+ bank: number | null;
2933
+ }[];
2934
+ return rows.map((row) => ({
2935
+ id: row.id,
2936
+ address: row.address,
2937
+ name: row.name,
2938
+ kind: row.kind as LabelKind,
2939
+ bank: row.bank,
2940
+ }));
2941
+ }
2942
+
2943
+ /**
2944
+ * Stores `text` as the `commentType` comment at `address`, replacing whatever
2945
+ * was there. One comment per `(address, comment_type)` pair -- the DDL's own
2946
+ * unique constraint -- so the two placements coexist at one address and a
2947
+ * repeated write of the same placement replaces rather than accumulates.
2948
+ *
2949
+ * A byte-identical repeat reports `changed:false`: it is accepted, not refused,
2950
+ * and the revision still advances (see `AnnoWriteResult`).
2951
+ */
2952
+ export function setComment(
2953
+ handle: AnnoStoreHandle,
2954
+ args: { address: number | string; commentType: unknown; text: unknown; baseRevision?: number },
2955
+ ): AnnoWriteResult {
2956
+ const address = parseStoreAddress(args.address, { what: "address" });
2957
+ const commentType: CommentType = assertCommentType(args.commentType);
2958
+ const text = assertCommentText(args.text);
2959
+
2960
+ const { revision, result } = applyWrite(
2961
+ handle,
2962
+ (db) => {
2963
+ const existing = db.prepare("select id, text from anno_comment where address = ? and comment_type = ?").get(address, commentType) as
2964
+ | { id: number; text: string }
2965
+ | undefined;
2966
+
2967
+ if (existing) {
2968
+ if (existing.text === text) return false;
2969
+ db.prepare("update anno_comment set text = ? where id = ?").run(text, existing.id);
2970
+ return true;
2971
+ }
2972
+
2973
+ db.prepare("insert into anno_comment(address, comment_type, text, bank) values (?, ?, ?, ?)").run(address, commentType, text, null);
2974
+ return true;
2975
+ },
2976
+ { baseRevision: args.baseRevision },
2977
+ );
2978
+ return { revision, changed: result };
2979
+ }
2980
+
2981
+ /** Every comment, in ascending `id` order. Reads the reserved `bank` column. */
2982
+ export function listComments(handle: AnnoStoreHandle): CommentRow[] {
2983
+ const rows = handle.db.prepare("select id, address, comment_type, text, bank from anno_comment order by id").all() as {
2984
+ id: number;
2985
+ address: number;
2986
+ comment_type: string;
2987
+ text: string;
2988
+ bank: number | null;
2989
+ }[];
2990
+ return rows.map((row) => ({
2991
+ id: row.id,
2992
+ address: row.address,
2993
+ commentType: row.comment_type as CommentType,
2994
+ text: row.text,
2995
+ bank: row.bank,
2996
+ }));
2997
+ }
2998
+
2999
+ /**
3000
+ * Adds a lexical scope over the inclusive range `start..endInclusive`.
3001
+ *
3002
+ * NO NESTING, and that is a faithful mirror rather than a shortcut: the schema
3003
+ * this store mirrors says in as many words that nested scopes are not supported
3004
+ * (`anno-tools.ts:320-324`). Inventing nesting here would create annotations no
3005
+ * exporter downstream can express.
3006
+ *
3007
+ * The shape check passes a NON-SPLIT data type on purpose. `assertRangeShape()`
3008
+ * carries the split-table even-count rule, and a scope is not a table -- a
3009
+ * three-byte routine is a perfectly good scope. Passing `"byte"` selects the
3010
+ * two rules that do apply (both ends inside the address space; the end not below
3011
+ * the start) and none of the ones that do not.
3012
+ *
3013
+ * ENFORCED, as of 28-21, by the overlap refusal below: a scope that is nested
3014
+ * inside, contains, or partially overlaps an existing scope is REFUSED with an
3015
+ * `AnnoRangeShapeError` naming BOTH scopes -- the incoming one's two ends and
3016
+ * the existing one's id and two ends. Before that refusal existed this comment
3017
+ * and `ScopeRow`'s made a claim the code did not honour, which is exactly the
3018
+ * shape prohibition 28-07 P3 forbids. Adjacency is NOT overlap: two scopes that
3019
+ * merely touch at a boundary are two scopes, consistent with STORE-02's
3020
+ * treatment of ranges.
3021
+ *
3022
+ * A BYTE-IDENTICAL REPEAT IS AN ACCEPTED NO-OP reporting `changed: false`, and
3023
+ * this REVERSES a decision recorded here in as many words. The deleted
3024
+ * paragraph read "ADDITIVE, matching the verb's own name in the schema
3025
+ * (`add_scope`): two identical calls produce two rows [...] collapsing
3026
+ * duplicates here would be this module inventing a policy the surface does not
3027
+ * have." That reading is rejected on two grounds it could not see. First,
3028
+ * `AnnoWriteResult`'s own doc comment states that `changed` is the ONLY signal
3029
+ * distinguishing a no-op from a real edit -- and for scopes it could never say
3030
+ * no-op, so the module already had the policy and simply could not express it
3031
+ * here. Second, Phase 29's success criterion 5 requires a repeated edit to
3032
+ * SUCCEED reporting no change, so the surface this store mirrors does have the
3033
+ * policy after all. The repeat is therefore accepted rather than refused, and
3034
+ * the revision still advances by one, exactly like every other write entry
3035
+ * point in this module.
3036
+ */
3037
+ export function addScope(
3038
+ handle: AnnoStoreHandle,
3039
+ args: { start: number | string; endInclusive: number | string; baseRevision?: number },
3040
+ ): AnnoWriteResult {
3041
+ const start = parseStoreAddress(args.start, { what: "start" });
3042
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3043
+ assertRangeShape(start, endInclusive, "byte");
3044
+
3045
+ const { revision, result } = applyWrite(
3046
+ handle,
3047
+ (db) => {
3048
+ // IDEMPOTENCE FIRST, in the same shape `setLabel` and `setComment` use:
3049
+ // read the existing row inside the transaction and return `false`. It has
3050
+ // to run before the overlap check, because a byte-identical scope
3051
+ // overlaps itself and would otherwise be refused rather than accepted as
3052
+ // the no-op Phase 29's criterion 5 requires.
3053
+ const identical = db.prepare("select id from anno_scope where start = ? and end_inclusive = ?").get(start, endInclusive) as
3054
+ | { id: number }
3055
+ | undefined;
3056
+ if (identical) return false;
3057
+
3058
+ // TWO RANGES OVERLAP IFF each starts at or before the other ends.
3059
+ // ADJACENCY FALLS OUT OF THE `>=`: an existing scope ending at exactly
3060
+ // `start - 1` fails `end_inclusive >= start`, so touching is not
3061
+ // overlapping. `order by id limit 1` reports the FIRST conflicting row
3062
+ // rather than an arbitrary one, so the message is reproducible.
3063
+ const overlapper = db
3064
+ .prepare("select id, start, end_inclusive from anno_scope where start <= ? and end_inclusive >= ? order by id limit 1")
3065
+ .get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
3066
+ if (overlapper) {
3067
+ throw new AnnoRangeShapeError(
3068
+ `scope ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
3069
+ `overlaps the existing scope id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
3070
+ `($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}) -- ` +
3071
+ `nested and overlapping scopes are UNSUPPORTED by the schema this store mirrors, so the write is REFUSED rather than stored ` +
3072
+ `as a shape nothing downstream can express. The incoming scope is NOT trimmed and NOT split: supply a range disjoint from ` +
3073
+ `every existing scope. Two scopes that merely TOUCH at a boundary are disjoint and both accepted.`,
3074
+ { start, endInclusive },
3075
+ );
3076
+ }
3077
+
3078
+ db.prepare("insert into anno_scope(start, end_inclusive) values (?, ?)").run(start, endInclusive);
3079
+ return true;
3080
+ },
3081
+ { baseRevision: args.baseRevision },
3082
+ );
3083
+ return { revision, changed: result };
3084
+ }
3085
+
3086
+ /** Every scope, in ascending `id` order. `anno_scope` has no `bank` column --
3087
+ * a scope is a lexical region, not a memory view. */
3088
+ export function listScopes(handle: AnnoStoreHandle): ScopeRow[] {
3089
+ const rows = handle.db.prepare("select id, start, end_inclusive from anno_scope order by id").all() as {
3090
+ id: number;
3091
+ start: number;
3092
+ end_inclusive: number;
3093
+ }[];
3094
+ return rows.map((row) => ({ id: row.id, start: row.start, endInclusive: row.end_inclusive }));
3095
+ }
3096
+
3097
+ /**
3098
+ * Removes the scope whose span is EXACTLY `start..endInclusive`, and returns
3099
+ * `changed: false` when no scope has that span.
3100
+ *
3101
+ * WHY THIS EXISTS, and why it is not an omission being corrected quietly.
3102
+ * `28-VERIFICATION.md`'s `WR-28` recorded that `addScope`'s overlap refusal had
3103
+ * no inverse and carried the finding to Phase 29 in as many words, "which puts
3104
+ * `addScope` on an agent-driven surface where a mistyped span is likelier".
3105
+ * `28-REVIEW.md:1788-1814` spells out the consequence: one transposed end --
3106
+ * `addScope($1000, $ffff)` -- makes every future scope from `$1000` upward
3107
+ * permanently unaddable, recoverable only through `revertTo` inside the
3108
+ * 32-revision ring, after which the mistake is permanent for the life of the
3109
+ * project file. Its stated fix is to ship the inverse in the same phase as the
3110
+ * refusal. This is that inverse.
3111
+ *
3112
+ * THE SPAN MUST MATCH EXACTLY -- both ends, as stored. A scope is not trimmed,
3113
+ * split, or partially removed, for the same reason `addScope` does not trim an
3114
+ * overlapping incoming scope: a partial removal would leave a shape the schema
3115
+ * this store mirrors cannot express, and it would do so while reporting
3116
+ * success. A caller that does not know the stored span reads it from
3117
+ * `listScopes()` first.
3118
+ *
3119
+ * REMOVING A SCOPE THAT IS NOT THERE IS AN ACCEPTED NO-OP reporting
3120
+ * `changed: false`, matching `clearEnumUsage`'s direction: an inverse that
3121
+ * refuses when there is nothing to undo makes "undo this" conditional on
3122
+ * knowing whether it was ever done.
3123
+ *
3124
+ * THIS IS THE MODULE'S FOURTH ROW-DELETING STATEMENT. `clearEnumUsage`'s doc
3125
+ * block states the count as three; that sentence was true when it was written
3126
+ * and this one supersedes it. The count is written in prose, deliberately
3127
+ * without spelling the SQL prefix a census greps for, so a census over this
3128
+ * module counts STATEMENTS and not the sentences describing them. The statement
3129
+ * runs inside the write sequence's transaction, so a refusal raised anywhere in
3130
+ * the sequence rolls it back with everything else.
3131
+ */
3132
+ export function removeScope(
3133
+ handle: AnnoStoreHandle,
3134
+ args: { start: number | string; endInclusive: number | string; baseRevision?: number },
3135
+ ): AnnoWriteResult {
3136
+ const start = parseStoreAddress(args.start, { what: "start" });
3137
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3138
+ // The SAME non-split shape check `addScope` uses, and for the same reason: a
3139
+ // scope is not a table, so the split-table even-count rule must not apply to
3140
+ // it. Passing a different type here would make the inverse refuse spans the
3141
+ // forward verb accepts.
3142
+ assertRangeShape(start, endInclusive, "byte");
3143
+
3144
+ const { revision, result } = applyWrite(
3145
+ handle,
3146
+ (db) => {
3147
+ const existing = db.prepare("select id from anno_scope where start = ? and end_inclusive = ?").get(start, endInclusive) as
3148
+ | { id: number }
3149
+ | undefined;
3150
+ if (!existing) return false;
3151
+ db.prepare("delete from anno_scope where id = ?").run(existing.id);
3152
+ return true;
3153
+ },
3154
+ { baseRevision: args.baseRevision },
3155
+ );
3156
+ return { revision, changed: result };
3157
+ }
3158
+
3159
+ /**
3160
+ * Records a user-requested exclusion of `start..endInclusive`, with `reason`
3161
+ * stating WHY the user asked for it -- added at `SCHEMA_VERSION` 5
3162
+ * (`BUILD-07`).
3163
+ *
3164
+ * RECORDING AN EXCLUSION CHANGES NOTHING ABOUT WHICH BYTES THE EXPORT EMITS.
3165
+ * The exporter still walks this range's full byte span and emits a real
3166
+ * block, tagged with a visible marker comment, rather than a hole -- that is
3167
+ * `BUILD-07`'s whole invariant. An exporter implementation that skipped the
3168
+ * block on seeing an exclusion row would satisfy the word "exclude" and fail
3169
+ * the requirement outright: this table is a RECORD, never a filter, and the
3170
+ * store answers "what did the user record", never "should this range be
3171
+ * excluded".
3172
+ *
3173
+ * `reason` is validated through `assertCommentText()` -- the ONE comment-text
3174
+ * vocabulary this store has -- BEFORE the write opens, and an empty or
3175
+ * whitespace-only reason is refused with its own message: a `not null`
3176
+ * column satisfied by `""` records that something was excluded and loses WHY,
3177
+ * which is precisely the half of criterion 2 this record exists to carry.
3178
+ *
3179
+ * IDEMPOTENCE FIRST, inside the transaction and BEFORE the overlap check, the
3180
+ * same shape `addScope` uses: an identical repeat -- same extent, same reason
3181
+ * -- is an accepted NO-OP reporting `changed: false`. The SAME extent with a
3182
+ * DIFFERENT reason is REFUSED rather than silently overwritten -- the stored
3183
+ * reason is left exactly as it was, and the route to change it is to remove
3184
+ * the record with `removeExcludedRange` and add it again. Silently replacing
3185
+ * what somebody wrote and reporting success is the failure mode this store's
3186
+ * comment and label verbs already refuse.
3187
+ *
3188
+ * OVERLAP IS REFUSED using `addScope()`'s EXACT predicate --
3189
+ * `start <= ? and end_inclusive >= ?` with the two arguments TRANSPOSED, and
3190
+ * `order by id limit 1` so the message is reproducible -- so adjacency falls
3191
+ * out of the `>=` rather than a second rule: two exclusion records that
3192
+ * merely TOUCH at a boundary are disjoint and both accepted, and they stay
3193
+ * TWO records. The incoming record is NEVER trimmed or split; a caller
3194
+ * wanting a disjoint span reads `listExcludedRanges()` first, or removes the
3195
+ * conflicting record with `removeExcludedRange`.
3196
+ */
3197
+ export function addExcludedRange(
3198
+ handle: AnnoStoreHandle,
3199
+ args: { start: number | string; endInclusive: number | string; reason: string; baseRevision?: number },
3200
+ ): AnnoWriteResult {
3201
+ const start = parseStoreAddress(args.start, { what: "start" });
3202
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3203
+ assertRangeShape(start, endInclusive, "byte");
3204
+
3205
+ const reason = assertCommentText(args.reason, { what: "exclusion reason" });
3206
+ if (reason.trim() === "") {
3207
+ throw new AnnoCommentError(
3208
+ `exclusion reason is empty or whitespace-only -- a "reason" column satisfied by an empty string records that something was excluded ` +
3209
+ `and loses WHY, which is precisely the half of BUILD-07's criterion 2 this record exists to carry. Supply the reason the user gave.`,
3210
+ { reason: "empty reason" },
3211
+ );
3212
+ }
3213
+
3214
+ const { revision, result } = applyWrite(
3215
+ handle,
3216
+ (db) => {
3217
+ // IDEMPOTENCE FIRST, `addScope`'s own shape: read the existing row
3218
+ // inside the transaction and BEFORE the overlap check, because a
3219
+ // byte-identical exclusion overlaps itself and would otherwise be
3220
+ // refused rather than accepted as the no-op an identical repeat requires.
3221
+ const identical = db
3222
+ .prepare("select id, reason from anno_excluded_range where start = ? and end_inclusive = ?")
3223
+ .get(start, endInclusive) as { id: number; reason: string } | undefined;
3224
+ if (identical) {
3225
+ if (identical.reason === reason) return false;
3226
+ throw new AnnoRangeShapeError(
3227
+ `exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
3228
+ `is already recorded (id=${identical.id}) with a DIFFERENT reason -- the stored reason is left EXACTLY as it was. Refusing rather ` +
3229
+ `than silently overwriting what somebody wrote: remove the record with removeExcludedRange and add it again to change the reason.`,
3230
+ { start, endInclusive },
3231
+ );
3232
+ }
3233
+
3234
+ // TWO EXCLUSIONS OVERLAP IFF each starts at or before the other ends.
3235
+ // ADJACENCY FALLS OUT OF THE `>=`, `addScope()`'s exact predicate: an
3236
+ // existing exclusion ending at exactly `start - 1` fails
3237
+ // `end_inclusive >= start`, so touching is not overlapping. `order by id
3238
+ // limit 1` reports the FIRST conflicting row so the message is
3239
+ // reproducible.
3240
+ const overlapper = db
3241
+ .prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
3242
+ .get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
3243
+ if (overlapper) {
3244
+ throw new AnnoRangeShapeError(
3245
+ `exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
3246
+ `overlaps the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
3247
+ `($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}) -- the incoming ` +
3248
+ `record is NOT trimmed and NOT split: supply a range disjoint from every existing exclusion. Two exclusions that merely TOUCH at a ` +
3249
+ `boundary are disjoint and both accepted. Read listExcludedRanges() first, or removeExcludedRange the conflicting record.`,
3250
+ { start, endInclusive },
3251
+ );
3252
+ }
3253
+
3254
+ db.prepare("insert into anno_excluded_range(start, end_inclusive, reason) values (?, ?, ?)").run(start, endInclusive, reason);
3255
+ return true;
3256
+ },
3257
+ { baseRevision: args.baseRevision },
3258
+ );
3259
+ return { revision, changed: result };
3260
+ }
3261
+
3262
+ /** Every recorded exclusion, in ascending `id` order -- insertion order,
3263
+ * matching `listScopes()` and `listRanges()`. A consumer needing address
3264
+ * order sorts it itself, because a second ordering in the store would be a
3265
+ * second answer to the same question. `anno_excluded_range` has no `bank`
3266
+ * column, in the same shape `listScopes()`'s own doc comment uses for the
3267
+ * same absence: an exclusion is a statement about the subject program, not a
3268
+ * memory view. */
3269
+ export function listExcludedRanges(handle: AnnoStoreHandle): ExcludedRangeRow[] {
3270
+ const rows = handle.db.prepare("select id, start, end_inclusive, reason from anno_excluded_range order by id").all() as {
3271
+ id: number;
3272
+ start: number;
3273
+ end_inclusive: number;
3274
+ reason: string;
3275
+ }[];
3276
+ return rows.map((row) => ({ id: row.id, start: row.start, endInclusive: row.end_inclusive, reason: row.reason }));
3277
+ }
3278
+
3279
+ /**
3280
+ * Removes the exclusion whose span is EXACTLY `start..endInclusive`, and
3281
+ * returns `changed: false` when NO exclusion overlaps that span at all --
3282
+ * the exact inverse of `addExcludedRange`, following `removeScope()`.
3283
+ *
3284
+ * THE SPAN MUST MATCH EXACTLY -- both ends, as stored. A record is never
3285
+ * trimmed, split, or partially removed: a span that PARTIALLY OVERLAPS an
3286
+ * existing record (but does not match it end-for-end) is REFUSED BY NAME
3287
+ * rather than silently ignored, because a partial removal would leave a
3288
+ * shape nothing downstream can express, while reporting success. This is
3289
+ * stricter than `removeScope()`, which reports a mismatched span as a plain
3290
+ * no-op -- an exclusion's reason makes a near-miss removal more dangerous to
3291
+ * treat as "nothing happened", since a caller who meant to clear the record
3292
+ * would otherwise walk away believing it gone. A caller that does not know
3293
+ * the stored span reads it from `listExcludedRanges()` first.
3294
+ *
3295
+ * REMOVING A SPAN THAT DOES NOT OVERLAP ANYTHING STORED IS AN ACCEPTED NO-OP
3296
+ * reporting `changed: false`, matching `removeScope`'s own direction: an
3297
+ * inverse that refuses when there is genuinely nothing to undo makes "undo
3298
+ * this" conditional on knowing whether it was ever done.
3299
+ */
3300
+ export function removeExcludedRange(
3301
+ handle: AnnoStoreHandle,
3302
+ args: { start: number | string; endInclusive: number | string; baseRevision?: number },
3303
+ ): AnnoWriteResult {
3304
+ const start = parseStoreAddress(args.start, { what: "start" });
3305
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3306
+ // The SAME non-split shape check `addExcludedRange`/`removeScope` use, and
3307
+ // for the same reason: an exclusion is not a table, so the split-table
3308
+ // even-count rule must not apply to it.
3309
+ assertRangeShape(start, endInclusive, "byte");
3310
+
3311
+ const { revision, result } = applyWrite(
3312
+ handle,
3313
+ (db) => {
3314
+ const existing = db.prepare("select id from anno_excluded_range where start = ? and end_inclusive = ?").get(start, endInclusive) as
3315
+ | { id: number }
3316
+ | undefined;
3317
+ if (existing) {
3318
+ db.prepare("delete from anno_excluded_range where id = ?").run(existing.id);
3319
+ return true;
3320
+ }
3321
+
3322
+ // NO EXACT MATCH. Before reporting the ordinary "nothing to undo"
3323
+ // no-op, check whether the incoming span PARTIALLY overlaps a stored
3324
+ // record -- the same overlap predicate `addExcludedRange` uses. That
3325
+ // case is refused BY NAME rather than treated as a no-op, because the
3326
+ // caller plainly meant to remove something that exists and a silent
3327
+ // no-op would misreport the outcome.
3328
+ const overlapper = db
3329
+ .prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
3330
+ .get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
3331
+ if (overlapper) {
3332
+ throw new AnnoRangeShapeError(
3333
+ `removeExcludedRange: ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..${endInclusive
3334
+ .toString(16)
3335
+ .padStart(4, "0")}) does not EXACTLY match the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
3336
+ `($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}), which it ` +
3337
+ `partially overlaps -- a record is never trimmed, split, or partially removed, because that would leave a shape nothing downstream ` +
3338
+ `can express while reporting success. Read listExcludedRanges() first to find the exact stored span.`,
3339
+ { start, endInclusive },
3340
+ );
3341
+ }
3342
+
3343
+ return false;
3344
+ },
3345
+ { baseRevision: args.baseRevision },
3346
+ );
3347
+ return { revision, changed: result };
3348
+ }
3349
+
3350
+ /**
3351
+ * Validates one project enum's variants mapping and returns it with its KEYS
3352
+ * VERBATIM.
3353
+ *
3354
+ * Every key goes through `parseVariantKey()`, which accepts exactly the forms
3355
+ * the schema names -- decimal, `0x`/`$` hex, `0b`/`%` binary -- and refuses
3356
+ * anything else. The parsed VALUE is used only to detect two keys naming the
3357
+ * same number, which is refused: `"64"` and `"$40"` in one mapping would mean
3358
+ * two variant names for one value, and nothing downstream could say which one
3359
+ * was meant.
3360
+ *
3361
+ * The keys are NOT canonicalised. A caller that wrote `"$40"` reads back
3362
+ * `"$40"`, because round-tripping by value is the store's contract and a
3363
+ * rewritten key is a value the caller never supplied.
3364
+ *
3365
+ * Every variant NAME is checked as an identifier, for the same reason a label
3366
+ * name is: it is emitted as a symbol downstream, so garbage accepted here
3367
+ * becomes an export failure a long way from its cause.
3368
+ */
3369
+ function validatedVariants(variants: unknown): Record<string, string> {
3370
+ if (typeof variants !== "object" || variants === null || Array.isArray(variants)) {
3371
+ throw new AnnoTypeError(`enum variants ${JSON.stringify(variants)} is not a mapping of numeric-string keys to variant names`, {
3372
+ dataType: variants,
3373
+ });
3374
+ }
3375
+ const out: Record<string, string> = {};
3376
+ const seenValues = new Map<number, string>();
3377
+ for (const [key, value] of Object.entries(variants as Record<string, unknown>)) {
3378
+ const numeric = parseVariantKey(key);
3379
+ const alreadyAt = seenValues.get(numeric);
3380
+ if (alreadyAt !== undefined) {
3381
+ throw new AnnoTypeError(
3382
+ `enum variant keys ${JSON.stringify(alreadyAt)} and ${JSON.stringify(key)} both name the value ${numeric} -- refusing a mapping ` +
3383
+ `with two names for one value, because nothing downstream could say which was meant`,
3384
+ { dataType: key },
3385
+ );
3386
+ }
3387
+ seenValues.set(numeric, key);
3388
+ out[key] = assertEnumName(value);
3389
+ }
3390
+ return out;
3391
+ }
3392
+
3393
+ /** Bounds a project enum's free-text description with the same byte bound
3394
+ * comment text carries, and without the semicolon rule -- a description is not
3395
+ * assembler comment text, so a leading `';'` is merely a character. */
3396
+ function validatedDescription(description: unknown): string | null {
3397
+ if (description === undefined || description === null) return null;
3398
+ return assertCommentText(description, { what: "description", allowLeadingSemicolon: true });
3399
+ }
3400
+
3401
+ function readEnumRow(db: DatabaseSync, name: string): { id: number; name: string; variants: string; description: string | null } | undefined {
3402
+ return db.prepare("select id, name, variants, description from anno_enum where name = ?").get(name) as
3403
+ | { id: number; name: string; variants: string; description: string | null }
3404
+ | undefined;
3405
+ }
3406
+
3407
+ /**
3408
+ * Creates a project-local enum.
3409
+ *
3410
+ * A byte-identical repeat is a no-op reporting `changed:false`, so re-running a
3411
+ * generation pass is safe. A DIFFERENT enum under an existing name is REFUSED
3412
+ * with `AnnoLabelError` naming the collision, rather than overwritten -- the
3413
+ * same rule as a label, for the same reason.
3414
+ *
3415
+ * THERE IS NO DELETE VERB, here or anywhere in this module, and that is a
3416
+ * decision rather than an omission: the delete tool on the surface this store
3417
+ * mirrors has zero callers, and a regenerated enum set replaces an old one
3418
+ * through create-then-update. A delete verb whose only exercise is a test is a
3419
+ * data-loss path with no user.
3420
+ */
3421
+ export function createProjectEnum(
3422
+ handle: AnnoStoreHandle,
3423
+ args: { name: unknown; variants: unknown; description?: unknown; baseRevision?: number },
3424
+ ): AnnoWriteResult {
3425
+ const name = assertEnumName(args.name);
3426
+ const variants = validatedVariants(args.variants);
3427
+ const description = validatedDescription(args.description);
3428
+ const variantsJson = JSON.stringify(variants);
3429
+
3430
+ const { revision, result } = applyWrite(
3431
+ handle,
3432
+ (db) => {
3433
+ const existing = readEnumRow(db, name);
3434
+ if (existing) {
3435
+ if (existing.variants === variantsJson && existing.description === description) return false;
3436
+ throw new AnnoLabelError(
3437
+ `project enum ${JSON.stringify(name)} already exists with different contents -- the write is REFUSED rather than overwriting it. ` +
3438
+ `Use the update entry point, which replaces the variants mapping wholesale and says so.`,
3439
+ { identifier: name, reason: "enum name already in use with different contents" },
3440
+ );
3441
+ }
3442
+ db.prepare("insert into anno_enum(name, variants, description) values (?, ?, ?)").run(name, variantsJson, description);
3443
+ return true;
3444
+ },
3445
+ { baseRevision: args.baseRevision },
3446
+ );
3447
+ return { revision, changed: result };
3448
+ }
3449
+
3450
+ /**
3451
+ * Updates a project-local enum: renames it, replaces its variants mapping, and
3452
+ * replaces its description, in any combination.
3453
+ *
3454
+ * THE VARIANTS MAPPING IS REPLACED WHOLESALE when one is supplied, matching the
3455
+ * schema's own words ("complete updated variants mapping"). It is not merged: a
3456
+ * merge would make a variant impossible to REMOVE, since there would be no way
3457
+ * to express its absence.
3458
+ *
3459
+ * A rename onto a name another enum already holds is refused, not merged.
3460
+ */
3461
+ export function updateProjectEnum(
3462
+ handle: AnnoStoreHandle,
3463
+ args: { name: unknown; newName?: unknown; variants?: unknown; description?: unknown; baseRevision?: number },
3464
+ ): AnnoWriteResult {
3465
+ const name = assertEnumName(args.name);
3466
+ const newName = args.newName === undefined ? undefined : assertEnumName(args.newName);
3467
+ const variants = args.variants === undefined ? undefined : validatedVariants(args.variants);
3468
+ const variantsJson = variants === undefined ? undefined : JSON.stringify(variants);
3469
+ const description = args.description === undefined ? undefined : validatedDescription(args.description);
3470
+
3471
+ const { revision, result } = applyWrite(
3472
+ handle,
3473
+ (db) => {
3474
+ const existing = readEnumRow(db, name);
3475
+ if (!existing) {
3476
+ throw new AnnoLabelError(`project enum ${JSON.stringify(name)} does not exist, so there is nothing to update`, {
3477
+ identifier: name,
3478
+ reason: "no such enum",
3479
+ });
3480
+ }
3481
+ if (newName !== undefined && newName !== name) {
3482
+ const clash = readEnumRow(db, newName);
3483
+ if (clash) {
3484
+ throw new AnnoLabelError(
3485
+ `cannot rename project enum ${JSON.stringify(name)} to ${JSON.stringify(newName)}: that name is already held by another enum -- ` +
3486
+ `the rename is REFUSED rather than merging two enums into one`,
3487
+ { identifier: newName, reason: "rename target already in use" },
3488
+ );
3489
+ }
3490
+ }
3491
+
3492
+ const nextName = newName ?? existing.name;
3493
+ const nextVariants = variantsJson ?? existing.variants;
3494
+ const nextDescription = description === undefined ? existing.description : description;
3495
+ if (nextName === existing.name && nextVariants === existing.variants && nextDescription === existing.description) {
3496
+ return false;
3497
+ }
3498
+
3499
+ db.prepare("update anno_enum set name = ?, variants = ?, description = ? where id = ?").run(
3500
+ nextName,
3501
+ nextVariants,
3502
+ nextDescription,
3503
+ existing.id,
3504
+ );
3505
+ return true;
3506
+ },
3507
+ { baseRevision: args.baseRevision },
3508
+ );
3509
+ return { revision, changed: result };
3510
+ }
3511
+
3512
+ /** Every project enum, in ascending `id` order, with its variants mapping
3513
+ * parsed back out of the single JSON text column. */
3514
+ export function listProjectEnums(handle: AnnoStoreHandle): ProjectEnumRow[] {
3515
+ const rows = handle.db.prepare("select id, name, variants, description from anno_enum order by id").all() as {
3516
+ id: number;
3517
+ name: string;
3518
+ variants: string;
3519
+ description: string | null;
3520
+ }[];
3521
+ return rows.map((row) => ({
3522
+ id: row.id,
3523
+ name: row.name,
3524
+ variants: JSON.parse(row.variants) as Record<string, string>,
3525
+ description: row.description,
3526
+ }));
3527
+ }
3528
+
3529
+ /**
3530
+ * Associates ONE address with ONE project enum, so the address's operand is
3531
+ * formatted through that enum's variants (`SCHEMA_VERSION` 3, D-15).
3532
+ *
3533
+ * THE ASSOCIATION IS BY `anno_enum.id`, NEVER BY NAME, and that is the whole
3534
+ * design of the table. `updateProjectEnum` renames an enum in place, keeping
3535
+ * its id; a usage row that persisted the NAME would either be orphaned by the
3536
+ * rename or -- worse, because it is silent -- re-pointed at whatever enum next
3537
+ * took the old name. The name a caller passes here is resolved to an id ONCE,
3538
+ * at write time, and `listEnumUsage` resolves it back through a join at read
3539
+ * time.
3540
+ *
3541
+ * AN ENUM NAME NO `anno_enum` ROW CARRIES IS REFUSED BY NAME, naming the enum
3542
+ * that was not found, and nothing is written. The alternative -- creating the
3543
+ * enum implicitly -- would let a typo produce a real, empty enum that formats
3544
+ * nothing and looks deliberate.
3545
+ *
3546
+ * THE IDEMPOTENCY SHAPE IS `putXref`'s, COPIED RATHER THAN REINVENTED: the
3547
+ * existing row is selected first and `changed: false` is returned when the same
3548
+ * enum is already applied at the same address. The REVISION still advances --
3549
+ * see `AnnoWriteResult`, where that is the module's stated invariant for every
3550
+ * accepted write, `changed` being the only signal that separates a no-op from
3551
+ * a real edit.
3552
+ *
3553
+ * Every argument is validated through `anno-types.ts`'s own assertions before
3554
+ * any SQL runs. `parseStoreAddress` in particular refuses an UNPREFIXED numeric
3555
+ * string such as `"53280"` outright rather than guessing a base -- WR-22's
3556
+ * recorded failure, in which a JSON `"1"` arrived verbatim and SQLite's column
3557
+ * affinity turned an argument error into a corruption refusal.
3558
+ */
3559
+ export function applyEnumUsage(
3560
+ handle: AnnoStoreHandle,
3561
+ args: { address: number | string; name: unknown; baseRevision?: number },
3562
+ ): AnnoWriteResult {
3563
+ const address = parseStoreAddress(args.address, { what: "address" });
3564
+ const name = assertEnumName(args.name);
3565
+
3566
+ const { revision, result } = applyWrite(
3567
+ handle,
3568
+ (db) => {
3569
+ // READ INSIDE THE TRANSACTION, for the reason the write sequence's own
3570
+ // rollback comment gives: resolving the enum outside it would open a
3571
+ // window in which a concurrent writer renames or removes it between the
3572
+ // read and the insert.
3573
+ const target = readEnumRow(db, name);
3574
+ if (!target) {
3575
+ throw new AnnoLabelError(
3576
+ `project enum ${JSON.stringify(name)} does not exist, so there is nothing to apply at $${address.toString(16).padStart(4, "0")} -- the write is ` +
3577
+ `REFUSED rather than creating the enum implicitly, because a mistyped name would otherwise become a real, empty enum that ` +
3578
+ `formats nothing and looks deliberate. Create it first.`,
3579
+ { identifier: name, reason: "no such enum" },
3580
+ );
3581
+ }
3582
+
3583
+ const existing = db.prepare("select id, enum_id from anno_enum_usage where address = ? and bank is ?").get(address, null) as
3584
+ | { id: number; enum_id: number }
3585
+ | undefined;
3586
+ if (existing) {
3587
+ if (existing.enum_id === target.id) return false;
3588
+ // ONE ADDRESS CARRIES AT MOST ONE ENUM (the `unique(address, bank)`
3589
+ // constraint), so applying a DIFFERENT enum replaces rather than
3590
+ // refuses: the schema's own words for the verb are "Applies an enum
3591
+ // definition to format the immediate operand ... at a specific
3592
+ // address", which is a set, not an add.
3593
+ db.prepare("update anno_enum_usage set enum_id = ? where id = ?").run(target.id, existing.id);
3594
+ return true;
3595
+ }
3596
+ db.prepare("insert into anno_enum_usage(address, enum_id, bank) values (?, ?, ?)").run(address, target.id, null);
3597
+ return true;
3598
+ },
3599
+ { baseRevision: args.baseRevision },
3600
+ );
3601
+ return { revision, changed: result };
3602
+ }
3603
+
3604
+ /**
3605
+ * Clears the enum usage at one address. An address that carries none returns
3606
+ * `changed: false` and is NOT an error -- clearing is idempotent in the same
3607
+ * direction applying is, and the schema's own words make the empty name the
3608
+ * clear ("Omit or send empty to clear"), so a caller clearing twice is doing
3609
+ * the ordinary thing rather than a mistake worth refusing.
3610
+ *
3611
+ * THIS IS THE MODULE'S **THIRD** ROW-DELETING STATEMENT, and the count is
3612
+ * stated here rather than left to be rediscovered. `28-REVIEW` recorded exactly
3613
+ * TWO -- the snapshot ring's prune, which removes an `anno_snapshot` pointer
3614
+ * row, and `retype()`, which removes an overlapped `anno_range` row -- and a
3615
+ * reader who checks that number against this tree will find THREE. The third is
3616
+ * this one.
3617
+ *
3618
+ * The count is written in prose, deliberately without spelling the SQL prefix a
3619
+ * census greps for, so that a `grep` over this module counts STATEMENTS and not
3620
+ * the sentence describing them. Nothing else about the deletion discipline
3621
+ * changed: this statement runs inside the write sequence's transaction, so a
3622
+ * refusal raised anywhere in the sequence rolls it back with everything else.
3623
+ */
3624
+ export function clearEnumUsage(handle: AnnoStoreHandle, args: { address: number | string; baseRevision?: number }): AnnoWriteResult {
3625
+ const address = parseStoreAddress(args.address, { what: "address" });
3626
+
3627
+ const { revision, result } = applyWrite(
3628
+ handle,
3629
+ (db) => {
3630
+ const existing = db.prepare("select id from anno_enum_usage where address = ? and bank is ?").get(address, null) as
3631
+ | { id: number }
3632
+ | undefined;
3633
+ if (!existing) return false;
3634
+ db.prepare("delete from anno_enum_usage where id = ?").run(existing.id);
3635
+ return true;
3636
+ },
3637
+ { baseRevision: args.baseRevision },
3638
+ );
3639
+ return { revision, changed: result };
3640
+ }
3641
+
3642
+ /** Every enum usage, in ascending ADDRESS order, with the enum's name resolved
3643
+ * through a join on `anno_enum.id` rather than read from a second on-disk copy
3644
+ * of it. Reads the reserved `bank` column. */
3645
+ export function listEnumUsage(handle: AnnoStoreHandle): EnumUsageRow[] {
3646
+ const rows = handle.db
3647
+ .prepare(
3648
+ // ONE LITERAL, NOT A CONCATENATION. The statement is long enough to want
3649
+ // wrapping and is deliberately not wrapped: the module's SQL is written
3650
+ // as bare statement literals so a census over this file reads the
3651
+ // statement it executes, and so no reader has to prove that a `+` between
3652
+ // two fragments joined only literals.
3653
+ "select u.id as id, u.address as address, u.enum_id as enum_id, e.name as enum_name, u.bank as bank from anno_enum_usage u join anno_enum e on e.id = u.enum_id order by u.address, u.id",
3654
+ )
3655
+ .all() as { id: number; address: number; enum_id: number; enum_name: string; bank: number | null }[];
3656
+ return rows.map((row) => ({
3657
+ id: row.id,
3658
+ address: row.address,
3659
+ enumId: row.enum_id,
3660
+ enumName: row.enum_name,
3661
+ bank: row.bank,
3662
+ }));
3663
+ }
3664
+
3665
+ // ---------------------------------------------------------------------------
3666
+ // THE RUNTIME EVIDENCE TABLE (`anno_evid_exec`, `SCHEMA_VERSION` 4, EVID-01).
3667
+ // One raw shape used by all three functions below.
3668
+ // ---------------------------------------------------------------------------
3669
+
3670
+ /** One `anno_evid_exec` row exactly as the column names read on disk --
3671
+ * `snake_case`, matching every other raw-row shape in this module.
3672
+ *
3673
+ * A `type` ALIAS RATHER THAN AN `interface`, and that is load-bearing rather
3674
+ * than stylistic: `node:sqlite`'s `all()` returns `Record<string,
3675
+ * SQLOutputValue>[]`, and casting that to a NAMED `interface` fails TS's
3676
+ * type-assertion comparability check ("neither type sufficiently overlaps")
3677
+ * even though the shapes are identical -- a `type` alias to the same object
3678
+ * shape is accepted. Measured against this exact query shape during this
3679
+ * plan's own implementation. */
3680
+ type RawEvidExecRow = {
3681
+ id: number;
3682
+ image_sha256: string;
3683
+ argv_digest: string;
3684
+ seed: string;
3685
+ address: number;
3686
+ source_bank: string;
3687
+ };
3688
+
3689
+ /** `RawEvidExecRow` -> `EvidExecRow`, the one mapping site both read functions
3690
+ * below share, so the two never drift into disagreeing about the shape. */
3691
+ function toEvidExecRow(row: RawEvidExecRow): EvidExecRow {
3692
+ return {
3693
+ id: row.id,
3694
+ imageSha256: row.image_sha256,
3695
+ argvDigest: row.argv_digest,
3696
+ seed: row.seed,
3697
+ address: row.address,
3698
+ sourceBank: row.source_bank as EvidSourceBank,
3699
+ };
3700
+ }
3701
+
3702
+ /**
3703
+ * Inserts one or more runtime-execution observations for one run identity,
3704
+ * keyed `(imageSha256, argvDigest, seed, address, sourceBank)` -- the
3705
+ * `no-change` composite plan 43-01's live A/B selected, with no `run_class`
3706
+ * discriminator (`docs/phase43-instrumentation-perturbation-ab.md`).
3707
+ *
3708
+ * EVERY FIELD IS VALIDATED BEFORE THE FIRST STATEMENT RUNS, and every
3709
+ * refusal is a named `AnnoTypeError`/`AnnoAddressError` carrying the
3710
+ * offending value and the valid domain -- this module's existing refusal
3711
+ * register, never a fresh one. A caller-supplied `imageSha256`/`argvDigest`
3712
+ * that is not exactly 64 lowercase hex characters, a `seed` that is empty,
3713
+ * an `address` outside `ADDRESS_MIN..ADDRESS_MAX`, or a `sourceBank` outside
3714
+ * the frozen three is refused BEFORE the write transaction opens, so a bad
3715
+ * argument never reaches SQL and never partially inserts the rest of the
3716
+ * batch.
3717
+ *
3718
+ * THE WHOLE INSERT IS ONE `applyWrite` CALLBACK (T-43-09): every observation
3719
+ * in `args.observations` is written -- or skipped -- inside the SAME
3720
+ * transaction that `commitTransaction` commits, so a kill mid-ingest leaves
3721
+ * the set fully committed or fully absent, never a partial row set.
3722
+ *
3723
+ * AN OBSERVATION ALREADY PRESENT IS SKIPPED, NOT RE-INSERTED (EVID-01's
3724
+ * idempotent re-ingest): the existing row is selected first, by the full
3725
+ * unique key, and the insert only runs when it is absent. `changed` is
3726
+ * `false` exactly when every observation in this call was already present --
3727
+ * the same `changed`-is-the-only-no-op-signal contract `AnnoWriteResult`
3728
+ * states for every other write entry point in this module. `revision` still
3729
+ * advances by exactly one on every accepted call, no-op or not, for the same
3730
+ * reason.
3731
+ *
3732
+ * `insertedCount` (WR-02) IS COUNTED INSIDE THIS SAME TRANSACTION, never
3733
+ * derived from a separate read taken before `applyWrite` opens it: a caller
3734
+ * that wants "how many of these rows were actually new" must not be handed a
3735
+ * number computed from a `listExecObservations()` snapshot that a concurrent
3736
+ * writer to the SAME run identity could have moved past between that read
3737
+ * and this insert's own commit. Counting the per-row `existing`/insert
3738
+ * branch already taken above is the one place this number can be exact.
3739
+ */
3740
+ export interface InsertExecObservationsResult extends AnnoWriteResult {
3741
+ insertedCount: number;
3742
+ }
3743
+
3744
+ export function insertExecObservations(
3745
+ handle: AnnoStoreHandle,
3746
+ args: {
3747
+ imageSha256: unknown;
3748
+ argvDigest: unknown;
3749
+ seed: unknown;
3750
+ observations: readonly { address: unknown; sourceBank: unknown }[];
3751
+ baseRevision?: number;
3752
+ },
3753
+ ): InsertExecObservationsResult {
3754
+ const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
3755
+ const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
3756
+ const seed = assertRunIdentitySeed(args.seed);
3757
+
3758
+ if (!Array.isArray(args.observations) || args.observations.length === 0) {
3759
+ throw new AnnoTypeError(`observations must be a non-empty array, got ${JSON.stringify(args.observations)}`, {
3760
+ dataType: args.observations,
3761
+ });
3762
+ }
3763
+ // VALIDATED IN FULL BEFORE THE FIRST STATEMENT, per this function's own
3764
+ // doc comment: a bad entry at index 9 must not leave entries 0..8 written.
3765
+ const parsedObservations = args.observations.map((obs) => ({
3766
+ address: parseStoreAddress((obs as { address: unknown }).address, { what: "address" }),
3767
+ sourceBank: assertEvidSourceBank((obs as { sourceBank: unknown }).sourceBank),
3768
+ }));
3769
+
3770
+ const { revision, result } = applyWrite(
3771
+ handle,
3772
+ (db) => {
3773
+ let insertedCount = 0;
3774
+ for (const obs of parsedObservations) {
3775
+ const existing = db
3776
+ .prepare("select id from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? and address = ? and source_bank = ?")
3777
+ .get(imageSha256, argvDigest, seed, obs.address, obs.sourceBank) as { id: number } | undefined;
3778
+ if (existing) continue;
3779
+ db.prepare("insert into anno_evid_exec(image_sha256, argv_digest, seed, address, source_bank) values (?, ?, ?, ?, ?)").run(
3780
+ imageSha256,
3781
+ argvDigest,
3782
+ seed,
3783
+ obs.address,
3784
+ obs.sourceBank,
3785
+ );
3786
+ insertedCount++;
3787
+ }
3788
+ return insertedCount;
3789
+ },
3790
+ { baseRevision: args.baseRevision },
3791
+ );
3792
+ return { revision, changed: result > 0, insertedCount: result };
3793
+ }
3794
+
3795
+ /** One shape used by `listExecObservations`'s four fixed queries below. */
3796
+ type EvidRunIdentityFilter = { imageSha256: string; argvDigest: string; seed: string };
3797
+
3798
+ /**
3799
+ * Every runtime-execution observation, in ascending ADDRESS then `id` order,
3800
+ * with optional filters on `address` and on the full run identity. Never a
3801
+ * `select *` -- every column is named.
3802
+ *
3803
+ * A RUN-IDENTITY FILTER IS ALL THREE FIELDS TOGETHER OR NONE. A partial
3804
+ * identity (the seed alone, say) would silently widen the match to every
3805
+ * image/argv pair that happens to share it, which is not what "filter by run
3806
+ * identity" means -- refused BY NAME rather than accepted as a wider query
3807
+ * nobody asked for.
3808
+ */
3809
+ export function listExecObservations(
3810
+ handle: AnnoStoreHandle,
3811
+ opts: { address?: number | string; imageSha256?: unknown; argvDigest?: unknown; seed?: unknown } = {},
3812
+ ): EvidExecRow[] {
3813
+ const hasAddress = opts.address !== undefined;
3814
+ const identityFieldsGiven = [opts.imageSha256, opts.argvDigest, opts.seed].filter((v) => v !== undefined).length;
3815
+ if (identityFieldsGiven > 0 && identityFieldsGiven < 3) {
3816
+ throw new AnnoTypeError(
3817
+ "listExecObservations: a run-identity filter requires imageSha256, argvDigest AND seed together -- a partial identity would silently widen the match",
3818
+ { dataType: { imageSha256: opts.imageSha256, argvDigest: opts.argvDigest, seed: opts.seed } },
3819
+ );
3820
+ }
3821
+ const hasIdentity = identityFieldsGiven === 3;
3822
+
3823
+ const address = hasAddress ? parseStoreAddress(opts.address as number | string, { what: "address" }) : undefined;
3824
+ const identity: EvidRunIdentityFilter | undefined = hasIdentity
3825
+ ? {
3826
+ imageSha256: assertRunIdentityDigest(opts.imageSha256, "imageSha256"),
3827
+ argvDigest: assertRunIdentityDigest(opts.argvDigest, "argvDigest"),
3828
+ seed: assertRunIdentitySeed(opts.seed),
3829
+ }
3830
+ : undefined;
3831
+
3832
+ let rows: RawEvidExecRow[];
3833
+ if (address !== undefined && identity !== undefined) {
3834
+ rows = handle.db
3835
+ .prepare(
3836
+ "select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? and image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
3837
+ )
3838
+ .all(address, identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
3839
+ } else if (address !== undefined) {
3840
+ rows = handle.db
3841
+ .prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? order by address, id")
3842
+ .all(address) as RawEvidExecRow[];
3843
+ } else if (identity !== undefined) {
3844
+ rows = handle.db
3845
+ .prepare(
3846
+ "select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
3847
+ )
3848
+ .all(identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
3849
+ } else {
3850
+ rows = handle.db
3851
+ .prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec order by address, id")
3852
+ .all() as RawEvidExecRow[];
3853
+ }
3854
+
3855
+ return rows.map(toEvidExecRow);
3856
+ }
3857
+
3858
+ /**
3859
+ * Every distinct run identity with an `anno_evid_exec` row, its accumulated
3860
+ * observation count, and the `denominator` those counts are a fraction of
3861
+ * (EVID-04).
3862
+ *
3863
+ * WHY A DENOMINATOR IS RETURNED AT ALL, AND WHY IT NEVER FORMS A PERCENTAGE
3864
+ * ITSELF. A bare count invites the reading "the rest is data" -- exactly the
3865
+ * soundness violation EVID-04 forbids (see `RuntimeExecClass`'s own doc
3866
+ * comment in `anno-types.ts`). `denominator` is `ADDRESS_MAX - ADDRESS_MIN +
3867
+ * 1`, read from `anno-types.ts`'s own constants rather than the literal
3868
+ * `65536` -- a caller comparing a run's `observationCount` against it forms
3869
+ * its own fraction, and there is deliberately no rounding site in this
3870
+ * module to do that division for them (see this file's `toFixed` census in
3871
+ * this task's own acceptance criteria).
3872
+ */
3873
+ export function listObservedRuns(handle: AnnoStoreHandle): { runs: ObservedRunRow[]; denominator: number } {
3874
+ const rows = handle.db
3875
+ .prepare(
3876
+ "select image_sha256, argv_digest, seed, count(*) as observation_count from anno_evid_exec group by image_sha256, argv_digest, seed order by image_sha256, argv_digest, seed",
3877
+ )
3878
+ .all() as { image_sha256: string; argv_digest: string; seed: string; observation_count: number }[];
3879
+ return {
3880
+ runs: rows.map((row) => ({
3881
+ imageSha256: row.image_sha256,
3882
+ argvDigest: row.argv_digest,
3883
+ seed: row.seed,
3884
+ observationCount: row.observation_count,
3885
+ })),
3886
+ denominator: ADDRESS_MAX - ADDRESS_MIN + 1,
3887
+ };
3888
+ }
3889
+
3890
+ /**
3891
+ * Deletes every `anno_evid_exec` row for one run identity -- a bracket
3892
+ * reset (EVID-05). A run identity holding no rows returns `changed: false`
3893
+ * and is NOT an error: resetting an empty bracket is the ordinary thing,
3894
+ * matching `clearEnumUsage`'s own direction for the identical case.
3895
+ *
3896
+ * THIS IS THE MODULE'S FIFTH ROW-DELETING STATEMENT. `removeScope`'s doc
3897
+ * block (`anno-store.ts`) states the count as four; this one supersedes it.
3898
+ * The count is written in prose, deliberately without spelling the SQL
3899
+ * prefix a census greps for, so a `grep` over this module counts STATEMENTS
3900
+ * and not the sentences describing them. This statement runs inside the
3901
+ * write sequence's transaction, so a refusal raised anywhere in the
3902
+ * sequence rolls it back with everything else -- and a refusal here can
3903
+ * only come from validating the run-identity arguments themselves, since
3904
+ * deleting zero rows is success, not an error.
3905
+ */
3906
+ export function deleteExecObservationsForRun(
3907
+ handle: AnnoStoreHandle,
3908
+ args: { imageSha256: unknown; argvDigest: unknown; seed: unknown; baseRevision?: number },
3909
+ ): AnnoWriteResult {
3910
+ const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
3911
+ const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
3912
+ const seed = assertRunIdentitySeed(args.seed);
3913
+
3914
+ const { revision, result } = applyWrite(
3915
+ handle,
3916
+ (db) => {
3917
+ const info = db.prepare("delete from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ?").run(imageSha256, argvDigest, seed);
3918
+ return Number(info.changes) > 0;
3919
+ },
3920
+ { baseRevision: args.baseRevision },
3921
+ );
3922
+ return { revision, changed: result };
3923
+ }
3924
+
3925
+ /**
3926
+ * Records ONE NON-DERIVABLE cross-reference.
3927
+ *
3928
+ * THIS IS THE C-5 RECONCILIATION, written down here for a reader of the code
3929
+ * rather than left in a plan. Two requirement texts look like they conflict:
3930
+ * `STORE-05` requires cross-reference rows to carry their access kind from the
3931
+ * first write, while the cross-reference criterion requires references to be
3932
+ * DERIVED on every query and never cached on disk. Both hold at once, and this
3933
+ * entry point is where:
3934
+ *
3935
+ * * the table and its `access_kind` column exist from the first write (the
3936
+ * `DDL` above), so `STORE-05` is satisfied structurally;
3937
+ * * the only rows ever written here are references that CANNOT be recovered
3938
+ * from the bytes -- hand-asserted, or resolved from something outside the
3939
+ * program image. The `COMPUTED_JUMP` case is exactly that: a computed
3940
+ * dispatch produces no reference derivable from the bytes at all, which is
3941
+ * why it needs somewhere to live;
3942
+ * * nothing derivable is ever written here. A cached derivation would be a
3943
+ * SECOND ON-DISK TRUTH that can disagree with the range table it came from,
3944
+ * and the disagreement is invisible because both answers look
3945
+ * authoritative. `resolveSplitTargets()` in `anno-types.ts` derives and
3946
+ * returns; it never writes.
3947
+ *
3948
+ * The positive pin is in `anno-store.test.ts`: typing a `lo_hi_address` range,
3949
+ * whose targets are fully derivable from its bytes, leaves this table with zero
3950
+ * rows.
3951
+ *
3952
+ * Two references sharing a `from`/`to` pair but carrying different access kinds
3953
+ * are TWO ROWS. They are not merged: `READ` and `WRITE` at one pair of
3954
+ * addresses are two different facts, and merging them would invent a third.
3955
+ */
3956
+ export function putXref(
3957
+ handle: AnnoStoreHandle,
3958
+ args: { fromAddress: number | string; toAddress: number | string; accessKind: unknown; baseRevision?: number },
3959
+ ): AnnoWriteResult {
3960
+ const fromAddress = parseStoreAddress(args.fromAddress, { what: "fromAddress" });
3961
+ const toAddress = parseStoreAddress(args.toAddress, { what: "toAddress" });
3962
+ const accessKind: XrefAccessKind = assertAccessKind(args.accessKind);
3963
+
3964
+ const { revision, result } = applyWrite(
3965
+ handle,
3966
+ (db) => {
3967
+ const existing = db
3968
+ .prepare("select id from anno_xref where from_address = ? and to_address = ? and access_kind = ?")
3969
+ .get(fromAddress, toAddress, accessKind) as { id: number } | undefined;
3970
+ if (existing) return false;
3971
+ db.prepare("insert into anno_xref(from_address, to_address, access_kind, bank) values (?, ?, ?, ?)").run(
3972
+ fromAddress,
3973
+ toAddress,
3974
+ accessKind,
3975
+ null,
3976
+ );
3977
+ return true;
3978
+ },
3979
+ { baseRevision: args.baseRevision },
3980
+ );
3981
+ return { revision, changed: result };
3982
+ }
3983
+
3984
+ /** Every stored cross-reference, in ascending `id` order. Empty unless
3985
+ * something called `putXref()` -- typing a range never puts a row here, and a
3986
+ * test pins that. Reads the reserved `bank` column. */
3987
+ export function listXrefs(handle: AnnoStoreHandle): XrefRow[] {
3988
+ const rows = handle.db.prepare("select id, from_address, to_address, access_kind, bank from anno_xref order by id").all() as {
3989
+ id: number;
3990
+ from_address: number;
3991
+ to_address: number;
3992
+ access_kind: string;
3993
+ bank: number | null;
3994
+ }[];
3995
+ return rows.map((row) => ({
3996
+ id: row.id,
3997
+ fromAddress: row.from_address,
3998
+ toAddress: row.to_address,
3999
+ accessKind: row.access_kind as XrefAccessKind,
4000
+ bank: row.bank,
4001
+ }));
4002
+ }