@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/src/store.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  import { Claim, Context, Key, Uuidv7, Verb } from '@cavelang/core'
22
22
  import * as Canonical from '@cavelang/canonical'
23
23
  import type { Adapter } from './adapter.ts'
24
+ import { readSnapshot } from './read-snapshot.ts'
25
+ import { errorMessage } from './error-message.ts'
26
+ import { captureClaim } from './capture-claim.ts'
24
27
  import * as QuerySql from './query-sql.ts'
25
28
  import * as Resolve from './resolve.ts'
26
29
  import * as Row from './row.ts'
@@ -31,6 +34,36 @@ import * as Record from './record.ts'
31
34
 
32
35
  const currentSql = QuerySql.current()
33
36
 
37
+ const booleanOption = (value: unknown, name: string): boolean => {
38
+ if (value !== undefined && typeof value !== 'boolean') {
39
+ throw new TypeError(`${name} must be a boolean`)
40
+ }
41
+ return value === true
42
+ }
43
+
44
+ const validateSensitivity = (maximum: undefined | Sensitivity.Level): void => {
45
+ if (maximum !== undefined && Sensitivity.parse(maximum) === undefined) {
46
+ throw new TypeError('maxSensitivity must be public, internal, confidential or restricted')
47
+ }
48
+ }
49
+
50
+ const validateEdgeRole = (role: unknown): void => {
51
+ if (role !== 'WHEN' && role !== 'VIA' && role !== 'BECAUSE' && role !== 'QUALIFIES') {
52
+ throw new TypeError('CAVE edge role must be WHEN, VIA, BECAUSE or QUALIFIES')
53
+ }
54
+ }
55
+
56
+ /** Reject asynchronous callbacks without starting custom thenable work. */
57
+ const requireSynchronous = (result: unknown, message: string): void => {
58
+ if (result instanceof Promise ||
59
+ (result !== null && (typeof result === 'object' || typeof result === 'function') &&
60
+ typeof (result as { then?: unknown }).then === 'function')) {
61
+ try { void Promise.prototype.then.call(result, undefined, () => {}) }
62
+ catch { /* Non-native thenables have no promise rejection to observe. */ }
63
+ throw new TypeError(message)
64
+ }
65
+ }
66
+
34
67
  /**
35
68
  * The closure walked from a seed entity — the seed is the query's first
36
69
  * positional parameter. Requires `alias_edge` in scope.
@@ -83,7 +116,9 @@ export type AppendOptions = {
83
116
  * Explicit row identity (spec §28.1), index-aligned with the result's
84
117
  * claims: a claim with an id here is replayed under it — inserted with
85
118
  * `id = tx = ids[i]` when absent, skipped when the store already has the
86
- * row — and the generator observes it (spec §28.2). Claims without an
119
+ * row — and the generator observes it (spec §28.2). Every supplied ID
120
+ * used by a claim must be a canonical lowercase UUIDv7; the whole batch
121
+ * is validated before any ID is observed or row is inserted. Claims without an
87
122
  * entry mint fresh ids as usual. Edges deduplicate against stored edges
88
123
  * in this mode, so replaying a sync export is idempotent end to end.
89
124
  */
@@ -178,21 +213,34 @@ export const open = (
178
213
  path: string = ':memory:',
179
214
  options: { registry?: Canonical.Registry.t, access?: Access } = {}
180
215
  ) => {
181
- const access = options.access ?? 'migrate'
216
+ const { access = 'migrate' } = options
217
+ if (access !== 'read-only' && access !== 'no-migrate' && access !== 'migrate') {
218
+ throw new TypeError('store access must be read-only, no-migrate or migrate')
219
+ }
182
220
  const db = adapter.open(path, access === 'read-only' ? { readOnly: true } : {})
221
+ try {
222
+ return initializeStore(adapter, db, options, access)
223
+ } catch (error) {
224
+ try { db.close() } catch (closeError) {
225
+ throw new AggregateError([error, closeError],
226
+ `CAVE store initialization failed: ${errorMessage(error)}; database cleanup also failed: ${errorMessage(closeError)}`,
227
+ { cause: error })
228
+ }
229
+ throw error
230
+ }
231
+ }
232
+
233
+ /** The caller retains database ownership until initialization succeeds. */
234
+ const initializeStore = (adapter: Adapter, db: ReturnType<Adapter['open']>,
235
+ options: { registry?: Canonical.Registry.t, access?: Access }, access: Access) => {
183
236
  // Concurrent writers wait for the database allocation lock instead of
184
237
  // failing immediately with SQLITE_BUSY.
185
238
  db.exec('PRAGMA busy_timeout = 5000')
186
239
  db.exec('PRAGMA foreign_keys = ON')
187
- try {
188
- if (access === 'migrate') {
189
- Schema.init(db, adapter.capabilities)
190
- } else {
191
- Schema.check(db)
192
- }
193
- } catch (error) {
194
- db.close()
195
- throw error
240
+ if (access === 'migrate') {
241
+ Schema.init(db, adapter.capabilities)
242
+ } else {
243
+ Schema.check(db)
196
244
  }
197
245
 
198
246
  // The receive rule (spec §28.2): the store, not the wall clock, is the
@@ -209,6 +257,9 @@ export const open = (
209
257
 
210
258
  const baseRegistry = options.registry ?? Canonical.standardRegistry
211
259
  let registry = baseRegistry
260
+ const dataVersionStatement = db.prepare('PRAGMA data_version')
261
+ const dataVersion = (): number => (dataVersionStatement.get() as { data_version: number }).data_version
262
+ let registryDataVersion = dataVersion()
212
263
 
213
264
  const insertClaim = db.prepare(`
214
265
  INSERT INTO cave_claim (
@@ -223,6 +274,10 @@ export const open = (
223
274
  'INSERT OR IGNORE INTO cave_provenance (claim_id, dimension, value) VALUES (?, ?, ?)')
224
275
  const insertTag = db.prepare('INSERT INTO cave_tag (claim_id, key, value) VALUES (?, ?, ?)')
225
276
  const insertEdge = db.prepare('INSERT INTO cave_edge (parent_id, role, child_id) VALUES (?, ?, ?)')
277
+ const declarationAt = db.prepare(`SELECT 1 FROM cave_claim
278
+ WHERE id = ? AND negated = 0 AND object IS NOT NULL
279
+ AND (verb IN ('REVERSE', 'RENAMED-TO') OR (verb = 'IS' AND object = 'verb'))`)
280
+
226
281
  const insertFts = db.prepare(`
227
282
  INSERT INTO cave_fts (claim_id, subject, verb, object, attribute, value_text, comment, raw_line)
228
283
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -240,24 +295,36 @@ export const open = (
240
295
  const declarations = db.prepare(`
241
296
  SELECT subject, verb, object FROM cave_claim
242
297
  WHERE negated = 0 AND object IS NOT NULL AND verb IN ('REVERSE', 'RENAMED-TO', 'IS')
298
+ AND (verb <> 'IS' OR object = 'verb')
243
299
  AND id NOT IN (SELECT child_id FROM cave_edge WHERE role IN ('WHEN', 'VIA', 'BECAUSE'))
244
300
  ORDER BY tx
245
301
  `).all() as { subject: string, verb: string, object: string }[]
302
+ let rebuilt = baseRegistry
246
303
  for (const declaration of declarations) {
247
304
  if (!Verb.isVerbToken(declaration.subject)) {
248
305
  continue
249
306
  }
250
307
  if (declaration.verb === 'REVERSE' && Verb.isVerbToken(declaration.object)) {
251
- registry = Canonical.Registry.declareReverse(registry, declaration.subject, declaration.object).registry
308
+ rebuilt = Canonical.Registry.declareReverse(rebuilt, declaration.subject, declaration.object).registry
252
309
  } else if (declaration.verb === 'RENAMED-TO' && Verb.isVerbToken(declaration.object)) {
253
- registry = Canonical.Registry.declareRename(registry, declaration.subject, declaration.object).registry
310
+ rebuilt = Canonical.Registry.declareRename(rebuilt, declaration.subject, declaration.object).registry
254
311
  } else if (declaration.verb === 'IS' && declaration.object === 'verb') {
255
- registry = Canonical.Registry.declareVerb(registry, declaration.subject)
312
+ rebuilt = Canonical.Registry.declareVerb(rebuilt, declaration.subject)
256
313
  }
257
314
  }
315
+ registry = rebuilt
258
316
  }
259
317
  rebuildRegistry()
260
318
 
319
+ /** Refresh external commits without a write transaction; also works on read-only connections. */
320
+ const refreshRegistry = (): void => {
321
+ const currentVersion = dataVersion()
322
+ if (currentVersion !== registryDataVersion) {
323
+ rebuildRegistry()
324
+ registryDataVersion = currentVersion
325
+ }
326
+ }
327
+
261
328
  /**
262
329
  * The outer transaction takes SQLite's write-reservation lock before any
263
330
  * transaction id is allocated, serializing concurrent processes. Nested
@@ -266,28 +333,38 @@ export const open = (
266
333
  * Rollback also restores the in-memory verb registry.
267
334
  */
268
335
  let transactionDepth = 0
269
- const transaction = <T>(body: () => T): T => {
336
+ const transaction = <T>(body: (context: { readonly outermost: boolean }) => T): T => {
270
337
  const outer = transactionDepth === 0
271
338
  const savepoint = `cave_tx_${transactionDepth}`
272
339
  transactionDepth += 1
273
340
  const savedRegistry = registry
341
+ const savedRegistryDataVersion = registryDataVersion
274
342
  let started = false
275
343
  try {
276
344
  db.exec(outer ? 'BEGIN IMMEDIATE' : `SAVEPOINT ${savepoint}`)
277
345
  started = true
278
- const result = body()
346
+ const result = body({ outermost: outer })
347
+ requireSynchronous(result, 'CAVE transaction callback must be synchronous')
279
348
  db.exec(outer ? 'COMMIT' : `RELEASE ${savepoint}`)
280
349
  return result
281
350
  } catch (error) {
282
- if (started) {
283
- if (outer) {
284
- db.exec('ROLLBACK')
285
- } else {
286
- db.exec(`ROLLBACK TO ${savepoint}`)
287
- db.exec(`RELEASE ${savepoint}`)
351
+ try {
352
+ if (started) {
353
+ if (outer) {
354
+ db.exec('ROLLBACK')
355
+ } else {
356
+ db.exec(`ROLLBACK TO ${savepoint}`)
357
+ db.exec(`RELEASE ${savepoint}`)
358
+ }
288
359
  }
360
+ } catch (rollbackError) {
361
+ throw new AggregateError([error, rollbackError],
362
+ `CAVE transaction failed: ${errorMessage(error)}; rollback also failed: ${errorMessage(rollbackError)}`,
363
+ { cause: error })
364
+ } finally {
365
+ registry = savedRegistry
366
+ registryDataVersion = savedRegistryDataVersion
289
367
  }
290
- registry = savedRegistry
291
368
  throw error
292
369
  } finally {
293
370
  transactionDepth -= 1
@@ -300,16 +377,84 @@ export const open = (
300
377
  /** Appends a canonicalization result — one row per claim, per-row tx. */
301
378
  const insertResult = (result: Canonical.Result, options_: AppendOptions = {}): IngestResult =>
302
379
  transaction(() => {
380
+ // Validate and insert from the same caller values, including replay IDs.
381
+ const { ids: replayIds, contexts, source, lifecycle, provenance } = options_
382
+ if (source !== undefined && typeof source !== 'string') throw new TypeError('append source must be a string')
383
+ if (provenance !== undefined && (provenance === null || typeof provenance !== 'object' || Array.isArray(provenance))) {
384
+ throw new TypeError('append provenance must be an object')
385
+ }
386
+ const { actor, sources, run, domains } = provenance ?? {}
387
+ for (const [name, collection] of [
388
+ ['ids', replayIds], ['contexts', contexts],
389
+ ['provenance.sources', sources], ['provenance.domains', domains],
390
+ ] as const) {
391
+ if (collection !== undefined && !Array.isArray(collection)) throw new TypeError(`append ${name} must be an array`)
392
+ }
393
+ options_ = {
394
+ ids: replayIds === undefined ? undefined : Array.from(replayIds),
395
+ contexts: contexts === undefined ? undefined : Array.from(contexts),
396
+ source, lifecycle: booleanOption(lifecycle, 'append lifecycle'),
397
+ provenance: provenance === undefined ? undefined : {
398
+ actor, run,
399
+ sources: sources === undefined ? undefined : Array.from(sources),
400
+ domains: domains === undefined ? undefined : Array.from(domains),
401
+ },
402
+ }
403
+ for (const [name, value] of [['actor', actor], ['run', run]] as const) {
404
+ if (value !== undefined && typeof value !== 'string') throw new TypeError(`append provenance.${name} must be a string`)
405
+ }
406
+ for (const name of ['sources', 'domains'] as const) {
407
+ for (const value of options_.provenance?.[name] ?? []) {
408
+ if (typeof value !== 'string') throw new TypeError(`append provenance.${name} entries must be strings`)
409
+ }
410
+ }
411
+ const preparedEdges = Array.from(result.edges, ({ parent, role, child }) => ({ parent, role, child }))
412
+ // Validate the whole replay before even observing a valid earlier ID:
413
+ // rejected batches must not change ordering state or partially append.
414
+ const prepared = result.claims.map((entry, index) => {
415
+ const id = options_.ids?.[index]
416
+ if (id !== undefined && (typeof id !== 'string' || !Uuidv7.is(id))) {
417
+ throw new Error(`CAVE replay claim ${index + 1}: expected a canonical lowercase UUIDv7 id`)
418
+ }
419
+ const authored = addContexts(captureClaim(entry.claim), options_.contexts)
420
+ const claim = stampSource(authored, options_.source, options_.lifecycle === true)
421
+ const provided = options_.provenance ?? {}
422
+ const provenance = Provenance.entries(authored.contexts, {
423
+ ...provided,
424
+ actor: provided.actor ?? options_.source,
425
+ run: provided.run ?? (options_.lifecycle === true ? options_.source : undefined)
426
+ })
427
+ const columns = Row.toColumns(claim)
428
+ const emitted = Canonical.emitClaim(claim)
429
+ const rawLine = columns.rawLine === '' ? emitted : columns.rawLine
430
+ const textValues = [
431
+ ...Object.values(columns), rawLine, ...claim.contexts,
432
+ ...provenance.map(entry => entry.value),
433
+ ...claim.tags.flatMap(tag => [tag.key, tag.value])
434
+ ]
435
+ if (textValues.some(value => typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value))) {
436
+ throw new TypeError(`CAVE claim ${index + 1}: unpaired UTF-16 surrogate cannot be stored as UTF-8`)
437
+ }
438
+ return { claim, provenance, columns, rawLine }
439
+ })
303
440
  const ids: string[] = []
441
+ for (const edge of preparedEdges) {
442
+ validateEdgeRole(edge.role)
443
+ for (const [field, index] of [['parent', edge.parent], ['child', edge.child]] as const) {
444
+ if (!Number.isSafeInteger(index) || index < 0 || index >= prepared.length) {
445
+ throw new TypeError(`CAVE edge ${field} must index an existing claim`)
446
+ }
447
+ }
448
+ }
304
449
  const replay = options_.ids !== undefined
305
450
  let skipped = 0
306
451
  // BEGIN IMMEDIATE makes this read and all following inserts one
307
452
  // database-serialized allocation step. A process that opened before a
308
453
  // fast-clock peer wrote now observes that peer before minting (§28.2).
309
- if (result.claims.some((_, index) => options_.ids?.[index] === undefined)) {
454
+ if (prepared.some((_, index) => options_.ids?.[index] === undefined)) {
310
455
  observeMaxTx()
311
456
  }
312
- result.claims.forEach((entry, index) => {
457
+ prepared.forEach(({ claim, provenance, columns, rawLine }, index) => {
313
458
  const explicit = options_.ids?.[index]
314
459
  if (explicit !== undefined) {
315
460
  // Identity replay (spec §28.1): the id is the row — present means
@@ -321,17 +466,7 @@ export const open = (
321
466
  return
322
467
  }
323
468
  }
324
- const authored = addContexts(entry.claim, options_.contexts)
325
- const claim = stampSource(authored, options_.source, options_.lifecycle === true)
326
- const provided = options_.provenance ?? {}
327
- const provenance = Provenance.entries(authored.contexts, {
328
- ...provided,
329
- actor: provided.actor ?? options_.source,
330
- run: provided.run ?? (options_.lifecycle === true ? options_.source : undefined)
331
- })
332
469
  const id = explicit ?? Uuidv7.next()
333
- const columns = Row.toColumns(claim)
334
- const rawLine = columns.rawLine === '' ? Canonical.emitClaim(claim) : columns.rawLine
335
470
  insertClaim.run(
336
471
  id, id,
337
472
  columns.subject, columns.verb, columns.negated, columns.object, columns.attribute,
@@ -357,7 +492,7 @@ export const open = (
357
492
  ids.push(id)
358
493
  })
359
494
  let edges = 0
360
- for (const edge of result.edges) {
495
+ for (const edge of preparedEdges) {
361
496
  const parentId = ids[edge.parent]!
362
497
  const childId = ids[edge.child]!
363
498
  if (replay && edgeExists.get(parentId, edge.role, childId) !== undefined) {
@@ -370,8 +505,16 @@ export const open = (
370
505
  return { ids, edges, skipped, problems: result.problems }
371
506
  })
372
507
 
373
- const rows = (sql: string, ...params: (string | number)[]): Row.t[] =>
374
- db.prepare(sql).all(...params) as unknown as Row.t[]
508
+ const assertReadUnicode = (...values: (string | number)[]): void => {
509
+ if (values.some(value => typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value))) {
510
+ throw new TypeError('CAVE read: unpaired UTF-16 surrogate cannot be queried as UTF-8')
511
+ }
512
+ }
513
+
514
+ const rows = (sql: string, ...params: (string | number)[]): Row.t[] => {
515
+ assertReadUnicode(...params)
516
+ return db.prepare(sql).all(...params) as unknown as Row.t[]
517
+ }
375
518
 
376
519
  const contextsOf = (id: string): string[] =>
377
520
  (db.prepare('SELECT context FROM cave_context WHERE claim_id = ?').all(id) as { context: string }[])
@@ -385,6 +528,7 @@ export const open = (
385
528
 
386
529
  const provenanceOf = (row: Row.t | string): Provenance.t => {
387
530
  const id = typeof row === 'string' ? row : row.id
531
+ assertReadUnicode(id)
388
532
  const entries = db.prepare(`
389
533
  SELECT dimension, value FROM cave_provenance WHERE claim_id = ? ORDER BY dimension, value
390
534
  `).all(id) as Provenance.Entry[]
@@ -428,6 +572,22 @@ export const open = (
428
572
  Resolve.resolvedSql(readPolicy(), currentSql, { aliases: options.aliases === true }) :
429
573
  currentSql
430
574
 
575
+ const traverseRead = <T>(options: TraverseOptions, read: (captured: TraverseOptions) => T,
576
+ operation: 'resolution' | 'reverse' = 'resolution'): T => {
577
+ const { negated, retracted, aliases, resolve } = options
578
+ const captured = {
579
+ negated: booleanOption(negated, 'traversal negated'),
580
+ retracted: booleanOption(retracted, 'traversal retracted'),
581
+ aliases: booleanOption(aliases, 'traversal aliases'),
582
+ resolve: booleanOption(resolve, 'traversal resolve')
583
+ }
584
+ return resolve === true || operation === 'reverse' ?
585
+ readSnapshot(db, operation, () => read(captured)) : read(captured)
586
+ }
587
+
588
+ const closeCallbacks = new Set<() => void>()
589
+ let closed = false
590
+
431
591
  return {
432
592
  /** Adapter that owns this database connection and its capabilities. */
433
593
  adapter,
@@ -435,8 +595,11 @@ export const open = (
435
595
  /** Raw adapter database handle — used by `@cavelang/query`; treat as read-only. */
436
596
  db,
437
597
 
438
- /** Current verb registry (input registry + stored + ingested declarations). */
439
- registry: (): Canonical.Registry.t => registry,
598
+ /** Current verb registry; refreshes declarations committed by other connections. */
599
+ registry: (): Canonical.Registry.t => {
600
+ refreshRegistry()
601
+ return registry
602
+ },
440
603
 
441
604
  /** Configured registry before any in-band declarations are applied. */
442
605
  baseRegistry: (): Canonical.Registry.t => baseRegistry,
@@ -448,8 +611,9 @@ export const open = (
448
611
  * reopening.
449
612
  */
450
613
  reloadRegistry(): void {
451
- registry = baseRegistry
614
+ const currentVersion = dataVersion()
452
615
  rebuildRegistry()
616
+ registryDataVersion = currentVersion
453
617
  },
454
618
 
455
619
  /**
@@ -457,6 +621,8 @@ export const open = (
457
621
  * nested appends and their registry declarations. Nestable
458
622
  * (savepoints) — the write gate wraps ingest + check in one of these
459
623
  * (spec §20.3).
624
+ * The callback receives `outermost`: only that scope commits the
625
+ * database; a nested scope merely releases its savepoint.
460
626
  */
461
627
  transaction,
462
628
 
@@ -467,12 +633,18 @@ export const open = (
467
633
  * (spec §9.5).
468
634
  */
469
635
  ingest(text: string, options_: { strict?: boolean } & AppendOptions = {}): IngestResult {
470
- const result = Canonical.canonicalizeText(text, registry)
471
- if (options_.strict === true && result.problems.length > 0) {
472
- const detail = result.problems.map(problem => ` line ${problem.line}: ${problem.message}`).join('\n')
473
- throw new Error(`CAVE ingest failed with ${result.problems.length} problem(s):\n${detail}`)
474
- }
475
- return insertResult(result, options_)
636
+ const strict = booleanOption(options_.strict, 'ingest strict')
637
+ return transaction(() => {
638
+ // data_version changes for commits from other connections, not our own
639
+ // appends. Check under the write reservation before using the registry.
640
+ refreshRegistry()
641
+ const result = Canonical.canonicalizeText(text, registry)
642
+ if (strict && result.problems.length > 0) {
643
+ const detail = result.problems.map(problem => ` line ${problem.line}: ${problem.message}`).join('\n')
644
+ throw new Error(`CAVE ingest failed with ${result.problems.length} problem(s):\n${detail}`)
645
+ }
646
+ return insertResult(result, options_)
647
+ })
476
648
  },
477
649
 
478
650
  /** Appends an already-canonicalized result. */
@@ -480,13 +652,18 @@ export const open = (
480
652
 
481
653
  /** Latest row per claim key (spec §13.5), oldest first. */
482
654
  currentBeliefs(options_: { minConf?: number } = {}): Row.t[] {
483
- return options_.minConf === undefined ?
655
+ const minConf = options_.minConf
656
+ if (minConf !== undefined && (!Number.isFinite(minConf) || minConf < 0 || minConf > 1)) {
657
+ throw new TypeError('currentBeliefs minConf must be finite and in 0..1')
658
+ }
659
+ return minConf === undefined ?
484
660
  rows(`${currentSql} ORDER BY c.tx`) :
485
- rows(`${currentSql} WHERE c.conf >= ? ORDER BY c.tx`, options_.minConf)
661
+ rows(`${currentSql} WHERE c.conf >= ? ORDER BY c.tx`, minConf)
486
662
  },
487
663
 
488
664
  /** Current belief for one claim key, `undefined` if the fact is unknown. */
489
665
  currentBelief(claimKey: string): undefined | Row.t {
666
+ assertReadUnicode(claimKey)
490
667
  const row = db.prepare(
491
668
  'SELECT * FROM cave_claim WHERE claim_key = ? ORDER BY tx DESC LIMIT 1'
492
669
  ).get(claimKey) as undefined | Row.t
@@ -515,9 +692,10 @@ export const open = (
515
692
  * `aliases`, groups widen through the alias closure (spec §26.1).
516
693
  */
517
694
  resolvedBeliefs(options_: { aliases?: boolean } = {}): Row.t[] {
518
- const aliases = options_.aliases === true
695
+ const aliases = booleanOption(options_.aliases, 'resolvedBeliefs aliases')
519
696
  const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : ''
520
- return rows(`${prefix}SELECT * FROM (${Resolve.resolvedSql(readPolicy(), currentSql, { aliases })}) ORDER BY tx`)
697
+ return readSnapshot(db, 'resolution', () =>
698
+ rows(`${prefix}SELECT * FROM (${Resolve.resolvedSql(readPolicy(), currentSql, { aliases })}) ORDER BY tx`))
521
699
  },
522
700
 
523
701
  /**
@@ -528,21 +706,23 @@ export const open = (
528
706
  * picking) and for the `cave resolve` view.
529
707
  */
530
708
  contested(options_: { aliases?: boolean } = {}): Resolve.Contested[] {
531
- const aliases = options_.aliases === true
532
- const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : ''
533
- const ranked = rows(
534
- `${prefix}SELECT * FROM (${Resolve.rankedSql(readPolicy(), currentSql, { aliases })}) ORDER BY res_group, res_rank`
535
- ) as Resolve.Ranked[]
536
- const groups: { group: string, rows: Resolve.Ranked[] }[] = []
537
- for (const row of ranked) {
538
- const last = groups[groups.length - 1]
539
- if (last !== undefined && last.group === row.res_group) {
540
- last.rows.push(row)
541
- } else {
542
- groups.push({ group: row.res_group, rows: [row] })
709
+ const aliases = booleanOption(options_.aliases, 'contested aliases')
710
+ return readSnapshot(db, 'resolution', () => {
711
+ const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : ''
712
+ const ranked = rows(
713
+ `${prefix}SELECT * FROM (${Resolve.rankedSql(readPolicy(), currentSql, { aliases })}) ORDER BY res_group, res_rank`
714
+ ) as Resolve.Ranked[]
715
+ const groups: { group: string, rows: Resolve.Ranked[] }[] = []
716
+ for (const row of ranked) {
717
+ const last = groups[groups.length - 1]
718
+ if (last !== undefined && last.group === row.res_group) {
719
+ last.rows.push(row)
720
+ } else {
721
+ groups.push({ group: row.res_group, rows: [row] })
722
+ }
543
723
  }
544
- }
545
- return groups.filter(group => group.rows.length > 1)
724
+ return groups.filter(group => group.rows.length > 1)
725
+ })
546
726
  },
547
727
 
548
728
  /**
@@ -552,6 +732,7 @@ export const open = (
552
732
  * removes that link. The queried name first, the rest sorted.
553
733
  */
554
734
  aliasesOf(entity: string): string[] {
735
+ assertReadUnicode(entity)
555
736
  const names = db.prepare(`${aliasClosureSql} SELECT name FROM alias_closure WHERE name <> ? ORDER BY name`)
556
737
  .all(entity, entity) as { name: string }[]
557
738
  return [entity, ...names.map(row => row.name)]
@@ -559,7 +740,8 @@ export const open = (
559
740
 
560
741
  /** All rows about an entity, both directions, newest first (spec §13.5). */
561
742
  claimsAbout(entity: string, options_: { aliases?: boolean } = {}): Row.t[] {
562
- return options_.aliases === true ?
743
+ const aliases = booleanOption(options_.aliases, 'claimsAbout aliases')
744
+ return aliases === true ?
563
745
  rows(
564
746
  `${aliasClosureSql} SELECT * FROM cave_claim
565
747
  WHERE subject IN (SELECT name FROM alias_closure) OR object IN (SELECT name FROM alias_closure)
@@ -571,10 +753,12 @@ export const open = (
571
753
 
572
754
  /** Forward reads: current relational facts with `entity` as subject (spec §13.3). */
573
755
  forward(entity: string, options_: TraverseOptions = {}): ForwardFact[] {
574
- return rows(
575
- `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
576
- entity
577
- ).map(row => ({ verb: row.verb, target: row.object!, row }))
756
+ return traverseRead(options_, options_ => {
757
+ return rows(
758
+ `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
759
+ entity
760
+ ).map(row => ({ verb: row.verb, target: row.object!, row }))
761
+ })
578
762
  },
579
763
 
580
764
  /**
@@ -583,13 +767,16 @@ export const open = (
583
767
  * when one is declared.
584
768
  */
585
769
  reverse(entity: string, options_: TraverseOptions = {}): ReverseFact[] {
586
- return rows(
587
- `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
588
- entity
589
- ).map(row => {
590
- const rel = Canonical.Registry.inverseOf(registry, row.verb)
591
- return { verb: row.verb, ...rel === undefined ? {} : { rel }, source: row.subject, row }
592
- })
770
+ return traverseRead(options_, options_ => {
771
+ refreshRegistry()
772
+ return rows(
773
+ `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
774
+ entity
775
+ ).map(row => {
776
+ const rel = Canonical.Registry.inverseOf(registry, row.verb)
777
+ return { verb: row.verb, ...rel === undefined ? {} : { rel }, source: row.subject, row }
778
+ })
779
+ }, 'reverse')
593
780
  },
594
781
 
595
782
  /** Flat tag (`value` omitted → `value IS NULL`) or scoped tag rows (spec §13.5). */
@@ -625,18 +812,22 @@ export const open = (
625
812
 
626
813
  /** Members of a topic — forward `CONTAINS` traversal (spec §11.2). */
627
814
  topicMembers(topic: string, options_: TraverseOptions = {}): string[] {
628
- return rows(
629
- `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND verb = 'CONTAINS' AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
630
- topic
631
- ).map(row => row.object!)
815
+ return traverseRead(options_, options_ => {
816
+ return rows(
817
+ `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('subject', options_)} AND verb = 'CONTAINS' AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
818
+ topic
819
+ ).map(row => row.object!)
820
+ })
632
821
  },
633
822
 
634
823
  /** Topics containing an entity — the inverse `CONTAINS` read (spec §11.2). */
635
824
  topicsOf(entity: string, options_: TraverseOptions = {}): string[] {
636
- return rows(
637
- `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND verb = 'CONTAINS'${traversalFilter(options_)} ORDER BY tx`,
638
- entity
639
- ).map(row => row.subject)
825
+ return traverseRead(options_, options_ => {
826
+ return rows(
827
+ `${withAliases(options_)} SELECT * FROM (${universe(options_)}) WHERE ${entityMatch('object', options_)} AND verb = 'CONTAINS'${traversalFilter(options_)} ORDER BY tx`,
828
+ entity
829
+ ).map(row => row.subject)
830
+ })
640
831
  },
641
832
 
642
833
  /**
@@ -645,16 +836,38 @@ export const open = (
645
836
  * would otherwise parse as a column filter); pass `raw` to use full
646
837
  * FTS5 MATCH syntax. `limit` caps the rows inside the query itself,
647
838
  * so a broad search never materializes more than the caller reads.
839
+ * `currentOnly` selects non-retracted current rows before that cap;
840
+ * omission retains historical search.
648
841
  */
649
- search(query: string, options_: { raw?: boolean, limit?: number, maxSensitivity?: Sensitivity.Level } = {}): Row.t[] {
650
- const match = options_.raw === true ? query : `"${query.replaceAll('"', '""')}"`
842
+ search(query: string, options_: { raw?: boolean, limit?: number, maxSensitivity?: Sensitivity.Level, currentOnly?: boolean } = {}): Row.t[] {
843
+ const { raw, limit, maxSensitivity, currentOnly } = options_
844
+ validateSensitivity(maxSensitivity)
845
+ for (const [name, value] of [['raw', raw], ['currentOnly', currentOnly]] as const) {
846
+ if (value !== undefined && typeof value !== 'boolean') {
847
+ throw new TypeError(`search ${name} must be a boolean`)
848
+ }
849
+ }
850
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) {
851
+ throw new TypeError('search limit must be a non-negative safe integer')
852
+ }
853
+ // MATCH parsers treat NUL as a terminator, even in a bound SQL string.
854
+ if (query.includes('\0')) throw new TypeError('search query must not contain NUL characters')
855
+ // FTS4 does not accept FTS5's doubled-quote escaping within a phrase.
856
+ // Quotes are token separators in both engines' default tokenizers.
857
+ const literal = adapter.capabilities.fullText === 'fts4' ?
858
+ query.replaceAll('"', ' ') : query.replaceAll('"', '""')
859
+ const match = raw === true ? query : `"${literal}"`
860
+ // Probe the (claim_key, tx) index for each match rather than grouping
861
+ // every unrelated key. Newer rows count even when hidden or retracted.
651
862
  const sql = `
652
863
  SELECT c.* FROM cave_claim c JOIN cave_fts f ON c.id = f.claim_id
653
- WHERE cave_fts MATCH ?${options_.maxSensitivity === undefined ? '' : ` AND ${Sensitivity.sql('c', options_.maxSensitivity)}`}
864
+ WHERE cave_fts MATCH ?${currentOnly === true ? ` AND c.conf > 0 AND NOT EXISTS (
865
+ SELECT 1 FROM cave_claim newer WHERE newer.claim_key = c.claim_key AND newer.tx > c.tx
866
+ )` : ''}${maxSensitivity === undefined ? '' : ` AND ${Sensitivity.sql('c', maxSensitivity)}`}
654
867
  ORDER BY c.tx DESC`
655
- return options_.limit === undefined ?
868
+ return limit === undefined ?
656
869
  rows(sql, match) :
657
- rows(`${sql} LIMIT ?`, match, options_.limit)
870
+ rows(`${sql} LIMIT ?`, match, limit)
658
871
  },
659
872
 
660
873
  /**
@@ -666,15 +879,28 @@ export const open = (
666
879
  * keys reject unknown ids.
667
880
  */
668
881
  appendEdges(edges: readonly { parentId: string, role: Canonical.EdgeRole, childId: string }[]): void {
882
+ edges = Array.from(edges, ({ parentId, role, childId }) => ({ parentId, role, childId }))
883
+ for (const edge of edges) validateEdgeRole(edge.role)
669
884
  transaction(() => {
885
+ let changedDeclarations = false
670
886
  for (const edge of edges) {
671
887
  insertEdge.run(edge.parentId, edge.role, edge.childId)
888
+ if (!changedDeclarations && (edge.role === 'WHEN' || edge.role === 'VIA' || edge.role === 'BECAUSE')) {
889
+ changedDeclarations = declarationAt.get(edge.childId) !== undefined
890
+ }
891
+ }
892
+ if (changedDeclarations) {
893
+ // Qualifier children do not contribute in-band declarations. Keep
894
+ // this connection consistent with registry replay after reopening.
895
+ rebuildRegistry()
896
+ registryDataVersion = dataVersion()
672
897
  }
673
898
  })
674
899
  },
675
900
 
676
901
  /** Qualifier/grouping edges of a claim row (spec §13.2). */
677
902
  edgesOf(parentId: string): { role: string, child: Row.t }[] {
903
+ assertReadUnicode(parentId)
678
904
  return (db.prepare(`
679
905
  SELECT e.role AS role, c.* FROM cave_edge e JOIN cave_claim c ON c.id = e.child_id
680
906
  WHERE e.parent_id = ?`).all(parentId) as unknown as (Row.t & { role: string })[]
@@ -688,8 +914,9 @@ export const open = (
688
914
  * Emits the store as canonical CAVE text — all rows in transaction
689
915
  * order, or only current beliefs with `current`. With `tx`, every
690
916
  * claim line is preceded by its §28.4 transaction annotation
691
- * (`;@ <tx>`), so the text carries row identity: `cave sync` replays
692
- * it idempotently, plain `cave import` reads it unchanged.
917
+ * (`;@ <tx>` with optional JSON provenance), so text carries identity
918
+ * and exact dimensions: `cave sync` replays it idempotently, while plain
919
+ * `cave import` ignores annotation metadata.
693
920
  *
694
921
  * In current-only export an edge endpoint may be a superseded row;
695
922
  * dropping such edges would silently un-condition current claims and
@@ -698,46 +925,146 @@ export const open = (
698
925
  * resulting edges are deduplicated.
699
926
  */
700
927
  exportText(options_: { current?: boolean, tx?: boolean, maxSensitivity?: Sensitivity.Level } = {}): string {
701
- const current = options_.current === true
702
- const maximum = options_.maxSensitivity ?? Sensitivity.defaultMaximum
703
- const claimRows = current ?
704
- rows(`${currentSql} WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`) :
705
- rows(`SELECT c.* FROM cave_claim c WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`)
706
- const indexById = new Map(claimRows.map((row, index) => [row.id, index]))
707
- let resolve = (id: string): undefined | number => indexById.get(id)
708
- if (current) {
709
- const indexByKey = new Map(claimRows.map((row, index) => [row.claim_key, index]))
710
- const keyById = new Map(
711
- (db.prepare('SELECT id, claim_key FROM cave_claim').all() as { id: string, claim_key: string }[])
712
- .map(row => [row.id, row.claim_key])
713
- )
714
- resolve = id => indexById.get(id) ?? indexByKey.get(keyById.get(id) ?? '')
928
+ const current = options_.current
929
+ const maximum = options_.maxSensitivity
930
+ const tx = options_.tx
931
+ validateSensitivity(maximum)
932
+ for (const [name, value] of [['current', current], ['tx', tx]] as const) {
933
+ if (value !== undefined && typeof value !== 'boolean') {
934
+ throw new TypeError(`exportText ${name} must be a boolean`)
935
+ }
715
936
  }
716
- const claims = claimRows.map(row => ({ claim: toClaim(row), line: 0 }))
717
- const edgeRows = db.prepare('SELECT parent_id, role, child_id FROM cave_edge').all() as
718
- { parent_id: string, role: Canonical.EdgeRole, child_id: string }[]
719
- const seen = new Set<string>()
720
- const edges = edgeRows.flatMap(edge => {
721
- const parent = resolve(edge.parent_id)
722
- const child = resolve(edge.child_id)
723
- if (parent === undefined || child === undefined || parent === child) {
724
- return []
937
+ return readSnapshot(db, 'export', () => {
938
+ const claimRows = current ?
939
+ rows(`${currentSql} WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`) :
940
+ rows(`SELECT c.* FROM cave_claim c WHERE ${Sensitivity.sql('c', maximum)} ORDER BY c.tx`)
941
+ if (claimRows.length === 0) return ''
942
+ const indexById = new Map(claimRows.map((row, index) => [row.id, index]))
943
+ let resolve = (id: string): undefined | number => indexById.get(id)
944
+ if (current) {
945
+ let historicalIndex: ((id: string) => undefined | number) | undefined
946
+ resolve = id => {
947
+ const currentIndex = indexById.get(id)
948
+ if (currentIndex !== undefined) return currentIndex
949
+ if (historicalIndex === undefined) {
950
+ const indexByKey = new Map(claimRows.map((row, index) => [row.claim_key, index]))
951
+ const keyById = new Map(
952
+ (db.prepare(`SELECT id, claim_key FROM cave_claim WHERE id IN (
953
+ SELECT parent_id FROM cave_edge UNION SELECT child_id FROM cave_edge
954
+ )`).all() as { id: string, claim_key: string }[])
955
+ .map(row => [row.id, row.claim_key])
956
+ )
957
+ const verified = new Set<string>()
958
+ const historicalRow = db.prepare('SELECT * FROM cave_claim WHERE id = ?')
959
+ const historicalContexts = db.prepare('SELECT context FROM cave_context WHERE claim_id = ?')
960
+ const historicalTags = db.prepare('SELECT key, value FROM cave_tag WHERE claim_id = ?')
961
+ historicalIndex = id => {
962
+ const index = indexByKey.get(keyById.get(id) ?? '')
963
+ if (index !== undefined && !verified.has(id)) {
964
+ try {
965
+ const row = historicalRow.get(id) as Row.t
966
+ const contexts = (historicalContexts.all(id) as { context: string }[]).map(entry => entry.context)
967
+ const tags = historicalTags.all(id) as { key: string, value: null | string }[]
968
+ if (Key.of(Row.toClaim(row, contexts, tags)) !== row.claim_key) {
969
+ throw new Error('stored historical claim key does not agree with its semantic identity')
970
+ }
971
+ } catch (error) { throw invalidRow({ id }, error) }
972
+ verified.add(id)
973
+ }
974
+ return index
975
+ }
976
+ }
977
+ return historicalIndex(id)
978
+ }
725
979
  }
726
- const dedupe = `${parent}|${edge.role}|${child}`
727
- if (seen.has(dedupe)) {
728
- return []
980
+ const invalidRow = (row: Pick<Row.t, 'id'>, cause: unknown): Error =>
981
+ new Error(`CAVE export failed for claim ${row.id}: ${errorMessage(cause)}`, { cause })
982
+ const claims = claimRows.map(row => {
983
+ try {
984
+ if (tx && (typeof row.id !== 'string' || !Uuidv7.is(row.id) || row.tx !== row.id)) {
985
+ throw new Error('stored transaction identity must be a canonical lowercase UUIDv7 with id = tx')
986
+ }
987
+ const claim = toClaim(row)
988
+ if ((current || tx) && Key.of(claim) !== row.claim_key) {
989
+ throw new Error('stored claim key does not agree with its semantic identity')
990
+ }
991
+ return { claim, line: 0 }
992
+ } catch (error) { throw invalidRow(row, error) }
993
+ })
994
+ const edgeRows = db.prepare('SELECT parent_id, role, child_id FROM cave_edge').all() as
995
+ { parent_id: string, role: Canonical.EdgeRole, child_id: string }[]
996
+ const seen = new Set<string>()
997
+ const edges = edgeRows.flatMap(edge => {
998
+ const parent = resolve(edge.parent_id)
999
+ const child = resolve(edge.child_id)
1000
+ if (parent === undefined || child === undefined) {
1001
+ // Omitted sensitive claims are valid endpoints; absent rows are
1002
+ // corruption when this export includes the other end of the edge.
1003
+ if ((parent !== undefined || child !== undefined) &&
1004
+ ((parent === undefined && claimExists.get(edge.parent_id) === undefined) ||
1005
+ (child === undefined && claimExists.get(edge.child_id) === undefined))) {
1006
+ throw new Error('CAVE export failed for edge: stored relationship references a missing claim')
1007
+ }
1008
+ return []
1009
+ }
1010
+ if (edge.role !== 'WHEN' && edge.role !== 'VIA' && edge.role !== 'BECAUSE' && edge.role !== 'QUALIFIES') {
1011
+ throw new Error(`CAVE export failed for edge ${edge.parent_id} -> ${edge.child_id}: stored edge role must be WHEN, VIA, BECAUSE or QUALIFIES`)
1012
+ }
1013
+ const dedupe = `${parent}|${edge.role}|${child}`
1014
+ if (seen.has(dedupe)) {
1015
+ return []
1016
+ }
1017
+ seen.add(dedupe)
1018
+ return [{ parent, role: edge.role, child }]
1019
+ })
1020
+ try {
1021
+ return Canonical.emit(
1022
+ { claims, edges },
1023
+ tx ? { annotate: index => {
1024
+ const row = claimRows[index]!
1025
+ const actual = Provenance.parse(provenanceOf(row))
1026
+ if (actual === undefined) {
1027
+ throw invalidRow(row, new Error('stored provenance must contain nonempty Unicode strings in every dimension'))
1028
+ }
1029
+ const inferred = Provenance.normalize(Provenance.fromEntries(Provenance.entries(claims[index]!.claim.contexts)))
1030
+ const payload = JSON.stringify(actual) === JSON.stringify(inferred) ? '' :
1031
+ ` ${JSON.stringify({ provenance: actual })}`
1032
+ return Canonical.txComment(row.tx) + payload
1033
+ } } : {}
1034
+ )
1035
+ } catch (error) {
1036
+ // Locate invalid historical data only after emission fails; successful
1037
+ // exports do not pay for an additional pass through claim validation.
1038
+ for (const [index, entry] of claims.entries()) {
1039
+ try { Canonical.emitClaim(entry.claim) }
1040
+ catch (cause) { throw invalidRow(claimRows[index]!, cause) }
1041
+ }
1042
+ throw error
729
1043
  }
730
- seen.add(dedupe)
731
- return [{ parent, role: edge.role, child }]
732
1044
  })
733
- return Canonical.emit(
734
- { claims, edges },
735
- options_.tx === true ? { annotate: index => Canonical.txComment(claimRows[index]!.tx) } : {}
736
- )
1045
+ },
1046
+
1047
+ /** Register synchronous dependent-resource cleanup; returns an unsubscribe function. */
1048
+ onClose(callback: () => void): () => void {
1049
+ if (closed) throw new Error('CAVE store: cannot register cleanup after close')
1050
+ const registration = () => callback()
1051
+ closeCallbacks.add(registration)
1052
+ return () => { closeCallbacks.delete(registration) }
737
1053
  },
738
1054
 
739
1055
  close(): void {
740
- db.close()
1056
+ if (closed) return
1057
+ closed = true
1058
+ const callbacks = [...closeCallbacks]
1059
+ closeCallbacks.clear()
1060
+ const errors: unknown[] = []
1061
+ for (const callback of callbacks) {
1062
+ try { requireSynchronous(callback(), 'CAVE store cleanup callback must be synchronous') }
1063
+ catch (error) { errors.push(error) }
1064
+ }
1065
+ try { db.close() } catch (error) { closed = false; errors.push(error) }
1066
+ if (errors.length === 1) throw errors[0]
1067
+ if (errors.length > 1) throw new AggregateError(errors, `CAVE store: cleanup failed: ${errors.map(errorMessage).join('; ')}`)
741
1068
  }
742
1069
  }
743
1070
  }