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