@gnolith/taproot 0.1.0-rc.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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/COMPATIBILITY.md +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +128 -0
  5. package/SECURITY.md +19 -0
  6. package/SUPPORT.md +11 -0
  7. package/dist/canonical.d.ts +16 -0
  8. package/dist/canonical.d.ts.map +1 -0
  9. package/dist/canonical.js +519 -0
  10. package/dist/canonical.js.map +1 -0
  11. package/dist/errors.d.ts +35 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +42 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +57 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +53 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/iri.d.ts +3 -0
  20. package/dist/iri.d.ts.map +1 -0
  21. package/dist/iri.js +8 -0
  22. package/dist/iri.js.map +1 -0
  23. package/dist/rdf.d.ts +6 -0
  24. package/dist/rdf.d.ts.map +1 -0
  25. package/dist/rdf.js +292 -0
  26. package/dist/rdf.js.map +1 -0
  27. package/dist/repository.d.ts +94 -0
  28. package/dist/repository.d.ts.map +1 -0
  29. package/dist/repository.js +1499 -0
  30. package/dist/repository.js.map +1 -0
  31. package/dist/schema.d.ts +19 -0
  32. package/dist/schema.d.ts.map +1 -0
  33. package/dist/schema.js +390 -0
  34. package/dist/schema.js.map +1 -0
  35. package/dist/types.d.ts +299 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +21 -0
  38. package/dist/types.js.map +1 -0
  39. package/docs/api.md +47 -0
  40. package/docs/architecture.md +30 -0
  41. package/docs/completion-audit.md +31 -0
  42. package/docs/operations.md +47 -0
  43. package/docs/product-scope.md +20 -0
  44. package/docs/release-policy.md +16 -0
  45. package/docs/testing.md +21 -0
  46. package/docs/threat-model.md +40 -0
  47. package/examples/codex-site/.openai/hosting.json +3 -0
  48. package/examples/codex-site/README.md +13 -0
  49. package/examples/codex-site/demo.ts +152 -0
  50. package/migrations/0001_taproot.sql +64 -0
  51. package/migrations/0002_audit_operations.sql +48 -0
  52. package/package.json +89 -0
@@ -0,0 +1,1499 @@
1
+ import { QuadPatchConflictError, encodeTerm, decodeTerm, prepareQuadPatch, } from '@gnolith/diamond';
2
+ import { DataFactory } from 'rdf-data-factory';
3
+ import { cloneEntity, entityNumericId, exportEntityJson, MAX_ENTITY_BYTES, parseEntityJson, validateEntity, validateSnak, } from './canonical.js';
4
+ import { EntityAlreadyExistsError, EntityNotFoundError, InvalidEntityError, InvalidStatementError, InvalidCursorError, BulkLimitError, PropertyDatatypeMismatchError, PropertyNotFoundError, QuadPatchTooLargeError, RevisionConflictError, SchemaMismatchError, } from './errors.js';
5
+ import { buildEntityQuads } from './rdf.js';
6
+ import { withoutTrailingSlashes } from './iri.js';
7
+ import { TAPROOT_RDF_VERSION } from './schema.js';
8
+ export class TaprootRepository {
9
+ #db;
10
+ #options;
11
+ constructor(db, options) {
12
+ if (options.mappingVersion !== undefined &&
13
+ options.mappingVersion !== TAPROOT_RDF_VERSION) {
14
+ throw new SchemaMismatchError(`RDF mapping version ${options.mappingVersion} is not supported`);
15
+ }
16
+ try {
17
+ const base = new URL(options.baseIri);
18
+ if (base.protocol !== 'http:' && base.protocol !== 'https:')
19
+ throw new TypeError();
20
+ }
21
+ catch (cause) {
22
+ throw new InvalidEntityError('baseIri must be an absolute HTTP(S) IRI', {
23
+ cause,
24
+ });
25
+ }
26
+ this.#db = db;
27
+ this.#options = {
28
+ baseIri: options.baseIri,
29
+ mappingVersion: options.mappingVersion ?? TAPROOT_RDF_VERSION,
30
+ maxEntityBytes: options.maxEntityBytes ?? MAX_ENTITY_BYTES,
31
+ maxBulkEntities: options.maxBulkEntities ?? 100,
32
+ clock: options.clock ?? (() => new Date()),
33
+ createId: options.createId ?? (() => crypto.randomUUID()),
34
+ validators: options.validators ?? [],
35
+ requireAttribution: options.requireAttribution ?? false,
36
+ ...(options.observe ? { observe: options.observe } : {}),
37
+ ...(options.factory ? { factory: options.factory } : {}),
38
+ };
39
+ }
40
+ async getEntity(id) {
41
+ const row = await this.#loadRow(id);
42
+ if (!row)
43
+ throw new EntityNotFoundError(`Entity ${id} was not found`);
44
+ return storedFromRow(row);
45
+ }
46
+ async resolveEntity(id, maxDepth = 100) {
47
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 1000)
48
+ throw new RangeError('maxDepth must be an integer from 0 through 1000');
49
+ const redirects = [];
50
+ const seen = new Set();
51
+ let current = id;
52
+ for (;;) {
53
+ if (seen.has(current))
54
+ throw new InvalidEntityError(`Redirect cycle includes ${current}`);
55
+ seen.add(current);
56
+ const stored = await this.getEntity(current);
57
+ if (!stored.redirectTo)
58
+ return { ...stored, requestedId: id, resolvedId: current, redirects };
59
+ if (redirects.length >= maxDepth)
60
+ throw new InvalidEntityError(`Redirect depth exceeds ${maxDepth}`);
61
+ redirects.push(stored.redirectTo);
62
+ current = stored.redirectTo;
63
+ }
64
+ }
65
+ async getEntityRevision(id, revision) {
66
+ const result = await this.#db
67
+ .prepare(`SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
68
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
69
+ FROM taproot_entity_revisions WHERE entity_id = ? AND revision = ?`)
70
+ .bind(id, revision)
71
+ .all();
72
+ const row = result.results[0];
73
+ if (!row)
74
+ throw new EntityNotFoundError(`Revision ${id}@${revision} was not found`);
75
+ return revisionFromRow(row);
76
+ }
77
+ async listEntityRevisions(id, limit = 50) {
78
+ assertLimit(limit);
79
+ const result = await this.#db
80
+ .prepare(`SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
81
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
82
+ FROM taproot_entity_revisions WHERE entity_id = ?
83
+ ORDER BY revision DESC LIMIT ?`)
84
+ .bind(id, limit)
85
+ .all();
86
+ return result.results.map(revisionFromRow);
87
+ }
88
+ async listEntityRevisionsPage(id, options = {}) {
89
+ const limit = options.limit ?? 50;
90
+ assertLimit(limit);
91
+ const before = options.cursor
92
+ ? decodeCursor(options.cursor).revision
93
+ : Number.MAX_SAFE_INTEGER;
94
+ const result = await this.#db
95
+ .prepare(`SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
96
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
97
+ FROM taproot_entity_revisions WHERE entity_id = ? AND revision < ?
98
+ ORDER BY revision DESC LIMIT ?`)
99
+ .bind(id, before, limit + 1)
100
+ .all();
101
+ return page(result.results.map(revisionFromRow), limit, (entry) => ({
102
+ revision: entry.revision,
103
+ }));
104
+ }
105
+ async listEntities(options = {}) {
106
+ const limit = options.limit ?? 50;
107
+ assertLimit(limit);
108
+ const after = options.cursor
109
+ ? decodeCursor(options.cursor)
110
+ : null;
111
+ const result = await this.#db
112
+ .prepare(`SELECT entity_id, entity_json, deleted_at, redirect_to FROM taproot_entities
113
+ WHERE (? IS NULL OR entity_type > ? OR
114
+ (entity_type = ? AND CAST(substr(entity_id, 2) AS INTEGER) > ?))
115
+ AND (? IS NULL OR entity_type = ?)
116
+ AND (? = 1 OR deleted_at IS NULL)
117
+ ORDER BY entity_type, CAST(substr(entity_id, 2) AS INTEGER) LIMIT ?`)
118
+ .bind(after?.entityType ?? null, after?.entityType ?? null, after?.entityType ?? null, after?.numericId ?? 0, options.type ?? null, options.type ?? null, options.includeDeleted ? 1 : 0, limit + 1)
119
+ .all();
120
+ return page(result.results.map((row) => ({
121
+ entityId: row.entity_id,
122
+ ...storedFromRow(row),
123
+ })), limit, (entry) => ({
124
+ entityType: entry.entity.type,
125
+ numericId: entityNumericId(entry.entityId),
126
+ }));
127
+ }
128
+ async getAuditEvent(eventId) {
129
+ const result = await this.#db
130
+ .prepare(`SELECT rowid AS event_sequence, * FROM taproot_audit_events WHERE event_id = ?`)
131
+ .bind(eventId)
132
+ .all();
133
+ const row = result.results[0];
134
+ if (!row)
135
+ throw new EntityNotFoundError(`Audit event ${eventId} was not found`);
136
+ return auditFromRow(row);
137
+ }
138
+ async listAuditEvents(options = {}) {
139
+ const limit = options.limit ?? 50;
140
+ assertLimit(limit);
141
+ const before = options.cursor
142
+ ? decodeCursor(options.cursor).sequence
143
+ : Number.MAX_SAFE_INTEGER;
144
+ const result = await this.#db
145
+ .prepare(`SELECT rowid AS event_sequence, * FROM taproot_audit_events
146
+ WHERE (? IS NULL OR entity_id = ?) AND (? IS NULL OR request_id = ?)
147
+ AND (? IS NULL OR event_type = ?)
148
+ AND (? IS NULL OR json_extract(attribution_json, '$.id') = ?)
149
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?))
150
+ AND rowid < ?
151
+ ORDER BY rowid DESC LIMIT ?`)
152
+ .bind(options.entityId ?? null, options.entityId ?? null, options.requestId ?? null, options.requestId ?? null, options.type ?? null, options.type ?? null, options.attributionId ?? null, options.attributionId ?? null, options.tag ?? null, options.tag ?? null, before, limit + 1)
153
+ .all();
154
+ return page(result.results.map(auditFromRow), limit, (event) => ({
155
+ sequence: event.sequence,
156
+ }));
157
+ }
158
+ async searchEntities(query, options = {}) {
159
+ const limit = options.limit ?? 20;
160
+ assertLimit(limit);
161
+ const escaped = query.replace(/[\\%_]/gu, '\\$&');
162
+ const result = await this.#db
163
+ .prepare(`SELECT t.entity_id, e.entity_type, t.language, t.term_type, t.value
164
+ FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
165
+ WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
166
+ AND (? IS NULL OR t.language = ?)
167
+ AND (? = 1 OR e.deleted_at IS NULL)
168
+ ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
169
+ t.value COLLATE NOCASE, t.entity_id
170
+ LIMIT ?`)
171
+ .bind(`%${escaped}%`, options.language ?? null, options.language ?? null, options.includeDeleted ? 1 : 0, query, limit)
172
+ .all();
173
+ return result.results.map((row) => ({
174
+ entityId: row.entity_id,
175
+ entityType: row.entity_type,
176
+ language: row.language,
177
+ termType: row.term_type,
178
+ value: row.value,
179
+ }));
180
+ }
181
+ async searchEntitiesPage(query, options = {}) {
182
+ const limit = options.limit ?? 20;
183
+ assertLimit(limit);
184
+ const offset = options.cursor
185
+ ? decodeCursor(options.cursor).offset
186
+ : 0;
187
+ if (!Number.isSafeInteger(offset) || offset < 0)
188
+ throw new InvalidCursorError('Search cursor offset is invalid');
189
+ const escaped = query.replace(/[\\%_]/gu, '\\$&');
190
+ const result = await this.#db
191
+ .prepare(`SELECT t.entity_id, e.entity_type, t.language, t.term_type, t.value
192
+ FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
193
+ WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
194
+ AND (? IS NULL OR t.language = ?) AND (? = 1 OR e.deleted_at IS NULL)
195
+ ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
196
+ t.value COLLATE NOCASE, t.entity_id, t.language, t.term_type, t.ordinal
197
+ LIMIT ? OFFSET ?`)
198
+ .bind(`%${escaped}%`, options.language ?? null, options.language ?? null, options.includeDeleted ? 1 : 0, query, limit + 1, offset)
199
+ .all();
200
+ const items = result.results.slice(0, limit).map((row) => ({
201
+ entityId: row.entity_id,
202
+ entityType: row.entity_type,
203
+ language: row.language,
204
+ termType: row.term_type,
205
+ value: row.value,
206
+ }));
207
+ return {
208
+ items,
209
+ cursor: result.results.length > limit
210
+ ? encodeCursor({ offset: offset + limit })
211
+ : null,
212
+ };
213
+ }
214
+ async createItem(input = {}) {
215
+ for (let attempt = 0; attempt < 8; attempt += 1) {
216
+ const id = input.id ?? (await this.#nextId('item'));
217
+ const entity = {
218
+ id,
219
+ type: 'item',
220
+ labels: structuredClone(input.labels ?? {}),
221
+ descriptions: structuredClone(input.descriptions ?? {}),
222
+ aliases: structuredClone(input.aliases ?? {}),
223
+ claims: structuredClone(input.claims ?? {}),
224
+ sitelinks: structuredClone(input.sitelinks ?? {}),
225
+ lastrevid: 1,
226
+ modified: this.#options.clock().toISOString(),
227
+ };
228
+ try {
229
+ return await this.#create(entity, input, input.id === undefined, 'create');
230
+ }
231
+ catch (cause) {
232
+ if (input.id !== undefined ||
233
+ !(cause instanceof RevisionConflictError ||
234
+ cause instanceof EntityAlreadyExistsError)) {
235
+ throw cause;
236
+ }
237
+ }
238
+ }
239
+ throw new RevisionConflictError('Could not allocate an Item id after 8 attempts');
240
+ }
241
+ async createProperty(input) {
242
+ for (let attempt = 0; attempt < 8; attempt += 1) {
243
+ const id = input.id ?? (await this.#nextId('property'));
244
+ const entity = {
245
+ id,
246
+ type: 'property',
247
+ datatype: input.datatype,
248
+ labels: structuredClone(input.labels ?? {}),
249
+ descriptions: structuredClone(input.descriptions ?? {}),
250
+ aliases: structuredClone(input.aliases ?? {}),
251
+ claims: structuredClone(input.claims ?? {}),
252
+ lastrevid: 1,
253
+ modified: this.#options.clock().toISOString(),
254
+ };
255
+ try {
256
+ return await this.#create(entity, input, input.id === undefined, 'create');
257
+ }
258
+ catch (cause) {
259
+ if (input.id !== undefined ||
260
+ !(cause instanceof RevisionConflictError ||
261
+ cause instanceof EntityAlreadyExistsError)) {
262
+ throw cause;
263
+ }
264
+ }
265
+ }
266
+ throw new RevisionConflictError('Could not allocate a Property id after 8 attempts');
267
+ }
268
+ async importEntity(entity, metadata = {}) {
269
+ const imported = cloneEntity(entity);
270
+ imported.lastrevid = Math.max(1, imported.lastrevid);
271
+ return this.#create(imported, metadata, false, 'import');
272
+ }
273
+ async replaceEntity(id, replacement, edit) {
274
+ if (replacement.id !== id)
275
+ throw new InvalidEntityError('Replacement entity id cannot change');
276
+ return this.#mutate(id, edit, () => cloneEntity(replacement), undefined, 'update');
277
+ }
278
+ async revertEntity(id, targetRevision, edit) {
279
+ const target = await this.getEntityRevision(id, targetRevision);
280
+ return this.#mutate(id, edit, () => cloneEntity(target.entity), { deletedAt: target.deletedAt, redirectTo: target.redirectTo }, 'revert');
281
+ }
282
+ async softDeleteEntity(id, edit) {
283
+ return this.#mutate(id, edit, (entity) => entity, {
284
+ deletedAt: this.#options.clock().toISOString(),
285
+ redirectTo: null,
286
+ }, 'delete');
287
+ }
288
+ async restoreEntity(id, edit) {
289
+ return this.#mutate(id, edit, (entity) => entity, {
290
+ deletedAt: null,
291
+ redirectTo: null,
292
+ }, 'restore');
293
+ }
294
+ async redirectEntity(id, target, edit) {
295
+ if (id === target)
296
+ throw new InvalidEntityError('An entity cannot redirect to itself');
297
+ const source = await this.getEntity(id);
298
+ const targetEntity = await this.getEntity(target);
299
+ if (source.entity.type !== targetEntity.entity.type)
300
+ throw new InvalidEntityError('Redirect source and target must have the same entity type');
301
+ if (targetEntity.deletedAt)
302
+ throw new InvalidEntityError('An entity cannot redirect to a deleted target');
303
+ const seen = new Set([id]);
304
+ let cursor = target;
305
+ for (let depth = 0; cursor && depth < 100; depth += 1) {
306
+ if (seen.has(cursor))
307
+ throw new InvalidEntityError('Redirect would create a cycle');
308
+ seen.add(cursor);
309
+ cursor = (await this.getEntity(cursor)).redirectTo;
310
+ }
311
+ if (cursor)
312
+ throw new InvalidEntityError('Redirect chain exceeds 100 entities');
313
+ return this.#mutate(id, edit, (entity) => entity, {
314
+ deletedAt: null,
315
+ redirectTo: target,
316
+ }, 'redirect');
317
+ }
318
+ async setLabel(id, language, value, edit) {
319
+ return this.#mutate(id, edit, (entity) => {
320
+ entity.labels[language] = { language, value };
321
+ return entity;
322
+ });
323
+ }
324
+ async removeLabel(id, language, edit) {
325
+ return this.#mutate(id, edit, (entity) => {
326
+ delete entity.labels[language];
327
+ return entity;
328
+ });
329
+ }
330
+ async setDescription(id, language, value, edit) {
331
+ return this.#mutate(id, edit, (entity) => {
332
+ entity.descriptions[language] = { language, value };
333
+ return entity;
334
+ });
335
+ }
336
+ async removeDescription(id, language, edit) {
337
+ return this.#mutate(id, edit, (entity) => {
338
+ delete entity.descriptions[language];
339
+ return entity;
340
+ });
341
+ }
342
+ async addAlias(id, language, value, edit) {
343
+ return this.#mutate(id, edit, (entity) => {
344
+ entity.aliases[language] ??= [];
345
+ entity.aliases[language].push({ language, value });
346
+ return entity;
347
+ });
348
+ }
349
+ async removeAlias(id, language, ordinal, edit) {
350
+ return this.#mutate(id, edit, (entity) => {
351
+ const aliases = entity.aliases[language];
352
+ if (!aliases?.[ordinal])
353
+ throw new InvalidEntityError(`Alias ${language}[${ordinal}] does not exist`);
354
+ aliases.splice(ordinal, 1);
355
+ if (!aliases.length)
356
+ delete entity.aliases[language];
357
+ return entity;
358
+ });
359
+ }
360
+ async setSitelink(id, site, value, edit) {
361
+ return this.#mutate(id, edit, (entity) => {
362
+ if (entity.type !== 'item')
363
+ throw new InvalidEntityError('Properties cannot have sitelinks');
364
+ entity.sitelinks[site] = { ...structuredClone(value), site };
365
+ return entity;
366
+ });
367
+ }
368
+ async removeSitelink(id, site, edit) {
369
+ return this.#mutate(id, edit, (entity) => {
370
+ if (entity.type !== 'item')
371
+ throw new InvalidEntityError('Properties cannot have sitelinks');
372
+ delete entity.sitelinks[site];
373
+ return entity;
374
+ });
375
+ }
376
+ async addStatement(id, statement, edit) {
377
+ return this.#mutate(id, edit, (entity) => {
378
+ const property = statement.mainsnak.property;
379
+ entity.claims[property] ??= [];
380
+ entity.claims[property].push(structuredClone(statement));
381
+ return entity;
382
+ });
383
+ }
384
+ async replaceStatement(id, statementId, statement, edit) {
385
+ return this.#mutate(id, edit, (entity) => {
386
+ const located = locateStatement(entity, statementId);
387
+ located.statements.splice(located.index, 1);
388
+ if (!located.statements.length)
389
+ delete entity.claims[located.property];
390
+ const target = statement.mainsnak.property;
391
+ entity.claims[target] ??= [];
392
+ entity.claims[target].push(structuredClone(statement));
393
+ return entity;
394
+ });
395
+ }
396
+ async removeStatement(id, statementId, edit) {
397
+ return this.#mutate(id, edit, (entity) => {
398
+ const located = locateStatement(entity, statementId);
399
+ located.statements.splice(located.index, 1);
400
+ if (!located.statements.length)
401
+ delete entity.claims[located.property];
402
+ return entity;
403
+ });
404
+ }
405
+ async setStatementRank(id, statementId, rank, edit) {
406
+ return this.#mutate(id, edit, (entity) => {
407
+ locateStatement(entity, statementId).statement.rank = rank;
408
+ return entity;
409
+ });
410
+ }
411
+ async addQualifier(id, statementId, snak, edit) {
412
+ validateSnak(snak);
413
+ return this.#mutate(id, edit, (entity) => {
414
+ const statement = locateStatement(entity, statementId).statement;
415
+ const property = snak.property;
416
+ if (!statement.qualifiers[property]) {
417
+ statement.qualifiers[property] = [];
418
+ statement['qualifiers-order'].push(property);
419
+ }
420
+ statement.qualifiers[property].push(structuredClone(snak));
421
+ return entity;
422
+ });
423
+ }
424
+ async removeQualifier(id, statementId, property, ordinal, edit) {
425
+ return this.#mutate(id, edit, (entity) => {
426
+ const statement = locateStatement(entity, statementId).statement;
427
+ const snaks = statement.qualifiers[property];
428
+ if (!snaks?.[ordinal])
429
+ throw new InvalidStatementError('Qualifier does not exist');
430
+ snaks.splice(ordinal, 1);
431
+ if (!snaks.length) {
432
+ delete statement.qualifiers[property];
433
+ statement['qualifiers-order'] = statement['qualifiers-order'].filter((id) => id !== property);
434
+ }
435
+ return entity;
436
+ });
437
+ }
438
+ async addReference(id, statementId, reference, edit) {
439
+ return this.#mutate(id, edit, (entity) => {
440
+ locateStatement(entity, statementId).statement.references.push(structuredClone(reference));
441
+ return entity;
442
+ });
443
+ }
444
+ async removeReference(id, statementId, hash, edit) {
445
+ return this.#mutate(id, edit, (entity) => {
446
+ const references = locateStatement(entity, statementId).statement
447
+ .references;
448
+ const index = references.findIndex((reference) => reference.hash === hash);
449
+ if (index < 0)
450
+ throw new InvalidStatementError(`Reference ${hash} does not exist`);
451
+ references.splice(index, 1);
452
+ return entity;
453
+ });
454
+ }
455
+ async replaceReference(id, statementId, hash, reference, edit) {
456
+ return this.#mutate(id, edit, (entity) => {
457
+ const references = locateStatement(entity, statementId).statement
458
+ .references;
459
+ const index = references.findIndex((item) => item.hash === hash);
460
+ if (index < 0)
461
+ throw new InvalidStatementError(`Reference ${hash} does not exist`);
462
+ references[index] = structuredClone(reference);
463
+ return entity;
464
+ });
465
+ }
466
+ async importEntities(entities, options = {}) {
467
+ const values = [...entities];
468
+ if (values.length > this.#options.maxBulkEntities) {
469
+ throw new BulkLimitError(`Bulk import contains ${values.length} entities; maximum is ${this.#options.maxBulkEntities}`);
470
+ }
471
+ const succeeded = new Map();
472
+ const failed = [];
473
+ let pending = values
474
+ .map((entity, index) => ({ entity, index }))
475
+ .sort((a, b) => Number(a.entity.type !== 'property') -
476
+ Number(b.entity.type !== 'property'));
477
+ while (pending.length) {
478
+ const deferred = [];
479
+ let progress = false;
480
+ for (const entry of pending) {
481
+ try {
482
+ succeeded.set(entry.index, await this.#importOne(entry.entity, options));
483
+ progress = true;
484
+ }
485
+ catch (error) {
486
+ if (error instanceof PropertyNotFoundError) {
487
+ deferred.push(entry);
488
+ continue;
489
+ }
490
+ failed.push({
491
+ index: entry.index,
492
+ entityId: entry.entity.id,
493
+ error: toError(error),
494
+ });
495
+ if (!options.continueOnError)
496
+ return {
497
+ succeeded: [...succeeded.entries()]
498
+ .sort(([a], [b]) => a - b)
499
+ .map(([, item]) => item),
500
+ failed,
501
+ };
502
+ }
503
+ }
504
+ if (!deferred.length)
505
+ break;
506
+ if (!progress) {
507
+ for (const entry of options.continueOnError
508
+ ? deferred
509
+ : deferred.slice(0, 1)) {
510
+ failed.push({
511
+ index: entry.index,
512
+ entityId: entry.entity.id,
513
+ error: new PropertyNotFoundError(`Entity ${entry.entity.id} has unresolved Property dependencies`),
514
+ });
515
+ }
516
+ break;
517
+ }
518
+ pending = deferred;
519
+ }
520
+ return {
521
+ succeeded: [...succeeded.entries()]
522
+ .sort(([a], [b]) => a - b)
523
+ .map(([, item]) => item),
524
+ failed: failed.sort((a, b) => a.index - b.index),
525
+ };
526
+ }
527
+ async #importOne(entity, options) {
528
+ if (options.mode === 'upsert') {
529
+ try {
530
+ const current = await this.getEntity(entity.id);
531
+ return await this.replaceEntity(entity.id, entity, {
532
+ expectedRevision: current.entity.lastrevid,
533
+ ...options.metadata,
534
+ });
535
+ }
536
+ catch (error) {
537
+ if (!(error instanceof EntityNotFoundError))
538
+ throw error;
539
+ }
540
+ }
541
+ return this.importEntity(entity, options.metadata);
542
+ }
543
+ async applyCommands(id, commands, edit) {
544
+ if (!commands.length)
545
+ throw new InvalidEntityError('At least one command is required');
546
+ if (commands.length > 100)
547
+ throw new BulkLimitError('An edit may contain at most 100 commands');
548
+ return this.#mutate(id, edit, (entity) => {
549
+ for (const command of commands)
550
+ applyEntityCommand(entity, command);
551
+ return entity;
552
+ });
553
+ }
554
+ async exportEntities(options = {}) {
555
+ const lines = [];
556
+ let cursor = options.cursor;
557
+ do {
558
+ const current = await this.listEntities({
559
+ ...options,
560
+ ...(cursor ? { cursor } : {}),
561
+ limit: options.limit ?? 100,
562
+ });
563
+ lines.push(...current.items.map(({ entity }) => exportEntityJson(entity, this.#options.maxEntityBytes)));
564
+ cursor = current.cursor ?? undefined;
565
+ } while (cursor);
566
+ return lines.length ? `${lines.join('\n')}\n` : '';
567
+ }
568
+ async inspectEntityIntegrity(id) {
569
+ const stored = await this.getEntity(id);
570
+ const entity = stored.entity;
571
+ const issues = [];
572
+ const revisionResult = await this.#db
573
+ .prepare(`SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
574
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
575
+ FROM taproot_entity_revisions WHERE entity_id = ? AND revision = ?`)
576
+ .bind(id, entity.lastrevid)
577
+ .all();
578
+ const revision = revisionResult.results[0];
579
+ if (!revision) {
580
+ issues.push({
581
+ code: 'current-revision-mismatch',
582
+ message: `Current revision ${entity.lastrevid} is missing`,
583
+ });
584
+ }
585
+ else {
586
+ const currentJson = exportEntityJson(entity, this.#options.maxEntityBytes);
587
+ if (revision.entity_json !== currentJson)
588
+ issues.push({
589
+ code: 'revision-json-mismatch',
590
+ message: 'Current entity JSON differs from its immutable revision',
591
+ });
592
+ if (revision.content_hash !==
593
+ (await revisionContentHash(revision.entity_json, {
594
+ deletedAt: revision.deleted_at,
595
+ redirectTo: revision.redirect_to,
596
+ })))
597
+ issues.push({
598
+ code: 'content-hash-mismatch',
599
+ message: 'Revision content hash is invalid',
600
+ });
601
+ const audit = await this.#db
602
+ .prepare(`SELECT 1 AS found FROM taproot_audit_events WHERE event_id = ?`)
603
+ .bind(revision.event_id)
604
+ .all();
605
+ if (!audit.results.length)
606
+ issues.push({
607
+ code: 'audit-event-missing',
608
+ message: `Audit event ${revision.event_id} is missing`,
609
+ });
610
+ }
611
+ const actualTerms = await this.#db
612
+ .prepare(`SELECT language, term_type, value, ordinal FROM taproot_terms WHERE entity_id = ? ORDER BY language, term_type, ordinal`)
613
+ .bind(id)
614
+ .all();
615
+ const expectedTerms = terms(entity)
616
+ .map(({ language, termType, value, ordinal }) => ({
617
+ language,
618
+ term_type: termType,
619
+ value,
620
+ ordinal,
621
+ }))
622
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
623
+ const normalizedActual = [...actualTerms.results].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
624
+ if (JSON.stringify(normalizedActual) !== JSON.stringify(expectedTerms))
625
+ issues.push({
626
+ code: 'term-projection-mismatch',
627
+ message: 'Search term projection differs from canonical JSON',
628
+ });
629
+ const expectedQuads = this.#lifecycleQuads(entity, {
630
+ deletedAt: stored.deletedAt,
631
+ redirectTo: stored.redirectTo,
632
+ });
633
+ const actualQuads = await this.#ownedQuads(id);
634
+ if (!sameQuads(actualQuads, expectedQuads))
635
+ issues.push({
636
+ code: 'rdf-projection-mismatch',
637
+ message: 'RDF projection differs from canonical JSON',
638
+ });
639
+ return {
640
+ entityId: id,
641
+ revision: entity.lastrevid,
642
+ valid: issues.length === 0,
643
+ issues,
644
+ };
645
+ }
646
+ async inspectTaprootIntegrity(options = {}) {
647
+ const entities = await this.listEntities({
648
+ ...options,
649
+ includeDeleted: true,
650
+ });
651
+ const reports = [];
652
+ for (const { entityId } of entities.items)
653
+ reports.push(await this.inspectEntityIntegrity(entityId));
654
+ return { items: reports, cursor: entities.cursor };
655
+ }
656
+ async verifyAuditChain(id) {
657
+ const stored = await this.getEntity(id);
658
+ const rows = await this.#db
659
+ .prepare(`SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
660
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
661
+ FROM taproot_entity_revisions WHERE entity_id = ? ORDER BY revision`)
662
+ .bind(id)
663
+ .all();
664
+ const issues = [];
665
+ let parentHash = null;
666
+ let expectedRevision = rows.results[0]?.revision ?? 1;
667
+ for (const row of rows.results) {
668
+ if (row.revision !== expectedRevision)
669
+ issues.push({
670
+ code: 'current-revision-mismatch',
671
+ message: `Revision sequence skips from ${expectedRevision - 1} to ${row.revision}`,
672
+ });
673
+ const actualHash = await revisionContentHash(row.entity_json, {
674
+ deletedAt: row.deleted_at,
675
+ redirectTo: row.redirect_to,
676
+ });
677
+ if (actualHash !== row.content_hash)
678
+ issues.push({
679
+ code: 'content-hash-mismatch',
680
+ message: `Revision ${row.revision} content hash is invalid`,
681
+ });
682
+ if (row.parent_hash !== parentHash)
683
+ issues.push({
684
+ code: 'content-hash-mismatch',
685
+ message: `Revision ${row.revision} does not link to its parent`,
686
+ });
687
+ const audit = await this.#db
688
+ .prepare(`SELECT content_hash, parent_hash FROM taproot_audit_events WHERE event_id = ?`)
689
+ .bind(row.event_id)
690
+ .all();
691
+ if (!audit.results[0] ||
692
+ audit.results[0].content_hash !== row.content_hash ||
693
+ audit.results[0].parent_hash !== row.parent_hash)
694
+ issues.push({
695
+ code: 'audit-event-missing',
696
+ message: `Revision ${row.revision} has no matching audit event`,
697
+ });
698
+ parentHash = row.content_hash;
699
+ expectedRevision = row.revision + 1;
700
+ }
701
+ if (rows.results.at(-1)?.revision !== stored.entity.lastrevid)
702
+ issues.push({
703
+ code: 'current-revision-mismatch',
704
+ message: 'Current entity revision is not the chain head',
705
+ });
706
+ return {
707
+ entityId: id,
708
+ revision: stored.entity.lastrevid,
709
+ valid: issues.length === 0,
710
+ issues,
711
+ };
712
+ }
713
+ async repairEntityProjection(id, metadata = {}) {
714
+ const started = performance.now();
715
+ const stored = await this.getEntity(id);
716
+ const before = await this.inspectEntityIntegrity(id);
717
+ const expected = this.#lifecycleQuads(stored.entity, {
718
+ deletedAt: stored.deletedAt,
719
+ redirectTo: stored.redirectTo,
720
+ });
721
+ const actual = await this.#ownedQuads(id);
722
+ const patch = this.#preparePatch({ insert: expected });
723
+ const json = exportEntityJson(stored.entity, this.#options.maxEntityBytes);
724
+ const parentHash = await revisionContentHash(json, {
725
+ deletedAt: stored.deletedAt,
726
+ redirectTo: stored.redirectTo,
727
+ });
728
+ const context = await this.#writeContext(stored.entity, json, metadata, parentHash, 'repair', {
729
+ deletedAt: stored.deletedAt,
730
+ redirectTo: stored.redirectTo,
731
+ });
732
+ const statements = [
733
+ this.#db
734
+ .prepare(`DELETE FROM taproot_terms WHERE entity_id = ?`)
735
+ .bind(id),
736
+ this.#termsInsert(stored.entity),
737
+ this.#auditInsert(stored.entity, metadata, context),
738
+ ...patch.statements,
739
+ ...this.#ownershipPatch(id, actual, expected).statements,
740
+ ];
741
+ try {
742
+ await this.#db.batch(statements);
743
+ }
744
+ catch (error) {
745
+ const cause = toError(error);
746
+ this.#emitObservation('repair', started, 'error', id, stored.entity.lastrevid, cause);
747
+ if (isEventIdUniqueError(cause))
748
+ throw new RevisionConflictError('Generated audit event id already exists', { cause });
749
+ throw cause;
750
+ }
751
+ const after = await this.inspectEntityIntegrity(id);
752
+ this.#emitObservation('repair', started, 'success', id, stored.entity.lastrevid);
753
+ if (!after.valid &&
754
+ before.issues.some((issue) => issue.code !== 'term-projection-mismatch' &&
755
+ issue.code !== 'rdf-projection-mismatch')) {
756
+ return after;
757
+ }
758
+ return after;
759
+ }
760
+ async #create(entity, metadata, allocated, eventType) {
761
+ const started = performance.now();
762
+ validateEntity(entity);
763
+ if (await this.#loadRow(entity.id))
764
+ throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`);
765
+ await this.#validatePropertyDatatypes(entity);
766
+ await this.#runValidators(entity, null, metadata);
767
+ const json = exportEntityJson(entity, this.#options.maxEntityBytes);
768
+ const lifecycle = { deletedAt: null, redirectTo: null };
769
+ const context = await this.#writeContext(entity, json, metadata, null, eventType, lifecycle);
770
+ const newQuads = this.#lifecycleQuads(entity, lifecycle);
771
+ const marker = revisionQuad(entity, this.#options);
772
+ const patch = this.#preparePatch({ forbid: [marker], insert: newQuads });
773
+ const statements = this.#namespaceStatements();
774
+ if (allocated) {
775
+ const type = entity.type;
776
+ const numeric = entityNumericId(entity.id);
777
+ statements.push(this.#db
778
+ .prepare(`UPDATE taproot_id_counters SET next_numeric_id = ? WHERE entity_type = ? AND next_numeric_id = ?`)
779
+ .bind(numeric + 1, type, numeric), this.#assertion(`EXISTS (SELECT 1 FROM taproot_id_counters WHERE entity_type = ? AND next_numeric_id = ?)`, type, numeric + 1));
780
+ }
781
+ else {
782
+ statements.push(this.#db
783
+ .prepare(`UPDATE taproot_id_counters SET next_numeric_id = MAX(next_numeric_id, ?) WHERE entity_type = ?`)
784
+ .bind(entityNumericId(entity.id) + 1, entity.type));
785
+ }
786
+ statements.push(this.#db
787
+ .prepare(`INSERT INTO taproot_entities(entity_id, entity_type, datatype, revision, entity_json, modified_at) VALUES (?, ?, ?, ?, ?, ?)`)
788
+ .bind(entity.id, entity.type, entity.type === 'property' ? entity.datatype : null, entity.lastrevid, json, entity.modified), this.#revisionInsert(entity, json, metadata, context), this.#auditInsert(entity, metadata, context), this.#termsInsert(entity));
789
+ const patchOffset = statements.length;
790
+ statements.push(...patch.statements);
791
+ const ownership = this.#ownershipPatch(entity.id, [], newQuads);
792
+ const ownershipOffset = statements.length;
793
+ statements.push(...ownership.statements);
794
+ try {
795
+ const results = await this.#db.batch(statements);
796
+ const result = {
797
+ entityId: entity.id,
798
+ previousRevision: null,
799
+ newRevision: entity.lastrevid,
800
+ entity,
801
+ quadPatch: {
802
+ ...patch.readResult(results, patchOffset),
803
+ deleted: ownership.deleted(results, ownershipOffset),
804
+ },
805
+ eventId: context.eventId,
806
+ contentHash: context.contentHash,
807
+ };
808
+ this.#emitObservation(eventType, started, 'success', entity.id, entity.lastrevid);
809
+ return result;
810
+ }
811
+ catch (cause) {
812
+ this.#emitObservation(eventType, started, 'error', entity.id, entity.lastrevid, cause);
813
+ const mapped = patch.mapError(cause);
814
+ if (isAssertionError(mapped) && (await this.#namespaceMismatch())) {
815
+ throw new SchemaMismatchError(`This database is bound to a different Taproot base IRI`, { cause: mapped });
816
+ }
817
+ if (mapped instanceof QuadPatchConflictError ||
818
+ isUniqueEntityError(mapped)) {
819
+ throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`, { cause: mapped });
820
+ }
821
+ if (isEventIdUniqueError(mapped))
822
+ throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
823
+ if (allocated && isAssertionError(mapped))
824
+ throw new RevisionConflictError(`ID allocation for ${entity.id} was stale`, { cause: mapped });
825
+ throw mapped;
826
+ }
827
+ }
828
+ async #mutate(id, edit, transform, lifecycleOverride, eventType = 'update') {
829
+ const started = performance.now();
830
+ const row = await this.#loadRow(id);
831
+ if (!row)
832
+ throw new EntityNotFoundError(`Entity ${id} was not found`);
833
+ const stored = storedFromRow(row);
834
+ if (stored.entity.lastrevid !== edit.expectedRevision) {
835
+ throw new RevisionConflictError(`Expected ${id}@${edit.expectedRevision}, found ${stored.entity.lastrevid}`);
836
+ }
837
+ const previous = stored.entity.lastrevid;
838
+ const next = transform(cloneEntity(stored.entity));
839
+ next.lastrevid = previous + 1;
840
+ next.modified = this.#options.clock().toISOString();
841
+ if (next.id !== id || next.type !== stored.entity.type)
842
+ throw new InvalidEntityError('Entity identity and type are immutable');
843
+ if (stored.entity.type === 'property' &&
844
+ next.type === 'property' &&
845
+ stored.entity.datatype !== next.datatype &&
846
+ (await this.#propertyInUse(stored.entity.id))) {
847
+ throw new InvalidEntityError('Property datatype is immutable after use');
848
+ }
849
+ validateEntity(next);
850
+ await this.#validatePropertyDatatypes(next);
851
+ await this.#runValidators(next, stored.entity, edit);
852
+ const oldLifecycle = {
853
+ deletedAt: stored.deletedAt,
854
+ redirectTo: stored.redirectTo,
855
+ };
856
+ const json = exportEntityJson(next, this.#options.maxEntityBytes);
857
+ const parentHash = row.content_hash ??
858
+ (await revisionContentHash(row.entity_json, oldLifecycle));
859
+ const newLifecycle = lifecycleOverride ?? oldLifecycle;
860
+ const context = await this.#writeContext(next, json, edit, parentHash, eventType, newLifecycle);
861
+ const oldQuads = this.#lifecycleQuads(stored.entity, oldLifecycle);
862
+ const newQuads = this.#lifecycleQuads(next, newLifecycle);
863
+ const patch = this.#preparePatch({
864
+ require: [revisionQuad(stored.entity, this.#options)],
865
+ insert: newQuads,
866
+ });
867
+ const statements = [
868
+ ...this.#namespaceStatements(),
869
+ this.#db
870
+ .prepare(`UPDATE taproot_entities SET datatype = ?, revision = ?, entity_json = ?, modified_at = ?, deleted_at = ?, redirect_to = ? WHERE entity_id = ? AND revision = ?`)
871
+ .bind(next.type === 'property' ? next.datatype : null, next.lastrevid, json, next.modified, newLifecycle.deletedAt, newLifecycle.redirectTo, id, previous),
872
+ this.#assertion(`EXISTS (SELECT 1 FROM taproot_entities WHERE entity_id = ? AND revision = ?)`, id, next.lastrevid),
873
+ this.#revisionInsert(next, json, edit, context),
874
+ this.#auditInsert(next, edit, context),
875
+ this.#db
876
+ .prepare('DELETE FROM taproot_terms WHERE entity_id = ?')
877
+ .bind(id),
878
+ this.#termsInsert(next),
879
+ ];
880
+ const patchOffset = statements.length;
881
+ statements.push(...patch.statements);
882
+ const ownership = this.#ownershipPatch(id, oldQuads, newQuads);
883
+ const ownershipOffset = statements.length;
884
+ statements.push(...ownership.statements);
885
+ try {
886
+ const results = await this.#db.batch(statements);
887
+ const result = {
888
+ entityId: id,
889
+ previousRevision: previous,
890
+ newRevision: next.lastrevid,
891
+ entity: next,
892
+ quadPatch: {
893
+ ...patch.readResult(results, patchOffset),
894
+ deleted: ownership.deleted(results, ownershipOffset),
895
+ },
896
+ eventId: context.eventId,
897
+ contentHash: context.contentHash,
898
+ };
899
+ this.#emitObservation(eventType, started, 'success', id, next.lastrevid);
900
+ return result;
901
+ }
902
+ catch (cause) {
903
+ this.#emitObservation(eventType, started, 'error', id, next.lastrevid, cause);
904
+ const mapped = patch.mapError(cause);
905
+ if (isAssertionError(mapped) && (await this.#namespaceMismatch())) {
906
+ throw new SchemaMismatchError(`This database is bound to a different Taproot base IRI`, { cause: mapped });
907
+ }
908
+ if (mapped instanceof QuadPatchConflictError ||
909
+ isAssertionError(mapped) ||
910
+ isRevisionUniqueError(mapped)) {
911
+ throw new RevisionConflictError(`Entity ${id} changed after revision ${previous}`, { cause: mapped });
912
+ }
913
+ if (isEventIdUniqueError(mapped))
914
+ throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
915
+ throw mapped;
916
+ }
917
+ }
918
+ #preparePatch(patch) {
919
+ try {
920
+ return prepareQuadPatch(this.#db, patch);
921
+ }
922
+ catch (cause) {
923
+ if (cause instanceof RangeError)
924
+ throw new QuadPatchTooLargeError(cause.message, { cause });
925
+ throw cause;
926
+ }
927
+ }
928
+ #ownershipPatch(entityId, oldQuads, newQuads) {
929
+ const oldRows = oldQuads.map(ownershipRow);
930
+ const newRows = newQuads.map(ownershipRow);
931
+ const statements = [
932
+ this.#db
933
+ .prepare(`DELETE FROM taproot_rdf_ownership WHERE entity_id = ?`)
934
+ .bind(entityId),
935
+ ];
936
+ if (newRows.length) {
937
+ statements.push(this.#db
938
+ .prepare(`INSERT INTO taproot_rdf_ownership(entity_id, subject_key, predicate_key, object_key, graph_key)
939
+ SELECT ?, json_extract(value, '$.subjectKey'), json_extract(value, '$.predicateKey'),
940
+ json_extract(value, '$.objectKey'), json_extract(value, '$.graphKey') FROM json_each(?)`)
941
+ .bind(entityId, JSON.stringify(newRows)));
942
+ }
943
+ let deleteIndex = -1;
944
+ if (oldRows.length) {
945
+ deleteIndex = statements.length;
946
+ statements.push(this.#db
947
+ .prepare(`DELETE FROM rdf_quads AS q WHERE EXISTS (
948
+ SELECT 1 FROM json_each(?) old
949
+ WHERE q.subject_key = json_extract(old.value, '$.subjectKey')
950
+ AND q.predicate_key = json_extract(old.value, '$.predicateKey')
951
+ AND q.object_key = json_extract(old.value, '$.objectKey')
952
+ AND q.graph_key = json_extract(old.value, '$.graphKey')
953
+ ) AND NOT EXISTS (
954
+ SELECT 1 FROM taproot_rdf_ownership own
955
+ WHERE own.subject_key = q.subject_key AND own.predicate_key = q.predicate_key
956
+ AND own.object_key = q.object_key AND own.graph_key = q.graph_key
957
+ )`)
958
+ .bind(JSON.stringify(oldRows)));
959
+ }
960
+ return {
961
+ statements,
962
+ deleted: (results, offset) => deleteIndex < 0
963
+ ? 0
964
+ : Number(results[offset + deleteIndex]?.meta?.changes ?? 0),
965
+ };
966
+ }
967
+ async #ownedQuads(entityId) {
968
+ const result = await this.#db
969
+ .prepare(`SELECT q.subject_key, q.subject_json, q.predicate_key, q.predicate_json,
970
+ q.object_key, q.object_json, q.graph_key, q.graph_json
971
+ FROM taproot_rdf_ownership own JOIN rdf_quads q
972
+ ON q.subject_key = own.subject_key AND q.predicate_key = own.predicate_key
973
+ AND q.object_key = own.object_key AND q.graph_key = own.graph_key
974
+ WHERE own.entity_id = ?`)
975
+ .bind(entityId)
976
+ .all();
977
+ const factory = this.#options.factory ?? new DataFactory();
978
+ return result.results.map((row) => factory.quad(decodeTerm(row.subject_json), decodeTerm(row.predicate_json), decodeTerm(row.object_json), decodeTerm(row.graph_json)));
979
+ }
980
+ async #validatePropertyDatatypes(entity) {
981
+ const used = new Map();
982
+ for (const statements of Object.values(entity.claims)) {
983
+ for (const statement of statements) {
984
+ collectSnak(statement.mainsnak, used);
985
+ for (const snaks of Object.values(statement.qualifiers))
986
+ for (const snak of snaks)
987
+ collectSnak(snak, used);
988
+ for (const reference of statement.references)
989
+ for (const snaks of Object.values(reference.snaks))
990
+ for (const snak of snaks)
991
+ collectSnak(snak, used);
992
+ }
993
+ }
994
+ if (!used.size)
995
+ return;
996
+ const ids = JSON.stringify([...used.keys()]);
997
+ const properties = await this.#db
998
+ .prepare(`SELECT entity_id, datatype FROM taproot_entities WHERE entity_id IN (SELECT value FROM json_each(?)) AND entity_type = 'property' AND deleted_at IS NULL`)
999
+ .bind(ids)
1000
+ .all();
1001
+ const actual = new Map(properties.results.map((row) => [row.entity_id, row.datatype]));
1002
+ if (entity.type === 'property')
1003
+ actual.set(entity.id, entity.datatype);
1004
+ for (const [property, datatype] of used) {
1005
+ const expected = actual.get(property);
1006
+ if (!expected)
1007
+ throw new PropertyNotFoundError(`Property ${property} was not found`);
1008
+ if (expected !== datatype)
1009
+ throw new PropertyDatatypeMismatchError(`Property ${property} requires ${expected}, received ${datatype}`);
1010
+ }
1011
+ }
1012
+ async #nextId(type) {
1013
+ const result = await this.#db
1014
+ .prepare('SELECT next_numeric_id FROM taproot_id_counters WHERE entity_type = ?')
1015
+ .bind(type)
1016
+ .all();
1017
+ const numeric = result.results[0]?.next_numeric_id;
1018
+ if (!numeric)
1019
+ throw new InvalidEntityError(`Missing ${type} ID counter`);
1020
+ return `${type === 'item' ? 'Q' : 'P'}${numeric}`;
1021
+ }
1022
+ async #propertyInUse(id) {
1023
+ const result = await this.#db
1024
+ .prepare(`SELECT 1 AS used FROM taproot_entities
1025
+ WHERE entity_json LIKE ? ESCAPE '\\' LIMIT 1`)
1026
+ .bind(`%"property":"${id}"%`)
1027
+ .all();
1028
+ return result.results.length > 0;
1029
+ }
1030
+ async #loadRow(id) {
1031
+ const result = await this.#db
1032
+ .prepare(`SELECT e.entity_json, e.deleted_at, e.redirect_to, r.content_hash
1033
+ FROM taproot_entities e LEFT JOIN taproot_entity_revisions r
1034
+ ON r.entity_id = e.entity_id AND r.revision = e.revision
1035
+ WHERE e.entity_id = ?`)
1036
+ .bind(id)
1037
+ .all();
1038
+ return result.results[0];
1039
+ }
1040
+ #revisionInsert(entity, json, metadata, context) {
1041
+ return this.#db
1042
+ .prepare(`INSERT INTO taproot_entity_revisions(
1043
+ entity_id, revision, entity_json, actor, attribution_json, edit_summary,
1044
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
1045
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1046
+ .bind(entity.id, entity.lastrevid, json, metadata.actor ?? context.attribution?.id ?? null, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), context.eventId, context.contentHash, context.parentHash, context.lifecycle.deletedAt, context.lifecycle.redirectTo, context.createdAt);
1047
+ }
1048
+ #auditInsert(entity, metadata, context) {
1049
+ return this.#db
1050
+ .prepare(`INSERT INTO taproot_audit_events(
1051
+ event_id, entity_id, revision, event_type, attribution_json, edit_summary,
1052
+ tags_json, request_id, content_hash, parent_hash, details_json, created_at
1053
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1054
+ .bind(context.eventId, entity.id, entity.lastrevid, context.eventType, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), metadata.requestId ?? null, context.contentHash, context.parentHash, JSON.stringify(context.lifecycle), context.createdAt);
1055
+ }
1056
+ async #writeContext(entity, json, metadata, parentHash, eventType, lifecycle) {
1057
+ const attribution = normalizeAttribution(metadata);
1058
+ if (this.#options.requireAttribution && !attribution)
1059
+ throw new InvalidEntityError('Attribution is required for knowledge writes');
1060
+ const tags = normalizeTags(metadata.tags);
1061
+ if (metadata.editSummary !== undefined &&
1062
+ (typeof metadata.editSummary !== 'string' ||
1063
+ metadata.editSummary.length > 1_000))
1064
+ throw new InvalidEntityError('Edit summary must be a string of at most 1,000 characters');
1065
+ if (metadata.requestId !== undefined &&
1066
+ (typeof metadata.requestId !== 'string' ||
1067
+ !metadata.requestId.trim() ||
1068
+ metadata.requestId.length > 256))
1069
+ throw new InvalidEntityError('Request id must contain 1 through 256 characters');
1070
+ const eventId = this.#options.createId();
1071
+ if (typeof eventId !== 'string' || !eventId.trim() || eventId.length > 128)
1072
+ throw new InvalidEntityError('Generated event id must contain 1 through 128 characters');
1073
+ return {
1074
+ eventId,
1075
+ contentHash: await revisionContentHash(json, lifecycle),
1076
+ parentHash,
1077
+ eventType,
1078
+ attribution,
1079
+ tags,
1080
+ createdAt: this.#options.clock().toISOString(),
1081
+ lifecycle,
1082
+ };
1083
+ }
1084
+ async #runValidators(entity, previous, metadata) {
1085
+ for (const validator of this.#options.validators) {
1086
+ await validator(entity, { previous, metadata });
1087
+ }
1088
+ }
1089
+ #emitObservation(operation, started, outcome, entityId, revision, error) {
1090
+ if (!this.#options.observe)
1091
+ return;
1092
+ try {
1093
+ const result = this.#options.observe({
1094
+ operation,
1095
+ outcome,
1096
+ durationMs: performance.now() - started,
1097
+ ...(entityId ? { entityId } : {}),
1098
+ ...(revision === undefined ? {} : { revision }),
1099
+ ...(error === undefined ? {} : { error }),
1100
+ });
1101
+ if (result instanceof Promise)
1102
+ void result.catch(() => undefined);
1103
+ }
1104
+ catch {
1105
+ // Observers are deliberately isolated from committed knowledge writes.
1106
+ }
1107
+ }
1108
+ #termsInsert(entity) {
1109
+ const rows = terms(entity);
1110
+ return this.#db
1111
+ .prepare(`INSERT INTO taproot_terms(entity_id, language, term_type, value, ordinal)
1112
+ SELECT json_extract(value, '$.entityId'), json_extract(value, '$.language'), json_extract(value, '$.termType'), json_extract(value, '$.value'), json_extract(value, '$.ordinal')
1113
+ FROM json_each(?)`)
1114
+ .bind(JSON.stringify(rows));
1115
+ }
1116
+ #assertion(condition, ...values) {
1117
+ return this.#db
1118
+ .prepare(`INSERT INTO taproot_assertions(assertion_key) SELECT NULL WHERE NOT (${condition})`)
1119
+ .bind(...values);
1120
+ }
1121
+ #namespaceStatements() {
1122
+ return [
1123
+ this.#db
1124
+ .prepare(`INSERT INTO taproot_metadata(metadata_key, metadata_value)
1125
+ VALUES ('base_iri', ?) ON CONFLICT(metadata_key) DO NOTHING`)
1126
+ .bind(withoutTrailingSlashes(this.#options.baseIri)),
1127
+ this.#assertion(`EXISTS (SELECT 1 FROM taproot_metadata
1128
+ WHERE metadata_key = 'base_iri' AND metadata_value = ?)`, withoutTrailingSlashes(this.#options.baseIri)),
1129
+ ];
1130
+ }
1131
+ async #namespaceMismatch() {
1132
+ const result = await this.#db
1133
+ .prepare(`SELECT metadata_value FROM taproot_metadata
1134
+ WHERE metadata_key = 'base_iri'`)
1135
+ .all();
1136
+ const actual = result.results[0]?.metadata_value;
1137
+ return (actual !== undefined &&
1138
+ actual !== withoutTrailingSlashes(this.#options.baseIri));
1139
+ }
1140
+ #lifecycleQuads(entity, lifecycle) {
1141
+ if (!lifecycle.deletedAt && !lifecycle.redirectTo)
1142
+ return buildEntityQuads(entity, this.#options);
1143
+ const factory = this.#options.factory ?? new DataFactory();
1144
+ const base = withoutTrailingSlashes(this.#options.baseIri);
1145
+ const subject = factory.namedNode(`${base}/entity/${entity.id}`);
1146
+ const quads = [revisionQuad(entity, { ...this.#options, factory })];
1147
+ if (lifecycle.deletedAt) {
1148
+ quads.push(factory.quad(subject, factory.namedNode(`${base}/vocab/deletedAt`), factory.literal(lifecycle.deletedAt, factory.namedNode('http://www.w3.org/2001/XMLSchema#dateTime'))));
1149
+ }
1150
+ if (lifecycle.redirectTo) {
1151
+ quads.push(factory.quad(subject, factory.namedNode('http://www.w3.org/2002/07/owl#sameAs'), factory.namedNode(`${base}/entity/${lifecycle.redirectTo}`)));
1152
+ }
1153
+ return quads;
1154
+ }
1155
+ }
1156
+ function storedFromRow(row) {
1157
+ return {
1158
+ entity: parseEntityJson(row.entity_json),
1159
+ deletedAt: row.deleted_at,
1160
+ redirectTo: row.redirect_to,
1161
+ };
1162
+ }
1163
+ function revisionFromRow(row) {
1164
+ return {
1165
+ entityId: row.entity_id,
1166
+ revision: row.revision,
1167
+ entity: parseEntityJson(row.entity_json),
1168
+ actor: row.actor,
1169
+ attribution: row.attribution_json
1170
+ ? JSON.parse(row.attribution_json)
1171
+ : null,
1172
+ editSummary: row.edit_summary,
1173
+ tags: JSON.parse(row.tags_json),
1174
+ eventId: row.event_id,
1175
+ contentHash: row.content_hash,
1176
+ parentHash: row.parent_hash,
1177
+ deletedAt: row.deleted_at,
1178
+ redirectTo: row.redirect_to,
1179
+ createdAt: row.created_at,
1180
+ };
1181
+ }
1182
+ function auditFromRow(row) {
1183
+ return {
1184
+ sequence: row.event_sequence,
1185
+ eventId: row.event_id,
1186
+ entityId: row.entity_id,
1187
+ revision: row.revision,
1188
+ type: row.event_type,
1189
+ attribution: row.attribution_json
1190
+ ? JSON.parse(row.attribution_json)
1191
+ : null,
1192
+ editSummary: row.edit_summary,
1193
+ tags: JSON.parse(row.tags_json),
1194
+ requestId: row.request_id,
1195
+ contentHash: row.content_hash,
1196
+ parentHash: row.parent_hash,
1197
+ lifecycle: JSON.parse(row.details_json),
1198
+ createdAt: row.created_at,
1199
+ };
1200
+ }
1201
+ function terms(entity) {
1202
+ const rows = [];
1203
+ for (const [language, term] of Object.entries(entity.labels))
1204
+ rows.push({
1205
+ entityId: entity.id,
1206
+ language,
1207
+ termType: 'label',
1208
+ value: term.value,
1209
+ ordinal: 0,
1210
+ });
1211
+ for (const [language, term] of Object.entries(entity.descriptions))
1212
+ rows.push({
1213
+ entityId: entity.id,
1214
+ language,
1215
+ termType: 'description',
1216
+ value: term.value,
1217
+ ordinal: 0,
1218
+ });
1219
+ for (const [language, aliases] of Object.entries(entity.aliases))
1220
+ aliases.forEach((term, ordinal) => rows.push({
1221
+ entityId: entity.id,
1222
+ language,
1223
+ termType: 'alias',
1224
+ value: term.value,
1225
+ ordinal,
1226
+ }));
1227
+ return rows;
1228
+ }
1229
+ function locateStatement(entity, id) {
1230
+ for (const [property, statements] of Object.entries(entity.claims)) {
1231
+ const index = statements.findIndex((statement) => statement.id === id);
1232
+ if (index >= 0)
1233
+ return {
1234
+ property,
1235
+ statements,
1236
+ statement: statements[index],
1237
+ index,
1238
+ };
1239
+ }
1240
+ throw new InvalidStatementError(`Statement ${id} does not exist`);
1241
+ }
1242
+ function collectSnak(snak, used) {
1243
+ const current = used.get(snak.property);
1244
+ if (current && current !== snak.datatype)
1245
+ throw new PropertyDatatypeMismatchError(`Property ${snak.property} is used with multiple datatypes`);
1246
+ used.set(snak.property, snak.datatype);
1247
+ }
1248
+ function applyEntityCommand(entity, command) {
1249
+ switch (command.type) {
1250
+ case 'set-label':
1251
+ entity.labels[command.language] = {
1252
+ language: command.language,
1253
+ value: command.value,
1254
+ };
1255
+ break;
1256
+ case 'remove-label':
1257
+ delete entity.labels[command.language];
1258
+ break;
1259
+ case 'set-description':
1260
+ entity.descriptions[command.language] = {
1261
+ language: command.language,
1262
+ value: command.value,
1263
+ };
1264
+ break;
1265
+ case 'remove-description':
1266
+ delete entity.descriptions[command.language];
1267
+ break;
1268
+ case 'add-alias':
1269
+ (entity.aliases[command.language] ??= []).push({
1270
+ language: command.language,
1271
+ value: command.value,
1272
+ });
1273
+ break;
1274
+ case 'remove-alias': {
1275
+ const aliases = entity.aliases[command.language];
1276
+ if (!aliases?.[command.ordinal])
1277
+ throw new InvalidEntityError('Alias does not exist');
1278
+ aliases.splice(command.ordinal, 1);
1279
+ if (!aliases.length)
1280
+ delete entity.aliases[command.language];
1281
+ break;
1282
+ }
1283
+ case 'set-sitelink':
1284
+ if (entity.type !== 'item')
1285
+ throw new InvalidEntityError('Properties cannot have sitelinks');
1286
+ entity.sitelinks[command.site] = structuredClone(command.value);
1287
+ break;
1288
+ case 'remove-sitelink':
1289
+ if (entity.type !== 'item')
1290
+ throw new InvalidEntityError('Properties cannot have sitelinks');
1291
+ delete entity.sitelinks[command.site];
1292
+ break;
1293
+ case 'add-statement': {
1294
+ const property = command.statement.mainsnak.property;
1295
+ (entity.claims[property] ??= []).push(structuredClone(command.statement));
1296
+ break;
1297
+ }
1298
+ case 'replace-statement': {
1299
+ const located = locateStatement(entity, command.statementId);
1300
+ located.statements.splice(located.index, 1);
1301
+ const property = command.statement.mainsnak.property;
1302
+ (entity.claims[property] ??= []).push(structuredClone(command.statement));
1303
+ if (!located.statements.length)
1304
+ delete entity.claims[located.property];
1305
+ break;
1306
+ }
1307
+ case 'remove-statement': {
1308
+ const located = locateStatement(entity, command.statementId);
1309
+ located.statements.splice(located.index, 1);
1310
+ if (!located.statements.length)
1311
+ delete entity.claims[located.property];
1312
+ break;
1313
+ }
1314
+ case 'set-statement-rank':
1315
+ locateStatement(entity, command.statementId).statement.rank =
1316
+ command.rank;
1317
+ break;
1318
+ case 'add-qualifier': {
1319
+ validateSnak(command.snak);
1320
+ const statement = locateStatement(entity, command.statementId).statement;
1321
+ const property = command.snak.property;
1322
+ if (!statement.qualifiers[property]) {
1323
+ statement.qualifiers[property] = [];
1324
+ statement['qualifiers-order'].push(property);
1325
+ }
1326
+ statement.qualifiers[property].push(structuredClone(command.snak));
1327
+ break;
1328
+ }
1329
+ case 'remove-qualifier': {
1330
+ const statement = locateStatement(entity, command.statementId).statement;
1331
+ const snaks = statement.qualifiers[command.property];
1332
+ if (!snaks?.[command.ordinal])
1333
+ throw new InvalidStatementError('Qualifier does not exist');
1334
+ snaks.splice(command.ordinal, 1);
1335
+ if (!snaks.length) {
1336
+ delete statement.qualifiers[command.property];
1337
+ statement['qualifiers-order'] = statement['qualifiers-order'].filter((id) => id !== command.property);
1338
+ }
1339
+ break;
1340
+ }
1341
+ case 'add-reference':
1342
+ locateStatement(entity, command.statementId).statement.references.push(structuredClone(command.reference));
1343
+ break;
1344
+ case 'replace-reference': {
1345
+ const references = locateStatement(entity, command.statementId).statement
1346
+ .references;
1347
+ const index = references.findIndex(({ hash }) => hash === command.hash);
1348
+ if (index < 0)
1349
+ throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
1350
+ references[index] = structuredClone(command.reference);
1351
+ break;
1352
+ }
1353
+ case 'remove-reference': {
1354
+ const references = locateStatement(entity, command.statementId).statement
1355
+ .references;
1356
+ const index = references.findIndex(({ hash }) => hash === command.hash);
1357
+ if (index < 0)
1358
+ throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
1359
+ references.splice(index, 1);
1360
+ break;
1361
+ }
1362
+ }
1363
+ }
1364
+ function revisionQuad(entity, options) {
1365
+ const factory = options.factory ?? new DataFactory();
1366
+ const base = withoutTrailingSlashes(options.baseIri);
1367
+ return factory.quad(factory.namedNode(`${base}/entity/${entity.id}`), factory.namedNode(`${base}/vocab/revision`), factory.literal(String(entity.lastrevid), factory.namedNode('http://www.w3.org/2001/XMLSchema#integer')));
1368
+ }
1369
+ function assertLimit(limit) {
1370
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 500)
1371
+ throw new RangeError('limit must be an integer from 1 through 500');
1372
+ }
1373
+ function isAssertionError(cause) {
1374
+ return /NOT NULL constraint failed: taproot_assertions\.assertion_key/iu.test(cause.message);
1375
+ }
1376
+ function isUniqueEntityError(cause) {
1377
+ return /UNIQUE constraint failed: taproot_entities\.entity_id/iu.test(cause.message);
1378
+ }
1379
+ function isRevisionUniqueError(cause) {
1380
+ return /UNIQUE constraint failed: taproot_entity_revisions\.entity_id, taproot_entity_revisions\.revision/iu.test(cause.message);
1381
+ }
1382
+ function isEventIdUniqueError(cause) {
1383
+ return /UNIQUE constraint failed: taproot_audit_events\.event_id/iu.test(cause.message);
1384
+ }
1385
+ function normalizeAttribution(metadata) {
1386
+ if (metadata.actor !== undefined && typeof metadata.actor !== 'string')
1387
+ throw new InvalidEntityError('Legacy actor must be a string');
1388
+ const attribution = metadata.attribution ??
1389
+ (metadata.actor ? { id: metadata.actor, kind: 'human' } : null);
1390
+ if (!attribution)
1391
+ return null;
1392
+ if (typeof attribution.id !== 'string' || !attribution.id.trim())
1393
+ throw new InvalidEntityError('Attribution id cannot be empty');
1394
+ if (!['human', 'agent', 'import', 'system'].includes(attribution.kind))
1395
+ throw new InvalidEntityError('Attribution kind is invalid');
1396
+ for (const field of ['name', 'organization', 'tool']) {
1397
+ const value = attribution[field];
1398
+ if (value !== undefined && (typeof value !== 'string' || !value.trim()))
1399
+ throw new InvalidEntityError(`Attribution ${field} must be a non-empty string`);
1400
+ }
1401
+ if (new TextEncoder().encode(JSON.stringify(attribution)).byteLength > 16_384)
1402
+ throw new InvalidEntityError('Attribution cannot exceed 16 KiB');
1403
+ for (const field of ['url']) {
1404
+ const value = attribution[field];
1405
+ if (value !== undefined) {
1406
+ if (typeof value !== 'string')
1407
+ throw new InvalidEntityError(`Attribution ${field} must be a string`);
1408
+ try {
1409
+ new URL(value);
1410
+ }
1411
+ catch (cause) {
1412
+ throw new InvalidEntityError(`Attribution ${field} must be an absolute URL`, { cause });
1413
+ }
1414
+ }
1415
+ }
1416
+ return structuredClone(attribution);
1417
+ }
1418
+ function normalizeTags(tags) {
1419
+ if (!tags)
1420
+ return [];
1421
+ if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string'))
1422
+ throw new InvalidEntityError('Tags must be an array of strings');
1423
+ if (tags.length > 50)
1424
+ throw new InvalidEntityError('An edit may have at most 50 tags');
1425
+ const normalized = [...new Set(tags.map((tag) => tag.trim()))].sort();
1426
+ if (normalized.some((tag) => !tag || tag.length > 64))
1427
+ throw new InvalidEntityError('Tags must contain 1 through 64 characters');
1428
+ return normalized;
1429
+ }
1430
+ async function sha256(value) {
1431
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
1432
+ return [...new Uint8Array(digest)]
1433
+ .map((byte) => byte.toString(16).padStart(2, '0'))
1434
+ .join('');
1435
+ }
1436
+ function revisionContentHash(json, lifecycle) {
1437
+ return sha256(`${json}\n${JSON.stringify(lifecycle)}`);
1438
+ }
1439
+ function encodeCursor(value) {
1440
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
1441
+ let binary = '';
1442
+ for (const byte of bytes)
1443
+ binary += String.fromCharCode(byte);
1444
+ return btoa(binary)
1445
+ .replace(/\+/gu, '-')
1446
+ .replace(/\//gu, '_')
1447
+ .replace(/=+$/gu, '');
1448
+ }
1449
+ function decodeCursor(cursor) {
1450
+ try {
1451
+ const padded = cursor.replace(/-/gu, '+').replace(/_/gu, '/') +
1452
+ '='.repeat((4 - (cursor.length % 4)) % 4);
1453
+ const binary = atob(padded);
1454
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
1455
+ }
1456
+ catch (cause) {
1457
+ throw new InvalidCursorError('Cursor is invalid', { cause });
1458
+ }
1459
+ }
1460
+ function page(items, limit, cursorValue) {
1461
+ const hasMore = items.length > limit;
1462
+ const visible = items.slice(0, limit);
1463
+ return {
1464
+ items: visible,
1465
+ cursor: hasMore && visible.length
1466
+ ? encodeCursor(cursorValue(visible[visible.length - 1]))
1467
+ : null,
1468
+ };
1469
+ }
1470
+ function termKey(term) {
1471
+ return JSON.stringify({
1472
+ type: term.termType,
1473
+ value: term.value,
1474
+ language: term.termType === 'Literal' ? term.language : undefined,
1475
+ datatype: term.termType === 'Literal' ? term.datatype.value : undefined,
1476
+ });
1477
+ }
1478
+ function quadKeyValue(quad) {
1479
+ return [quad.subject, quad.predicate, quad.object, quad.graph]
1480
+ .map(termKey)
1481
+ .join('|');
1482
+ }
1483
+ function ownershipRow(quad) {
1484
+ return {
1485
+ subjectKey: encodeTerm(quad.subject).key,
1486
+ predicateKey: encodeTerm(quad.predicate).key,
1487
+ objectKey: encodeTerm(quad.object).key,
1488
+ graphKey: encodeTerm(quad.graph).key,
1489
+ };
1490
+ }
1491
+ function sameQuads(left, right) {
1492
+ const a = [...new Set(left.map(quadKeyValue))].sort();
1493
+ const b = [...new Set(right.map(quadKeyValue))].sort();
1494
+ return JSON.stringify(a) === JSON.stringify(b);
1495
+ }
1496
+ function toError(error) {
1497
+ return error instanceof Error ? error : new Error(String(error));
1498
+ }
1499
+ //# sourceMappingURL=repository.js.map