@cavelang/store 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +884 -15
  2. package/dist/src/backup.d.ts.map +1 -1
  3. package/dist/src/backup.js +143 -70
  4. package/dist/src/backup.js.map +1 -1
  5. package/dist/src/capture-claim.d.ts +4 -0
  6. package/dist/src/capture-claim.d.ts.map +1 -0
  7. package/dist/src/capture-claim.js +26 -0
  8. package/dist/src/capture-claim.js.map +1 -0
  9. package/dist/src/descriptor.d.ts +3 -0
  10. package/dist/src/descriptor.d.ts.map +1 -0
  11. package/dist/src/descriptor.js +21 -0
  12. package/dist/src/descriptor.js.map +1 -0
  13. package/dist/src/error-message.d.ts +3 -0
  14. package/dist/src/error-message.d.ts.map +1 -0
  15. package/dist/src/error-message.js +10 -0
  16. package/dist/src/error-message.js.map +1 -0
  17. package/dist/src/locate.d.ts.map +1 -1
  18. package/dist/src/locate.js +33 -13
  19. package/dist/src/locate.js.map +1 -1
  20. package/dist/src/node-adapter.d.ts.map +1 -1
  21. package/dist/src/node-adapter.js +29 -1
  22. package/dist/src/node-adapter.js.map +1 -1
  23. package/dist/src/provenance.d.ts +4 -0
  24. package/dist/src/provenance.d.ts.map +1 -1
  25. package/dist/src/provenance.js +36 -1
  26. package/dist/src/provenance.js.map +1 -1
  27. package/dist/src/query-sql.d.ts.map +1 -1
  28. package/dist/src/query-sql.js +4 -2
  29. package/dist/src/query-sql.js.map +1 -1
  30. package/dist/src/read-snapshot.d.ts +4 -0
  31. package/dist/src/read-snapshot.d.ts.map +1 -0
  32. package/dist/src/read-snapshot.js +22 -0
  33. package/dist/src/read-snapshot.js.map +1 -0
  34. package/dist/src/record.d.ts.map +1 -1
  35. package/dist/src/record.js +102 -25
  36. package/dist/src/record.js.map +1 -1
  37. package/dist/src/resolve.d.ts.map +1 -1
  38. package/dist/src/resolve.js +42 -18
  39. package/dist/src/resolve.js.map +1 -1
  40. package/dist/src/row.d.ts.map +1 -1
  41. package/dist/src/row.js +45 -17
  42. package/dist/src/row.js.map +1 -1
  43. package/dist/src/runtime.d.ts.map +1 -1
  44. package/dist/src/runtime.js +8 -4
  45. package/dist/src/runtime.js.map +1 -1
  46. package/dist/src/schema.d.ts +1 -2
  47. package/dist/src/schema.d.ts.map +1 -1
  48. package/dist/src/schema.js +116 -15
  49. package/dist/src/schema.js.map +1 -1
  50. package/dist/src/store.d.ts +17 -5
  51. package/dist/src/store.d.ts.map +1 -1
  52. package/dist/src/store.js +451 -104
  53. package/dist/src/store.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/backup.ts +141 -65
  56. package/src/capture-claim.ts +30 -0
  57. package/src/descriptor.ts +19 -0
  58. package/src/error-message.ts +8 -0
  59. package/src/locate.ts +31 -12
  60. package/src/node-adapter.ts +28 -1
  61. package/src/provenance.ts +33 -1
  62. package/src/query-sql.ts +4 -2
  63. package/src/read-snapshot.ts +21 -0
  64. package/src/record.ts +102 -26
  65. package/src/resolve.ts +43 -20
  66. package/src/row.ts +29 -2
  67. package/src/runtime.ts +8 -4
  68. package/src/schema.ts +117 -18
  69. package/src/store.ts +452 -125
package/dist/src/store.js CHANGED
@@ -19,6 +19,9 @@
19
19
  */
20
20
  import { Claim, Context, Key, Uuidv7, Verb } from '@cavelang/core';
21
21
  import * as Canonical from '@cavelang/canonical';
22
+ import { readSnapshot } from "./read-snapshot.js";
23
+ import { errorMessage } from "./error-message.js";
24
+ import { captureClaim } from "./capture-claim.js";
22
25
  import * as QuerySql from "./query-sql.js";
23
26
  import * as Resolve from "./resolve.js";
24
27
  import * as Row from "./row.js";
@@ -27,6 +30,34 @@ import * as Sensitivity from "./sensitivity.js";
27
30
  import * as Provenance from "./provenance.js";
28
31
  import * as Record from "./record.js";
29
32
  const currentSql = QuerySql.current();
33
+ const booleanOption = (value, name) => {
34
+ if (value !== undefined && typeof value !== 'boolean') {
35
+ throw new TypeError(`${name} must be a boolean`);
36
+ }
37
+ return value === true;
38
+ };
39
+ const validateSensitivity = (maximum) => {
40
+ if (maximum !== undefined && Sensitivity.parse(maximum) === undefined) {
41
+ throw new TypeError('maxSensitivity must be public, internal, confidential or restricted');
42
+ }
43
+ };
44
+ const validateEdgeRole = (role) => {
45
+ if (role !== 'WHEN' && role !== 'VIA' && role !== 'BECAUSE' && role !== 'QUALIFIES') {
46
+ throw new TypeError('CAVE edge role must be WHEN, VIA, BECAUSE or QUALIFIES');
47
+ }
48
+ };
49
+ /** Reject asynchronous callbacks without starting custom thenable work. */
50
+ const requireSynchronous = (result, message) => {
51
+ if (result instanceof Promise ||
52
+ (result !== null && (typeof result === 'object' || typeof result === 'function') &&
53
+ typeof result.then === 'function')) {
54
+ try {
55
+ void Promise.prototype.then.call(result, undefined, () => { });
56
+ }
57
+ catch { /* Non-native thenables have no promise rejection to observe. */ }
58
+ throw new TypeError(message);
59
+ }
60
+ };
30
61
  /**
31
62
  * The closure walked from a seed entity — the seed is the query's first
32
63
  * positional parameter. Requires `alias_edge` in scope.
@@ -65,23 +96,35 @@ export const defaultDbPath = () => process.env['CAVE_DB'] ?? 'cave.db';
65
96
  * start. `access` decides whether the open may create or migrate.
66
97
  */
67
98
  export const open = (adapter, path = ':memory:', options = {}) => {
68
- const access = options.access ?? 'migrate';
99
+ const { access = 'migrate' } = options;
100
+ if (access !== 'read-only' && access !== 'no-migrate' && access !== 'migrate') {
101
+ throw new TypeError('store access must be read-only, no-migrate or migrate');
102
+ }
69
103
  const db = adapter.open(path, access === 'read-only' ? { readOnly: true } : {});
104
+ try {
105
+ return initializeStore(adapter, db, options, access);
106
+ }
107
+ catch (error) {
108
+ try {
109
+ db.close();
110
+ }
111
+ catch (closeError) {
112
+ throw new AggregateError([error, closeError], `CAVE store initialization failed: ${errorMessage(error)}; database cleanup also failed: ${errorMessage(closeError)}`, { cause: error });
113
+ }
114
+ throw error;
115
+ }
116
+ };
117
+ /** The caller retains database ownership until initialization succeeds. */
118
+ const initializeStore = (adapter, db, options, access) => {
70
119
  // Concurrent writers wait for the database allocation lock instead of
71
120
  // failing immediately with SQLITE_BUSY.
72
121
  db.exec('PRAGMA busy_timeout = 5000');
73
122
  db.exec('PRAGMA foreign_keys = ON');
74
- try {
75
- if (access === 'migrate') {
76
- Schema.init(db, adapter.capabilities);
77
- }
78
- else {
79
- Schema.check(db);
80
- }
123
+ if (access === 'migrate') {
124
+ Schema.init(db, adapter.capabilities);
81
125
  }
82
- catch (error) {
83
- db.close();
84
- throw error;
126
+ else {
127
+ Schema.check(db);
85
128
  }
86
129
  // The receive rule (spec §28.2): the store, not the wall clock, is the
87
130
  // monotonic authority — every append outsorts every tx already stored,
@@ -96,6 +139,9 @@ export const open = (adapter, path = ':memory:', options = {}) => {
96
139
  observeMaxTx();
97
140
  const baseRegistry = options.registry ?? Canonical.standardRegistry;
98
141
  let registry = baseRegistry;
142
+ const dataVersionStatement = db.prepare('PRAGMA data_version');
143
+ const dataVersion = () => dataVersionStatement.get().data_version;
144
+ let registryDataVersion = dataVersion();
99
145
  const insertClaim = db.prepare(`
100
146
  INSERT INTO cave_claim (
101
147
  id, tx, subject, verb, negated, object, attribute,
@@ -108,6 +154,9 @@ export const open = (adapter, path = ':memory:', options = {}) => {
108
154
  const insertProvenance = db.prepare('INSERT OR IGNORE INTO cave_provenance (claim_id, dimension, value) VALUES (?, ?, ?)');
109
155
  const insertTag = db.prepare('INSERT INTO cave_tag (claim_id, key, value) VALUES (?, ?, ?)');
110
156
  const insertEdge = db.prepare('INSERT INTO cave_edge (parent_id, role, child_id) VALUES (?, ?, ?)');
157
+ const declarationAt = db.prepare(`SELECT 1 FROM cave_claim
158
+ WHERE id = ? AND negated = 0 AND object IS NOT NULL
159
+ AND (verb IN ('REVERSE', 'RENAMED-TO') OR (verb = 'IS' AND object = 'verb'))`);
111
160
  const insertFts = db.prepare(`
112
161
  INSERT INTO cave_fts (claim_id, subject, verb, object, attribute, value_text, comment, raw_line)
113
162
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -124,25 +173,36 @@ export const open = (adapter, path = ':memory:', options = {}) => {
124
173
  const declarations = db.prepare(`
125
174
  SELECT subject, verb, object FROM cave_claim
126
175
  WHERE negated = 0 AND object IS NOT NULL AND verb IN ('REVERSE', 'RENAMED-TO', 'IS')
176
+ AND (verb <> 'IS' OR object = 'verb')
127
177
  AND id NOT IN (SELECT child_id FROM cave_edge WHERE role IN ('WHEN', 'VIA', 'BECAUSE'))
128
178
  ORDER BY tx
129
179
  `).all();
180
+ let rebuilt = baseRegistry;
130
181
  for (const declaration of declarations) {
131
182
  if (!Verb.isVerbToken(declaration.subject)) {
132
183
  continue;
133
184
  }
134
185
  if (declaration.verb === 'REVERSE' && Verb.isVerbToken(declaration.object)) {
135
- registry = Canonical.Registry.declareReverse(registry, declaration.subject, declaration.object).registry;
186
+ rebuilt = Canonical.Registry.declareReverse(rebuilt, declaration.subject, declaration.object).registry;
136
187
  }
137
188
  else if (declaration.verb === 'RENAMED-TO' && Verb.isVerbToken(declaration.object)) {
138
- registry = Canonical.Registry.declareRename(registry, declaration.subject, declaration.object).registry;
189
+ rebuilt = Canonical.Registry.declareRename(rebuilt, declaration.subject, declaration.object).registry;
139
190
  }
140
191
  else if (declaration.verb === 'IS' && declaration.object === 'verb') {
141
- registry = Canonical.Registry.declareVerb(registry, declaration.subject);
192
+ rebuilt = Canonical.Registry.declareVerb(rebuilt, declaration.subject);
142
193
  }
143
194
  }
195
+ registry = rebuilt;
144
196
  };
145
197
  rebuildRegistry();
198
+ /** Refresh external commits without a write transaction; also works on read-only connections. */
199
+ const refreshRegistry = () => {
200
+ const currentVersion = dataVersion();
201
+ if (currentVersion !== registryDataVersion) {
202
+ rebuildRegistry();
203
+ registryDataVersion = currentVersion;
204
+ }
205
+ };
146
206
  /**
147
207
  * The outer transaction takes SQLite's write-reservation lock before any
148
208
  * transaction id is allocated, serializing concurrent processes. Nested
@@ -156,25 +216,35 @@ export const open = (adapter, path = ':memory:', options = {}) => {
156
216
  const savepoint = `cave_tx_${transactionDepth}`;
157
217
  transactionDepth += 1;
158
218
  const savedRegistry = registry;
219
+ const savedRegistryDataVersion = registryDataVersion;
159
220
  let started = false;
160
221
  try {
161
222
  db.exec(outer ? 'BEGIN IMMEDIATE' : `SAVEPOINT ${savepoint}`);
162
223
  started = true;
163
- const result = body();
224
+ const result = body({ outermost: outer });
225
+ requireSynchronous(result, 'CAVE transaction callback must be synchronous');
164
226
  db.exec(outer ? 'COMMIT' : `RELEASE ${savepoint}`);
165
227
  return result;
166
228
  }
167
229
  catch (error) {
168
- if (started) {
169
- if (outer) {
170
- db.exec('ROLLBACK');
171
- }
172
- else {
173
- db.exec(`ROLLBACK TO ${savepoint}`);
174
- db.exec(`RELEASE ${savepoint}`);
230
+ try {
231
+ if (started) {
232
+ if (outer) {
233
+ db.exec('ROLLBACK');
234
+ }
235
+ else {
236
+ db.exec(`ROLLBACK TO ${savepoint}`);
237
+ db.exec(`RELEASE ${savepoint}`);
238
+ }
175
239
  }
176
240
  }
177
- registry = savedRegistry;
241
+ catch (rollbackError) {
242
+ throw new AggregateError([error, rollbackError], `CAVE transaction failed: ${errorMessage(error)}; rollback also failed: ${errorMessage(rollbackError)}`, { cause: error });
243
+ }
244
+ finally {
245
+ registry = savedRegistry;
246
+ registryDataVersion = savedRegistryDataVersion;
247
+ }
178
248
  throw error;
179
249
  }
180
250
  finally {
@@ -185,16 +255,88 @@ export const open = (adapter, path = ':memory:', options = {}) => {
185
255
  const edgeExists = db.prepare('SELECT 1 FROM cave_edge WHERE parent_id = ? AND role = ? AND child_id = ?');
186
256
  /** Appends a canonicalization result — one row per claim, per-row tx. */
187
257
  const insertResult = (result, options_ = {}) => transaction(() => {
258
+ // Validate and insert from the same caller values, including replay IDs.
259
+ const { ids: replayIds, contexts, source, lifecycle, provenance } = options_;
260
+ if (source !== undefined && typeof source !== 'string')
261
+ throw new TypeError('append source must be a string');
262
+ if (provenance !== undefined && (provenance === null || typeof provenance !== 'object' || Array.isArray(provenance))) {
263
+ throw new TypeError('append provenance must be an object');
264
+ }
265
+ const { actor, sources, run, domains } = provenance ?? {};
266
+ for (const [name, collection] of [
267
+ ['ids', replayIds], ['contexts', contexts],
268
+ ['provenance.sources', sources], ['provenance.domains', domains],
269
+ ]) {
270
+ if (collection !== undefined && !Array.isArray(collection))
271
+ throw new TypeError(`append ${name} must be an array`);
272
+ }
273
+ options_ = {
274
+ ids: replayIds === undefined ? undefined : Array.from(replayIds),
275
+ contexts: contexts === undefined ? undefined : Array.from(contexts),
276
+ source, lifecycle: booleanOption(lifecycle, 'append lifecycle'),
277
+ provenance: provenance === undefined ? undefined : {
278
+ actor, run,
279
+ sources: sources === undefined ? undefined : Array.from(sources),
280
+ domains: domains === undefined ? undefined : Array.from(domains),
281
+ },
282
+ };
283
+ for (const [name, value] of [['actor', actor], ['run', run]]) {
284
+ if (value !== undefined && typeof value !== 'string')
285
+ throw new TypeError(`append provenance.${name} must be a string`);
286
+ }
287
+ for (const name of ['sources', 'domains']) {
288
+ for (const value of options_.provenance?.[name] ?? []) {
289
+ if (typeof value !== 'string')
290
+ throw new TypeError(`append provenance.${name} entries must be strings`);
291
+ }
292
+ }
293
+ const preparedEdges = Array.from(result.edges, ({ parent, role, child }) => ({ parent, role, child }));
294
+ // Validate the whole replay before even observing a valid earlier ID:
295
+ // rejected batches must not change ordering state or partially append.
296
+ const prepared = result.claims.map((entry, index) => {
297
+ const id = options_.ids?.[index];
298
+ if (id !== undefined && (typeof id !== 'string' || !Uuidv7.is(id))) {
299
+ throw new Error(`CAVE replay claim ${index + 1}: expected a canonical lowercase UUIDv7 id`);
300
+ }
301
+ const authored = addContexts(captureClaim(entry.claim), options_.contexts);
302
+ const claim = stampSource(authored, options_.source, options_.lifecycle === true);
303
+ const provided = options_.provenance ?? {};
304
+ const provenance = Provenance.entries(authored.contexts, {
305
+ ...provided,
306
+ actor: provided.actor ?? options_.source,
307
+ run: provided.run ?? (options_.lifecycle === true ? options_.source : undefined)
308
+ });
309
+ const columns = Row.toColumns(claim);
310
+ const emitted = Canonical.emitClaim(claim);
311
+ const rawLine = columns.rawLine === '' ? emitted : columns.rawLine;
312
+ const textValues = [
313
+ ...Object.values(columns), rawLine, ...claim.contexts,
314
+ ...provenance.map(entry => entry.value),
315
+ ...claim.tags.flatMap(tag => [tag.key, tag.value])
316
+ ];
317
+ if (textValues.some(value => typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value))) {
318
+ throw new TypeError(`CAVE claim ${index + 1}: unpaired UTF-16 surrogate cannot be stored as UTF-8`);
319
+ }
320
+ return { claim, provenance, columns, rawLine };
321
+ });
188
322
  const ids = [];
323
+ for (const edge of preparedEdges) {
324
+ validateEdgeRole(edge.role);
325
+ for (const [field, index] of [['parent', edge.parent], ['child', edge.child]]) {
326
+ if (!Number.isSafeInteger(index) || index < 0 || index >= prepared.length) {
327
+ throw new TypeError(`CAVE edge ${field} must index an existing claim`);
328
+ }
329
+ }
330
+ }
189
331
  const replay = options_.ids !== undefined;
190
332
  let skipped = 0;
191
333
  // BEGIN IMMEDIATE makes this read and all following inserts one
192
334
  // database-serialized allocation step. A process that opened before a
193
335
  // fast-clock peer wrote now observes that peer before minting (§28.2).
194
- if (result.claims.some((_, index) => options_.ids?.[index] === undefined)) {
336
+ if (prepared.some((_, index) => options_.ids?.[index] === undefined)) {
195
337
  observeMaxTx();
196
338
  }
197
- result.claims.forEach((entry, index) => {
339
+ prepared.forEach(({ claim, provenance, columns, rawLine }, index) => {
198
340
  const explicit = options_.ids?.[index];
199
341
  if (explicit !== undefined) {
200
342
  // Identity replay (spec §28.1): the id is the row — present means
@@ -206,17 +348,7 @@ export const open = (adapter, path = ':memory:', options = {}) => {
206
348
  return;
207
349
  }
208
350
  }
209
- const authored = addContexts(entry.claim, options_.contexts);
210
- const claim = stampSource(authored, options_.source, options_.lifecycle === true);
211
- const provided = options_.provenance ?? {};
212
- const provenance = Provenance.entries(authored.contexts, {
213
- ...provided,
214
- actor: provided.actor ?? options_.source,
215
- run: provided.run ?? (options_.lifecycle === true ? options_.source : undefined)
216
- });
217
351
  const id = explicit ?? Uuidv7.next();
218
- const columns = Row.toColumns(claim);
219
- const rawLine = columns.rawLine === '' ? Canonical.emitClaim(claim) : columns.rawLine;
220
352
  insertClaim.run(id, id, columns.subject, columns.verb, columns.negated, columns.object, columns.attribute, columns.valueText, columns.valueNum, columns.valueUnit, columns.valueApprox, columns.deltaText, columns.deltaNum, columns.deltaUnit, columns.sigmaLevel, columns.conf, columns.importance, columns.comment, rawLine, Key.of(claim));
221
353
  for (const context of claim.contexts) {
222
354
  insertContext.run(id, context);
@@ -231,7 +363,7 @@ export const open = (adapter, path = ':memory:', options = {}) => {
231
363
  ids.push(id);
232
364
  });
233
365
  let edges = 0;
234
- for (const edge of result.edges) {
366
+ for (const edge of preparedEdges) {
235
367
  const parentId = ids[edge.parent];
236
368
  const childId = ids[edge.child];
237
369
  if (replay && edgeExists.get(parentId, edge.role, childId) !== undefined) {
@@ -243,13 +375,22 @@ export const open = (adapter, path = ':memory:', options = {}) => {
243
375
  registry = result.registry;
244
376
  return { ids, edges, skipped, problems: result.problems };
245
377
  });
246
- const rows = (sql, ...params) => db.prepare(sql).all(...params);
378
+ const assertReadUnicode = (...values) => {
379
+ if (values.some(value => typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value))) {
380
+ throw new TypeError('CAVE read: unpaired UTF-16 surrogate cannot be queried as UTF-8');
381
+ }
382
+ };
383
+ const rows = (sql, ...params) => {
384
+ assertReadUnicode(...params);
385
+ return db.prepare(sql).all(...params);
386
+ };
247
387
  const contextsOf = (id) => db.prepare('SELECT context FROM cave_context WHERE claim_id = ?').all(id)
248
388
  .map(row => row.context);
249
389
  const tagsOf = (id) => db.prepare('SELECT key, value FROM cave_tag WHERE claim_id = ?').all(id);
250
390
  const toClaim = (row) => Row.toClaim(row, contextsOf(row.id), tagsOf(row.id));
251
391
  const provenanceOf = (row) => {
252
392
  const id = typeof row === 'string' ? row : row.id;
393
+ assertReadUnicode(id);
253
394
  const entries = db.prepare(`
254
395
  SELECT dimension, value FROM cave_provenance WHERE claim_id = ? ORDER BY dimension, value
255
396
  `).all(id);
@@ -280,13 +421,29 @@ export const open = (adapter, path = ':memory:', options = {}) => {
280
421
  const universe = (options) => options.resolve === true ?
281
422
  Resolve.resolvedSql(readPolicy(), currentSql, { aliases: options.aliases === true }) :
282
423
  currentSql;
424
+ const traverseRead = (options, read, operation = 'resolution') => {
425
+ const { negated, retracted, aliases, resolve } = options;
426
+ const captured = {
427
+ negated: booleanOption(negated, 'traversal negated'),
428
+ retracted: booleanOption(retracted, 'traversal retracted'),
429
+ aliases: booleanOption(aliases, 'traversal aliases'),
430
+ resolve: booleanOption(resolve, 'traversal resolve')
431
+ };
432
+ return resolve === true || operation === 'reverse' ?
433
+ readSnapshot(db, operation, () => read(captured)) : read(captured);
434
+ };
435
+ const closeCallbacks = new Set();
436
+ let closed = false;
283
437
  return {
284
438
  /** Adapter that owns this database connection and its capabilities. */
285
439
  adapter,
286
440
  /** Raw adapter database handle — used by `@cavelang/query`; treat as read-only. */
287
441
  db,
288
- /** Current verb registry (input registry + stored + ingested declarations). */
289
- registry: () => registry,
442
+ /** Current verb registry; refreshes declarations committed by other connections. */
443
+ registry: () => {
444
+ refreshRegistry();
445
+ return registry;
446
+ },
290
447
  /** Configured registry before any in-band declarations are applied. */
291
448
  baseRegistry: () => baseRegistry,
292
449
  /**
@@ -296,14 +453,17 @@ export const open = (adapter, path = ':memory:', options = {}) => {
296
453
  * reopening.
297
454
  */
298
455
  reloadRegistry() {
299
- registry = baseRegistry;
456
+ const currentVersion = dataVersion();
300
457
  rebuildRegistry();
458
+ registryDataVersion = currentVersion;
301
459
  },
302
460
  /**
303
461
  * Runs `body` atomically; throwing rolls everything back, including
304
462
  * nested appends and their registry declarations. Nestable
305
463
  * (savepoints) — the write gate wraps ingest + check in one of these
306
464
  * (spec §20.3).
465
+ * The callback receives `outermost`: only that scope commits the
466
+ * database; a nested scope merely releases its savepoint.
307
467
  */
308
468
  transaction,
309
469
  /**
@@ -313,23 +473,34 @@ export const open = (adapter, path = ':memory:', options = {}) => {
313
473
  * (spec §9.5).
314
474
  */
315
475
  ingest(text, options_ = {}) {
316
- const result = Canonical.canonicalizeText(text, registry);
317
- if (options_.strict === true && result.problems.length > 0) {
318
- const detail = result.problems.map(problem => ` line ${problem.line}: ${problem.message}`).join('\n');
319
- throw new Error(`CAVE ingest failed with ${result.problems.length} problem(s):\n${detail}`);
320
- }
321
- return insertResult(result, options_);
476
+ const strict = booleanOption(options_.strict, 'ingest strict');
477
+ return transaction(() => {
478
+ // data_version changes for commits from other connections, not our own
479
+ // appends. Check under the write reservation before using the registry.
480
+ refreshRegistry();
481
+ const result = Canonical.canonicalizeText(text, registry);
482
+ if (strict && result.problems.length > 0) {
483
+ const detail = result.problems.map(problem => ` line ${problem.line}: ${problem.message}`).join('\n');
484
+ throw new Error(`CAVE ingest failed with ${result.problems.length} problem(s):\n${detail}`);
485
+ }
486
+ return insertResult(result, options_);
487
+ });
322
488
  },
323
489
  /** Appends an already-canonicalized result. */
324
490
  insertResult,
325
491
  /** Latest row per claim key (spec §13.5), oldest first. */
326
492
  currentBeliefs(options_ = {}) {
327
- return options_.minConf === undefined ?
493
+ const minConf = options_.minConf;
494
+ if (minConf !== undefined && (!Number.isFinite(minConf) || minConf < 0 || minConf > 1)) {
495
+ throw new TypeError('currentBeliefs minConf must be finite and in 0..1');
496
+ }
497
+ return minConf === undefined ?
328
498
  rows(`${currentSql} ORDER BY c.tx`) :
329
- rows(`${currentSql} WHERE c.conf >= ? ORDER BY c.tx`, options_.minConf);
499
+ rows(`${currentSql} WHERE c.conf >= ? ORDER BY c.tx`, minConf);
330
500
  },
331
501
  /** Current belief for one claim key, `undefined` if the fact is unknown. */
332
502
  currentBelief(claimKey) {
503
+ assertReadUnicode(claimKey);
333
504
  const row = db.prepare('SELECT * FROM cave_claim WHERE claim_key = ? ORDER BY tx DESC LIMIT 1').get(claimKey);
334
505
  return row;
335
506
  },
@@ -353,9 +524,9 @@ export const open = (adapter, path = ':memory:', options = {}) => {
353
524
  * `aliases`, groups widen through the alias closure (spec §26.1).
354
525
  */
355
526
  resolvedBeliefs(options_ = {}) {
356
- const aliases = options_.aliases === true;
527
+ const aliases = booleanOption(options_.aliases, 'resolvedBeliefs aliases');
357
528
  const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : '';
358
- return rows(`${prefix}SELECT * FROM (${Resolve.resolvedSql(readPolicy(), currentSql, { aliases })}) ORDER BY tx`);
529
+ return readSnapshot(db, 'resolution', () => rows(`${prefix}SELECT * FROM (${Resolve.resolvedSql(readPolicy(), currentSql, { aliases })}) ORDER BY tx`));
359
530
  },
360
531
  /**
361
532
  * Contested facts (spec §26.4): resolution groups where more than one
@@ -365,20 +536,22 @@ export const open = (adapter, path = ':memory:', options = {}) => {
365
536
  * picking) and for the `cave resolve` view.
366
537
  */
367
538
  contested(options_ = {}) {
368
- const aliases = options_.aliases === true;
369
- const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : '';
370
- const ranked = rows(`${prefix}SELECT * FROM (${Resolve.rankedSql(readPolicy(), currentSql, { aliases })}) ORDER BY res_group, res_rank`);
371
- const groups = [];
372
- for (const row of ranked) {
373
- const last = groups[groups.length - 1];
374
- if (last !== undefined && last.group === row.res_group) {
375
- last.rows.push(row);
376
- }
377
- else {
378
- groups.push({ group: row.res_group, rows: [row] });
539
+ const aliases = booleanOption(options_.aliases, 'contested aliases');
540
+ return readSnapshot(db, 'resolution', () => {
541
+ const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : '';
542
+ const ranked = rows(`${prefix}SELECT * FROM (${Resolve.rankedSql(readPolicy(), currentSql, { aliases })}) ORDER BY res_group, res_rank`);
543
+ const groups = [];
544
+ for (const row of ranked) {
545
+ const last = groups[groups.length - 1];
546
+ if (last !== undefined && last.group === row.res_group) {
547
+ last.rows.push(row);
548
+ }
549
+ else {
550
+ groups.push({ group: row.res_group, rows: [row] });
551
+ }
379
552
  }
380
- }
381
- return groups.filter(group => group.rows.length > 1);
553
+ return groups.filter(group => group.rows.length > 1);
554
+ });
382
555
  },
383
556
  /**
384
557
  * The alias closure of an entity (spec §13.6): the entity itself plus
@@ -387,13 +560,15 @@ export const open = (adapter, path = ':memory:', options = {}) => {
387
560
  * removes that link. The queried name first, the rest sorted.
388
561
  */
389
562
  aliasesOf(entity) {
563
+ assertReadUnicode(entity);
390
564
  const names = db.prepare(`${aliasClosureSql} SELECT name FROM alias_closure WHERE name <> ? ORDER BY name`)
391
565
  .all(entity, entity);
392
566
  return [entity, ...names.map(row => row.name)];
393
567
  },
394
568
  /** All rows about an entity, both directions, newest first (spec §13.5). */
395
569
  claimsAbout(entity, options_ = {}) {
396
- return options_.aliases === true ?
570
+ const aliases = booleanOption(options_.aliases, 'claimsAbout aliases');
571
+ return aliases === true ?
397
572
  rows(`${aliasClosureSql} SELECT * FROM cave_claim
398
573
  WHERE subject IN (SELECT name FROM alias_closure) OR object IN (SELECT name FROM alias_closure)
399
574
  ORDER BY tx DESC`, entity) :
@@ -401,7 +576,9 @@ export const open = (adapter, path = ':memory:', options = {}) => {
401
576
  },
402
577
  /** Forward reads: current relational facts with `entity` as subject (spec §13.3). */
403
578
  forward(entity, options_ = {}) {
404
- return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, entity).map(row => ({ verb: row.verb, target: row.object, row }));
579
+ return traverseRead(options_, options_ => {
580
+ return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, entity).map(row => ({ verb: row.verb, target: row.object, row }));
581
+ });
405
582
  },
406
583
  /**
407
584
  * Inverse reads (spec §13.3): current relational facts
@@ -409,10 +586,13 @@ export const open = (adapter, path = ':memory:', options = {}) => {
409
586
  * when one is declared.
410
587
  */
411
588
  reverse(entity, options_ = {}) {
412
- return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, entity).map(row => {
413
- const rel = Canonical.Registry.inverseOf(registry, row.verb);
414
- return { verb: row.verb, ...rel === undefined ? {} : { rel }, source: row.subject, row };
415
- });
589
+ return traverseRead(options_, options_ => {
590
+ refreshRegistry();
591
+ return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, entity).map(row => {
592
+ const rel = Canonical.Registry.inverseOf(registry, row.verb);
593
+ return { verb: row.verb, ...rel === undefined ? {} : { rel }, source: row.subject, row };
594
+ });
595
+ }, 'reverse');
416
596
  },
417
597
  /** Flat tag (`value` omitted → `value IS NULL`) or scoped tag rows (spec §13.5). */
418
598
  byTag(key, value) {
@@ -442,11 +622,15 @@ export const open = (adapter, path = ':memory:', options = {}) => {
442
622
  recordOf,
443
623
  /** Members of a topic — forward `CONTAINS` traversal (spec §11.2). */
444
624
  topicMembers(topic, options_ = {}) {
445
- return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND verb = 'CONTAINS' AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, topic).map(row => row.object);
625
+ return traverseRead(options_, options_ => {
626
+ return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND verb = 'CONTAINS' AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`, topic).map(row => row.object);
627
+ });
446
628
  },
447
629
  /** Topics containing an entity — the inverse `CONTAINS` read (spec §11.2). */
448
630
  topicsOf(entity, options_ = {}) {
449
- return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND verb = 'CONTAINS'${traversalFilter(options_)} ORDER BY tx`, entity).map(row => row.subject);
631
+ return traverseRead(options_, options_ => {
632
+ return rows(`${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND verb = 'CONTAINS'${traversalFilter(options_)} ORDER BY tx`, entity).map(row => row.subject);
633
+ });
450
634
  },
451
635
  /**
452
636
  * Full-text search, newest first. The query is treated as a literal
@@ -454,16 +638,39 @@ export const open = (adapter, path = ':memory:', options = {}) => {
454
638
  * would otherwise parse as a column filter); pass `raw` to use full
455
639
  * FTS5 MATCH syntax. `limit` caps the rows inside the query itself,
456
640
  * so a broad search never materializes more than the caller reads.
641
+ * `currentOnly` selects non-retracted current rows before that cap;
642
+ * omission retains historical search.
457
643
  */
458
644
  search(query, options_ = {}) {
459
- const match = options_.raw === true ? query : `"${query.replaceAll('"', '""')}"`;
645
+ const { raw, limit, maxSensitivity, currentOnly } = options_;
646
+ validateSensitivity(maxSensitivity);
647
+ for (const [name, value] of [['raw', raw], ['currentOnly', currentOnly]]) {
648
+ if (value !== undefined && typeof value !== 'boolean') {
649
+ throw new TypeError(`search ${name} must be a boolean`);
650
+ }
651
+ }
652
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) {
653
+ throw new TypeError('search limit must be a non-negative safe integer');
654
+ }
655
+ // MATCH parsers treat NUL as a terminator, even in a bound SQL string.
656
+ if (query.includes('\0'))
657
+ throw new TypeError('search query must not contain NUL characters');
658
+ // FTS4 does not accept FTS5's doubled-quote escaping within a phrase.
659
+ // Quotes are token separators in both engines' default tokenizers.
660
+ const literal = adapter.capabilities.fullText === 'fts4' ?
661
+ query.replaceAll('"', ' ') : query.replaceAll('"', '""');
662
+ const match = raw === true ? query : `"${literal}"`;
663
+ // Probe the (claim_key, tx) index for each match rather than grouping
664
+ // every unrelated key. Newer rows count even when hidden or retracted.
460
665
  const sql = `
461
666
  SELECT c.* FROM cave_claim c JOIN cave_fts f ON c.id = f.claim_id
462
- WHERE cave_fts MATCH ?${options_.maxSensitivity === undefined ? '' : ` AND ${Sensitivity.sql('c', options_.maxSensitivity)}`}
667
+ WHERE cave_fts MATCH ?${currentOnly === true ? ` AND c.conf > 0 AND NOT EXISTS (
668
+ SELECT 1 FROM cave_claim newer WHERE newer.claim_key = c.claim_key AND newer.tx > c.tx
669
+ )` : ''}${maxSensitivity === undefined ? '' : ` AND ${Sensitivity.sql('c', maxSensitivity)}`}
463
670
  ORDER BY c.tx DESC`;
464
- return options_.limit === undefined ?
671
+ return limit === undefined ?
465
672
  rows(sql, match) :
466
- rows(`${sql} LIMIT ?`, match, options_.limit);
673
+ rows(`${sql} LIMIT ?`, match, limit);
467
674
  },
468
675
  /**
469
676
  * Appends edges between *existing* claim rows (spec §13.2) — the
@@ -474,14 +681,28 @@ export const open = (adapter, path = ':memory:', options = {}) => {
474
681
  * keys reject unknown ids.
475
682
  */
476
683
  appendEdges(edges) {
684
+ edges = Array.from(edges, ({ parentId, role, childId }) => ({ parentId, role, childId }));
685
+ for (const edge of edges)
686
+ validateEdgeRole(edge.role);
477
687
  transaction(() => {
688
+ let changedDeclarations = false;
478
689
  for (const edge of edges) {
479
690
  insertEdge.run(edge.parentId, edge.role, edge.childId);
691
+ if (!changedDeclarations && (edge.role === 'WHEN' || edge.role === 'VIA' || edge.role === 'BECAUSE')) {
692
+ changedDeclarations = declarationAt.get(edge.childId) !== undefined;
693
+ }
694
+ }
695
+ if (changedDeclarations) {
696
+ // Qualifier children do not contribute in-band declarations. Keep
697
+ // this connection consistent with registry replay after reopening.
698
+ rebuildRegistry();
699
+ registryDataVersion = dataVersion();
480
700
  }
481
701
  });
482
702
  },
483
703
  /** Qualifier/grouping edges of a claim row (spec §13.2). */
484
704
  edgesOf(parentId) {
705
+ assertReadUnicode(parentId);
485
706
  return db.prepare(`
486
707
  SELECT e.role AS role, c.* FROM cave_edge e JOIN cave_claim c ON c.id = e.child_id
487
708
  WHERE e.parent_id = ?`).all(parentId).map(({ role, ...child }) => ({ role, child: child }));
@@ -492,8 +713,9 @@ export const open = (adapter, path = ':memory:', options = {}) => {
492
713
  * Emits the store as canonical CAVE text — all rows in transaction
493
714
  * order, or only current beliefs with `current`. With `tx`, every
494
715
  * claim line is preceded by its §28.4 transaction annotation
495
- * (`;@ <tx>`), so the text carries row identity: `cave sync` replays
496
- * it idempotently, plain `cave import` reads it unchanged.
716
+ * (`;@ <tx>` with optional JSON provenance), so text carries identity
717
+ * and exact dimensions: `cave sync` replays it idempotently, while plain
718
+ * `cave import` ignores annotation metadata.
497
719
  *
498
720
  * In current-only export an edge endpoint may be a superseded row;
499
721
  * dropping such edges would silently un-condition current claims and
@@ -502,39 +724,164 @@ export const open = (adapter, path = ':memory:', options = {}) => {
502
724
  * resulting edges are deduplicated.
503
725
  */
504
726
  exportText(options_ = {}) {
505
- const current = options_.current === true;
506
- const maximum = options_.maxSensitivity ?? Sensitivity.defaultMaximum;
507
- const claimRows = current ?
508
- rows(`${currentSql} WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`) :
509
- rows(`SELECT c.* FROM cave_claim c WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`);
510
- const indexById = new Map(claimRows.map((row, index) => [row.id, index]));
511
- let resolve = (id) => indexById.get(id);
512
- if (current) {
513
- const indexByKey = new Map(claimRows.map((row, index) => [row.claim_key, index]));
514
- const keyById = new Map(db.prepare('SELECT id, claim_key FROM cave_claim').all()
515
- .map(row => [row.id, row.claim_key]));
516
- resolve = id => indexById.get(id) ?? indexByKey.get(keyById.get(id) ?? '');
727
+ const current = options_.current;
728
+ const maximum = options_.maxSensitivity;
729
+ const tx = options_.tx;
730
+ validateSensitivity(maximum);
731
+ for (const [name, value] of [['current', current], ['tx', tx]]) {
732
+ if (value !== undefined && typeof value !== 'boolean') {
733
+ throw new TypeError(`exportText ${name} must be a boolean`);
734
+ }
517
735
  }
518
- const claims = claimRows.map(row => ({ claim: toClaim(row), line: 0 }));
519
- const edgeRows = db.prepare('SELECT parent_id, role, child_id FROM cave_edge').all();
520
- const seen = new Set();
521
- const edges = edgeRows.flatMap(edge => {
522
- const parent = resolve(edge.parent_id);
523
- const child = resolve(edge.child_id);
524
- if (parent === undefined || child === undefined || parent === child) {
525
- return [];
736
+ return readSnapshot(db, 'export', () => {
737
+ const claimRows = current ?
738
+ rows(`${currentSql} WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`) :
739
+ rows(`SELECT c.* FROM cave_claim c WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`);
740
+ if (claimRows.length === 0)
741
+ return '';
742
+ const indexById = new Map(claimRows.map((row, index) => [row.id, index]));
743
+ let resolve = (id) => indexById.get(id);
744
+ if (current) {
745
+ let historicalIndex;
746
+ resolve = id => {
747
+ const currentIndex = indexById.get(id);
748
+ if (currentIndex !== undefined)
749
+ return currentIndex;
750
+ if (historicalIndex === undefined) {
751
+ const indexByKey = new Map(claimRows.map((row, index) => [row.claim_key, index]));
752
+ const keyById = new Map(db.prepare(`SELECT id, claim_key FROM cave_claim WHERE id IN (
753
+ SELECT parent_id FROM cave_edge UNION SELECT child_id FROM cave_edge
754
+ )`).all()
755
+ .map(row => [row.id, row.claim_key]));
756
+ const verified = new Set();
757
+ const historicalRow = db.prepare('SELECT * FROM cave_claim WHERE id = ?');
758
+ const historicalContexts = db.prepare('SELECT context FROM cave_context WHERE claim_id = ?');
759
+ const historicalTags = db.prepare('SELECT key, value FROM cave_tag WHERE claim_id = ?');
760
+ historicalIndex = id => {
761
+ const index = indexByKey.get(keyById.get(id) ?? '');
762
+ if (index !== undefined && !verified.has(id)) {
763
+ try {
764
+ const row = historicalRow.get(id);
765
+ const contexts = historicalContexts.all(id).map(entry => entry.context);
766
+ const tags = historicalTags.all(id);
767
+ if (Key.of(Row.toClaim(row, contexts, tags)) !== row.claim_key) {
768
+ throw new Error('stored historical claim key does not agree with its semantic identity');
769
+ }
770
+ }
771
+ catch (error) {
772
+ throw invalidRow({ id }, error);
773
+ }
774
+ verified.add(id);
775
+ }
776
+ return index;
777
+ };
778
+ }
779
+ return historicalIndex(id);
780
+ };
526
781
  }
527
- const dedupe = `${parent}|${edge.role}|${child}`;
528
- if (seen.has(dedupe)) {
529
- return [];
782
+ const invalidRow = (row, cause) => new Error(`CAVE export failed for claim ${row.id}: ${errorMessage(cause)}`, { cause });
783
+ const claims = claimRows.map(row => {
784
+ try {
785
+ if (tx && (typeof row.id !== 'string' || !Uuidv7.is(row.id) || row.tx !== row.id)) {
786
+ throw new Error('stored transaction identity must be a canonical lowercase UUIDv7 with id = tx');
787
+ }
788
+ const claim = toClaim(row);
789
+ if ((current || tx) && Key.of(claim) !== row.claim_key) {
790
+ throw new Error('stored claim key does not agree with its semantic identity');
791
+ }
792
+ return { claim, line: 0 };
793
+ }
794
+ catch (error) {
795
+ throw invalidRow(row, error);
796
+ }
797
+ });
798
+ const edgeRows = db.prepare('SELECT parent_id, role, child_id FROM cave_edge').all();
799
+ const seen = new Set();
800
+ const edges = edgeRows.flatMap(edge => {
801
+ const parent = resolve(edge.parent_id);
802
+ const child = resolve(edge.child_id);
803
+ if (parent === undefined || child === undefined) {
804
+ // Omitted sensitive claims are valid endpoints; absent rows are
805
+ // corruption when this export includes the other end of the edge.
806
+ if ((parent !== undefined || child !== undefined) &&
807
+ ((parent === undefined && claimExists.get(edge.parent_id) === undefined) ||
808
+ (child === undefined && claimExists.get(edge.child_id) === undefined))) {
809
+ throw new Error('CAVE export failed for edge: stored relationship references a missing claim');
810
+ }
811
+ return [];
812
+ }
813
+ if (edge.role !== 'WHEN' && edge.role !== 'VIA' && edge.role !== 'BECAUSE' && edge.role !== 'QUALIFIES') {
814
+ throw new Error(`CAVE export failed for edge ${edge.parent_id} -> ${edge.child_id}: stored edge role must be WHEN, VIA, BECAUSE or QUALIFIES`);
815
+ }
816
+ const dedupe = `${parent}|${edge.role}|${child}`;
817
+ if (seen.has(dedupe)) {
818
+ return [];
819
+ }
820
+ seen.add(dedupe);
821
+ return [{ parent, role: edge.role, child }];
822
+ });
823
+ try {
824
+ return Canonical.emit({ claims, edges }, tx ? { annotate: index => {
825
+ const row = claimRows[index];
826
+ const actual = Provenance.parse(provenanceOf(row));
827
+ if (actual === undefined) {
828
+ throw invalidRow(row, new Error('stored provenance must contain nonempty Unicode strings in every dimension'));
829
+ }
830
+ const inferred = Provenance.normalize(Provenance.fromEntries(Provenance.entries(claims[index].claim.contexts)));
831
+ const payload = JSON.stringify(actual) === JSON.stringify(inferred) ? '' :
832
+ ` ${JSON.stringify({ provenance: actual })}`;
833
+ return Canonical.txComment(row.tx) + payload;
834
+ } } : {});
835
+ }
836
+ catch (error) {
837
+ // Locate invalid historical data only after emission fails; successful
838
+ // exports do not pay for an additional pass through claim validation.
839
+ for (const [index, entry] of claims.entries()) {
840
+ try {
841
+ Canonical.emitClaim(entry.claim);
842
+ }
843
+ catch (cause) {
844
+ throw invalidRow(claimRows[index], cause);
845
+ }
846
+ }
847
+ throw error;
530
848
  }
531
- seen.add(dedupe);
532
- return [{ parent, role: edge.role, child }];
533
849
  });
534
- return Canonical.emit({ claims, edges }, options_.tx === true ? { annotate: index => Canonical.txComment(claimRows[index].tx) } : {});
850
+ },
851
+ /** Register synchronous dependent-resource cleanup; returns an unsubscribe function. */
852
+ onClose(callback) {
853
+ if (closed)
854
+ throw new Error('CAVE store: cannot register cleanup after close');
855
+ const registration = () => callback();
856
+ closeCallbacks.add(registration);
857
+ return () => { closeCallbacks.delete(registration); };
535
858
  },
536
859
  close() {
537
- db.close();
860
+ if (closed)
861
+ return;
862
+ closed = true;
863
+ const callbacks = [...closeCallbacks];
864
+ closeCallbacks.clear();
865
+ const errors = [];
866
+ for (const callback of callbacks) {
867
+ try {
868
+ requireSynchronous(callback(), 'CAVE store cleanup callback must be synchronous');
869
+ }
870
+ catch (error) {
871
+ errors.push(error);
872
+ }
873
+ }
874
+ try {
875
+ db.close();
876
+ }
877
+ catch (error) {
878
+ closed = false;
879
+ errors.push(error);
880
+ }
881
+ if (errors.length === 1)
882
+ throw errors[0];
883
+ if (errors.length > 1)
884
+ throw new AggregateError(errors, `CAVE store: cleanup failed: ${errors.map(errorMessage).join('; ')}`);
538
885
  }
539
886
  };
540
887
  };