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