@cavelang/store 0.34.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.
- package/README.md +884 -15
- package/dist/src/backup.d.ts.map +1 -1
- package/dist/src/backup.js +143 -70
- package/dist/src/backup.js.map +1 -1
- package/dist/src/capture-claim.d.ts +4 -0
- package/dist/src/capture-claim.d.ts.map +1 -0
- package/dist/src/capture-claim.js +26 -0
- package/dist/src/capture-claim.js.map +1 -0
- package/dist/src/descriptor.d.ts +3 -0
- package/dist/src/descriptor.d.ts.map +1 -0
- package/dist/src/descriptor.js +21 -0
- package/dist/src/descriptor.js.map +1 -0
- package/dist/src/error-message.d.ts +3 -0
- package/dist/src/error-message.d.ts.map +1 -0
- package/dist/src/error-message.js +10 -0
- package/dist/src/error-message.js.map +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/locate.d.ts +13 -1
- package/dist/src/locate.d.ts.map +1 -1
- package/dist/src/locate.js +35 -13
- package/dist/src/locate.js.map +1 -1
- package/dist/src/node-adapter.d.ts.map +1 -1
- package/dist/src/node-adapter.js +29 -1
- package/dist/src/node-adapter.js.map +1 -1
- package/dist/src/provenance.d.ts +4 -0
- package/dist/src/provenance.d.ts.map +1 -1
- package/dist/src/provenance.js +36 -1
- package/dist/src/provenance.js.map +1 -1
- package/dist/src/query-sql.d.ts.map +1 -1
- package/dist/src/query-sql.js +4 -2
- package/dist/src/query-sql.js.map +1 -1
- package/dist/src/read-snapshot.d.ts +4 -0
- package/dist/src/read-snapshot.d.ts.map +1 -0
- package/dist/src/read-snapshot.js +22 -0
- package/dist/src/read-snapshot.js.map +1 -0
- package/dist/src/record.d.ts.map +1 -1
- package/dist/src/record.js +102 -25
- package/dist/src/record.js.map +1 -1
- package/dist/src/resolve.d.ts.map +1 -1
- package/dist/src/resolve.js +42 -18
- package/dist/src/resolve.js.map +1 -1
- package/dist/src/row.d.ts.map +1 -1
- package/dist/src/row.js +45 -17
- package/dist/src/row.js.map +1 -1
- package/dist/src/runtime.d.ts.map +1 -1
- package/dist/src/runtime.js +8 -4
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/schema.d.ts +1 -2
- package/dist/src/schema.d.ts.map +1 -1
- package/dist/src/schema.js +116 -15
- package/dist/src/schema.js.map +1 -1
- package/dist/src/store.d.ts +17 -5
- package/dist/src/store.d.ts.map +1 -1
- package/dist/src/store.js +451 -104
- package/dist/src/store.js.map +1 -1
- package/package.json +4 -4
- package/src/backup.ts +141 -65
- package/src/capture-claim.ts +30 -0
- package/src/descriptor.ts +19 -0
- package/src/error-message.ts +8 -0
- package/src/index.ts +1 -1
- package/src/locate.ts +45 -13
- package/src/node-adapter.ts +28 -1
- package/src/provenance.ts +33 -1
- package/src/query-sql.ts +4 -2
- package/src/read-snapshot.ts +21 -0
- package/src/record.ts +102 -26
- package/src/resolve.ts +43 -20
- package/src/row.ts +29 -2
- package/src/runtime.ts +8 -4
- package/src/schema.ts +117 -18
- 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).
|
|
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 =
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
308
|
+
rebuilt = Canonical.Registry.declareReverse(rebuilt, declaration.subject, declaration.object).registry
|
|
252
309
|
} else if (declaration.verb === 'RENAMED-TO' && Verb.isVerbToken(declaration.object)) {
|
|
253
|
-
|
|
310
|
+
rebuilt = Canonical.Registry.declareRename(rebuilt, declaration.subject, declaration.object).registry
|
|
254
311
|
} else if (declaration.verb === 'IS' && declaration.object === 'verb') {
|
|
255
|
-
|
|
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
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 (
|
|
454
|
+
if (prepared.some((_, index) => options_.ids?.[index] === undefined)) {
|
|
310
455
|
observeMaxTx()
|
|
311
456
|
}
|
|
312
|
-
|
|
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
|
|
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
|
|
374
|
-
|
|
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
|
|
439
|
-
registry: (): Canonical.Registry.t =>
|
|
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
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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`,
|
|
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
|
|
695
|
+
const aliases = booleanOption(options_.aliases, 'resolvedBeliefs aliases')
|
|
519
696
|
const prefix = aliases ? `WITH RECURSIVE ${QuerySql.aliasPairs(currentSql)}\n` : ''
|
|
520
|
-
return
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
last.
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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 ?${
|
|
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
|
|
868
|
+
return limit === undefined ?
|
|
656
869
|
rows(sql, match) :
|
|
657
|
-
rows(`${sql} LIMIT ?`, match,
|
|
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
|
|
692
|
-
*
|
|
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
|
|
702
|
-
const maximum = options_.maxSensitivity
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
if (
|
|
724
|
-
|
|
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
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
}
|