@henols/vice-mcp 0.2.2 → 0.2.4
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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/anno-store.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The ONE module in this repo that names `node:sqlite`. Nothing else may open,
|
|
5
5
|
// query or write an annotation store file; every other module reaches the
|
|
6
|
-
// store through the functions below
|
|
6
|
+
// store through the functions below.
|
|
7
7
|
//
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// WHY THIS FILE EXISTS
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
// module is not yet reachable from the published entry point's import closure,
|
|
55
55
|
// and `scripts/check-npm-packages.mjs` asserts only one direction -- every
|
|
56
56
|
// REACHABLE module must be listed -- never the converse. The real reason to
|
|
57
|
-
// list it:
|
|
57
|
+
// list it: the shipped-module assertion scans `shippedTsModules()`, which is derived
|
|
58
58
|
// from `files[]`, so an unlisted module makes that assertion VACUOUS. It would
|
|
59
59
|
// pass by scanning a set this file is not in. Copying the reachability sentence
|
|
60
60
|
// here would plant a false claim in a brand-new seam header, which is the
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
// `LIKE 'prefix%'` is 2.02 ms against FTS5 `MATCH`'s 2.99 ms, with a
|
|
86
86
|
// 121.8 ms index rebuild, over 20,000 rows. Adding FTS5 later is
|
|
87
87
|
// ADDITIVE; removing it is a schema migration. The search surface belongs
|
|
88
|
-
//
|
|
88
|
+
// elsewhere and this module must simply not foreclose it.
|
|
89
89
|
// 6. NEVER add an explicit save or flush verb. Durability is this module's
|
|
90
90
|
// responsibility, not the caller's: every accepted write commits before it
|
|
91
91
|
// returns. A save verb is a way for a caller to lose data by forgetting.
|
|
@@ -103,9 +103,9 @@
|
|
|
103
103
|
// look like tightening and are the opposite. A REFUSAL would push a caller
|
|
104
104
|
// toward deleting the comment to get the retype through, converting a
|
|
105
105
|
// reported loss into a silent one -- the exact outcome the report exists to
|
|
106
|
-
// prevent
|
|
106
|
+
// prevent. A WIDENED rule would fire on every retype of a
|
|
107
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
|
|
108
|
+
// reads, so the one case that matters stops being noticed.
|
|
109
109
|
// 10. NEVER prune the snapshot ring INSIDE the write transaction, and never
|
|
110
110
|
// let a revert fall back to the nearest retained revision. A filesystem
|
|
111
111
|
// unlink is not part of the transaction, so pruning inside it means a
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
// conclusion. And a revert that SUBSTITUTES the nearest
|
|
126
126
|
// retained revision for the one asked for changes the caller's intent with
|
|
127
127
|
// nothing recording that it happened, so a revert past the bound is
|
|
128
|
-
// refused BY NAME instead
|
|
128
|
+
// refused BY NAME instead.
|
|
129
129
|
import { randomUUID } from "node:crypto";
|
|
130
130
|
import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
131
131
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -138,11 +138,17 @@ import {
|
|
|
138
138
|
assertCommentType,
|
|
139
139
|
assertDataType,
|
|
140
140
|
assertEnumName,
|
|
141
|
+
assertEvidSourceBank,
|
|
141
142
|
assertLabelKind,
|
|
142
143
|
assertLegalLabel,
|
|
143
144
|
assertRangeShape,
|
|
145
|
+
assertRunIdentityDigest,
|
|
146
|
+
assertRunIdentitySeed,
|
|
144
147
|
isSplitDataType,
|
|
145
148
|
splitEntryAddressPairs,
|
|
149
|
+
ADDRESS_MAX,
|
|
150
|
+
ADDRESS_MIN,
|
|
151
|
+
AnnoCommentError,
|
|
146
152
|
AnnoCommentGradeError,
|
|
147
153
|
AnnoLabelError,
|
|
148
154
|
AnnoRangeShapeError,
|
|
@@ -162,9 +168,13 @@ import {
|
|
|
162
168
|
type CommentType,
|
|
163
169
|
type ContradictedComment,
|
|
164
170
|
type DataType,
|
|
171
|
+
type EvidExecRow,
|
|
172
|
+
type EvidSourceBank,
|
|
173
|
+
type ExcludedRangeRow,
|
|
165
174
|
type LabelKind,
|
|
166
175
|
type LabelRow,
|
|
167
176
|
type EnumUsageRow,
|
|
177
|
+
type ObservedRunRow,
|
|
168
178
|
type ProjectEnumRow,
|
|
169
179
|
type RangeRow,
|
|
170
180
|
type ScopeRow,
|
|
@@ -179,7 +189,7 @@ import { CONFIDENCE_GRADES, parseConfidencePrefix, AnnoConfidenceGradeError } fr
|
|
|
179
189
|
// decide "rethrow unchanged" versus "wrap". Every `Anno*Error` in `anno-types.ts`
|
|
180
190
|
// already extends it, so nothing new enters the module graph -- `anno-types.ts`
|
|
181
191
|
// imports the same class from the same file.
|
|
182
|
-
import { ViceError } from "./vice.ts";
|
|
192
|
+
import { ViceError } from "./vice-errors.ts";
|
|
183
193
|
|
|
184
194
|
/**
|
|
185
195
|
* What every write entry point in this module returns.
|
|
@@ -206,8 +216,8 @@ export interface AnnoWriteResult {
|
|
|
206
216
|
* the very first write. The SECOND half became false: `anno_snapshot` carried a
|
|
207
217
|
* `path text not null` column holding the snapshot's ABSOLUTE location, and two
|
|
208
218
|
* destructive consequences were reproduced against committed code -- two stores
|
|
209
|
-
* in one directory sharing one ring
|
|
210
|
-
* write destroying the whole revert history
|
|
219
|
+
* in one directory sharing one ring and a directory rename plus one
|
|
220
|
+
* write destroying the whole revert history. The column is DROPPED at
|
|
211
221
|
* `SCHEMA_VERSION` 2 and the location is computed from the handle by
|
|
212
222
|
* `snapshotDirFor()` at every read and every delete, so there is no persisted
|
|
213
223
|
* absolute string left for a second namespace -- a bind mount seen from the
|
|
@@ -220,7 +230,7 @@ export interface AnnoWriteResult {
|
|
|
220
230
|
* was byte-identical to version 1's.
|
|
221
231
|
*
|
|
222
232
|
* THAT SENTENCE IS KEPT AND SCOPED RATHER THAN DELETED, because at
|
|
223
|
-
* `SCHEMA_VERSION` 3 it stopped being the whole truth:
|
|
233
|
+
* `SCHEMA_VERSION` 3 it stopped being the whole truth: version 3 (2026-08-29) ADDS
|
|
224
234
|
* one table, `anno_enum_usage`, and its index. It changes no existing table's
|
|
225
235
|
* column list, so the scoped claim above still holds of every table version 2
|
|
226
236
|
* had. The version 3 table associates ONE address with ONE `anno_enum` row by
|
|
@@ -229,7 +239,7 @@ export interface AnnoWriteResult {
|
|
|
229
239
|
* the day that cost was accepted.
|
|
230
240
|
*
|
|
231
241
|
* `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:
|
|
242
|
+
* Two requirement texts look like they conflict here and do not: one
|
|
233
243
|
* requires the column, while the cross-reference criterion forbids CACHING a
|
|
234
244
|
* DERIVED cross-reference on disk. Both hold at once -- the table exists, and
|
|
235
245
|
* only non-derivable references (hand-asserted, or resolved from something
|
|
@@ -241,6 +251,29 @@ export interface AnnoWriteResult {
|
|
|
241
251
|
* that NOTHING in this module interprets and no code path reads except the row
|
|
242
252
|
* mapper in `listRanges()`. It is reserved, and every row written today has it
|
|
243
253
|
* null.
|
|
254
|
+
*
|
|
255
|
+
* AT `SCHEMA_VERSION` 4, ONE MORE TABLE IS ADDED:
|
|
256
|
+
* `anno_evid_exec`, the durable runtime-execution evidence table. See
|
|
257
|
+
* `anno-types.ts`'s `SCHEMA_VERSION` doc comment for what the bump buys and
|
|
258
|
+
* the decided, dated fate of an existing version-3 store (`reaffirm-refusal`
|
|
259
|
+
* -- no migration arm). `anno_evid_exec` carries NO `bank` column and NO
|
|
260
|
+
* nullable column at all: unlike the annotation tables above, every field on
|
|
261
|
+
* a row here is a fact the runtime evidence layer is licensed to assert, or
|
|
262
|
+
* the row does not exist. Its run-identity key is the bare triple
|
|
263
|
+
* `(image_sha256, argv_digest, seed)`, selected by a live A/B that measured
|
|
264
|
+
* `no-perturbation` from instrumentation -- there is deliberately no
|
|
265
|
+
* `run_class` column.
|
|
266
|
+
*
|
|
267
|
+
* AT `SCHEMA_VERSION` 5, ONE MORE TABLE IS ADDED:
|
|
268
|
+
* `anno_excluded_range`, the durable record of a user-requested exclusion --
|
|
269
|
+
* its extent and the reason the user gave. See `anno-types.ts`'s
|
|
270
|
+
* `SCHEMA_VERSION` doc comment for what the bump buys, why a table was chosen
|
|
271
|
+
* over a column on `anno_range`, and the decided, dated fate of an existing
|
|
272
|
+
* version-4 store (`reaffirm-refusal` -- no migration arm). `reason` is `not
|
|
273
|
+
* null`: an exclusion with no reason is a hole with a row in front of it.
|
|
274
|
+
* `anno_excluded_range` carries NO `bank` column, for the same reason
|
|
275
|
+
* `ExcludedRangeRow`'s own doc comment gives -- an exclusion is a statement
|
|
276
|
+
* about the subject program, not a memory view.
|
|
244
277
|
*/
|
|
245
278
|
export const DDL = `
|
|
246
279
|
create table anno_meta (
|
|
@@ -307,11 +340,31 @@ create table anno_snapshot (
|
|
|
307
340
|
revision integer primary key
|
|
308
341
|
);
|
|
309
342
|
|
|
343
|
+
create table anno_evid_exec (
|
|
344
|
+
id integer primary key autoincrement,
|
|
345
|
+
image_sha256 text not null,
|
|
346
|
+
argv_digest text not null,
|
|
347
|
+
seed text not null,
|
|
348
|
+
address integer not null,
|
|
349
|
+
source_bank text not null,
|
|
350
|
+
unique(image_sha256, argv_digest, seed, address, source_bank)
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
create table anno_excluded_range (
|
|
354
|
+
id integer primary key autoincrement,
|
|
355
|
+
start integer not null,
|
|
356
|
+
end_inclusive integer not null,
|
|
357
|
+
reason text not null,
|
|
358
|
+
unique(start, end_inclusive)
|
|
359
|
+
);
|
|
360
|
+
|
|
310
361
|
create index anno_range_end_start on anno_range(end_inclusive, start);
|
|
311
362
|
create index anno_label_address on anno_label(address);
|
|
312
363
|
create index anno_comment_address on anno_comment(address);
|
|
313
364
|
create index anno_enum_usage_address on anno_enum_usage(address);
|
|
314
365
|
create index anno_xref_to on anno_xref(to_address);
|
|
366
|
+
create index anno_evid_exec_address on anno_evid_exec(address);
|
|
367
|
+
create index anno_excluded_range_start on anno_excluded_range(start);
|
|
315
368
|
`;
|
|
316
369
|
|
|
317
370
|
/** An open store: the connection, the resolved store path, and the directory
|
|
@@ -325,7 +378,7 @@ export interface AnnoStoreHandle {
|
|
|
325
378
|
/**
|
|
326
379
|
* THIS CONNECTION'S TRANSACTION STATE IS UNKNOWN: a housekeeping sweep run on
|
|
327
380
|
* it reported that its own `rollback` threw, so it may still hold an open
|
|
328
|
-
* transaction and the store's write lock
|
|
381
|
+
* transaction and the store's write lock.
|
|
329
382
|
*
|
|
330
383
|
* THE REMEDY IS THE ONE THE COMMIT HANDLER ALREADY PRINTS, in the same words:
|
|
331
384
|
* CLOSE IT AND REOPEN rather than reusing it. Node 22's `DatabaseSync` exposes
|
|
@@ -340,7 +393,7 @@ export interface AnnoStoreHandle {
|
|
|
340
393
|
* what prohibition 28-11 P5 forbids, and would send a caller to retry an
|
|
341
394
|
* additive verb. So the accepted write returns its revision unchanged and it
|
|
342
395
|
* is the NEXT call on this connection that refuses BY NAME -- which is what
|
|
343
|
-
* turns
|
|
396
|
+
* turns the bare `cannot start a transaction within a transaction` error into a
|
|
344
397
|
* diagnosis.
|
|
345
398
|
*
|
|
346
399
|
* `false` on every freshly opened handle, set in `openStore` at the one place
|
|
@@ -387,7 +440,7 @@ function fsyncPath(path: string): void {
|
|
|
387
440
|
*
|
|
388
441
|
* A `workspaceRoot` IS REQUIRED unless the caller explicitly asks for the
|
|
389
442
|
* unconfined path with `unconfinedModuleDerivedPath: true`, and the inversion is
|
|
390
|
-
* deliberate
|
|
443
|
+
* deliberate. Confinement used to be opt-IN, which made the mitigation
|
|
391
444
|
* for the one unvalidated input this module's own header calls out the one a
|
|
392
445
|
* caller could forget -- and two of this store's recorded blockers were confinement
|
|
393
446
|
* escapes. The escape exists for exactly one shape: a path THIS MODULE derived
|
|
@@ -433,12 +486,12 @@ export function openStore(
|
|
|
433
486
|
path: string,
|
|
434
487
|
opts: { workspaceRoot?: string; mustExist?: boolean; unconfinedModuleDerivedPath?: boolean } = {},
|
|
435
488
|
): AnnoStoreHandle {
|
|
436
|
-
// CONFINEMENT IS THE DEFAULT, AND THE ESCAPE IS A WORD A GREP CAN FIND
|
|
437
|
-
//
|
|
489
|
+
// CONFINEMENT IS THE DEFAULT, AND THE ESCAPE IS A WORD A GREP CAN FIND.
|
|
490
|
+
// `anno-types.ts`'s header names the three things nothing upstream
|
|
438
491
|
// validates -- "an address of 65536, a misspelled data type, and a store path
|
|
439
492
|
// pointing outside the workspace all look identical to the transport" -- and
|
|
440
493
|
// this was the only one of the three whose mitigation a caller could simply
|
|
441
|
-
// forget. Two of this
|
|
494
|
+
// forget. Two of this project's own review findings were confinement
|
|
442
495
|
// escapes.
|
|
443
496
|
//
|
|
444
497
|
// REFUSED BEFORE THE PATH IS RESOLVED AND LONG BEFORE `new DatabaseSync`, for
|
|
@@ -539,10 +592,21 @@ export function openStore(
|
|
|
539
592
|
|
|
540
593
|
if (meta.schema_version !== SCHEMA_VERSION) {
|
|
541
594
|
db.close();
|
|
542
|
-
|
|
595
|
+
// NAMES THE REMEDY AND DENIES NOTHING IS LOST. This build refuses rather than upgrades -- see
|
|
596
|
+
// `anno-types.ts`'s `SCHEMA_VERSION` doc comment for the decided,
|
|
597
|
+
// dated reason -- and the refusal happens BEFORE any write, so the
|
|
598
|
+
// file on disk is exactly what it was a moment ago: its labels,
|
|
599
|
+
// comments and enums are not lost, only unreadable by this build.
|
|
600
|
+
throw new AnnoStoreCorruptError(
|
|
601
|
+
`${resolved}: schema_version ${meta.schema_version}, expected ${SCHEMA_VERSION} -- refusing to open rather than upgrade. This file is ` +
|
|
602
|
+
`left exactly as it was: nothing on it is read, rewritten or deleted by this refusal. Open it with a build whose SCHEMA_VERSION is ` +
|
|
603
|
+
`${meta.schema_version} to read it (see anno-types.ts's SCHEMA_VERSION doc comment for what changed at each version), or hand-copy ` +
|
|
604
|
+
`its rows into a fresh store at this build's version.`,
|
|
605
|
+
{ path: resolved },
|
|
606
|
+
);
|
|
543
607
|
}
|
|
544
608
|
|
|
545
|
-
//
|
|
609
|
+
// THE LAST KNOWN FAMILY ESCAPE IN THIS FUNCTION. The two blocks either
|
|
546
610
|
// side of this one are already wrapped, and for the same two reasons: an
|
|
547
611
|
// unwrapped failure here leaks the CONNECTION as well as escaping the
|
|
548
612
|
// `ViceError` family, so the caller loses the file handle with no way to
|
|
@@ -583,14 +647,14 @@ export function currentRevision(handle: AnnoStoreHandle): number {
|
|
|
583
647
|
* The suffix appended to the store FILENAME to name its snapshot ring
|
|
584
648
|
* directory. Appended to the FILENAME rather than being a fixed directory name
|
|
585
649
|
* (`<dir>/snapshots`, which is what this was), and the distinction is the whole
|
|
586
|
-
* of
|
|
650
|
+
* of the fix: two distinct store files in one directory have distinct
|
|
587
651
|
* basenames by definition of a filesystem, so distinct basenames give distinct
|
|
588
652
|
* rings BY CONSTRUCTION rather than by an ownership predicate layered over a
|
|
589
653
|
* shared location.
|
|
590
654
|
*
|
|
591
655
|
* THE PREDICATE ROUTE WAS ALREADY TRIED AND COULD NOT SEE THE DEFECT. Plan
|
|
592
656
|
* 28-07 added a per-revision ownership check over the shared `<dir>/snapshots`
|
|
593
|
-
* ring; it was structurally blind to
|
|
657
|
+
* ring; it was structurally blind to the collision because revision numbers are not
|
|
594
658
|
* unique ACROSS stores -- two stores in one directory both write `r1.db`, and
|
|
595
659
|
* every per-revision predicate says "yes, revision 1 is mine" to both of them.
|
|
596
660
|
* A location that cannot collide has no such blind spot to test for.
|
|
@@ -604,12 +668,12 @@ const SNAPSHOT_DIR_SUFFIX = ".snapshots";
|
|
|
604
668
|
* `<dir>/proj.annostore.snapshots`.
|
|
605
669
|
*
|
|
606
670
|
* THE RESIDUAL, STATED RATHER THAN CLAIMED CLOSED -- AND RESTATED AFTER THIS
|
|
607
|
-
* PARAGRAPH'S EARLIER VERSION WAS FALSIFIED BY DRIVING THE CODE
|
|
671
|
+
* PARAGRAPH'S EARLIER VERSION WAS FALSIFIED BY DRIVING THE CODE. What
|
|
608
672
|
* it got RIGHT and keeps: the location is a pure function of the handle, the
|
|
609
673
|
* sweep only ever reads `snapshotDirFor(handle)` so it cannot see a ring it
|
|
610
674
|
* 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
|
|
612
|
-
* rename test
|
|
675
|
+
* -- the ring moves with the directory, so nothing is lost, confirmed by a
|
|
676
|
+
* directory-rename test. What became FALSE: it claimed the old ring was never deleted at
|
|
613
677
|
* all and that `retainedRevisions()` reporting an empty list was therefore a
|
|
614
678
|
* truthful under-claim. That was true of the FILES and false of the ROWS -- so
|
|
615
679
|
* the claim is not repeated here even to disown it, because the next reader
|
|
@@ -622,7 +686,7 @@ const SNAPSHOT_DIR_SUFFIX = ".snapshots";
|
|
|
622
686
|
* a SYMLINK ALIAS, or a store-file rename (`mv proj.annostore
|
|
623
687
|
* other.annostore`) -- names a DIFFERENT ring, so a handle opened under it
|
|
624
688
|
* publishes into a SECOND ring. The first ring's files are never deleted, and
|
|
625
|
-
* since
|
|
689
|
+
* since the fix above its pointer rows are never deleted BY THE SWEEP -- but
|
|
626
690
|
* `pruneSnapshots`' doomed loop still deletes every row below
|
|
627
691
|
* `currentRevision() - MAX_SNAPSHOT_REVISIONS`, so restoring the original name
|
|
628
692
|
* restores the floor ONLY while the wrong-spelling handle has not advanced past
|
|
@@ -672,8 +736,8 @@ export const NO_RETAINED_REVISION = -1;
|
|
|
672
736
|
* reconciliation below derives from a filename alone.
|
|
673
737
|
*
|
|
674
738
|
* ANCHORED ON PURPOSE, and the anchoring is load-bearing rather than tidy:
|
|
675
|
-
*
|
|
676
|
-
* a different suffix, and a sweep that matched them would delete another
|
|
739
|
+
* this module's own staging mechanism writes per-attempt STAGING files in this
|
|
740
|
+
* same directory under a different suffix, and a sweep that matched them would delete another
|
|
677
741
|
* writer's in-flight snapshot -- the exact loss this reconciliation exists to
|
|
678
742
|
* prevent, committed by the repair itself.
|
|
679
743
|
*
|
|
@@ -692,7 +756,7 @@ const SNAPSHOT_FILE_PATTERN = /^r(\d+)\.db$/;
|
|
|
692
756
|
*
|
|
693
757
|
* IT REPLACED A PRESENCE TEST AT BOTH OF THE TWO SITES THAT CARRIED ONE -- the
|
|
694
758
|
* filter inside `retainedRevisions` and `revertTo`'s step-2 gate -- and the
|
|
695
|
-
* promotion is the whole of
|
|
759
|
+
* promotion is the whole of the supporting half of this fix. Presence was never a
|
|
696
760
|
* witness that a file is a store: this module's FIRST MEASURED FACT (header,
|
|
697
761
|
* `:22-31`) is that a ZERO-LENGTH FILE OPENS as a SQLite database and reports
|
|
698
762
|
* `integrity_check ok`. So the store advertised a revision whose image was not a
|
|
@@ -789,7 +853,7 @@ function claimedRevisions(handle: AnnoStoreHandle): number[] {
|
|
|
789
853
|
* one file are two things that can disagree. They did, twice, both reproduced:
|
|
790
854
|
* a directory rename invalidated every persisted path at once, after which this
|
|
791
855
|
* function reported NO retained revisions while the files sat there on disk, and
|
|
792
|
-
* the next write's prune destroyed them
|
|
856
|
+
* the next write's prune destroyed them. The same shape covers every
|
|
793
857
|
* adjacent case rather than just that one repro -- a bind mount seen from two
|
|
794
858
|
* namespaces (this repo's entire architecture is built around that boundary), a
|
|
795
859
|
* symlinked ancestor, a container/host path pair, a case-insensitive filesystem,
|
|
@@ -815,8 +879,8 @@ function claimedRevisions(handle: AnnoStoreHandle): number[] {
|
|
|
815
879
|
* `begin immediate` would have opened up to 32 databases with the store's write
|
|
816
880
|
* lock held. Every remaining consumer reads this function rather than deciding
|
|
817
881
|
* for itself what "retained" means: three independent decisions is precisely how
|
|
818
|
-
* the three answers came to disagree
|
|
819
|
-
* a fourth would also hide the row-only regression from the proofs that exist to
|
|
882
|
+
* the three answers came to disagree -- that was the gap between two of them --
|
|
883
|
+
* and a fourth would also hide the row-only regression from the proofs that exist to
|
|
820
884
|
* catch it.
|
|
821
885
|
*/
|
|
822
886
|
export function retainedRevisions(handle: AnnoStoreHandle): number[] {
|
|
@@ -842,7 +906,7 @@ export function retainedRevisions(handle: AnnoStoreHandle): number[] {
|
|
|
842
906
|
* the first element of `retainedRevisions()` -- and that reading was then one
|
|
843
907
|
* step short a SECOND time, in the same direction: an existence check published
|
|
844
908
|
* `0` on a store whose `r0.db` was present but was not a database, and
|
|
845
|
-
* following THAT floor destroyed the live store
|
|
909
|
+
* following THAT floor destroyed the live store. The floor now requires
|
|
846
910
|
* the image to OPEN, not merely to exist, so the store still cannot publish a
|
|
847
911
|
* number it will then refuse -- in either direction.
|
|
848
912
|
*/
|
|
@@ -860,8 +924,8 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
860
924
|
*
|
|
861
925
|
* * AN ORPHAN ROW (a pointer row whose file is gone) IS NO LONGER SWEPT AT
|
|
862
926
|
* 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.
|
|
864
|
-
*
|
|
927
|
+
* from an absence. This function used to delete every such row. A live
|
|
928
|
+
* reproduction showed, twice, what that costs: the ring is named from
|
|
865
929
|
* `basename(handle.path)` -- a PATH SPELLING -- so a SYMLINK ALIAS of the
|
|
866
930
|
* store file, or a store-file rename (`mv proj.annostore
|
|
867
931
|
* other.annostore`), makes `retainedRevisions()` report every EXISTING
|
|
@@ -889,11 +953,11 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
889
953
|
* ring. That is no longer true: the keep-set below reads
|
|
890
954
|
* `claimedRevisions`, so this sweep now looks at rows the advertisement
|
|
891
955
|
* ignores. THE NEW BASIS IS BETTER RATHER THAN WEAKER, and it is the
|
|
892
|
-
* conclusion
|
|
956
|
+
* conclusion that promotion forced: a row the sweep KEEPS is precisely what makes a
|
|
893
957
|
* corrupt image survive on disk as EVIDENCE instead of being unlinked. A
|
|
894
958
|
* sweep that deleted the image of a failure would be destroying the only
|
|
895
959
|
* record of the failure that has to be diagnosed -- a second destruction
|
|
896
|
-
* dressed as a repair.
|
|
960
|
+
* dressed as a repair. That conclusion is unchanged: the row direction
|
|
897
961
|
* stays abandoned, for the ownership reason above.
|
|
898
962
|
*
|
|
899
963
|
* THE KEEP-SET QUERY RUNS ON THE CONNECTION ALREADY IN HAND, and that is
|
|
@@ -937,7 +1001,7 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
937
1001
|
* over a ring with no half-states). It must NOT run from `openStore`:
|
|
938
1002
|
* `anno-durability.test.ts:291-347` asserts that an orphan snapshot file left
|
|
939
1003
|
* in the kill window SURVIVES a reopen and is identified by its revision, and
|
|
940
|
-
* that is a verified truth
|
|
1004
|
+
* that is a verified truth -- merely LOOKING at a store must not
|
|
941
1005
|
* change it, and the orphan a kill window leaves is deliberately the harmless
|
|
942
1006
|
* direction. Reconciling on open would redden that test, and rightly. BOTH
|
|
943
1007
|
* SITES ARE OUTSIDE ANY OPEN TRANSACTION, which is now a REQUIREMENT rather
|
|
@@ -945,7 +1009,7 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
945
1009
|
* own, so calling it from inside one is not supported.
|
|
946
1010
|
*
|
|
947
1011
|
* IT NOW TAKES THE STORE'S WRITE LOCK BEFORE IT DECIDES ANYTHING, and the
|
|
948
|
-
* reason is a reproduced defect
|
|
1012
|
+
* reason is a reproduced defect rather than caution. A snapshot becomes
|
|
949
1013
|
* a FILESYSTEM fact (the `renameSync` inside `publishSnapshot`) before it
|
|
950
1014
|
* becomes a TRANSACTIONAL one (the pointer-row insert), so a sweep reading only
|
|
951
1015
|
* its own committed view sees a live writer's published file as unowned and
|
|
@@ -1000,7 +1064,7 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
1000
1064
|
* NO SECOND DISCRIMINATOR WAS ADDED FOR `deferred`'s TWO CAUSES, and the reason
|
|
1001
1065
|
* is not economy. Its only consumer, `pruneSnapshots`, returns early
|
|
1002
1066
|
* identically in both cases, so a discriminator would have no reader -- and
|
|
1003
|
-
*
|
|
1067
|
+
* the actual complaint here, that a LEAKED transaction makes every later sweep
|
|
1004
1068
|
* report `deferred` indistinguishably from contention, is removed AT ITS SOURCE
|
|
1005
1069
|
* by the handler rather than papered over with a label. A field describing a
|
|
1006
1070
|
* state this code can no longer reach would be exactly the kind of comment
|
|
@@ -1012,7 +1076,7 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
1012
1076
|
* for up to the connection's five-second `busy_timeout` before it proceeds --
|
|
1013
1077
|
* at BOTH of the two call sites: every accepted write (through `pruneSnapshots`
|
|
1014
1078
|
* at `runWriteSequence` step 9) and every `revertTo` (through its own step-6
|
|
1015
|
-
* sweep on the restored handle).
|
|
1079
|
+
* sweep on the restored handle). Both sit on an MCP tool path. Each is
|
|
1016
1080
|
* bounded at ONE timeout and not two, because `pruneSnapshots` returns early
|
|
1017
1081
|
* when this function reports `deferred` rather than running its own autocommit
|
|
1018
1082
|
* deletes into the same contention. An honest cost stated at the seam is worth
|
|
@@ -1024,8 +1088,8 @@ export function oldestRetainedRevision(handle: AnnoStoreHandle): number {
|
|
|
1024
1088
|
* left (see the ORPHAN ROW bullet above), so what the ordering now guarantees is
|
|
1025
1089
|
* narrower and is stated narrowly: an interruption between the commit and the
|
|
1026
1090
|
* 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
|
|
1028
|
-
* asserts the ABSENCE of any pointer-row delete in this body as well as the
|
|
1091
|
+
* Pinned by a source-order control in `anno-store.test.ts`, which since the fix
|
|
1092
|
+
* above asserts the ABSENCE of any pointer-row delete in this body as well as the
|
|
1029
1093
|
* surviving commit-before-unlink order -- a presence assertion cannot see
|
|
1030
1094
|
* either.
|
|
1031
1095
|
*/
|
|
@@ -1054,12 +1118,12 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1054
1118
|
const orphanFiles: string[] = [];
|
|
1055
1119
|
|
|
1056
1120
|
// EVERYTHING FROM HERE TO THE COMMIT IS BRACKETED, AND THE BRACKET IS THE
|
|
1057
|
-
// FIX
|
|
1121
|
+
// FIX. `begin immediate` above has already opened a transaction on
|
|
1058
1122
|
// the CALLER's connection. Before this handler existed, any throw between
|
|
1059
1123
|
// that statement and the commit -- `readdirSync` on a ring directory that
|
|
1060
1124
|
// became unreadable, a failure of the keep-set `select`, anything --
|
|
1061
1125
|
// propagated out with the transaction still
|
|
1062
|
-
// OPEN. Step 9's
|
|
1126
|
+
// OPEN. Step 9's own error-swallowing wrap then swallowed it, so an ordinary `setDataType`
|
|
1063
1127
|
// reported SUCCESS while leaving the handle permanently inside a transaction:
|
|
1064
1128
|
// every later write failed with "cannot start a transaction within a
|
|
1065
1129
|
// transaction", and every later sweep reported `deferred` indistinguishably
|
|
@@ -1067,8 +1131,8 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1067
1131
|
// path-dependent: there is no route out of this block that does not either
|
|
1068
1132
|
// commit or roll back.
|
|
1069
1133
|
try {
|
|
1070
|
-
// STEP 2. With the lock held, compute the drop set -- which since
|
|
1071
|
-
// exactly ONE direction, the FILE direction -- before changing anything.
|
|
1134
|
+
// STEP 2. With the lock held, compute the drop set -- which since the fix
|
|
1135
|
+
// above has exactly ONE direction, the FILE direction -- before changing anything.
|
|
1072
1136
|
//
|
|
1073
1137
|
// THE KEEP-SET IS THE POINTER-ROW SET, AND THAT IS A DIFFERENT QUESTION
|
|
1074
1138
|
// rather than a fourth answer to "what is retained". This resolver still
|
|
@@ -1080,12 +1144,12 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1080
1144
|
// WHY THE QUESTION IS GENUINELY DIFFERENT, AND WHY THE ANSWERS ONLY DIVERGE
|
|
1081
1145
|
// NOW. A sweep over FILES asks "is this file claimed by a pointer row";
|
|
1082
1146
|
// `retainedRevisions` asks "can a caller revert to this revision". Before
|
|
1083
|
-
//
|
|
1147
|
+
// that promotion those two were identical for every reachable input,
|
|
1084
1148
|
// because the presence half of the old definition is trivially true of a
|
|
1085
1149
|
// file `readdirSync` just returned. The promotion is what separates them,
|
|
1086
1150
|
// and the separation runs in the SAFE direction: an image that fails to open
|
|
1087
1151
|
// but that a row still claims is kept on disk as EVIDENCE. Leaving this
|
|
1088
|
-
// keep-set on `retainedRevisions` would have made
|
|
1152
|
+
// keep-set on `retainedRevisions` would have made that fix its own
|
|
1089
1153
|
// second destroyer -- the sweep would unlink exactly the corrupt image whose
|
|
1090
1154
|
// refusal has to be diagnosed, one ordinary write after the refusal.
|
|
1091
1155
|
//
|
|
@@ -1118,7 +1182,7 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1118
1182
|
}
|
|
1119
1183
|
}
|
|
1120
1184
|
|
|
1121
|
-
// STEP 3 IS GONE ON PURPOSE, and its absence is the fix
|
|
1185
|
+
// STEP 3 IS GONE ON PURPOSE, and its absence is the fix described above. It
|
|
1122
1186
|
// deleted every pointer row this handle's spelling of the ring could not
|
|
1123
1187
|
// vouch for; under a second spelling of the same store file that was every
|
|
1124
1188
|
// row it had. The whole argument is in the ORPHAN ROW bullet above.
|
|
@@ -1133,7 +1197,7 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1133
1197
|
} catch {
|
|
1134
1198
|
// ROLLED BACK INSIDE ITS OWN SWALLOWING `try`: there is nothing useful to
|
|
1135
1199
|
// 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
|
|
1200
|
+
// WHAT IS NEW IS THAT THE OUTCOME IS RECORDED RATHER THAN ASSUMED.
|
|
1137
1201
|
// Node 22's `DatabaseSync` exposes no transaction-state accessor, so this
|
|
1138
1202
|
// boolean is the only thing that can tell a caller which of the two
|
|
1139
1203
|
// happened -- and this function cannot tell it by throwing, because
|
|
@@ -1147,7 +1211,7 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
|
|
|
1147
1211
|
rolledBack = false;
|
|
1148
1212
|
}
|
|
1149
1213
|
// AND DELIBERATELY NOT RETHROWN. A throw from here is swallowed by step 9's
|
|
1150
|
-
//
|
|
1214
|
+
// own error-swallowing wrap anyway, so rethrowing would buy nothing on the write path --
|
|
1151
1215
|
// and on `revertTo`'s own step-6 call site it would convert a COMMITTED
|
|
1152
1216
|
// write into a caller-visible failure, which prohibition 28-11 P5 forbids.
|
|
1153
1217
|
// The sweep changed nothing, which is precisely what `deferred` reports --
|
|
@@ -1241,7 +1305,7 @@ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
|
|
|
1241
1305
|
// revision number, and the same direction a deferred sweep already accepts.
|
|
1242
1306
|
//
|
|
1243
1307
|
// AND ITS `rollbackFailed` IS CONSUMED FOR THE SAME REASON, RETURNED RATHER
|
|
1244
|
-
// THAN DISCARDED
|
|
1308
|
+
// THAN DISCARDED. The argument recorded above for consuming
|
|
1245
1309
|
// `.deferred` is the argument for consuming this one, so it is extended here
|
|
1246
1310
|
// rather than restated: a fact this function throws away is a fact its caller
|
|
1247
1311
|
// cannot act on, and `rollbackFailed` reports the ONE state the sweep's own
|
|
@@ -1280,7 +1344,7 @@ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
|
|
|
1280
1344
|
// an orphan FILE -- which the reconciliation at the top of the NEXT prune
|
|
1281
1345
|
// can still see and retry. Under the earlier arrangement the row was
|
|
1282
1346
|
// deleted unconditionally after a swallowed failure, so the file became
|
|
1283
|
-
// invisible to the bound forever
|
|
1347
|
+
// invisible to the bound forever.
|
|
1284
1348
|
try {
|
|
1285
1349
|
// THE PATH IS COMPUTED HERE, at the delete, from the handle -- never read
|
|
1286
1350
|
// from the row. A persisted absolute path is environment-controlled input
|
|
@@ -1326,7 +1390,7 @@ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
|
|
|
1326
1390
|
* because `vacuum into` refuses an existing target and because two writers
|
|
1327
1391
|
* filling one file is the very collision this staging exists to remove.
|
|
1328
1392
|
*
|
|
1329
|
-
* AND THE IMAGE IS FSYNCED BEFORE THIS FUNCTION RETURNS
|
|
1393
|
+
* AND THE IMAGE IS FSYNCED BEFORE THIS FUNCTION RETURNS. The pointer row
|
|
1330
1394
|
* that names this file is inserted inside the write transaction and committed by
|
|
1331
1395
|
* SQLite, WHICH DOES FSYNC -- so without the `fsyncPath` below the ROW is
|
|
1332
1396
|
* durable and the FILE it names is not. Trap 10's durability premise covers a
|
|
@@ -1334,7 +1398,7 @@ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
|
|
|
1334
1398
|
* anyway; it does NOT cover a host crash, which loses the cache. The consequence
|
|
1335
1399
|
* is not an extra file: it is a PRESENT, PARTIAL snapshot that
|
|
1336
1400
|
* `retainedRevisions()` would advertise as revertible, which is exactly the input
|
|
1337
|
-
*
|
|
1401
|
+
* this defect was reproduced with. 28-16's step-2 and step-3b gates make that input a
|
|
1338
1402
|
* REFUSAL rather than a destruction; this call removes the input at its source
|
|
1339
1403
|
* rather than relying on the refusal, because a refusal on the only route back
|
|
1340
1404
|
* is still a lost history.
|
|
@@ -1378,7 +1442,7 @@ export function stageSnapshot(handle: AnnoStoreHandle, revision: number): string
|
|
|
1378
1442
|
* store that has been reverted and written forward again, rather than leaving
|
|
1379
1443
|
* it as an argument.
|
|
1380
1444
|
*
|
|
1381
|
-
* AND THE RING DIRECTORY IS FSYNCED AFTER THE RENAME
|
|
1445
|
+
* AND THE RING DIRECTORY IS FSYNCED AFTER THE RENAME. A rename is
|
|
1382
1446
|
* VISIBLE immediately and DURABLE only after the directory is fsynced -- the
|
|
1383
1447
|
* distinction `fsyncPath`'s own doc sentence records. The pointer row that names
|
|
1384
1448
|
* this file is inserted inside the write transaction a few statements below and
|
|
@@ -1389,7 +1453,7 @@ export function stageSnapshot(handle: AnnoStoreHandle, revision: number): string
|
|
|
1389
1453
|
* `SIGKILL` and NOT of a host crash, which loses the page cache; the surviving
|
|
1390
1454
|
* half-state there is a durable row naming a file whose bytes never reached
|
|
1391
1455
|
* disk, i.e. the PRESENT, PARTIAL snapshot `retainedRevisions()` would advertise
|
|
1392
|
-
* and the exact input
|
|
1456
|
+
* and the exact input this defect was reproduced with. 28-16 made that input a refusal
|
|
1393
1457
|
* rather than a destruction; this call removes the input at its source instead of
|
|
1394
1458
|
* relying on that refusal. The order is the same as `revertTo`'s steps 3 and 5
|
|
1395
1459
|
* and uses the same helper, deliberately -- a second durability idiom in one
|
|
@@ -1409,7 +1473,7 @@ export function stageSnapshot(handle: AnnoStoreHandle, revision: number): string
|
|
|
1409
1473
|
* half-state for a store that cannot be written at all: `openSync(dir, "r")`
|
|
1410
1474
|
* needs the ring directory READABLE, so a writable-but-unreadable ring (mode
|
|
1411
1475
|
* 0300 -- measured) would make every `setDataType` throw, and that is also the
|
|
1412
|
-
* precondition
|
|
1476
|
+
* precondition this fix's only behavioural control is built from. So the two
|
|
1413
1477
|
* reachable outcomes after an interruption stay exactly the two this module
|
|
1414
1478
|
* bounds them to -- a missing entry, or an entry whose contents ARE durable --
|
|
1415
1479
|
* and the failure of this call moves the outcome from the second to the first
|
|
@@ -1435,7 +1499,7 @@ function publishSnapshot(stagingPath: string, snapPath: string): void {
|
|
|
1435
1499
|
* are on.
|
|
1436
1500
|
*
|
|
1437
1501
|
* AND IT IS THE ONE PLACE A STAGING FILE IS REMOVED, which is why `revertTo`'s
|
|
1438
|
-
* three cleanup exits route through it too
|
|
1502
|
+
* three cleanup exits route through it too. Those three used to
|
|
1439
1503
|
* be bare `rmSync(staging, { force: true })` calls, so the module had two
|
|
1440
1504
|
* answers to "where does a staging file get removed" and a later reader looking
|
|
1441
1505
|
* for the one place found only half of them. The swallowing semantics below are
|
|
@@ -1510,12 +1574,12 @@ function runWriteSequence<T>(
|
|
|
1510
1574
|
doCommit: boolean,
|
|
1511
1575
|
baseRevision?: number,
|
|
1512
1576
|
): { revision: number; result: T } {
|
|
1513
|
-
// BEFORE `begin immediate`, AND BEFORE ANYTHING IS READ
|
|
1577
|
+
// BEFORE `begin immediate`, AND BEFORE ANYTHING IS READ. A previous
|
|
1514
1578
|
// write's housekeeping sweep ran on THIS connection and reported that its own
|
|
1515
1579
|
// `rollback` threw, so the connection may still hold an open transaction and
|
|
1516
1580
|
// the store's write lock. Without this refusal the next `begin immediate`
|
|
1517
1581
|
// surfaces SQLite's bare `cannot start a transaction within a transaction` --
|
|
1518
|
-
//
|
|
1582
|
+
// the exact reported symptom, outside the `ViceError` family, with nothing
|
|
1519
1583
|
// naming the cause or the remedy.
|
|
1520
1584
|
//
|
|
1521
1585
|
// AN EXISTING IN-FAMILY CLASS, NOT A NEW ONE: this is the same fact the commit
|
|
@@ -1542,7 +1606,7 @@ function runWriteSequence<T>(
|
|
|
1542
1606
|
);
|
|
1543
1607
|
}
|
|
1544
1608
|
|
|
1545
|
-
//
|
|
1609
|
+
// THE PRE-LOCK ARM. `stageSnapshot` runs BEFORE `begin immediate`, so
|
|
1546
1610
|
// it gets its own handler rather than sharing the outer one below: there is
|
|
1547
1611
|
// no transaction to roll back yet and no staged file to discard, so the two
|
|
1548
1612
|
// arms genuinely differ in what they have to undo. The reachable input is a
|
|
@@ -1561,7 +1625,7 @@ function runWriteSequence<T>(
|
|
|
1561
1625
|
);
|
|
1562
1626
|
}
|
|
1563
1627
|
|
|
1564
|
-
//
|
|
1628
|
+
// THE MAIN WINDOW: `begin immediate`, the compare-and-swap, the
|
|
1565
1629
|
// publication and the pointer-row insert, wrapped as ONE region. Its catch
|
|
1566
1630
|
// undoes both kinds of state this region can leave behind -- an open
|
|
1567
1631
|
// transaction with the compare-and-swap applied, and a staged `.tmp` -- and
|
|
@@ -1570,7 +1634,7 @@ function runWriteSequence<T>(
|
|
|
1570
1634
|
// keeps the family closed.
|
|
1571
1635
|
//
|
|
1572
1636
|
// 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
|
|
1637
|
+
// REDUNDANT AND MUST NOT BE "SIMPLIFIED" AWAY. `anno-store.test.ts`'s own
|
|
1574
1638
|
// control extracts the slice between `cas.changes` and `publishSnapshot` and
|
|
1575
1639
|
// asserts a `rollback` is present inside it, positioned after the
|
|
1576
1640
|
// `select revision from anno_meta` read -- that positioning is a VERIFIED
|
|
@@ -1614,7 +1678,7 @@ function runWriteSequence<T>(
|
|
|
1614
1678
|
// claims (see `retainedRevisions`).
|
|
1615
1679
|
handle.db.prepare("insert into anno_snapshot(revision) values (?)").run(rev);
|
|
1616
1680
|
} catch (e) {
|
|
1617
|
-
// THE ROLLBACK'S OUTCOME IS RECORDED, NOT ASSUMED
|
|
1681
|
+
// THE ROLLBACK'S OUTCOME IS RECORDED, NOT ASSUMED. The message below
|
|
1618
1682
|
// used to state the rollback as a fact after this `catch` had swallowed that
|
|
1619
1683
|
// rollback's own failure, so the one case in which the claim is false is
|
|
1620
1684
|
// exactly the case in which it was printed. Node 22's `DatabaseSync` exposes
|
|
@@ -1688,7 +1752,7 @@ function runWriteSequence<T>(
|
|
|
1688
1752
|
}
|
|
1689
1753
|
|
|
1690
1754
|
if (doCommit) {
|
|
1691
|
-
//
|
|
1755
|
+
// THE COMMIT ARM. This is the ONE statement in the sequence whose
|
|
1692
1756
|
// failure leaves the transaction OPEN with everything already applied -- the
|
|
1693
1757
|
// compare-and-swap, the caller's mutation and the pointer-row insert -- and
|
|
1694
1758
|
// it was outside every handler until this arm was added. A concurrent READER
|
|
@@ -1698,7 +1762,7 @@ function runWriteSequence<T>(
|
|
|
1698
1762
|
// transaction: a bare `Error: database is locked` after the connection's
|
|
1699
1763
|
// 5000 ms `busy_timeout`, outside the `ViceError` family, with the write
|
|
1700
1764
|
// lock still held and `currentRevision()` on this connection reporting the
|
|
1701
|
-
// ADVANCED revision for a write that never landed. On the
|
|
1765
|
+
// ADVANCED revision for a write that never landed. On the MCP tool
|
|
1702
1766
|
// path a handle lives as long as the session, so the leaked write lock
|
|
1703
1767
|
// locks every other connection out for that long.
|
|
1704
1768
|
//
|
|
@@ -1720,18 +1784,18 @@ function runWriteSequence<T>(
|
|
|
1720
1784
|
// accepted write's sweep reclaims.
|
|
1721
1785
|
//
|
|
1722
1786
|
// The refusal carries `code` from the underlying error when it has one
|
|
1723
|
-
// (
|
|
1787
|
+
// (the cheap half of that, on this wrap only), so a caller can ask whether the
|
|
1724
1788
|
// failure was lock contention without substring-matching the message.
|
|
1725
1789
|
// `cause` is deliberately NOT added: that needs a new field on
|
|
1726
1790
|
// `ViceErrorOptions` in `vice.ts`, a shared module outside this phase.
|
|
1727
1791
|
try {
|
|
1728
1792
|
commitTransaction(handle.db);
|
|
1729
1793
|
} catch (e) {
|
|
1730
|
-
// RECORDED, NOT ASSERTED
|
|
1794
|
+
// RECORDED, NOT ASSERTED. "the transaction has been rolled back"
|
|
1731
1795
|
// was stated as a fact directly under a `catch` that swallowed the
|
|
1732
1796
|
// rollback's own failure -- so on the one path where the claim is false it
|
|
1733
|
-
// was still printed, and a refusal that reports
|
|
1734
|
-
// repair sends the caller straight back into reusing a connection that may
|
|
1797
|
+
// was still printed, and a refusal that reports that half-committed state
|
|
1798
|
+
// as its own repair sends the caller straight back into reusing a connection that may
|
|
1735
1799
|
// still hold the store's write lock. There is no cheap check available:
|
|
1736
1800
|
// Node 22's `DatabaseSync` exposes no transaction-state accessor (surface
|
|
1737
1801
|
// measured on this host: `open, close, prepare, exec, function, location,
|
|
@@ -1761,8 +1825,8 @@ function runWriteSequence<T>(
|
|
|
1761
1825
|
// The wording here is FREE. It used to be constrained: the
|
|
1762
1826
|
// single-commit-site control in `anno-seam.test.ts` counted the WORD
|
|
1763
1827
|
// `commit` over this module's stripped source, so a `step` value
|
|
1764
|
-
// reading "commit ..." reddened a control in a different file.
|
|
1765
|
-
// replaced that count with a match on `exec()` calls carrying a bare
|
|
1828
|
+
// reading "commit ..." reddened a control in a different file. A later
|
|
1829
|
+
// revision replaced that count with a match on `exec()` calls carrying a bare
|
|
1766
1830
|
// statement literal, which no error message can satisfy, and the
|
|
1767
1831
|
// constraint went with it -- this value is unchanged only because
|
|
1768
1832
|
// changing it would be a gratuitous behaviour change.
|
|
@@ -1775,12 +1839,12 @@ function runWriteSequence<T>(
|
|
|
1775
1839
|
// branch because a sequence that never commits has no accepted write to
|
|
1776
1840
|
// bound.
|
|
1777
1841
|
//
|
|
1778
|
-
//
|
|
1842
|
+
// WRAPPED, AND DELIBERATELY NOT RETHROWN. By this line the
|
|
1779
1843
|
// transaction has already returned, so THE WRITE HAPPENED -- the mutation
|
|
1780
1844
|
// and the pointer row are durable. A housekeeping failure that threw from
|
|
1781
1845
|
// 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
|
|
1783
|
-
//
|
|
1846
|
+
// would retry an ADDITIVE verb and produce a second row. That is exactly
|
|
1847
|
+
// the failure this wrap exists to prevent, and it became more likely rather than less once the
|
|
1784
1848
|
// sweep started taking the write lock.
|
|
1785
1849
|
//
|
|
1786
1850
|
// The consequence of swallowing is an UN-PRUNED RING -- extra files, the
|
|
@@ -1790,7 +1854,7 @@ function runWriteSequence<T>(
|
|
|
1790
1854
|
// one here would be new surface with its own stdio hazards on an MCP
|
|
1791
1855
|
// transport.
|
|
1792
1856
|
//
|
|
1793
|
-
// AND ITS REPORT IS CONSUMED
|
|
1857
|
+
// AND ITS REPORT IS CONSUMED. `pruneSnapshots` returns the sweep's
|
|
1794
1858
|
// `rollbackFailed` -- the one state the sweep's own handler cannot fix --
|
|
1795
1859
|
// and it is RECORDED ON THE HANDLE rather than thrown or logged. Not thrown,
|
|
1796
1860
|
// because by this line the write is committed and 28-11 P5 forbids reporting
|
|
@@ -1842,7 +1906,7 @@ export function applyWriteWithoutCommit<T>(
|
|
|
1842
1906
|
|
|
1843
1907
|
/**
|
|
1844
1908
|
* The module's ONE range insert. `bank` is a parameter rather than a hardcoded
|
|
1845
|
-
* `null
|
|
1909
|
+
* `null`: a remainder re-inserted by split-and-preserve carries the
|
|
1846
1910
|
* overlapped row's own `bank` forward, and a newly typed range carries `null`.
|
|
1847
1911
|
* `bank` is reserved and interpreted by nothing today, which is exactly why a
|
|
1848
1912
|
* write path that silently dropped it would be an unobservable loss a future
|
|
@@ -1931,7 +1995,7 @@ function entryPairKey(pair: readonly [number, number]): string {
|
|
|
1931
1995
|
|
|
1932
1996
|
/**
|
|
1933
1997
|
* What fragmenting `row` at the caller's range COSTS, or `null` when it costs
|
|
1934
|
-
* nothing this record could describe
|
|
1998
|
+
* nothing this record could describe.
|
|
1935
1999
|
*
|
|
1936
2000
|
* `null` in exactly two cases, both of them honest:
|
|
1937
2001
|
* * the row is not a split-table layout -- asked through `isSplitDataType`,
|
|
@@ -2032,15 +2096,14 @@ function splitReinterpretation(
|
|
|
2032
2096
|
* signal that distinguishes a no-op, and the revision is never that signal.
|
|
2033
2097
|
*
|
|
2034
2098
|
* ---------------------------------------------------------------------------
|
|
2035
|
-
* DECISION 1: THE SPLIT-ROW REMAINDER RULE -- TWO OUTCOMES, NEITHER SILENT
|
|
2036
|
-
* (CR-09 and CR-10).
|
|
2099
|
+
* DECISION 1: THE SPLIT-ROW REMAINDER RULE -- TWO OUTCOMES, NEITHER SILENT.
|
|
2037
2100
|
* ---------------------------------------------------------------------------
|
|
2038
2101
|
* A split-table row has TWO things that can go wrong when a caller's range
|
|
2039
2102
|
* fragments it, and this function answers them differently on purpose. The
|
|
2040
2103
|
* comment and the code below state ONE rule, and both halves of it are here.
|
|
2041
2104
|
*
|
|
2042
|
-
* (1) THE ODD REMAINDER IS REFUSED, and the whole retype is refused with it
|
|
2043
|
-
*
|
|
2105
|
+
* (1) THE ODD REMAINDER IS REFUSED, and the whole retype is refused with it.
|
|
2106
|
+
* A remainder that is not a legal shape for its OWN type -- the
|
|
2044
2107
|
* odd-byte-count tail of a fragmented split table is the reachable case -- is a
|
|
2045
2108
|
* row `setDataType` would decline to create and `resolveSplitTargets()` cannot
|
|
2046
2109
|
* decode. The store never persists a range row it would refuse at its own entry
|
|
@@ -2052,7 +2115,7 @@ function splitReinterpretation(
|
|
|
2052
2115
|
* outward to an entry boundary is forbidden outright by `anno-types.ts` trap 7.
|
|
2053
2116
|
*
|
|
2054
2117
|
* (2) THE EVEN REMAINDER IS ACCEPTED **WITH A REPORT** -- never accepted
|
|
2055
|
-
* silently
|
|
2118
|
+
* silently. Parity is not the only thing a fragment can break. A split
|
|
2056
2119
|
* table pairs byte `i` with byte `n + i`, so an entry's partner is a function of
|
|
2057
2120
|
* the row's START and its LENGTH, and changing either end re-pairs EVERY entry.
|
|
2058
2121
|
*
|
|
@@ -2091,17 +2154,17 @@ function splitReinterpretation(
|
|
|
2091
2154
|
* `delete`, and the ordering is the guarantee, not a tidiness preference: a
|
|
2092
2155
|
* refusal must cost nothing observable, and leaning on the transaction's
|
|
2093
2156
|
* rollback to undo a half-applied mutation would make that depend on a rollback
|
|
2094
|
-
* that the
|
|
2157
|
+
* that the commit handler's own `rollbackFailed` handling shows can itself fail.
|
|
2095
2158
|
* Compute, refuse, then mutate. The parity check runs FIRST and is untouched by
|
|
2096
2159
|
* the disclosure: a refusing retype returns no report because it returns nothing
|
|
2097
2160
|
* at all.
|
|
2098
2161
|
*
|
|
2099
2162
|
* ---------------------------------------------------------------------------
|
|
2100
|
-
* DECISION 2: THE UNION COLLAPSE IS INTENDED
|
|
2163
|
+
* DECISION 2: THE UNION COLLAPSE IS INTENDED.
|
|
2101
2164
|
* ---------------------------------------------------------------------------
|
|
2102
2165
|
* 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
|
|
2104
|
-
*
|
|
2166
|
+
* inserts one row. That is intended, and it does not contradict the store's
|
|
2167
|
+
* own adjacency rule: that rule forbids the store joining adjacent ranges OF ITS OWN ACCORD, and
|
|
2105
2168
|
* here the caller asked for exactly one range and got exactly one range. The
|
|
2106
2169
|
* store still never joins two rows nobody asked about -- see the behavioural
|
|
2107
2170
|
* and structural adjacency controls.
|
|
@@ -2148,7 +2211,7 @@ function retype(
|
|
|
2148
2211
|
// for a split row that survives that question, what the fragmentation COSTS.
|
|
2149
2212
|
//
|
|
2150
2213
|
// WHAT THIS GATE DOES NOT ASK, said here because "THE GATE" reads absolute
|
|
2151
|
-
// and a reader will otherwise take it for one (
|
|
2214
|
+
// and a reader will otherwise take it for one (28-21 P1 / 28-07 P3):
|
|
2152
2215
|
// the shape question is asked of REMAINDERS, never of the overlapped row
|
|
2153
2216
|
// itself. A row the caller's range covers in full has no head and no tail,
|
|
2154
2217
|
// so neither branch below runs and its shape is never examined -- correctly,
|
|
@@ -2397,7 +2460,7 @@ export function listRanges(handle: AnnoStoreHandle): RangeRow[] {
|
|
|
2397
2460
|
* retained for it", with the oldest retained revision, the bound and the
|
|
2398
2461
|
* available list.
|
|
2399
2462
|
* * A ROW CLAIMS IT BUT ITS IMAGE WILL NOT OPEN as an annotation store (step
|
|
2400
|
-
* 2, second arm) -- the arm
|
|
2463
|
+
* 2, second arm) -- the arm added to cover an absent image and a
|
|
2401
2464
|
* present-but-unusable one alike, with the underlying reason quoted so the
|
|
2402
2465
|
* caller can tell which. This is the arm the old presence-only gate did not
|
|
2403
2466
|
* have, and its absence is what let the store be destroyed installing an
|
|
@@ -2420,7 +2483,7 @@ export function listRanges(handle: AnnoStoreHandle): RangeRow[] {
|
|
|
2420
2483
|
*/
|
|
2421
2484
|
/**
|
|
2422
2485
|
* The one gate on a revision-shaped argument, and it exists because ONE
|
|
2423
|
-
* unvalidated value lands in TWO places that can then disagree
|
|
2486
|
+
* unvalidated value lands in TWO places that can then disagree: a bound
|
|
2424
2487
|
* SQL parameter, and a snapshot FILENAME.
|
|
2425
2488
|
*
|
|
2426
2489
|
* Accepts a non-negative safe integer and nothing else. A numeric STRING is
|
|
@@ -2431,9 +2494,9 @@ export function listRanges(handle: AnnoStoreHandle): RangeRow[] {
|
|
|
2431
2494
|
* ring, which is the confusion `AnnoRevisionArgumentError`'s doc comment records
|
|
2432
2495
|
* in full.
|
|
2433
2496
|
*
|
|
2434
|
-
* DELIBERATELY NOT REUSED FOR `baseRevision`.
|
|
2435
|
-
*
|
|
2436
|
-
* this round, so the declination is recorded here rather than left looking like
|
|
2497
|
+
* DELIBERATELY NOT REUSED FOR `baseRevision`. An earlier round's review sketch
|
|
2498
|
+
* suggested it, but that suggestion is a separate finding the same round's
|
|
2499
|
+
* verification did not route to this round, so the declination is recorded here rather than left looking like
|
|
2437
2500
|
* an omission -- `runWriteSequence`'s existing `baseRevision` staleness refusal
|
|
2438
2501
|
* is this validator's SIBLING, not its client.
|
|
2439
2502
|
*/
|
|
@@ -2456,10 +2519,10 @@ function assertRevisionArgument(value: unknown, parameter: string): number {
|
|
|
2456
2519
|
}
|
|
2457
2520
|
|
|
2458
2521
|
export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHandle {
|
|
2459
|
-
// STEP 0, AND IT IS FIRST FOR THE REASON
|
|
2522
|
+
// STEP 0, AND IT IS FIRST FOR THE REASON RECORDED ABOVE: this argument reaches
|
|
2460
2523
|
// a bound SQL parameter AND a filename, so it is judged before either exists.
|
|
2461
2524
|
// Before this line, `revertTo(handle, "1")` silently reverted the store and
|
|
2462
|
-
// `revertTo(handle, "0001")` refused with
|
|
2525
|
+
// `revertTo(handle, "0001")` refused with a CORRUPTION message.
|
|
2463
2526
|
assertRevisionArgument(revision, "revision");
|
|
2464
2527
|
|
|
2465
2528
|
// STEP 1. The pointer row -- the INDEX half of "retained". An EXISTENCE check
|
|
@@ -2477,8 +2540,8 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2477
2540
|
//
|
|
2478
2541
|
// THE FILE HALF NOW READS `snapshotOpenFailure` -- the same witness
|
|
2479
2542
|
// `retainedRevisions` reads, in its other position. It used to read
|
|
2480
|
-
// `existsSync` and nothing more, which
|
|
2481
|
-
// a database
|
|
2543
|
+
// `existsSync` and nothing more, which let a present image that was not
|
|
2544
|
+
// a database pass this gate, and the store was destroyed installing it. The
|
|
2482
2545
|
// "oldest retained" and "available revisions" figures are built from
|
|
2483
2546
|
// `retainedRevisions()` and never from the raw rows, so a refusal cannot
|
|
2484
2547
|
// steer the caller at a revision the very next call would also refuse.
|
|
@@ -2486,8 +2549,8 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2486
2549
|
// THE ARMS SPLIT ON THE ROW, NOT ON THE FILE'S PRESENCE, and that is
|
|
2487
2550
|
// deliberate rather than an omission. Asking "is the image absent" separately
|
|
2488
2551
|
// 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
|
|
2490
|
-
// happened. The two file-half sub-cases are distinguished by the QUOTED
|
|
2552
|
+
// path -- a second truth about one file, which is exactly how two of this
|
|
2553
|
+
// store's past defects happened. The two file-half sub-cases are distinguished by the QUOTED
|
|
2491
2554
|
// REASON instead: an absent image quotes "the file does not exist", a corrupt
|
|
2492
2555
|
// one quotes what SQLite or `openStore` said. One witness, one message, and
|
|
2493
2556
|
// the caller can still tell them apart.
|
|
@@ -2520,7 +2583,7 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2520
2583
|
|
|
2521
2584
|
const storePath = handle.path;
|
|
2522
2585
|
const dir = handle.dir;
|
|
2523
|
-
// THE STAGING NAME IS UNIQUE PER ATTEMPT, NOT PER (PID, REVISION)
|
|
2586
|
+
// THE STAGING NAME IS UNIQUE PER ATTEMPT, NOT PER (PID, REVISION),
|
|
2524
2587
|
// and it is built from `randomUUID` -- the SAME primitive `stageSnapshot`
|
|
2525
2588
|
// uses, so there is ONE answer in this module to "how is a staging name made
|
|
2526
2589
|
// unique" rather than two that can drift. `stageSnapshot`'s own doc comment
|
|
@@ -2537,12 +2600,12 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2537
2600
|
// addressed the same path.
|
|
2538
2601
|
//
|
|
2539
2602
|
// 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
|
|
2541
|
-
//
|
|
2603
|
+
// would be prohibition 28-07 P3's exact shape: THE LEAK HALF STAYS OPEN.
|
|
2604
|
+
// A process killed between the copy and any of the three cleanups
|
|
2542
2605
|
// still leaves this file behind, and it sits beside the store rather than
|
|
2543
2606
|
// inside the ring directory, so `reconcileSnapshotRing`'s sweep -- anchored on
|
|
2544
2607
|
// `r<digits>.db` inside `snapshotDirFor()` -- does not and must not match it.
|
|
2545
|
-
// Nothing reclaims it. That is
|
|
2608
|
+
// Nothing reclaims it. That is the other half of this gap and it is not closed here.
|
|
2546
2609
|
//
|
|
2547
2610
|
// THE PID IS KEPT deliberately: it is the diagnostic that lets a human finding
|
|
2548
2611
|
// a leaked file say which process produced it, and the `.revert-` marker is
|
|
@@ -2553,8 +2616,8 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2553
2616
|
// failure reachable here -- `ENOENT`, `ENOSPC`, `EACCES` -- therefore leaves
|
|
2554
2617
|
// the caller a USABLE handle: nothing has been replaced yet, so
|
|
2555
2618
|
// `currentRevision(handle)` and `listRanges(handle)` still answer and the
|
|
2556
|
-
// caller can decide what to do. That is
|
|
2557
|
-
// fixed by ordering rather than by a rescue path.
|
|
2619
|
+
// caller can decide what to do. That is exactly the failure this ordering
|
|
2620
|
+
// exists to prevent, and it is fixed by ordering rather than by a rescue path.
|
|
2558
2621
|
try {
|
|
2559
2622
|
copyFileSync(snapPath, staging);
|
|
2560
2623
|
fsyncPath(staging);
|
|
@@ -2568,7 +2631,7 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2568
2631
|
);
|
|
2569
2632
|
}
|
|
2570
2633
|
|
|
2571
|
-
// STEP 3b, AND ITS POSITION IS THE GUARANTEE
|
|
2634
|
+
// STEP 3b, AND ITS POSITION IS THE GUARANTEE. OPEN THE STAGED IMAGE
|
|
2572
2635
|
// BEFORE ANYTHING IS CLOSED AND BEFORE ANYTHING IS RENAMED. Until this step
|
|
2573
2636
|
// existed the only witness that the image about to be installed was a store at
|
|
2574
2637
|
// all was `existsSync` -- and this module's own FIRST MEASURED FACT (see the
|
|
@@ -2633,8 +2696,8 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2633
2696
|
// function. The image just restored carries `anno_snapshot` rows for
|
|
2634
2697
|
// revisions whose files an earlier prune removed, and it leaves every
|
|
2635
2698
|
// snapshot taken AFTER `revision` unclaimed by any row. ONLY THE FILE HALF IS
|
|
2636
|
-
// RESOLVED HERE, and the ROW half is deliberately left: since
|
|
2637
|
-
// abstains from the pointer-row direction entirely, because it cannot
|
|
2699
|
+
// RESOLVED HERE, and the ROW half is deliberately left: since the fix above
|
|
2700
|
+
// the sweep abstains from the pointer-row direction entirely, because it cannot
|
|
2638
2701
|
// establish ownership of a row under a second spelling of the store file, so
|
|
2639
2702
|
// the restored image's stale rows are TOLERATED rather than deleted. This
|
|
2640
2703
|
// sentence previously claimed BOTH halves were resolved here -- the reversal
|
|
@@ -2668,7 +2731,7 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2668
2731
|
// sweep below can itself stall for up to the connection's five-second
|
|
2669
2732
|
// `busy_timeout` before `revertTo` returns.
|
|
2670
2733
|
//
|
|
2671
|
-
//
|
|
2734
|
+
// THE THIRD PROPERTY: THE SWEEP IS HOUSEKEEPING AND MUST NEVER COST THE
|
|
2672
2735
|
// CALLER A HANDLE. By this line the revert has ALREADY SUCCEEDED ON DISK --
|
|
2673
2736
|
// step 5's rename and directory fsync have returned, so the store file at
|
|
2674
2737
|
// `storePath` IS the reverted image whatever happens next. Round 3 observed
|
|
@@ -2697,7 +2760,7 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2697
2760
|
// thing in both of its controls rather than letting a green test imply a
|
|
2698
2761
|
// behavioural proof it does not carry.
|
|
2699
2762
|
//
|
|
2700
|
-
// AND THE REOPEN IS NOW INSIDE THE SAME GUARANTEE
|
|
2763
|
+
// AND THE REOPEN IS NOW INSIDE THE SAME GUARANTEE, WHICH IS WHERE IT
|
|
2701
2764
|
// BELONGED. The sentence above -- "a housekeeping failure never costs the
|
|
2702
2765
|
// caller a handle" -- used to hold only for the branch that CANNOT fire. The
|
|
2703
2766
|
// sweep call was guarded and is unreachable; the two `openStore` calls were
|
|
@@ -2739,14 +2802,14 @@ export function revertTo(handle: AnnoStoreHandle, revision: number): AnnoStoreHa
|
|
|
2739
2802
|
);
|
|
2740
2803
|
}
|
|
2741
2804
|
//
|
|
2742
|
-
// AND THE SWEEP'S RESULT IS BOUND RATHER THAN DISCARDED
|
|
2805
|
+
// AND THE SWEEP'S RESULT IS BOUND RATHER THAN DISCARDED, WHICH IS THE
|
|
2743
2806
|
// ARM THE `catch` ABOVE CANNOT SEE. `reconcileSnapshotRing` does not throw
|
|
2744
2807
|
// when its own `rollback` fails -- rethrowing there is forbidden by 28-11 P5,
|
|
2745
2808
|
// because on this very call site it would convert a LANDED revert into a
|
|
2746
2809
|
// caller-visible failure -- so it REPORTS the fact in `rollbackFailed`
|
|
2747
2810
|
// instead. Reaching that state WITHOUT a throw is exactly why the existing
|
|
2748
2811
|
// catch arm alone was not enough: `revertTo` would hand back a connection that
|
|
2749
|
-
// may still hold the store's write lock, which is
|
|
2812
|
+
// may still hold the store's write lock, which is the same reported symptom
|
|
2750
2813
|
// re-created on the revert path.
|
|
2751
2814
|
//
|
|
2752
2815
|
// THE REMEDY IS THE SAME BLOCK, REUSED RATHER THAN COPIED: close the
|
|
@@ -2951,7 +3014,7 @@ export function listComments(handle: AnnoStoreHandle): CommentRow[] {
|
|
|
2951
3014
|
* the existing one's id and two ends. Before that refusal existed this comment
|
|
2952
3015
|
* and `ScopeRow`'s made a claim the code did not honour, which is exactly the
|
|
2953
3016
|
* shape prohibition 28-07 P3 forbids. Adjacency is NOT overlap: two scopes that
|
|
2954
|
-
* merely touch at a boundary are two scopes, consistent with
|
|
3017
|
+
* merely touch at a boundary are two scopes, consistent with this store's own
|
|
2955
3018
|
* treatment of ranges.
|
|
2956
3019
|
*
|
|
2957
3020
|
* A BYTE-IDENTICAL REPEAT IS AN ACCEPTED NO-OP reporting `changed: false`, and
|
|
@@ -2963,7 +3026,7 @@ export function listComments(handle: AnnoStoreHandle): CommentRow[] {
|
|
|
2963
3026
|
* `AnnoWriteResult`'s own doc comment states that `changed` is the ONLY signal
|
|
2964
3027
|
* distinguishing a no-op from a real edit -- and for scopes it could never say
|
|
2965
3028
|
* no-op, so the module already had the policy and simply could not express it
|
|
2966
|
-
* here. Second,
|
|
3029
|
+
* here. Second, the MCP surface's own success criterion requires a repeated edit to
|
|
2967
3030
|
* SUCCEED reporting no change, so the surface this store mirrors does have the
|
|
2968
3031
|
* policy after all. The repeat is therefore accepted rather than refused, and
|
|
2969
3032
|
* the revision still advances by one, exactly like every other write entry
|
|
@@ -2984,7 +3047,7 @@ export function addScope(
|
|
|
2984
3047
|
// read the existing row inside the transaction and return `false`. It has
|
|
2985
3048
|
// to run before the overlap check, because a byte-identical scope
|
|
2986
3049
|
// overlaps itself and would otherwise be refused rather than accepted as
|
|
2987
|
-
// the no-op
|
|
3050
|
+
// the no-op that surface's own criterion requires.
|
|
2988
3051
|
const identical = db.prepare("select id from anno_scope where start = ? and end_inclusive = ?").get(start, endInclusive) as
|
|
2989
3052
|
| { id: number }
|
|
2990
3053
|
| undefined;
|
|
@@ -3034,10 +3097,10 @@ export function listScopes(handle: AnnoStoreHandle): ScopeRow[] {
|
|
|
3034
3097
|
* `changed: false` when no scope has that span.
|
|
3035
3098
|
*
|
|
3036
3099
|
* WHY THIS EXISTS, and why it is not an omission being corrected quietly.
|
|
3037
|
-
*
|
|
3038
|
-
* no inverse and carried the finding
|
|
3100
|
+
* A prior verification round recorded that `addScope`'s overlap refusal had
|
|
3101
|
+
* no inverse and carried the finding forward in as many words, "which puts
|
|
3039
3102
|
* `addScope` on an agent-driven surface where a mistyped span is likelier".
|
|
3040
|
-
*
|
|
3103
|
+
* The review that raised it spells out the consequence: one transposed end --
|
|
3041
3104
|
* `addScope($1000, $ffff)` -- makes every future scope from `$1000` upward
|
|
3042
3105
|
* permanently unaddable, recoverable only through `revertTo` inside the
|
|
3043
3106
|
* 32-revision ring, after which the mistake is permanent for the life of the
|
|
@@ -3091,6 +3154,196 @@ export function removeScope(
|
|
|
3091
3154
|
return { revision, changed: result };
|
|
3092
3155
|
}
|
|
3093
3156
|
|
|
3157
|
+
/**
|
|
3158
|
+
* Records a user-requested exclusion of `start..endInclusive`, with `reason`
|
|
3159
|
+
* stating WHY the user asked for it -- added at `SCHEMA_VERSION` 5.
|
|
3160
|
+
*
|
|
3161
|
+
* RECORDING AN EXCLUSION CHANGES NOTHING ABOUT WHICH BYTES THE EXPORT EMITS.
|
|
3162
|
+
* The exporter still walks this range's full byte span and emits a real
|
|
3163
|
+
* block, tagged with a visible marker comment, rather than a hole -- that is
|
|
3164
|
+
* this feature's whole invariant. An exporter implementation that skipped the
|
|
3165
|
+
* block on seeing an exclusion row would satisfy the word "exclude" and fail
|
|
3166
|
+
* the requirement outright: this table is a RECORD, never a filter, and the
|
|
3167
|
+
* store answers "what did the user record", never "should this range be
|
|
3168
|
+
* excluded".
|
|
3169
|
+
*
|
|
3170
|
+
* `reason` is validated through `assertCommentText()` -- the ONE comment-text
|
|
3171
|
+
* vocabulary this store has -- BEFORE the write opens, and an empty or
|
|
3172
|
+
* whitespace-only reason is refused with its own message: a `not null`
|
|
3173
|
+
* column satisfied by `""` records that something was excluded and loses WHY,
|
|
3174
|
+
* which is precisely the half of criterion 2 this record exists to carry.
|
|
3175
|
+
*
|
|
3176
|
+
* IDEMPOTENCE FIRST, inside the transaction and BEFORE the overlap check, the
|
|
3177
|
+
* same shape `addScope` uses: an identical repeat -- same extent, same reason
|
|
3178
|
+
* -- is an accepted NO-OP reporting `changed: false`. The SAME extent with a
|
|
3179
|
+
* DIFFERENT reason is REFUSED rather than silently overwritten -- the stored
|
|
3180
|
+
* reason is left exactly as it was, and the route to change it is to remove
|
|
3181
|
+
* the record with `removeExcludedRange` and add it again. Silently replacing
|
|
3182
|
+
* what somebody wrote and reporting success is the failure mode this store's
|
|
3183
|
+
* comment and label verbs already refuse.
|
|
3184
|
+
*
|
|
3185
|
+
* OVERLAP IS REFUSED using `addScope()`'s EXACT predicate --
|
|
3186
|
+
* `start <= ? and end_inclusive >= ?` with the two arguments TRANSPOSED, and
|
|
3187
|
+
* `order by id limit 1` so the message is reproducible -- so adjacency falls
|
|
3188
|
+
* out of the `>=` rather than a second rule: two exclusion records that
|
|
3189
|
+
* merely TOUCH at a boundary are disjoint and both accepted, and they stay
|
|
3190
|
+
* TWO records. The incoming record is NEVER trimmed or split; a caller
|
|
3191
|
+
* wanting a disjoint span reads `listExcludedRanges()` first, or removes the
|
|
3192
|
+
* conflicting record with `removeExcludedRange`.
|
|
3193
|
+
*/
|
|
3194
|
+
export function addExcludedRange(
|
|
3195
|
+
handle: AnnoStoreHandle,
|
|
3196
|
+
args: { start: number | string; endInclusive: number | string; reason: string; baseRevision?: number },
|
|
3197
|
+
): AnnoWriteResult {
|
|
3198
|
+
const start = parseStoreAddress(args.start, { what: "start" });
|
|
3199
|
+
const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
|
|
3200
|
+
assertRangeShape(start, endInclusive, "byte");
|
|
3201
|
+
|
|
3202
|
+
const reason = assertCommentText(args.reason, { what: "exclusion reason" });
|
|
3203
|
+
if (reason.trim() === "") {
|
|
3204
|
+
throw new AnnoCommentError(
|
|
3205
|
+
`exclusion reason is empty or whitespace-only -- a "reason" column satisfied by an empty string records that something was excluded ` +
|
|
3206
|
+
`and loses WHY, which is precisely the half of criterion 2 this record exists to carry. Supply the reason the user gave.`,
|
|
3207
|
+
{ reason: "empty reason" },
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
const { revision, result } = applyWrite(
|
|
3212
|
+
handle,
|
|
3213
|
+
(db) => {
|
|
3214
|
+
// IDEMPOTENCE FIRST, `addScope`'s own shape: read the existing row
|
|
3215
|
+
// inside the transaction and BEFORE the overlap check, because a
|
|
3216
|
+
// byte-identical exclusion overlaps itself and would otherwise be
|
|
3217
|
+
// refused rather than accepted as the no-op an identical repeat requires.
|
|
3218
|
+
const identical = db
|
|
3219
|
+
.prepare("select id, reason from anno_excluded_range where start = ? and end_inclusive = ?")
|
|
3220
|
+
.get(start, endInclusive) as { id: number; reason: string } | undefined;
|
|
3221
|
+
if (identical) {
|
|
3222
|
+
if (identical.reason === reason) return false;
|
|
3223
|
+
throw new AnnoRangeShapeError(
|
|
3224
|
+
`exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
|
|
3225
|
+
`is already recorded (id=${identical.id}) with a DIFFERENT reason -- the stored reason is left EXACTLY as it was. Refusing rather ` +
|
|
3226
|
+
`than silently overwriting what somebody wrote: remove the record with removeExcludedRange and add it again to change the reason.`,
|
|
3227
|
+
{ start, endInclusive },
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
// TWO EXCLUSIONS OVERLAP IFF each starts at or before the other ends.
|
|
3232
|
+
// ADJACENCY FALLS OUT OF THE `>=`, `addScope()`'s exact predicate: an
|
|
3233
|
+
// existing exclusion ending at exactly `start - 1` fails
|
|
3234
|
+
// `end_inclusive >= start`, so touching is not overlapping. `order by id
|
|
3235
|
+
// limit 1` reports the FIRST conflicting row so the message is
|
|
3236
|
+
// reproducible.
|
|
3237
|
+
const overlapper = db
|
|
3238
|
+
.prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
|
|
3239
|
+
.get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
|
|
3240
|
+
if (overlapper) {
|
|
3241
|
+
throw new AnnoRangeShapeError(
|
|
3242
|
+
`exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
|
|
3243
|
+
`overlaps the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
|
|
3244
|
+
`($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}) -- the incoming ` +
|
|
3245
|
+
`record is NOT trimmed and NOT split: supply a range disjoint from every existing exclusion. Two exclusions that merely TOUCH at a ` +
|
|
3246
|
+
`boundary are disjoint and both accepted. Read listExcludedRanges() first, or removeExcludedRange the conflicting record.`,
|
|
3247
|
+
{ start, endInclusive },
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
db.prepare("insert into anno_excluded_range(start, end_inclusive, reason) values (?, ?, ?)").run(start, endInclusive, reason);
|
|
3252
|
+
return true;
|
|
3253
|
+
},
|
|
3254
|
+
{ baseRevision: args.baseRevision },
|
|
3255
|
+
);
|
|
3256
|
+
return { revision, changed: result };
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/** Every recorded exclusion, in ascending `id` order -- insertion order,
|
|
3260
|
+
* matching `listScopes()` and `listRanges()`. A consumer needing address
|
|
3261
|
+
* order sorts it itself, because a second ordering in the store would be a
|
|
3262
|
+
* second answer to the same question. `anno_excluded_range` has no `bank`
|
|
3263
|
+
* column, in the same shape `listScopes()`'s own doc comment uses for the
|
|
3264
|
+
* same absence: an exclusion is a statement about the subject program, not a
|
|
3265
|
+
* memory view. */
|
|
3266
|
+
export function listExcludedRanges(handle: AnnoStoreHandle): ExcludedRangeRow[] {
|
|
3267
|
+
const rows = handle.db.prepare("select id, start, end_inclusive, reason from anno_excluded_range order by id").all() as {
|
|
3268
|
+
id: number;
|
|
3269
|
+
start: number;
|
|
3270
|
+
end_inclusive: number;
|
|
3271
|
+
reason: string;
|
|
3272
|
+
}[];
|
|
3273
|
+
return rows.map((row) => ({ id: row.id, start: row.start, endInclusive: row.end_inclusive, reason: row.reason }));
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
/**
|
|
3277
|
+
* Removes the exclusion whose span is EXACTLY `start..endInclusive`, and
|
|
3278
|
+
* returns `changed: false` when NO exclusion overlaps that span at all --
|
|
3279
|
+
* the exact inverse of `addExcludedRange`, following `removeScope()`.
|
|
3280
|
+
*
|
|
3281
|
+
* THE SPAN MUST MATCH EXACTLY -- both ends, as stored. A record is never
|
|
3282
|
+
* trimmed, split, or partially removed: a span that PARTIALLY OVERLAPS an
|
|
3283
|
+
* existing record (but does not match it end-for-end) is REFUSED BY NAME
|
|
3284
|
+
* rather than silently ignored, because a partial removal would leave a
|
|
3285
|
+
* shape nothing downstream can express, while reporting success. This is
|
|
3286
|
+
* stricter than `removeScope()`, which reports a mismatched span as a plain
|
|
3287
|
+
* no-op -- an exclusion's reason makes a near-miss removal more dangerous to
|
|
3288
|
+
* treat as "nothing happened", since a caller who meant to clear the record
|
|
3289
|
+
* would otherwise walk away believing it gone. A caller that does not know
|
|
3290
|
+
* the stored span reads it from `listExcludedRanges()` first.
|
|
3291
|
+
*
|
|
3292
|
+
* REMOVING A SPAN THAT DOES NOT OVERLAP ANYTHING STORED IS AN ACCEPTED NO-OP
|
|
3293
|
+
* reporting `changed: false`, matching `removeScope`'s own direction: an
|
|
3294
|
+
* inverse that refuses when there is genuinely nothing to undo makes "undo
|
|
3295
|
+
* this" conditional on knowing whether it was ever done.
|
|
3296
|
+
*/
|
|
3297
|
+
export function removeExcludedRange(
|
|
3298
|
+
handle: AnnoStoreHandle,
|
|
3299
|
+
args: { start: number | string; endInclusive: number | string; baseRevision?: number },
|
|
3300
|
+
): AnnoWriteResult {
|
|
3301
|
+
const start = parseStoreAddress(args.start, { what: "start" });
|
|
3302
|
+
const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
|
|
3303
|
+
// The SAME non-split shape check `addExcludedRange`/`removeScope` use, and
|
|
3304
|
+
// for the same reason: an exclusion is not a table, so the split-table
|
|
3305
|
+
// even-count rule must not apply to it.
|
|
3306
|
+
assertRangeShape(start, endInclusive, "byte");
|
|
3307
|
+
|
|
3308
|
+
const { revision, result } = applyWrite(
|
|
3309
|
+
handle,
|
|
3310
|
+
(db) => {
|
|
3311
|
+
const existing = db.prepare("select id from anno_excluded_range where start = ? and end_inclusive = ?").get(start, endInclusive) as
|
|
3312
|
+
| { id: number }
|
|
3313
|
+
| undefined;
|
|
3314
|
+
if (existing) {
|
|
3315
|
+
db.prepare("delete from anno_excluded_range where id = ?").run(existing.id);
|
|
3316
|
+
return true;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// NO EXACT MATCH. Before reporting the ordinary "nothing to undo"
|
|
3320
|
+
// no-op, check whether the incoming span PARTIALLY overlaps a stored
|
|
3321
|
+
// record -- the same overlap predicate `addExcludedRange` uses. That
|
|
3322
|
+
// case is refused BY NAME rather than treated as a no-op, because the
|
|
3323
|
+
// caller plainly meant to remove something that exists and a silent
|
|
3324
|
+
// no-op would misreport the outcome.
|
|
3325
|
+
const overlapper = db
|
|
3326
|
+
.prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
|
|
3327
|
+
.get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
|
|
3328
|
+
if (overlapper) {
|
|
3329
|
+
throw new AnnoRangeShapeError(
|
|
3330
|
+
`removeExcludedRange: ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..${endInclusive
|
|
3331
|
+
.toString(16)
|
|
3332
|
+
.padStart(4, "0")}) does not EXACTLY match the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
|
|
3333
|
+
`($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}), which it ` +
|
|
3334
|
+
`partially overlaps -- a record is never trimmed, split, or partially removed, because that would leave a shape nothing downstream ` +
|
|
3335
|
+
`can express while reporting success. Read listExcludedRanges() first to find the exact stored span.`,
|
|
3336
|
+
{ start, endInclusive },
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
return false;
|
|
3341
|
+
},
|
|
3342
|
+
{ baseRevision: args.baseRevision },
|
|
3343
|
+
);
|
|
3344
|
+
return { revision, changed: result };
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3094
3347
|
/**
|
|
3095
3348
|
* Validates one project enum's variants mapping and returns it with its KEYS
|
|
3096
3349
|
* VERBATIM.
|
|
@@ -3272,7 +3525,7 @@ export function listProjectEnums(handle: AnnoStoreHandle): ProjectEnumRow[] {
|
|
|
3272
3525
|
|
|
3273
3526
|
/**
|
|
3274
3527
|
* Associates ONE address with ONE project enum, so the address's operand is
|
|
3275
|
-
* formatted through that enum's variants (`SCHEMA_VERSION` 3
|
|
3528
|
+
* formatted through that enum's variants (`SCHEMA_VERSION` 3).
|
|
3276
3529
|
*
|
|
3277
3530
|
* THE ASSOCIATION IS BY `anno_enum.id`, NEVER BY NAME, and that is the whole
|
|
3278
3531
|
* design of the table. `updateProjectEnum` renames an enum in place, keeping
|
|
@@ -3296,8 +3549,8 @@ export function listProjectEnums(handle: AnnoStoreHandle): ProjectEnumRow[] {
|
|
|
3296
3549
|
*
|
|
3297
3550
|
* Every argument is validated through `anno-types.ts`'s own assertions before
|
|
3298
3551
|
* any SQL runs. `parseStoreAddress` in particular refuses an UNPREFIXED numeric
|
|
3299
|
-
* string such as `"53280"` outright rather than guessing a base --
|
|
3300
|
-
*
|
|
3552
|
+
* string such as `"53280"` outright rather than guessing a base -- a recorded
|
|
3553
|
+
* failure in which a JSON `"1"` arrived verbatim and SQLite's column
|
|
3301
3554
|
* affinity turned an argument error into a corruption refusal.
|
|
3302
3555
|
*/
|
|
3303
3556
|
export function applyEnumUsage(
|
|
@@ -3406,18 +3659,276 @@ export function listEnumUsage(handle: AnnoStoreHandle): EnumUsageRow[] {
|
|
|
3406
3659
|
}));
|
|
3407
3660
|
}
|
|
3408
3661
|
|
|
3662
|
+
// ---------------------------------------------------------------------------
|
|
3663
|
+
// THE RUNTIME EVIDENCE TABLE (`anno_evid_exec`, `SCHEMA_VERSION` 4).
|
|
3664
|
+
// One raw shape used by all three functions below.
|
|
3665
|
+
// ---------------------------------------------------------------------------
|
|
3666
|
+
|
|
3667
|
+
/** One `anno_evid_exec` row exactly as the column names read on disk --
|
|
3668
|
+
* `snake_case`, matching every other raw-row shape in this module.
|
|
3669
|
+
*
|
|
3670
|
+
* A `type` ALIAS RATHER THAN AN `interface`, and that is load-bearing rather
|
|
3671
|
+
* than stylistic: `node:sqlite`'s `all()` returns `Record<string,
|
|
3672
|
+
* SQLOutputValue>[]`, and casting that to a NAMED `interface` fails TS's
|
|
3673
|
+
* type-assertion comparability check ("neither type sufficiently overlaps")
|
|
3674
|
+
* even though the shapes are identical -- a `type` alias to the same object
|
|
3675
|
+
* shape is accepted. Measured against this exact query shape during this
|
|
3676
|
+
* plan's own implementation. */
|
|
3677
|
+
type RawEvidExecRow = {
|
|
3678
|
+
id: number;
|
|
3679
|
+
image_sha256: string;
|
|
3680
|
+
argv_digest: string;
|
|
3681
|
+
seed: string;
|
|
3682
|
+
address: number;
|
|
3683
|
+
source_bank: string;
|
|
3684
|
+
};
|
|
3685
|
+
|
|
3686
|
+
/** `RawEvidExecRow` -> `EvidExecRow`, the one mapping site both read functions
|
|
3687
|
+
* below share, so the two never drift into disagreeing about the shape. */
|
|
3688
|
+
function toEvidExecRow(row: RawEvidExecRow): EvidExecRow {
|
|
3689
|
+
return {
|
|
3690
|
+
id: row.id,
|
|
3691
|
+
imageSha256: row.image_sha256,
|
|
3692
|
+
argvDigest: row.argv_digest,
|
|
3693
|
+
seed: row.seed,
|
|
3694
|
+
address: row.address,
|
|
3695
|
+
sourceBank: row.source_bank as EvidSourceBank,
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/**
|
|
3700
|
+
* Inserts one or more runtime-execution observations for one run identity,
|
|
3701
|
+
* keyed `(imageSha256, argvDigest, seed, address, sourceBank)` -- a live A/B
|
|
3702
|
+
* measured `no-perturbation` from instrumentation, so there is deliberately
|
|
3703
|
+
* no `run_class` discriminator.
|
|
3704
|
+
*
|
|
3705
|
+
* EVERY FIELD IS VALIDATED BEFORE THE FIRST STATEMENT RUNS, and every
|
|
3706
|
+
* refusal is a named `AnnoTypeError`/`AnnoAddressError` carrying the
|
|
3707
|
+
* offending value and the valid domain -- this module's existing refusal
|
|
3708
|
+
* register, never a fresh one. A caller-supplied `imageSha256`/`argvDigest`
|
|
3709
|
+
* that is not exactly 64 lowercase hex characters, a `seed` that is empty,
|
|
3710
|
+
* an `address` outside `ADDRESS_MIN..ADDRESS_MAX`, or a `sourceBank` outside
|
|
3711
|
+
* the frozen three is refused BEFORE the write transaction opens, so a bad
|
|
3712
|
+
* argument never reaches SQL and never partially inserts the rest of the
|
|
3713
|
+
* batch.
|
|
3714
|
+
*
|
|
3715
|
+
* THE WHOLE INSERT IS ONE `applyWrite` CALLBACK (T-43-09): every observation
|
|
3716
|
+
* in `args.observations` is written -- or skipped -- inside the SAME
|
|
3717
|
+
* transaction that `commitTransaction` commits, so a kill mid-ingest leaves
|
|
3718
|
+
* the set fully committed or fully absent, never a partial row set.
|
|
3719
|
+
*
|
|
3720
|
+
* AN OBSERVATION ALREADY PRESENT IS SKIPPED, NOT RE-INSERTED: the existing row is selected first, by the full
|
|
3721
|
+
* unique key, and the insert only runs when it is absent. `changed` is
|
|
3722
|
+
* `false` exactly when every observation in this call was already present --
|
|
3723
|
+
* the same `changed`-is-the-only-no-op-signal contract `AnnoWriteResult`
|
|
3724
|
+
* states for every other write entry point in this module. `revision` still
|
|
3725
|
+
* advances by exactly one on every accepted call, no-op or not, for the same
|
|
3726
|
+
* reason.
|
|
3727
|
+
*
|
|
3728
|
+
* `insertedCount` IS COUNTED INSIDE THIS SAME TRANSACTION, never
|
|
3729
|
+
* derived from a separate read taken before `applyWrite` opens it: a caller
|
|
3730
|
+
* that wants "how many of these rows were actually new" must not be handed a
|
|
3731
|
+
* number computed from a `listExecObservations()` snapshot that a concurrent
|
|
3732
|
+
* writer to the SAME run identity could have moved past between that read
|
|
3733
|
+
* and this insert's own commit. Counting the per-row `existing`/insert
|
|
3734
|
+
* branch already taken above is the one place this number can be exact.
|
|
3735
|
+
*/
|
|
3736
|
+
export interface InsertExecObservationsResult extends AnnoWriteResult {
|
|
3737
|
+
insertedCount: number;
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
export function insertExecObservations(
|
|
3741
|
+
handle: AnnoStoreHandle,
|
|
3742
|
+
args: {
|
|
3743
|
+
imageSha256: unknown;
|
|
3744
|
+
argvDigest: unknown;
|
|
3745
|
+
seed: unknown;
|
|
3746
|
+
observations: readonly { address: unknown; sourceBank: unknown }[];
|
|
3747
|
+
baseRevision?: number;
|
|
3748
|
+
},
|
|
3749
|
+
): InsertExecObservationsResult {
|
|
3750
|
+
const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
|
|
3751
|
+
const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
|
|
3752
|
+
const seed = assertRunIdentitySeed(args.seed);
|
|
3753
|
+
|
|
3754
|
+
if (!Array.isArray(args.observations) || args.observations.length === 0) {
|
|
3755
|
+
throw new AnnoTypeError(`observations must be a non-empty array, got ${JSON.stringify(args.observations)}`, {
|
|
3756
|
+
dataType: args.observations,
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
// VALIDATED IN FULL BEFORE THE FIRST STATEMENT, per this function's own
|
|
3760
|
+
// doc comment: a bad entry at index 9 must not leave entries 0..8 written.
|
|
3761
|
+
const parsedObservations = args.observations.map((obs) => ({
|
|
3762
|
+
address: parseStoreAddress((obs as { address: unknown }).address, { what: "address" }),
|
|
3763
|
+
sourceBank: assertEvidSourceBank((obs as { sourceBank: unknown }).sourceBank),
|
|
3764
|
+
}));
|
|
3765
|
+
|
|
3766
|
+
const { revision, result } = applyWrite(
|
|
3767
|
+
handle,
|
|
3768
|
+
(db) => {
|
|
3769
|
+
let insertedCount = 0;
|
|
3770
|
+
for (const obs of parsedObservations) {
|
|
3771
|
+
const existing = db
|
|
3772
|
+
.prepare("select id from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? and address = ? and source_bank = ?")
|
|
3773
|
+
.get(imageSha256, argvDigest, seed, obs.address, obs.sourceBank) as { id: number } | undefined;
|
|
3774
|
+
if (existing) continue;
|
|
3775
|
+
db.prepare("insert into anno_evid_exec(image_sha256, argv_digest, seed, address, source_bank) values (?, ?, ?, ?, ?)").run(
|
|
3776
|
+
imageSha256,
|
|
3777
|
+
argvDigest,
|
|
3778
|
+
seed,
|
|
3779
|
+
obs.address,
|
|
3780
|
+
obs.sourceBank,
|
|
3781
|
+
);
|
|
3782
|
+
insertedCount++;
|
|
3783
|
+
}
|
|
3784
|
+
return insertedCount;
|
|
3785
|
+
},
|
|
3786
|
+
{ baseRevision: args.baseRevision },
|
|
3787
|
+
);
|
|
3788
|
+
return { revision, changed: result > 0, insertedCount: result };
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
/** One shape used by `listExecObservations`'s four fixed queries below. */
|
|
3792
|
+
type EvidRunIdentityFilter = { imageSha256: string; argvDigest: string; seed: string };
|
|
3793
|
+
|
|
3794
|
+
/**
|
|
3795
|
+
* Every runtime-execution observation, in ascending ADDRESS then `id` order,
|
|
3796
|
+
* with optional filters on `address` and on the full run identity. Never a
|
|
3797
|
+
* `select *` -- every column is named.
|
|
3798
|
+
*
|
|
3799
|
+
* A RUN-IDENTITY FILTER IS ALL THREE FIELDS TOGETHER OR NONE. A partial
|
|
3800
|
+
* identity (the seed alone, say) would silently widen the match to every
|
|
3801
|
+
* image/argv pair that happens to share it, which is not what "filter by run
|
|
3802
|
+
* identity" means -- refused BY NAME rather than accepted as a wider query
|
|
3803
|
+
* nobody asked for.
|
|
3804
|
+
*/
|
|
3805
|
+
export function listExecObservations(
|
|
3806
|
+
handle: AnnoStoreHandle,
|
|
3807
|
+
opts: { address?: number | string; imageSha256?: unknown; argvDigest?: unknown; seed?: unknown } = {},
|
|
3808
|
+
): EvidExecRow[] {
|
|
3809
|
+
const hasAddress = opts.address !== undefined;
|
|
3810
|
+
const identityFieldsGiven = [opts.imageSha256, opts.argvDigest, opts.seed].filter((v) => v !== undefined).length;
|
|
3811
|
+
if (identityFieldsGiven > 0 && identityFieldsGiven < 3) {
|
|
3812
|
+
throw new AnnoTypeError(
|
|
3813
|
+
"listExecObservations: a run-identity filter requires imageSha256, argvDigest AND seed together -- a partial identity would silently widen the match",
|
|
3814
|
+
{ dataType: { imageSha256: opts.imageSha256, argvDigest: opts.argvDigest, seed: opts.seed } },
|
|
3815
|
+
);
|
|
3816
|
+
}
|
|
3817
|
+
const hasIdentity = identityFieldsGiven === 3;
|
|
3818
|
+
|
|
3819
|
+
const address = hasAddress ? parseStoreAddress(opts.address as number | string, { what: "address" }) : undefined;
|
|
3820
|
+
const identity: EvidRunIdentityFilter | undefined = hasIdentity
|
|
3821
|
+
? {
|
|
3822
|
+
imageSha256: assertRunIdentityDigest(opts.imageSha256, "imageSha256"),
|
|
3823
|
+
argvDigest: assertRunIdentityDigest(opts.argvDigest, "argvDigest"),
|
|
3824
|
+
seed: assertRunIdentitySeed(opts.seed),
|
|
3825
|
+
}
|
|
3826
|
+
: undefined;
|
|
3827
|
+
|
|
3828
|
+
let rows: RawEvidExecRow[];
|
|
3829
|
+
if (address !== undefined && identity !== undefined) {
|
|
3830
|
+
rows = handle.db
|
|
3831
|
+
.prepare(
|
|
3832
|
+
"select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? and image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
|
|
3833
|
+
)
|
|
3834
|
+
.all(address, identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
|
|
3835
|
+
} else if (address !== undefined) {
|
|
3836
|
+
rows = handle.db
|
|
3837
|
+
.prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? order by address, id")
|
|
3838
|
+
.all(address) as RawEvidExecRow[];
|
|
3839
|
+
} else if (identity !== undefined) {
|
|
3840
|
+
rows = handle.db
|
|
3841
|
+
.prepare(
|
|
3842
|
+
"select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
|
|
3843
|
+
)
|
|
3844
|
+
.all(identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
|
|
3845
|
+
} else {
|
|
3846
|
+
rows = handle.db
|
|
3847
|
+
.prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec order by address, id")
|
|
3848
|
+
.all() as RawEvidExecRow[];
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
return rows.map(toEvidExecRow);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
/**
|
|
3855
|
+
* Every distinct run identity with an `anno_evid_exec` row, its accumulated
|
|
3856
|
+
* observation count, and the `denominator` those counts are a fraction of.
|
|
3857
|
+
*
|
|
3858
|
+
* WHY A DENOMINATOR IS RETURNED AT ALL, AND WHY IT NEVER FORMS A PERCENTAGE
|
|
3859
|
+
* ITSELF. A bare count invites the reading "the rest is data" -- exactly the
|
|
3860
|
+
* soundness violation this table's own discipline forbids (see `RuntimeExecClass`'s own doc
|
|
3861
|
+
* comment in `anno-types.ts`). `denominator` is `ADDRESS_MAX - ADDRESS_MIN +
|
|
3862
|
+
* 1`, read from `anno-types.ts`'s own constants rather than the literal
|
|
3863
|
+
* `65536` -- a caller comparing a run's `observationCount` against it forms
|
|
3864
|
+
* its own fraction, and there is deliberately no rounding site in this
|
|
3865
|
+
* module to do that division for them (see this file's `toFixed` census in
|
|
3866
|
+
* this task's own acceptance criteria).
|
|
3867
|
+
*/
|
|
3868
|
+
export function listObservedRuns(handle: AnnoStoreHandle): { runs: ObservedRunRow[]; denominator: number } {
|
|
3869
|
+
const rows = handle.db
|
|
3870
|
+
.prepare(
|
|
3871
|
+
"select image_sha256, argv_digest, seed, count(*) as observation_count from anno_evid_exec group by image_sha256, argv_digest, seed order by image_sha256, argv_digest, seed",
|
|
3872
|
+
)
|
|
3873
|
+
.all() as { image_sha256: string; argv_digest: string; seed: string; observation_count: number }[];
|
|
3874
|
+
return {
|
|
3875
|
+
runs: rows.map((row) => ({
|
|
3876
|
+
imageSha256: row.image_sha256,
|
|
3877
|
+
argvDigest: row.argv_digest,
|
|
3878
|
+
seed: row.seed,
|
|
3879
|
+
observationCount: row.observation_count,
|
|
3880
|
+
})),
|
|
3881
|
+
denominator: ADDRESS_MAX - ADDRESS_MIN + 1,
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/**
|
|
3886
|
+
* Deletes every `anno_evid_exec` row for one run identity -- a bracket
|
|
3887
|
+
* reset. A run identity holding no rows returns `changed: false`
|
|
3888
|
+
* and is NOT an error: resetting an empty bracket is the ordinary thing,
|
|
3889
|
+
* matching `clearEnumUsage`'s own direction for the identical case.
|
|
3890
|
+
*
|
|
3891
|
+
* THIS IS THE MODULE'S FIFTH ROW-DELETING STATEMENT. `removeScope`'s doc
|
|
3892
|
+
* block (`anno-store.ts`) states the count as four; this one supersedes it.
|
|
3893
|
+
* The count is written in prose, deliberately without spelling the SQL
|
|
3894
|
+
* prefix a census greps for, so a `grep` over this module counts STATEMENTS
|
|
3895
|
+
* and not the sentences describing them. This statement runs inside the
|
|
3896
|
+
* write sequence's transaction, so a refusal raised anywhere in the
|
|
3897
|
+
* sequence rolls it back with everything else -- and a refusal here can
|
|
3898
|
+
* only come from validating the run-identity arguments themselves, since
|
|
3899
|
+
* deleting zero rows is success, not an error.
|
|
3900
|
+
*/
|
|
3901
|
+
export function deleteExecObservationsForRun(
|
|
3902
|
+
handle: AnnoStoreHandle,
|
|
3903
|
+
args: { imageSha256: unknown; argvDigest: unknown; seed: unknown; baseRevision?: number },
|
|
3904
|
+
): AnnoWriteResult {
|
|
3905
|
+
const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
|
|
3906
|
+
const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
|
|
3907
|
+
const seed = assertRunIdentitySeed(args.seed);
|
|
3908
|
+
|
|
3909
|
+
const { revision, result } = applyWrite(
|
|
3910
|
+
handle,
|
|
3911
|
+
(db) => {
|
|
3912
|
+
const info = db.prepare("delete from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ?").run(imageSha256, argvDigest, seed);
|
|
3913
|
+
return Number(info.changes) > 0;
|
|
3914
|
+
},
|
|
3915
|
+
{ baseRevision: args.baseRevision },
|
|
3916
|
+
);
|
|
3917
|
+
return { revision, changed: result };
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3409
3920
|
/**
|
|
3410
3921
|
* Records ONE NON-DERIVABLE cross-reference.
|
|
3411
3922
|
*
|
|
3412
3923
|
* THIS IS THE C-5 RECONCILIATION, written down here for a reader of the code
|
|
3413
3924
|
* rather than left in a plan. Two requirement texts look like they conflict:
|
|
3414
|
-
*
|
|
3925
|
+
* one requires cross-reference rows to carry their access kind from the
|
|
3415
3926
|
* first write, while the cross-reference criterion requires references to be
|
|
3416
3927
|
* DERIVED on every query and never cached on disk. Both hold at once, and this
|
|
3417
3928
|
* entry point is where:
|
|
3418
3929
|
*
|
|
3419
3930
|
* * the table and its `access_kind` column exist from the first write (the
|
|
3420
|
-
* `DDL` above), so
|
|
3931
|
+
* `DDL` above), so that requirement is satisfied structurally;
|
|
3421
3932
|
* * the only rows ever written here are references that CANNOT be recovered
|
|
3422
3933
|
* from the bytes -- hand-asserted, or resolved from something outside the
|
|
3423
3934
|
* program image. The `COMPUTED_JUMP` case is exactly that: a computed
|