@henols/vice-mcp 0.2.1 → 0.2.2

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