@kubun/store-graph 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -1,6 +1,17 @@
1
- import { DocumentID, DocumentModelID } from '@kubun/id';
1
+ import { AbstractPostgresAdapter, chunked, IN_LIST_CHUNK } from '@kubun/db-adapter';
2
+ import { CatalogID, DocumentID, DocumentModelID } from '@kubun/id';
3
+ import { winnerStateDigestOf } from '@kubun/mutation';
4
+ import { isDocumentDateField } from '@kubun/protocol';
5
+ import { blake3 } from '@noble/hashes/blake3.js';
6
+ import { bytesToHex } from '@noble/hashes/utils.js';
7
+ import { sql } from 'kysely';
8
+ import { createGraphAccessDefaultRecordMethods } from './access-default-records.js';
9
+ import { createGraphCatalogRecordMethods } from './catalog-records.js';
10
+ import { parseScopeDate, parseScopeDuration, scopeRuleMatches, scopeRuleMatchesFacts, validateScopeCatalog } from './catalog-scope.js';
11
+ import { createCatalogStateMethods } from './catalog-state.js';
2
12
  import { serializeCursor } from './cursor.js';
3
- import { isMissingTableError, ModelNotDeployedError } from './errors.js';
13
+ import { DocumentLockRequiresTransactionError, DocumentNotHoldableError, InvalidModelDateFieldError, isMissingTableError, MissingGraphCommitContextError, ModelNotDeployedError } from './errors.js';
14
+ import { boundedIdentifier } from './identifiers.js';
4
15
  import { aggregateFieldName, applyAggregateSelections, applyDocumentFilter, applyDocumentOrderBy, applyGroupBySelections, applyPagination, groupKeyAlias, knownFieldColumn } from './query-builder.js';
5
16
  import { resolveSearchableFields, SearchNotEnabledError } from './search.js';
6
17
  // --- Helper functions ---
@@ -254,21 +265,23 @@ function compareGroups(a, b, orderBy) {
254
265
  }
255
266
  return 0;
256
267
  }
257
- function documentModelFromTable(row) {
268
+ export function documentModelFromTable(row) {
258
269
  const model = {
259
270
  version: row.version,
260
271
  name: row.name,
261
272
  behavior: row.behavior,
262
273
  interfaces: row.interfaces,
263
274
  schema: row.schema,
264
- fieldsMeta: row.fields_meta
275
+ fieldsMeta: row.fields_meta,
276
+ providerMergePolicy: row.provider_merge_policy ?? undefined,
277
+ dateField: row.date_field ?? undefined
265
278
  };
266
279
  if (model.behavior === 'unique') {
267
280
  model.uniqueFields = row.unique_fields;
268
281
  }
269
282
  return model;
270
283
  }
271
- function documentModelToTable(adapter, id, model) {
284
+ export function documentModelToTable(adapter, id, model) {
272
285
  return {
273
286
  id,
274
287
  version: model.version,
@@ -277,7 +290,10 @@ function documentModelToTable(adapter, id, model) {
277
290
  unique_fields: model.behavior === 'unique' ? adapter.encodeJSON(model.uniqueFields) : null,
278
291
  interfaces: adapter.encodeJSON(model.interfaces),
279
292
  schema: adapter.encodeJSON(model.schema),
280
- fields_meta: adapter.encodeJSON(model.fieldsMeta)
293
+ fields_meta: adapter.encodeJSON(model.fieldsMeta),
294
+ provider_merge_policy: model.providerMergePolicy ? adapter.encodeJSON(model.providerMergePolicy) : null,
295
+ date_field: model.dateField ?? null,
296
+ resolved_date_field: model.dateField ?? null
281
297
  };
282
298
  }
283
299
  function documentNodeFromTable(adapter, doc) {
@@ -286,8 +302,9 @@ function documentNodeFromTable(adapter, doc) {
286
302
  model: doc.model,
287
303
  owner: doc.owner,
288
304
  data: doc.data,
289
- createdAt: adapter.decodeTimestamp(doc.created_at),
290
- updatedAt: doc.updated_at ? adapter.decodeTimestamp(doc.updated_at) : null
305
+ createdAt: doc.mutation_created_at == null ? adapter.decodeTimestamp(doc.created_at) : new Date(Number(doc.mutation_created_at)),
306
+ updatedAt: doc.mutation_updated_at == null ? doc.updated_at ? adapter.decodeTimestamp(doc.updated_at) : null : new Date(Number(doc.mutation_updated_at)),
307
+ scopeDate: doc.scope_date == null ? undefined : Number(doc.scope_date)
291
308
  };
292
309
  }
293
310
  function toGlobalInterfaceID(modelID, interfaceID) {
@@ -307,10 +324,31 @@ function extractFieldValue(data, fieldPath) {
307
324
  async function addDocumentModel(transaction, adapter, params) {
308
325
  const { id, graphModelID, model } = params;
309
326
  const t = adapter.types;
310
- await transaction.schema.createTable(`k_${id}`).ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('data', t.json).addColumn('field_hlcs', t.json).addColumn('field_values', t.json).addColumn('unique', t.binary, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>{
327
+ const inheritedRows = model.interfaces.length === 0 ? [] : await transaction.selectFrom('kubun_graph_document_models').select('resolved_date_field').where('id', 'in', model.interfaces.map((interfaceID)=>toGlobalInterfaceID(id, interfaceID))).execute();
328
+ const inheritedFields = new Set(inheritedRows.map((row)=>row.resolved_date_field).filter((field)=>field != null));
329
+ if (model.dateField == null && inheritedFields.size > 1) {
330
+ throw new InvalidModelDateFieldError('Conflicting inherited date fields');
331
+ }
332
+ const resolvedDateField = model.dateField ?? inheritedFields.values().next().value ?? null;
333
+ if (resolvedDateField != null) {
334
+ if (!isDocumentDateField(model, resolvedDateField)) {
335
+ throw new InvalidModelDateFieldError(`Invalid model date field: ${resolvedDateField}`);
336
+ }
337
+ }
338
+ await transaction.schema.createTable(`k_${id}`).ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('data', t.json).addColumn('field_hlcs', t.json).addColumn('field_values', t.json).addColumn('unique', t.binary, (col)=>col.notNull()).addColumn('mutation_created_at', t.bigint).addColumn('mutation_updated_at', t.bigint).addColumn('scope_date', t.bigint).addColumn('created_at', t.timestamp, (col)=>{
311
339
  return col.defaultTo(adapter.functions.now).notNull();
312
340
  }).addColumn('updated_at', t.timestamp).execute();
313
- await transaction.insertInto('kubun_graph_document_models').values(documentModelToTable(adapter, id, model)).onConflict((oc)=>oc.doNothing()).execute();
341
+ await transaction.schema.createIndex(boundedIdentifier(`idx_scope_date_${id}`)).ifNotExists().on(`k_${id}`).column('scope_date').execute();
342
+ // Document-sync pages walk ids in byte order. SQLite text compares bytewise
343
+ // already; a Postgres database with a linguistic collation needs an index in
344
+ // the "C" collation for those pages to seek instead of sort.
345
+ if (adapter instanceof AbstractPostgresAdapter) {
346
+ await transaction.schema.createIndex(boundedIdentifier(`idx_id_bytes_${id}`)).ifNotExists().on(`k_${id}`).expression(sql`id collate "C"`).execute();
347
+ }
348
+ await transaction.insertInto('kubun_graph_document_models').values({
349
+ ...documentModelToTable(adapter, id, model),
350
+ resolved_date_field: resolvedDateField
351
+ }).onConflict((oc)=>oc.doNothing()).execute();
314
352
  for (const interfaceID of model.interfaces){
315
353
  await transaction.insertInto('kubun_graph_document_model_interfaces').values({
316
354
  interface_id: toGlobalInterfaceID(id, interfaceID),
@@ -322,27 +360,343 @@ async function addDocumentModel(transaction, adapter, params) {
322
360
  document_model_id: id
323
361
  }).onConflict((oc)=>oc.doNothing()).execute();
324
362
  }
325
- // --- Factory function ---
326
- export function createGraphStore(db, adapter) {
327
- const coerce = adapter.coerceFilterValue.bind(adapter);
328
- async function resolveCatalogFilter(modelIDs, catalogID) {
329
- if (catalogID == null) {
330
- return {
331
- effectiveModelIDs: modelIDs,
332
- effectiveOwners: undefined
333
- };
334
- }
335
- const { models, owners } = await api.resolveCatalogScope(catalogID);
336
- let effectiveModelIDs = modelIDs;
337
- if (models != null) {
338
- const catalogModelSet = new Set(models);
339
- effectiveModelIDs = effectiveModelIDs.filter((id)=>catalogModelSet.has(id));
363
+ // --- Transaction-scoped merge-policy winner read ---
364
+ /**
365
+ * Reads a document's `field_values` cell on the given transaction and
366
+ * delegates the merge-policy liveness comparison to `@kubun/mutation`'s pure
367
+ * `winnerStateDigestOf`. Returns the winning live `$setFetch` digest, the
368
+ * constant deleted-state digest when the delete side wins, or `null` when the
369
+ * document (or its fetch-order registers) is absent.
370
+ *
371
+ * Because graph and connector tables share one DB connection (the KubunDB
372
+ * coordinator), a caller with its own transaction over a wider `DB` type
373
+ * passes it here cast to `Kysely<GraphTables>` (the same pattern
374
+ * `addDocumentModel` above already relies on) so the read joins that
375
+ * transaction rather than opening a second connection.
376
+ */ export async function readWinnerStateDigest(tx, documentID) {
377
+ const modelID = DocumentID.fromString(documentID).model.toString();
378
+ const row = await tx.selectFrom(`k_${modelID}`).select('field_values').where('id', '=', documentID).executeTakeFirst();
379
+ if (row?.field_values == null) {
380
+ return null;
381
+ }
382
+ // ParseJSONResultsPlugin may have already parsed the value.
383
+ const fieldValues = typeof row.field_values === 'string' ? JSON.parse(row.field_values) : row.field_values;
384
+ return winnerStateDigestOf(fieldValues);
385
+ }
386
+ /**
387
+ * Batch form of `readWinnerStateDigest`: one chunked `id in` read per model rather than one
388
+ * read per document. A document whose digest cannot be read (bad id, model table not deployed,
389
+ * no row, no digest) is left out of the map.
390
+ */ export async function readWinnerStateDigests(params) {
391
+ const byModel = new Map();
392
+ for (const documentID of params.documentIDs){
393
+ let modelID;
394
+ try {
395
+ modelID = DocumentID.fromString(documentID).model.toString();
396
+ } catch {
397
+ continue;
340
398
  }
341
- return {
342
- effectiveModelIDs,
343
- effectiveOwners: owners
344
- };
399
+ const ids = byModel.get(modelID);
400
+ if (ids === undefined) byModel.set(modelID, [
401
+ documentID
402
+ ]);
403
+ else ids.push(documentID);
404
+ }
405
+ const digests = new Map();
406
+ for (const [modelID, ids] of byModel){
407
+ try {
408
+ for (const chunk of chunked(ids)){
409
+ const rows = await params.tx.selectFrom(`k_${modelID}`).select([
410
+ 'id',
411
+ 'field_values'
412
+ ]).where('id', 'in', chunk).execute();
413
+ for (const row of rows){
414
+ if (row.field_values == null) continue;
415
+ // ParseJSONResultsPlugin may have already parsed the value.
416
+ const fieldValues = typeof row.field_values === 'string' ? JSON.parse(row.field_values) : row.field_values;
417
+ const digest = winnerStateDigestOf(fieldValues);
418
+ if (digest != null) digests.set(String(row.id), digest);
419
+ }
420
+ }
421
+ } catch {}
422
+ }
423
+ return digests;
424
+ }
425
+ /** Whether a mark has waited out the grace: the sweep's own test, shared so a listing agrees. */ export function gcMarkPastGrace(mark, graceMS, nowMS) {
426
+ return mark.markedAt + graceMS <= nowMS;
427
+ }
428
+ /** Hex blake3 over mutation IDs, sorted and joined by newline: the document mutation digest. */ export function mutationIDsDigest(mutationIDs) {
429
+ const sorted = [
430
+ ...mutationIDs
431
+ ].sort();
432
+ return bytesToHex(blake3(new TextEncoder().encode(sorted.join('\n'))));
433
+ }
434
+ const scopeSchedulers = new WeakMap();
435
+ const allDocumentRefsLockKey = 'graph.document.refs:*';
436
+ // Transactions, by their Kysely handle, that hold the all-documents ref lock exclusively.
437
+ const exclusiveRefLockHolders = new WeakSet();
438
+ function getScopeScheduler(context) {
439
+ let state = scopeSchedulers.get(context.root);
440
+ if (state != null) return state;
441
+ state = {
442
+ pendingScopes: new Map(),
443
+ timer: null,
444
+ running: null,
445
+ failed: false,
446
+ retryDelayMS: 100,
447
+ generation: 0,
448
+ closed: false,
449
+ listeners: new Set()
450
+ };
451
+ scopeSchedulers.set(context.root, state);
452
+ context.onClose?.(async ()=>{
453
+ if (state == null) return;
454
+ state.closed = true;
455
+ state.generation++;
456
+ if (state.timer != null) clearTimeout(state.timer);
457
+ state.timer = null;
458
+ state.pendingScopes.clear();
459
+ await state.running;
460
+ state.listeners.clear();
461
+ });
462
+ return state;
463
+ }
464
+ /**
465
+ * Resolves once the root's scope scheduler has no timer armed, no recompute running and nothing
466
+ * queued. While recomputes keep failing it keeps retrying, so this does not resolve.
467
+ */ export async function scopeSchedulerIdle(root) {
468
+ const state = scopeSchedulers.get(root);
469
+ if (state == null) return;
470
+ while(!state.closed && (state.timer != null || state.running != null || state.pendingScopes.size > 0)){
471
+ if (state.running != null) await state.running;
472
+ else await new Promise((resolve)=>setTimeout(resolve, 1));
473
+ }
474
+ }
475
+ function requeueScopeTasks(state, tasks) {
476
+ for (const task of tasks){
477
+ const key = JSON.stringify([
478
+ task.modelID,
479
+ task.catalog?.owner,
480
+ task.catalog?.id
481
+ ]);
482
+ const previous = state.pendingScopes.get(key);
483
+ state.pendingScopes.set(key, {
484
+ ...task,
485
+ owners: previous == null ? task.owners : previous.owners == null || task.owners == null ? undefined : [
486
+ ...new Set([
487
+ ...previous.owners,
488
+ ...task.owners
489
+ ])
490
+ ]
491
+ });
492
+ }
493
+ }
494
+ // A scope change written outside the scheduler's recomputes, such as a sweep, still reaches
495
+ // whoever emits scope events.
496
+ function notifyScopeListeners(context) {
497
+ const state = getScopeScheduler(context);
498
+ if (state.closed) return;
499
+ for (const listener of state.listeners){
500
+ void Promise.resolve().then(listener).catch((error)=>console.error('catalog scope listener failed', error));
501
+ }
502
+ }
503
+ function armScopeScheduler(context, state, delayMS = 10) {
504
+ if (state.closed || state.timer != null || state.running || state.pendingScopes.size === 0) return;
505
+ const generation = state.generation;
506
+ state.timer = setTimeout(()=>{
507
+ if (generation !== state.generation) return;
508
+ state.timer = null;
509
+ const pending = Array.from(state.pendingScopes.values());
510
+ state.pendingScopes.clear();
511
+ state.running = (async ()=>{
512
+ try {
513
+ const store = await context.root.getStore('graph');
514
+ for(let index = 0; index < pending.length; index++){
515
+ if (generation !== state.generation) break;
516
+ const task = pending[index];
517
+ if (task == null) continue;
518
+ try {
519
+ const nowMS = Date.now();
520
+ const routeRules = await context.getRootCatalogRouteRules(nowMS, task.catalog);
521
+ await store.reconcileCatalogModelRefs({
522
+ modelID: task.modelID,
523
+ routeRules,
524
+ nowMS,
525
+ owners: task.owners,
526
+ catalog: task.catalog
527
+ });
528
+ } catch (error) {
529
+ requeueScopeTasks(state, pending.slice(index));
530
+ throw error;
531
+ }
532
+ }
533
+ state.failed = false;
534
+ state.retryDelayMS = 100;
535
+ return generation === state.generation ? [
536
+ ...state.listeners
537
+ ] : [];
538
+ } catch (error) {
539
+ if (state.pendingScopes.size === 0) requeueScopeTasks(state, pending);
540
+ state.failed = true;
541
+ console.error('catalog scope recomputation failed', error);
542
+ return [];
543
+ } finally{
544
+ state.running = null;
545
+ if (state.failed) {
546
+ const delay = state.retryDelayMS;
547
+ state.retryDelayMS = Math.min(delay * 2, 30_000);
548
+ armScopeScheduler(context, state, delay);
549
+ } else armScopeScheduler(context, state);
550
+ }
551
+ })().then((listeners)=>{
552
+ for (const listener of listeners){
553
+ void Promise.resolve().then(listener).catch((error)=>console.error('catalog scope listener failed', error));
554
+ }
555
+ });
556
+ }, delayMS);
557
+ }
558
+ const catalogRulePrefix = 'rule:';
559
+ function catalogRefPrefix(catalog) {
560
+ return `${catalogRulePrefix}${catalog.owner}:${catalog.id}:`;
561
+ }
562
+ const custodyPinRefPrefix = 'hold:custody:';
563
+ /** The sticky ref a custodian pins a document with: the catalog ID part holds no `:`. */ export function custodyPinRef(guard) {
564
+ const catalogID = new CatalogID(guard.catalog.owner, guard.catalog.id).toString();
565
+ return `${custodyPinRefPrefix}${guard.custodyOwner}:${catalogID}`;
566
+ }
567
+ export function isCustodyPinRef(ref) {
568
+ return ref.startsWith(custodyPinRefPrefix);
569
+ }
570
+ export function catalogRuleRef(catalog, ruleID) {
571
+ return `${catalogRefPrefix(catalog)}${ruleID}`;
572
+ }
573
+ /**
574
+ * Whether a ref is one of the catalog's rule refs. Catalog IDs may contain `:`, so a prefix
575
+ * alone would also claim the refs of a catalog whose ID extends this one.
576
+ */ export function isCatalogRuleRef(ref, catalog) {
577
+ const prefix = catalogRefPrefix(catalog);
578
+ return ref.startsWith(prefix) && /^[^:]+$/.test(ref.slice(prefix.length));
579
+ }
580
+ const SWEPT_PAGE = 500;
581
+ const catalogLevelBits = {
582
+ KNOWN: {
583
+ displays: 1,
584
+ retains: 0
585
+ },
586
+ OFFLINE: {
587
+ displays: 1,
588
+ retains: 1
589
+ },
590
+ SERVE_ONLY: {
591
+ displays: 0,
592
+ retains: 1
345
593
  }
594
+ };
595
+ function catalogUseFromRow(row) {
596
+ return {
597
+ level: row.level ?? null,
598
+ followed: Number(row.followed) === 1
599
+ };
600
+ }
601
+ function scheduleScopeTasks(context, tasks) {
602
+ const state = getScopeScheduler(context);
603
+ const pending = Array.from(tasks);
604
+ if (pending.length === 0) return;
605
+ context.provider.onCommit(()=>{
606
+ if (state.closed) return;
607
+ requeueScopeTasks(state, pending);
608
+ if (state.timer != null) {
609
+ clearTimeout(state.timer);
610
+ state.timer = null;
611
+ }
612
+ armScopeScheduler(context, state, state.failed ? state.retryDelayMS : undefined);
613
+ });
614
+ }
615
+ function scheduleScopeModels(context, modelIDs) {
616
+ scheduleScopeTasks(context, Array.from(modelIDs, (modelID)=>({
617
+ modelID
618
+ })));
619
+ }
620
+ /**
621
+ * The refs that start with a catalog's rule-ref prefix: a bytewise range rather
622
+ * than LIKE, whose wildcards and escape character catalog IDs may contain.
623
+ * On a Postgres adapter it compares `ref collate "C"`, which the ref index is
624
+ * built on. The caller keeps only the catalog's own refs with
625
+ * `isCatalogRuleRef`. Exported so the plan of the exact statement can be
626
+ * checked.
627
+ */ export function catalogRefHoldersQuery(db, adapter, catalog) {
628
+ const prefix = catalogRefPrefix(catalog);
629
+ const ref = adapter instanceof AbstractPostgresAdapter ? sql`ref collate "C"` : sql`ref`;
630
+ return db.selectFrom('kubun_graph_document_refs').select([
631
+ 'document_id',
632
+ 'ref'
633
+ ]).where(ref, '>=', prefix).where(ref, '<', `${prefix}\u{10FFFF}`);
634
+ }
635
+ /**
636
+ * The statement one model's keyset leaf page runs: matching documents after
637
+ * `after` (and up to `through`) in bytewise ID order, at most `limit`, each with
638
+ * whether it was created here and joined to its applied mutations. Exported so
639
+ * the plan of the exact statement can be checked.
640
+ */ export function documentLeafPageStatement(params) {
641
+ const { postgres } = params;
642
+ // Bytewise order on every adapter: SQLite compares text with BINARY,
643
+ // Postgres needs the "C" collation to agree with it.
644
+ const key = postgres ? sql`k.id collate "C"` : sql`k.id`;
645
+ const matches = params.rules.map((rule)=>{
646
+ const conditions = [];
647
+ if (rule.owners != null) {
648
+ conditions.push(rule.owners.length === 0 ? sql`1 = 0` : sql`k.owner in (${sql.join([
649
+ ...rule.owners
650
+ ])})`);
651
+ }
652
+ if (rule.dateField != null && (rule.from != null || rule.to != null)) {
653
+ const bounds = [
654
+ sql`sd.field = ${rule.dateField}`
655
+ ];
656
+ if (rule.from != null) bounds.push(sql`sd.scope_date >= ${rule.from}`);
657
+ if (rule.to != null) bounds.push(sql`sd.scope_date < ${rule.to}`);
658
+ conditions.push(sql`exists (select 1 from kubun_graph_document_scope_dates as sd where sd.document_id = k.id and ${sql.join(bounds, sql` and `)})`);
659
+ } else if (rule.from != null || rule.to != null) {
660
+ // A model without a declared date field windows on its scope date,
661
+ // which is the document's creation time.
662
+ if (rule.from != null) conditions.push(sql`k.scope_date >= ${rule.from}`);
663
+ if (rule.to != null) conditions.push(sql`k.scope_date < ${rule.to}`);
664
+ }
665
+ return conditions.length === 0 ? sql`1 = 1` : sql`(${sql.join(conditions, sql` and `)})`;
666
+ });
667
+ const range = [
668
+ sql`(${sql.join(matches, sql` or `)})`
669
+ ];
670
+ if (params.groupReadPredicate != null) range.push(params.groupReadPredicate);
671
+ if (params.after != null) range.push(sql`${key} > ${params.after}`);
672
+ if (params.through != null) range.push(sql`${key} <= ${params.through}`);
673
+ const outerKey = postgres ? sql`d.id collate "C"` : sql`d.id`;
674
+ // SQLite would otherwise pick the status index, which holds nearly every
675
+ // row: the unary plus keeps the join on the document index.
676
+ const applied = postgres ? sql`m.status = 'applied'` : sql`+m.status = 'applied'`;
677
+ return sql`
678
+ select d.id, d.owner, d.authored_here, m.mutation_hash
679
+ from (
680
+ select k.id, k.owner, exists (
681
+ select 1 from kubun_graph_document_refs as r
682
+ where r.document_id = k.id and r.ref = 'local'
683
+ ) as authored_here
684
+ from ${sql.table(`k_${params.modelID}`)} as k
685
+ where ${sql.join(range, sql` and `)}
686
+ order by ${key}
687
+ limit ${params.limit}
688
+ ) as d
689
+ left join kubun_graph_mutation_log as m
690
+ on m.document_id = d.id and ${applied}
691
+ order by ${outerKey}`;
692
+ }
693
+ // A custody pin, or a ref a SERVE_ONLY catalog could have written: retained, not displayed.
694
+ function mayNeedBytes(row) {
695
+ return isCustodyPinRef(row.ref) || row.ref.startsWith(catalogRulePrefix) && Number(row.displays) === 0 && Number(row.retains) === 1;
696
+ }
697
+ export function createGraphStore(db, adapter, context) {
698
+ if (context == null) throw new MissingGraphCommitContextError();
699
+ const coerce = adapter.coerceFilterValue.bind(adapter);
346
700
  // --- Write-path search index maintenance ---
347
701
  //
348
702
  // Runs inline in the caller's write transaction (`db` here IS that
@@ -368,7 +722,703 @@ export function createGraphStore(db, adapter) {
368
722
  if (fields == null) return;
369
723
  await adapter.removeSearchEntry(db, model, documentID);
370
724
  }
725
+ async function refreshDocumentDates(documentID) {
726
+ const modelID = DocumentID.fromString(documentID).model.toString();
727
+ const row = await db.selectFrom(`k_${modelID}`).selectAll().where('id', '=', documentID).executeTakeFirst();
728
+ if (row == null) return;
729
+ const extrema = await db.selectFrom('kubun_graph_mutation_log').select('hlc').where('document_id', '=', documentID).where('status', '=', 'applied').orderBy('hlc').execute();
730
+ const createdAt = extrema[0] == null ? adapter.decodeTimestamp(row.created_at).getTime() : Date.parse(extrema[0].hlc.slice(0, 24));
731
+ const lastMutation = extrema.at(-1);
732
+ const updatedAt = lastMutation == null ? null : Date.parse(lastMutation.hlc.slice(0, 24));
733
+ const model = await db.selectFrom('kubun_graph_document_models').select([
734
+ 'resolved_date_field',
735
+ 'schema'
736
+ ]).where('id', '=', modelID).executeTakeFirstOrThrow();
737
+ const data = row.data;
738
+ const selectedValue = model.resolved_date_field == null || data == null ? null : extractFieldValue(data, model.resolved_date_field);
739
+ const scopeDate = parseScopeDate(selectedValue) ?? createdAt;
740
+ await db.updateTable(`k_${modelID}`).set({
741
+ mutation_created_at: extrema[0] == null ? null : createdAt,
742
+ mutation_updated_at: updatedAt,
743
+ scope_date: scopeDate
744
+ }).where('id', '=', documentID).execute();
745
+ await db.deleteFrom('kubun_graph_document_scope_dates').where('document_id', '=', documentID).execute();
746
+ const dates = Object.keys(model.schema.properties).filter((field)=>isDocumentDateField(model, field)).map((field)=>({
747
+ document_id: documentID,
748
+ field,
749
+ scope_date: parseScopeDate(data == null ? null : extractFieldValue(data, field)) ?? createdAt
750
+ }));
751
+ if (dates.length > 0) await db.insertInto('kubun_graph_document_scope_dates').values(dates).execute();
752
+ }
753
+ // Same size as a custody scan batch: a batch is one transaction holding the connection.
754
+ const ReconcileBatch = 100;
755
+ const reconcileAll = async (nowMS, routeRules, shouldStop)=>{
756
+ const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
757
+ const documentIDs = [];
758
+ for (const model of models){
759
+ const docs = await db.selectFrom(`k_${model.id}`).select('id').execute();
760
+ for (const doc of docs)documentIDs.push(doc.id);
761
+ }
762
+ documentIDs.sort();
763
+ if (context.provider !== context.root) {
764
+ // The caller's transaction spans the whole pass, so there is no connection to release.
765
+ if (shouldStop()) return false;
766
+ await lockAllDocumentRefs();
767
+ await recomputeDocumentRefsBatch({
768
+ documentIDs,
769
+ routeRules,
770
+ nowMS
771
+ });
772
+ await api.refreshSweptDocuments({
773
+ nowMS
774
+ });
775
+ return true;
776
+ }
777
+ for(let i = 0; i < documentIDs.length; i += ReconcileBatch){
778
+ if (shouldStop()) return false;
779
+ await api.recomputeDocuments({
780
+ documentIDs: documentIDs.slice(i, i + ReconcileBatch),
781
+ routeRules,
782
+ nowMS
783
+ });
784
+ }
785
+ if (shouldStop()) return false;
786
+ await api.refreshSweptDocuments({
787
+ nowMS
788
+ });
789
+ return true;
790
+ };
791
+ async function tasksForCatalog(ref, rules) {
792
+ const selected = new Map();
793
+ let modelsQuery = db.selectFrom('kubun_graph_document_models').select('id');
794
+ if (!rules.some((rule)=>rule.models == null)) {
795
+ const modelIDs = [
796
+ ...new Set(rules.flatMap((rule)=>rule.models ?? []))
797
+ ];
798
+ if (modelIDs.length === 0) modelsQuery = modelsQuery.where(sql`1 = 0`);
799
+ else modelsQuery = modelsQuery.where((eb)=>eb.or([
800
+ eb('id', 'in', modelIDs),
801
+ eb('id', 'in', eb.selectFrom('kubun_graph_document_model_interfaces').select('implementation_id').where('interface_id', 'in', modelIDs))
802
+ ]));
803
+ }
804
+ const models = await modelsQuery.execute();
805
+ const interfacesOf = memoisedInterfaces();
806
+ for (const model of models){
807
+ const interfaces = await interfacesOf(model.id);
808
+ for (const rule of rules){
809
+ if (rule.models != null && !rule.models.some((id)=>id === model.id || interfaces.includes(id))) continue;
810
+ const previous = selected.get(model.id);
811
+ if (rule.owners == null || previous === null) {
812
+ selected.set(model.id, null);
813
+ } else {
814
+ const owners = previous ?? new Set();
815
+ for (const owner of rule.owners)owners.add(owner);
816
+ selected.set(model.id, owners);
817
+ }
818
+ }
819
+ }
820
+ for (const row of (await catalogRefHolders(ref))){
821
+ const modelID = DocumentID.fromString(row.document_id).model.toString();
822
+ if (!selected.has(modelID)) selected.set(modelID, new Set());
823
+ }
824
+ return Array.from(selected, ([modelID, owners])=>({
825
+ modelID,
826
+ owners: owners == null ? undefined : Array.from(owners),
827
+ catalog: ref
828
+ }));
829
+ }
830
+ async function catalogRefHolders(catalog) {
831
+ const rows = await catalogRefHoldersQuery(db, adapter, catalog).execute();
832
+ return rows.filter((row)=>isCatalogRuleRef(row.ref, catalog));
833
+ }
834
+ function visibleScopePredicate(modelID, refs) {
835
+ const refCondition = refs == null ? sql`` : sql`and scope_ref.ref in (${sql.join(refs)})`;
836
+ return sql`exists (
837
+ select 1 from kubun_graph_document_refs as scope_ref
838
+ where scope_ref.document_id = ${sql.ref(`k_${modelID}.id`)}
839
+ and scope_ref.displays = 1 ${refCondition}
840
+ )`;
841
+ }
842
+ /**
843
+ * Settle scope work queued before a synchronous mutation, skipping the catalog the mutation
844
+ * reconciles itself. Entries leave the queue only when the transaction commits.
845
+ */ const settlePendingScopes = async (nowMS, except)=>{
846
+ const scheduler = scopeSchedulers.get(context.root);
847
+ for (const [key, task] of scheduler?.pendingScopes ?? []){
848
+ if (except == null || task.catalog?.owner !== except.owner || task.catalog.id !== except.id) {
849
+ const pendingRules = await context.getCatalogRouteRules(nowMS, task.catalog);
850
+ await api.reconcileCatalogModelRefs({
851
+ modelID: task.modelID,
852
+ routeRules: pendingRules,
853
+ nowMS,
854
+ owners: task.owners,
855
+ catalog: task.catalog
856
+ });
857
+ }
858
+ context.provider.onCommit(()=>{
859
+ if (scheduler?.pendingScopes.get(key) === task) scheduler.pendingScopes.delete(key);
860
+ });
861
+ }
862
+ };
863
+ async function visibleCatalogRefs(ref) {
864
+ const catalog = await db.selectFrom('kubun_graph_catalog_use').select([
865
+ 'rules',
866
+ 'level'
867
+ ]).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
868
+ if (catalog?.level == null || catalogLevelBits[catalog.level].displays !== 1) return [];
869
+ const rules = typeof catalog.rules === 'string' ? JSON.parse(catalog.rules) : catalog.rules;
870
+ return rules.map((rule)=>catalogRuleRef(ref, rule.id));
871
+ }
872
+ function custodyReceiptFromRow(row) {
873
+ return {
874
+ documentID: row.document_id,
875
+ custodian: row.custodian,
876
+ groupID: row.group_id,
877
+ custodyOwner: row.custody_owner,
878
+ catalog: {
879
+ owner: row.catalog_owner,
880
+ id: row.catalog_id
881
+ },
882
+ assignmentDigest: row.assignment_digest,
883
+ headDigest: row.head_digest,
884
+ mutationDigest: row.mutation_digest,
885
+ receivedAt: Number(row.received_at)
886
+ };
887
+ }
888
+ function gcMarkFromRow(row) {
889
+ return {
890
+ documentID: row.document_id,
891
+ digest: row.digest,
892
+ markedAt: Number(row.marked_at)
893
+ };
894
+ }
895
+ function holdFromRow(row) {
896
+ return {
897
+ id: row.hold_id,
898
+ documentID: row.document_id,
899
+ createdBy: row.created_by,
900
+ reason: row.reason,
901
+ expiresAt: row.expires_at == null ? null : Number(row.expires_at)
902
+ };
903
+ }
904
+ const readScopeReader = async ()=>await db.selectFrom('kubun_graph_scope_identity').select([
905
+ 'reader_did',
906
+ 'default_read_level'
907
+ ]).where('id', '=', 1).executeTakeFirst();
908
+ // Whether the scope reader may read a document of this owner and model with this read rule;
909
+ // true with no reader, or when the reader owns it.
910
+ const scopeReaderMayRead = async (owner, model, override)=>await readerMayRead(await readScopeReader(), owner, model, override);
911
+ // One pass's read checks: nothing a ref recompute or swept refresh writes changes an answer.
912
+ const memoisedReadCheck = ()=>{
913
+ let reader;
914
+ const answers = new Map();
915
+ return async (owner, model, override)=>{
916
+ const key = JSON.stringify([
917
+ owner,
918
+ model,
919
+ override
920
+ ]);
921
+ let answer = answers.get(key);
922
+ if (answer == null) {
923
+ reader ??= readScopeReader();
924
+ answer = reader.then((row)=>readerMayRead(row, owner, model, override));
925
+ answers.set(key, answer);
926
+ }
927
+ return await answer;
928
+ };
929
+ };
930
+ // One pass's model interfaces, from one read on first use.
931
+ const memoisedInterfaces = ()=>{
932
+ let all;
933
+ return async (modelID)=>{
934
+ all ??= api.listModelInterfaces();
935
+ return (await all).get(modelID) ?? [];
936
+ };
937
+ };
938
+ const readerMayRead = async (reader, owner, model, override)=>{
939
+ if (reader == null || reader.reader_did === owner) return true;
940
+ const grant = override == null ? await api.getUserModelAccessDefault(owner, model, 'read') : null;
941
+ const access = override ?? grant;
942
+ const membership = context.provider.hasStore('p2p') ? await context.provider.getStore('p2p') : undefined;
943
+ return access == null ? !await api.hasControllerAccessDefaults(owner) && reader.default_read_level === 'anyone' : access.level === 'anyone' || access.level === 'restricted' && (access.allowedDIDs?.includes(reader.reader_did) === true || membership != null && (await membership.isMemberOfAnyCircle(reader.reader_did, access.allowedCircles ?? []) || await membership.isMemberOfAnyGroup(reader.reader_did, access.allowedGroups ?? [])));
944
+ };
945
+ function sweptFromRow(row) {
946
+ const parse = (value)=>typeof value === 'string' ? JSON.parse(value) : value;
947
+ return {
948
+ documentID: row.document_id,
949
+ modelID: row.model_id,
950
+ owner: row.owner,
951
+ createdAt: Number(row.created_at),
952
+ scopeDate: row.scope_date == null ? null : Number(row.scope_date),
953
+ fieldDates: parse(row.field_dates),
954
+ readAccess: row.read_access == null ? null : parse(row.read_access),
955
+ mutationDigest: row.mutation_digest,
956
+ sweptAt: Number(row.swept_at)
957
+ };
958
+ }
959
+ // Catalogs at a retaining level, and the pull route rules, as a ref recompute reads them.
960
+ const retainingRules = async (nowMS)=>{
961
+ const uses = [];
962
+ for (const use of (await db.selectFrom('kubun_graph_catalog_use').selectAll().execute())){
963
+ if (use.level == null || catalogLevelBits[use.level].retains !== 1) continue;
964
+ uses.push({
965
+ catalog: {
966
+ owner: use.owner_did,
967
+ id: use.catalog_id
968
+ },
969
+ rules: typeof use.rules === 'string' ? JSON.parse(use.rules) : use.rules
970
+ });
971
+ }
972
+ return {
973
+ uses,
974
+ routeRules: await context.getCatalogRouteRules(nowMS)
975
+ };
976
+ };
977
+ // Every rule of each catalog, whatever its level, keyed by `JSON.stringify([owner, id])`.
978
+ const catalogRulesByRef = async (catalogs)=>{
979
+ const byKey = new Map();
980
+ for (const catalog of catalogs)byKey.set(JSON.stringify([
981
+ catalog.owner,
982
+ catalog.id
983
+ ]), catalog);
984
+ const found = new Map();
985
+ // Two bound parameters per catalog.
986
+ for (const chunk of chunked(byKey.values(), IN_LIST_CHUNK / 2)){
987
+ const rows = await db.selectFrom('kubun_graph_catalog_use').select([
988
+ 'owner_did',
989
+ 'catalog_id',
990
+ 'rules'
991
+ ]).where((eb)=>eb.or(chunk.map((catalog)=>eb.and([
992
+ eb('owner_did', '=', catalog.owner),
993
+ eb('catalog_id', '=', catalog.id)
994
+ ])))).execute();
995
+ for (const row of rows){
996
+ found.set(JSON.stringify([
997
+ row.owner_did,
998
+ row.catalog_id
999
+ ]), typeof row.rules === 'string' ? JSON.parse(row.rules) : row.rules);
1000
+ }
1001
+ }
1002
+ return found;
1003
+ };
1004
+ /**
1005
+ * The catalogs whose rules would retain the swept document were it here: the same catalog
1006
+ * levels, route rules and read check a ref recompute applies, over the facts kept at sweep.
1007
+ */ async function sweptRetainingCatalogs(params) {
1008
+ const { entry, nowMS, retaining } = params;
1009
+ const checks = params.checks ?? {
1010
+ mayRead: scopeReaderMayRead,
1011
+ interfaces: (modelID)=>api.getModelInterfaces(modelID)
1012
+ };
1013
+ if (!await checks.mayRead(entry.owner, entry.modelID, entry.readAccess)) return [];
1014
+ const interfaces = await checks.interfaces(entry.modelID);
1015
+ const facts = {
1016
+ model: entry.modelID,
1017
+ owner: entry.owner,
1018
+ dateOf: (field)=>field == null ? entry.scopeDate ?? entry.createdAt : entry.fieldDates[field] ?? entry.createdAt
1019
+ };
1020
+ const matches = (rules)=>rules.some((rule)=>scopeRuleMatchesFacts(rule, facts, nowMS, interfaces));
1021
+ const found = new Map();
1022
+ for (const use of retaining.uses)if (matches(use.rules)) found.set(catalogRefPrefix(use.catalog), use.catalog);
1023
+ for (const route of retaining.routeRules)if (matches(route.rules)) found.set(catalogRefPrefix(route.catalog), route.catalog);
1024
+ return Array.from(found.values());
1025
+ }
1026
+ async function writeSweptDocument(doc, digest, nowMS) {
1027
+ const fieldDates = {};
1028
+ for (const row of (await db.selectFrom('kubun_graph_document_scope_dates').select([
1029
+ 'field',
1030
+ 'scope_date'
1031
+ ]).where('document_id', '=', doc.id).execute()))fieldDates[row.field] = Number(row.scope_date);
1032
+ const read = doc.data?.accessPermissions?.read;
1033
+ const values = {
1034
+ model_id: doc.model,
1035
+ owner: doc.owner,
1036
+ created_at: doc.createdAt.getTime(),
1037
+ scope_date: doc.scopeDate ?? null,
1038
+ field_dates: JSON.stringify(fieldDates),
1039
+ read_access: read == null ? null : JSON.stringify(read),
1040
+ mutation_digest: digest,
1041
+ swept_at: nowMS,
1042
+ in_scope: 0,
1043
+ scope_left_at: nowMS
1044
+ };
1045
+ await db.insertInto('kubun_graph_swept_documents').values({
1046
+ document_id: doc.id,
1047
+ ...values
1048
+ }).onConflict((oc)=>oc.column('document_id').doUpdateSet(values)).execute();
1049
+ }
1050
+ async function recordScopeChange(documentID) {
1051
+ await recordScopeChanges([
1052
+ documentID
1053
+ ]);
1054
+ }
1055
+ // Deduplicated per statement: Postgres refuses an upsert that updates one row twice.
1056
+ async function recordScopeChanges(documentIDs) {
1057
+ // Two bound parameters per row.
1058
+ for (const chunk of chunked(documentIDs, IN_LIST_CHUNK / 2)){
1059
+ await db.insertInto('kubun_graph_scope_changes').values(chunk.map((documentID)=>({
1060
+ document_id: documentID,
1061
+ version: 1
1062
+ }))).onConflict((oc)=>oc.column('document_id').doUpdateSet({
1063
+ version: sql`kubun_graph_scope_changes.version + 1`
1064
+ })).execute();
1065
+ }
1066
+ }
1067
+ // Ref writers decide from rows another transaction may be replacing, so each document's refs
1068
+ // take one writer at a time. A single-document writer takes the all-documents key shared, then
1069
+ // its document's key. A bulk writer takes only the all-documents key, exclusive: one lock
1070
+ // however many documents it rewrites, where a lock per document held to commit would exhaust
1071
+ // Postgres's shared lock table on a large graph.
1072
+ // SQLite serializes write transactions already; outside a transaction the lock would end
1073
+ // with its own statement.
1074
+ function refLocksApply() {
1075
+ return adapter instanceof AbstractPostgresAdapter && db.isTransaction;
1076
+ }
1077
+ async function lockAllDocumentRefs() {
1078
+ if (!refLocksApply()) return;
1079
+ await sql`select pg_advisory_xact_lock(hashtextextended(${allDocumentRefsLockKey}, 0))`.execute(db);
1080
+ exclusiveRefLockHolders.add(db);
1081
+ }
1082
+ // Bits alone cannot tell a SERVE_ONLY ref from a pull route's, so candidates are matched
1083
+ // against the catalogs this device serves.
1084
+ async function refsNeedBytes(rows, servedCatalogs = readServedCatalogs) {
1085
+ const candidates = rows.filter(mayNeedBytes);
1086
+ if (candidates.some((row)=>isCustodyPinRef(row.ref))) return true;
1087
+ if (candidates.length === 0) return false;
1088
+ const served = await servedCatalogs();
1089
+ return served.some((catalog)=>candidates.some((row)=>isCatalogRuleRef(row.ref, catalog)));
1090
+ }
1091
+ async function readServedCatalogs() {
1092
+ const served = await db.selectFrom('kubun_graph_catalog_use').select([
1093
+ 'owner_did',
1094
+ 'catalog_id'
1095
+ ]).where('level', '=', 'SERVE_ONLY').execute();
1096
+ return served.map((use)=>({
1097
+ owner: use.owner_did,
1098
+ id: use.catalog_id
1099
+ }));
1100
+ }
1101
+ async function readCatalogUses(catalog) {
1102
+ let query = db.selectFrom('kubun_graph_catalog_use').selectAll();
1103
+ if (catalog != null) query = query.where('owner_did', '=', catalog.owner).where('catalog_id', '=', catalog.id);
1104
+ return (await query.execute()).map((use)=>({
1105
+ catalog: {
1106
+ owner: use.owner_did,
1107
+ id: use.catalog_id
1108
+ },
1109
+ level: use.level,
1110
+ rules: typeof use.rules === 'string' ? JSON.parse(use.rules) : use.rules
1111
+ }));
1112
+ }
1113
+ // The reads of a single-document recompute, each as it is asked for.
1114
+ const directRecomputeInputs = (nowMS, catalog)=>({
1115
+ document: async (documentID)=>await api.getDocument(documentID),
1116
+ priorRefs: async (documentID)=>await db.selectFrom('kubun_graph_document_refs').select([
1117
+ 'ref',
1118
+ 'displays',
1119
+ 'retains'
1120
+ ]).where('document_id', '=', documentID).execute(),
1121
+ liveHoldRefs: async (documentID)=>new Set((await api.listDocumentHolds(documentID, nowMS)).map((hold)=>`hold:${hold.id}`)),
1122
+ interfaces: async (modelID)=>await api.getModelInterfaces(modelID),
1123
+ guardedRules: catalogRulesByRef,
1124
+ mayRead: scopeReaderMayRead,
1125
+ catalogs: async ()=>await readCatalogUses(catalog),
1126
+ servedCatalogs: readServedCatalogs,
1127
+ present: false
1128
+ });
1129
+ // Documents read by model, absent ones left out; an undeployed model throws as `getDocument`.
1130
+ async function readDocumentsByID(documentIDs) {
1131
+ const byModel = new Map();
1132
+ for (const documentID of documentIDs){
1133
+ const modelID = DocumentID.fromString(documentID).model.toString();
1134
+ const ids = byModel.get(modelID);
1135
+ if (ids == null) byModel.set(modelID, [
1136
+ documentID
1137
+ ]);
1138
+ else ids.push(documentID);
1139
+ }
1140
+ const found = new Map();
1141
+ for (const [modelID, ids] of byModel){
1142
+ const rows = await db.selectFrom(`k_${modelID}`).selectAll().where('id', 'in', ids).execute().catch((err)=>{
1143
+ if (isMissingTableError(err, `k_${modelID}`)) throw new ModelNotDeployedError(modelID, {
1144
+ cause: err
1145
+ });
1146
+ throw err;
1147
+ });
1148
+ for (const row of rows){
1149
+ const node = documentNodeFromTable(adapter, row);
1150
+ found.set(node.id, node);
1151
+ }
1152
+ }
1153
+ return found;
1154
+ }
1155
+ /**
1156
+ * A bulk writer's recompute: each document as `recomputeDocumentRefs` rewrites it, in the given
1157
+ * order, under the caller's ref lock. A document's recompute writes only its own refs, mark,
1158
+ * scope change and byte jobs, none of which another document's recompute reads, so the rest
1159
+ * is read once per chunk of documents or once per pass.
1160
+ */ const recomputeDocumentRefsBatch = async (params)=>{
1161
+ const { documentIDs, routeRules, nowMS, catalog, releaseCustodyPins: release } = params;
1162
+ if (documentIDs.length === 0) return;
1163
+ if (context.provider === context.root) {
1164
+ // Outside a transaction each document's recompute commits on its own.
1165
+ for (const documentID of documentIDs)await api.recomputeDocumentRefs({
1166
+ documentID,
1167
+ routeRules,
1168
+ nowMS,
1169
+ catalog,
1170
+ releaseCustodyPins: release
1171
+ });
1172
+ return;
1173
+ }
1174
+ await lockDocumentRefs(documentIDs);
1175
+ const effectiveRouteRules = routeRules.length === 0 ? await context.getCatalogRouteRules(nowMS, catalog) : routeRules;
1176
+ const interfaces = memoisedInterfaces();
1177
+ const mayRead = memoisedReadCheck();
1178
+ let catalogs;
1179
+ let served;
1180
+ const guardRules = new Map();
1181
+ for (const chunk of chunked(documentIDs)){
1182
+ const documents = await readDocumentsByID(chunk);
1183
+ const prior = new Map();
1184
+ for (const row of (await db.selectFrom('kubun_graph_document_refs').select([
1185
+ 'document_id',
1186
+ 'ref',
1187
+ 'displays',
1188
+ 'retains'
1189
+ ]).where('document_id', 'in', chunk).execute())){
1190
+ const rows = prior.get(row.document_id) ?? [];
1191
+ rows.push({
1192
+ ref: row.ref,
1193
+ displays: row.displays,
1194
+ retains: row.retains
1195
+ });
1196
+ prior.set(row.document_id, rows);
1197
+ }
1198
+ const holds = new Map();
1199
+ for (const row of (await db.selectFrom('kubun_graph_document_holds').select([
1200
+ 'document_id',
1201
+ 'hold_id'
1202
+ ]).where('document_id', 'in', chunk).where('released_at', 'is', null).where((eb)=>eb.or([
1203
+ eb('expires_at', 'is', null),
1204
+ eb('expires_at', '>', nowMS)
1205
+ ])).execute())){
1206
+ const refs = holds.get(row.document_id) ?? new Set();
1207
+ refs.add(`hold:${row.hold_id}`);
1208
+ holds.set(row.document_id, refs);
1209
+ }
1210
+ const inputs = {
1211
+ document: async (documentID)=>documents.get(documentID) ?? null,
1212
+ priorRefs: async (documentID)=>prior.get(documentID) ?? [],
1213
+ liveHoldRefs: async (documentID)=>holds.get(documentID) ?? new Set(),
1214
+ interfaces,
1215
+ guardedRules: async (wanted)=>{
1216
+ const missing = wanted.filter((catalog)=>!guardRules.has(JSON.stringify([
1217
+ catalog.owner,
1218
+ catalog.id
1219
+ ])));
1220
+ if (missing.length > 0) {
1221
+ const found = await catalogRulesByRef(missing);
1222
+ for (const catalog of missing){
1223
+ const key = JSON.stringify([
1224
+ catalog.owner,
1225
+ catalog.id
1226
+ ]);
1227
+ guardRules.set(key, found.get(key) ?? []);
1228
+ }
1229
+ }
1230
+ return guardRules;
1231
+ },
1232
+ mayRead,
1233
+ catalogs: async ()=>{
1234
+ catalogs ??= readCatalogUses(catalog);
1235
+ return await catalogs;
1236
+ },
1237
+ servedCatalogs: async ()=>{
1238
+ served ??= readServedCatalogs();
1239
+ return await served;
1240
+ },
1241
+ // Nothing in the pass removes a document.
1242
+ present: true
1243
+ };
1244
+ for (const documentID of chunk){
1245
+ const released = new Set(release != null && release.refs.length > 0 && await release.confirm() ? release.refs : []);
1246
+ await recomputeRefsWith({
1247
+ documentID,
1248
+ routeRules: effectiveRouteRules,
1249
+ nowMS,
1250
+ catalog,
1251
+ released
1252
+ }, inputs);
1253
+ }
1254
+ }
1255
+ };
1256
+ // Rewrites one document's refs and mark from what `inputs` reads: false when it is absent.
1257
+ const recomputeRefsWith = async (step, inputs)=>{
1258
+ const { documentID, routeRules, nowMS, catalog, released } = step;
1259
+ const doc = await inputs.document(documentID);
1260
+ if (doc == null) return false;
1261
+ const prior = await inputs.priorRefs(documentID);
1262
+ const liveHoldRefs = await inputs.liveHoldRefs(documentID);
1263
+ const refs = new Map();
1264
+ for (const row of prior){
1265
+ if (row.ref === 'local' || // Custody pins are sticky: scope exit, read loss and a lost assignment keep them. Null
1266
+ // data is only ever a tombstone winning, which releases them.
1267
+ isCustodyPinRef(row.ref) && doc.data != null && !released.has(row.ref) || // A hold with no row (never written, or released) has nothing keeping the ref alive.
1268
+ liveHoldRefs.has(row.ref) || catalog != null && !row.ref.startsWith('hold:') && !isCatalogRuleRef(row.ref, catalog)) refs.set(row.ref, {
1269
+ displays: Number(row.displays),
1270
+ retains: Number(row.retains)
1271
+ });
1272
+ }
1273
+ const interfaces = await inputs.interfaces(doc.model);
1274
+ // Computed before the read check: a custodian keeps what it guards whatever it may read.
1275
+ const guards = doc.data == null ? [] : await context.custody?.guards() ?? [];
1276
+ const guardedRules = await inputs.guardedRules(guards.filter((guard)=>!refs.has(custodyPinRef(guard))).map((guard)=>guard.catalog));
1277
+ for (const guard of guards){
1278
+ const ref = custodyPinRef(guard);
1279
+ if (refs.has(ref)) continue;
1280
+ // Any rule of the guarded catalog, whatever level this device keeps it at.
1281
+ const rules = guardedRules.get(JSON.stringify([
1282
+ guard.catalog.owner,
1283
+ guard.catalog.id
1284
+ ])) ?? [];
1285
+ if (!rules.some((rule)=>scopeRuleMatches(rule, doc, nowMS, interfaces))) continue;
1286
+ if (!await context.custody?.keepsOwner(guard.custodyOwner, doc.owner)) continue;
1287
+ refs.set(ref, {
1288
+ displays: 0,
1289
+ retains: 1
1290
+ });
1291
+ }
1292
+ const finish = async ()=>{
1293
+ await replaceDocumentRefs({
1294
+ documentID,
1295
+ prior,
1296
+ refs,
1297
+ catalog,
1298
+ nowMS,
1299
+ present: inputs.present
1300
+ });
1301
+ // In this transaction, so kept bytes are queued, or released ones dropped, before the
1302
+ // document can change again. Prior refs are read without their catalog's current level:
1303
+ // a level change commits before the recompute that drops the catalog's refs.
1304
+ if (doc.data != null && await refsNeedBytes(Array.from(refs, ([ref, bits])=>({
1305
+ ref,
1306
+ ...bits
1307
+ })), inputs.servedCatalogs)) await context.documentBytes.needed(documentID);
1308
+ else if (prior.some(mayNeedBytes)) await context.documentBytes.released(documentID);
1309
+ return true;
1310
+ };
1311
+ const data = doc.data;
1312
+ if (!await inputs.mayRead(doc.owner, doc.model, data?.accessPermissions?.read ?? null)) return await finish();
1313
+ for (const use of (await inputs.catalogs())){
1314
+ if (use.level == null) continue;
1315
+ const bits = catalogLevelBits[use.level];
1316
+ for (const rule of use.rules){
1317
+ if (!scopeRuleMatches(rule, doc, nowMS, interfaces)) continue;
1318
+ refs.set(catalogRuleRef(use.catalog, rule.id), bits);
1319
+ }
1320
+ }
1321
+ for (const route of routeRules){
1322
+ if (catalog != null && (route.catalog.owner !== catalog.owner || route.catalog.id !== catalog.id)) continue;
1323
+ for (const rule of route.rules){
1324
+ if (!scopeRuleMatches(rule, doc, nowMS, interfaces)) continue;
1325
+ const ref = catalogRuleRef(route.catalog, rule.id);
1326
+ refs.set(ref, {
1327
+ displays: refs.get(ref)?.displays ?? 0,
1328
+ retains: 1
1329
+ });
1330
+ }
1331
+ }
1332
+ return await finish();
1333
+ };
1334
+ async function lockDocumentRefs(documentIDs) {
1335
+ if (!refLocksApply()) return;
1336
+ if (exclusiveRefLockHolders.has(db)) {
1337
+ // Taken again rather than trusted, as a rolled-back savepoint releases its locks. A held
1338
+ // lock is taken again without another lock table entry.
1339
+ await lockAllDocumentRefs();
1340
+ return;
1341
+ }
1342
+ await sql`select pg_advisory_xact_lock_shared(hashtextextended(${allDocumentRefsLockKey}, 0))`.execute(db);
1343
+ for (const documentID of [
1344
+ ...new Set(documentIDs)
1345
+ ].sort()){
1346
+ await sql`select pg_advisory_xact_lock(hashtextextended(${`graph.document.refs:${documentID}`}, 0))`.execute(db);
1347
+ }
1348
+ }
1349
+ async function mutationDigest(documentID) {
1350
+ const rows = await db.selectFrom('kubun_graph_mutation_log').select('mutation_hash').where('document_id', '=', documentID).where('status', '=', 'applied').execute();
1351
+ return mutationIDsDigest(rows.map((row)=>row.mutation_hash));
1352
+ }
1353
+ // Runs in the ref writer's locked transaction, after its write, so a mark never disagrees with
1354
+ // the refs a concurrent reader can see. A mark restarts only when the document's mutations
1355
+ // changed, so grace time is kept across repeated recomputes.
1356
+ async function syncGCMark(documentID, nowMS, present = false) {
1357
+ const retaining = await db.selectFrom('kubun_graph_document_refs').select('ref').where('document_id', '=', documentID).where('retains', '>', 0).limit(1).executeTakeFirst();
1358
+ if (retaining != null || !present && await api.getDocument(documentID) == null) {
1359
+ await db.deleteFrom('kubun_graph_gc_marks').where('document_id', '=', documentID).execute();
1360
+ return;
1361
+ }
1362
+ const digest = await mutationDigest(documentID);
1363
+ await db.insertInto('kubun_graph_gc_marks').values({
1364
+ document_id: documentID,
1365
+ digest,
1366
+ marked_at: nowMS
1367
+ }).onConflict((oc)=>oc.column('document_id').doUpdateSet({
1368
+ digest,
1369
+ marked_at: nowMS
1370
+ }).where('kubun_graph_gc_marks.digest', '!=', digest)).execute();
1371
+ }
1372
+ async function replaceDocumentRefs(params) {
1373
+ await writeDocumentRefs(params);
1374
+ await syncGCMark(params.documentID, params.nowMS, params.present);
1375
+ }
1376
+ async function writeDocumentRefs(params) {
1377
+ const { documentID, prior, refs, catalog } = params;
1378
+ // Hold refs are managed on every path so a stale one is removed even in a catalog-scoped pass.
1379
+ const managed = (ref)=>catalog == null || isCatalogRuleRef(ref, catalog) || ref.startsWith('hold:');
1380
+ const previous = prior.filter((row)=>managed(row.ref));
1381
+ const selected = new Map([
1382
+ ...refs
1383
+ ].filter(([ref])=>managed(ref)));
1384
+ if (previous.length === selected.size && previous.every((row)=>{
1385
+ const bits = selected.get(row.ref);
1386
+ return bits != null && bits.displays === Number(row.displays) && bits.retains === Number(row.retains);
1387
+ })) return;
1388
+ const removal = db.deleteFrom('kubun_graph_document_refs').where('document_id', '=', documentID);
1389
+ if (catalog == null) await removal.execute();
1390
+ else if (previous.length > 0) await removal.where('ref', 'in', previous.map((row)=>row.ref)).execute();
1391
+ if (selected.size > 0) {
1392
+ await db.insertInto('kubun_graph_document_refs').values(Array.from(selected, ([ref, bits])=>({
1393
+ document_id: documentID,
1394
+ ref,
1395
+ displays: bits.displays,
1396
+ retains: bits.retains
1397
+ }))).execute();
1398
+ }
1399
+ await recordScopeChange(documentID);
1400
+ }
1401
+ const pendingLifetimeMS = 24 * 60 * 60 * 1000;
1402
+ async function expirePushedMutations(now, groupID) {
1403
+ // Guard deletion and parking must share the same lock, including an upsert racing deletion.
1404
+ let guards = db.selectFrom('kubun_graph_pending_groups').select('group_id');
1405
+ if (groupID != null) guards = guards.where('group_id', '=', groupID);
1406
+ const groups = await (adapter instanceof AbstractPostgresAdapter ? guards.orderBy('group_id').forUpdate() : guards).execute();
1407
+ let query = db.deleteFrom('kubun_graph_mutation_log').where('status', '=', 'pending').where('arrival_group_id', 'is not', null).where('parked_at', '<=', now - pendingLifetimeMS);
1408
+ if (groupID != null) query = query.where('arrival_group_id', '=', groupID);
1409
+ await query.execute();
1410
+ // A parking transaction still needs its guard until it has inserted the new row.
1411
+ if (groupID != null || groups.length === 0) return;
1412
+ await db.deleteFrom('kubun_graph_pending_groups').where('group_id', 'in', groups.map((group)=>group.group_id)).where((eb)=>eb.not(eb.exists(eb.selectFrom('kubun_graph_mutation_log').select('mutation_hash').where('status', '=', 'pending').whereRef('arrival_group_id', '=', 'kubun_graph_pending_groups.group_id')))).execute();
1413
+ }
371
1414
  const api = {
1415
+ ...createGraphAccessDefaultRecordMethods(db, adapter),
1416
+ ...createGraphCatalogRecordMethods(db, adapter, {
1417
+ onClose: context.onClose,
1418
+ // A custody change can complete or stop a pin handoff.
1419
+ onCustodyRecordStored: ()=>context.provider.onCommit(()=>context.custodyRecordsChanged?.())
1420
+ }),
1421
+ ...createCatalogStateMethods(db),
372
1422
  // --- Document operations ---
373
1423
  async getDocument (id) {
374
1424
  const parsedID = DocumentID.from(id);
@@ -410,7 +1460,11 @@ export function createGraphStore(db, adapter) {
410
1460
  // duplicate id does not depend on which query returned first.
411
1461
  const rowsByModel = await Promise.all(Array.from(idsByModel, async ([modelID, documentIDs])=>{
412
1462
  try {
413
- return await db.selectFrom(`k_${modelID}`).selectAll().where('id', 'in', documentIDs).execute();
1463
+ const rows = [];
1464
+ for (const chunk of chunked(documentIDs)){
1465
+ rows.push(...await db.selectFrom(`k_${modelID}`).selectAll().where('id', 'in', chunk).execute());
1466
+ }
1467
+ return rows;
414
1468
  } catch (err) {
415
1469
  // A batch spans models, so one undeployed model contributes nothing
416
1470
  // rather than failing the read for every other id asked for. The
@@ -436,6 +1490,27 @@ export function createGraphStore(db, adapter) {
436
1490
  const rows = await db.selectFrom('kubun_graph_document_model_interfaces').select('interface_id').where('implementation_id', '=', modelID).execute();
437
1491
  return rows.map((row)=>row.interface_id);
438
1492
  },
1493
+ async listModelInterfaces () {
1494
+ const links = await db.selectFrom('kubun_graph_document_model_interfaces').select([
1495
+ 'implementation_id',
1496
+ 'interface_id'
1497
+ ]).execute();
1498
+ const interfaces = new Map();
1499
+ for (const link of links){
1500
+ const list = interfaces.get(link.implementation_id) ?? [];
1501
+ list.push(link.interface_id);
1502
+ interfaces.set(link.implementation_id, list);
1503
+ }
1504
+ return interfaces;
1505
+ },
1506
+ async getDocumentModelsByID (ids) {
1507
+ const models = new Map();
1508
+ for (const chunk of chunked(new Set(ids))){
1509
+ const rows = await db.selectFrom('kubun_graph_document_models').selectAll().where('id', 'in', chunk).execute();
1510
+ for (const row of rows)models.set(row.id, documentModelFromTable(row));
1511
+ }
1512
+ return models;
1513
+ },
439
1514
  async createDocument (params) {
440
1515
  const id = params.id.toString();
441
1516
  const model = params.id.model.toString();
@@ -443,13 +1518,28 @@ export function createGraphStore(db, adapter) {
443
1518
  id,
444
1519
  owner: params.owner,
445
1520
  model,
446
- data: adapter.encodeJSON(params.data),
1521
+ // Null data (a first-seen tombstone establishing a dead row) persists
1522
+ // as SQL null, not the JSON string "null" — mirrors `saveDocument`.
1523
+ data: params.data ? adapter.encodeJSON(params.data) : null,
447
1524
  unique: adapter.encodeBinary(params.unique)
448
1525
  }).returningAll().executeTakeFirstOrThrow(()=>new Error(`createDocument failed to return row for ${id}`));
1526
+ // Re-acquired, by an answer or a pushed version: nothing left to restore.
1527
+ await db.deleteFrom('kubun_graph_swept_documents').where('document_id', '=', id).execute();
449
1528
  if (params.data) {
450
1529
  await reindexDocument(model, id, params.data);
451
1530
  }
452
- return documentNodeFromTable(adapter, doc);
1531
+ await refreshDocumentDates(id);
1532
+ if (params.authoredHere !== false) await api.markDocumentLocal(id);
1533
+ await api.recomputeDocumentRefs({
1534
+ documentID: id,
1535
+ routeRules: []
1536
+ });
1537
+ // markDocumentLocal already told the listeners for an authored create.
1538
+ if (params.authoredHere === false) await context.documentApplied({
1539
+ documentID: id,
1540
+ authored: false
1541
+ });
1542
+ return await api.getDocument(id) ?? documentNodeFromTable(adapter, doc);
453
1543
  },
454
1544
  async saveDocument (params) {
455
1545
  const id = params.id.toString();
@@ -463,22 +1553,955 @@ export function createGraphStore(db, adapter) {
463
1553
  } else {
464
1554
  await unindexDocument(model, id);
465
1555
  }
466
- return documentNodeFromTable(adapter, doc);
1556
+ await refreshDocumentDates(id);
1557
+ await api.recomputeDocumentRefs({
1558
+ documentID: id,
1559
+ routeRules: []
1560
+ });
1561
+ // Not authored: a local apply calls markDocumentLocal after this, which says so.
1562
+ await context.documentApplied({
1563
+ documentID: id,
1564
+ authored: false
1565
+ });
1566
+ return await api.getDocument(id) ?? documentNodeFromTable(adapter, doc);
1567
+ },
1568
+ async getDocumentDates (documentID) {
1569
+ const modelID = DocumentID.fromString(documentID).model.toString();
1570
+ const row = await db.selectFrom(`k_${modelID}`).select([
1571
+ 'mutation_created_at',
1572
+ 'mutation_updated_at',
1573
+ 'scope_date'
1574
+ ]).where('id', '=', documentID).executeTakeFirst();
1575
+ if (row == null) return null;
1576
+ return {
1577
+ createdAt: row.mutation_created_at == null ? null : Number(row.mutation_created_at),
1578
+ updatedAt: row.mutation_updated_at == null ? null : Number(row.mutation_updated_at),
1579
+ scopeDate: row.scope_date == null ? null : Number(row.scope_date)
1580
+ };
1581
+ },
1582
+ async setCatalogDefinition (ref, catalog) {
1583
+ validateScopeCatalog(catalog);
1584
+ const dateModelIDs = new Set();
1585
+ for (const rule of catalog.rules){
1586
+ if (rule.dateField == null) continue;
1587
+ for (const modelID of rule.models ?? [])dateModelIDs.add(modelID);
1588
+ }
1589
+ const dateModels = new Map();
1590
+ for (const chunk of chunked(dateModelIDs)){
1591
+ const rows = await db.selectFrom('kubun_graph_document_models').select([
1592
+ 'id',
1593
+ 'schema'
1594
+ ]).where('id', 'in', chunk).execute();
1595
+ for (const row of rows)dateModels.set(row.id, row);
1596
+ }
1597
+ for (const rule of catalog.rules){
1598
+ if (rule.dateField == null) continue;
1599
+ for (const modelID of rule.models ?? []){
1600
+ const model = dateModels.get(modelID);
1601
+ if (model == null || !isDocumentDateField(model, rule.dateField)) {
1602
+ throw new InvalidModelDateFieldError(`Invalid date-field override: ${rule.dateField}`);
1603
+ }
1604
+ }
1605
+ }
1606
+ const previous = await db.selectFrom('kubun_graph_catalog_use').select([
1607
+ 'rules',
1608
+ 'level'
1609
+ ]).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
1610
+ const previousRules = previous == null ? [] : typeof previous.rules === 'string' ? JSON.parse(previous.rules) : previous.rules;
1611
+ const affectedTasks = previous?.level != null ? await tasksForCatalog(ref, [
1612
+ ...previousRules,
1613
+ ...catalog.rules
1614
+ ]) : [];
1615
+ await db.insertInto('kubun_graph_catalog_use').values({
1616
+ owner_did: ref.owner,
1617
+ catalog_id: ref.id,
1618
+ name: catalog.name,
1619
+ rules: adapter.encodeJSON(catalog.rules),
1620
+ level: null,
1621
+ followed: 0
1622
+ }).onConflict((oc)=>oc.columns([
1623
+ 'owner_did',
1624
+ 'catalog_id'
1625
+ ]).doUpdateSet({
1626
+ name: catalog.name,
1627
+ rules: adapter.encodeJSON(catalog.rules)
1628
+ })).execute();
1629
+ scheduleScopeTasks(context, affectedTasks);
1630
+ },
1631
+ async setScopeReaderDID (did, defaultReadLevel = 'only_owner') {
1632
+ const current = await db.selectFrom('kubun_graph_scope_identity').select([
1633
+ 'reader_did',
1634
+ 'default_read_level'
1635
+ ]).where('id', '=', 1).executeTakeFirst();
1636
+ if (current?.reader_did === did && current.default_read_level === defaultReadLevel) return;
1637
+ await db.insertInto('kubun_graph_scope_identity').values({
1638
+ id: 1,
1639
+ reader_did: did,
1640
+ default_read_level: defaultReadLevel
1641
+ }).onConflict((oc)=>oc.column('id').doUpdateSet({
1642
+ reader_did: did,
1643
+ default_read_level: defaultReadLevel
1644
+ })).execute();
1645
+ const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
1646
+ scheduleScopeModels(context, models.map((model)=>model.id));
1647
+ },
1648
+ async setCatalogUse (ref, use) {
1649
+ const catalog = await db.selectFrom('kubun_graph_catalog_use').select([
1650
+ 'rules',
1651
+ 'level'
1652
+ ]).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
1653
+ const rules = catalog == null ? [] : typeof catalog.rules === 'string' ? JSON.parse(catalog.rules) : catalog.rules;
1654
+ const affectedTasks = catalog != null && (catalog.level ?? null) !== use.level ? await tasksForCatalog(ref, rules) : [];
1655
+ await db.updateTable('kubun_graph_catalog_use').set({
1656
+ level: use.level,
1657
+ followed: Number(use.followed)
1658
+ }).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).execute();
1659
+ scheduleScopeTasks(context, affectedTasks);
1660
+ },
1661
+ async getCatalogUse (ref) {
1662
+ const row = await db.selectFrom('kubun_graph_catalog_use').select([
1663
+ 'level',
1664
+ 'followed'
1665
+ ]).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
1666
+ return row == null ? null : catalogUseFromRow(row);
1667
+ },
1668
+ async getCatalogUses (refs) {
1669
+ if (refs.length === 0) return [];
1670
+ // The exact keys as one row-value list, as the batched record decisions read them.
1671
+ const unique = new Map(refs.map((ref)=>[
1672
+ JSON.stringify([
1673
+ ref.owner,
1674
+ ref.id
1675
+ ]),
1676
+ ref
1677
+ ]));
1678
+ const wanted = sql.join(Array.from(unique.values(), (ref)=>sql`(${ref.owner}, ${ref.id})`));
1679
+ const rows = await db.selectFrom('kubun_graph_catalog_use').select([
1680
+ 'owner_did',
1681
+ 'catalog_id',
1682
+ 'level',
1683
+ 'followed'
1684
+ ]).where(sql`(${sql.ref('owner_did')}, ${sql.ref('catalog_id')}) in (values ${wanted})`).execute();
1685
+ const uses = new Map(rows.map((row)=>[
1686
+ JSON.stringify([
1687
+ row.owner_did,
1688
+ row.catalog_id
1689
+ ]),
1690
+ catalogUseFromRow(row)
1691
+ ]));
1692
+ return refs.map((ref)=>uses.get(JSON.stringify([
1693
+ ref.owner,
1694
+ ref.id
1695
+ ])) ?? null);
1696
+ },
1697
+ async deleteCatalogUse (ref) {
1698
+ if (context.provider === context.root) {
1699
+ await context.root.withTransaction(async (tx)=>{
1700
+ await (await tx.getStore('graph')).deleteCatalogUse(ref);
1701
+ });
1702
+ return;
1703
+ }
1704
+ const existing = await db.selectFrom('kubun_graph_catalog_use').select([
1705
+ 'rules',
1706
+ 'level'
1707
+ ]).where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
1708
+ if (context.provider.hasStore('p2p')) {
1709
+ const p2p = await context.provider.getStore('p2p');
1710
+ await p2p.deleteCatalogRoutes(ref.owner, ref.id);
1711
+ await p2p.removeCatalogOffersForCatalog(ref.owner, ref.id);
1712
+ }
1713
+ if (existing === undefined) return;
1714
+ const rules = typeof existing.rules === 'string' ? JSON.parse(existing.rules) : existing.rules;
1715
+ const affectedTasks = existing.level != null ? await tasksForCatalog(ref, rules) : [];
1716
+ await db.deleteFrom('kubun_graph_catalog_use').where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).execute();
1717
+ scheduleScopeTasks(context, affectedTasks);
1718
+ await api.reconcileCatalogRefs(Date.now(), [], ref);
1719
+ },
1720
+ async markDocumentLocal (documentID) {
1721
+ if (context.provider === context.root) {
1722
+ await context.root.withTransaction(async (tx)=>{
1723
+ await (await tx.getStore('graph')).markDocumentLocal(documentID);
1724
+ });
1725
+ return;
1726
+ }
1727
+ await lockDocumentRefs([
1728
+ documentID
1729
+ ]);
1730
+ const inserted = await db.insertInto('kubun_graph_document_refs').values({
1731
+ document_id: documentID,
1732
+ ref: 'local',
1733
+ displays: 1,
1734
+ retains: 0
1735
+ }).onConflict((oc)=>oc.doNothing()).returning('ref').execute();
1736
+ if (inserted.length > 0) await recordScopeChange(documentID);
1737
+ await syncGCMark(documentID, Date.now());
1738
+ // Called once per apply authored here, after its data is final.
1739
+ await context.documentApplied({
1740
+ documentID,
1741
+ authored: true
1742
+ });
1743
+ },
1744
+ async placeDocumentHold (params) {
1745
+ if (context.provider === context.root) {
1746
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).placeDocumentHold(params));
1747
+ }
1748
+ const { holdID, documentID, createdBy, reason, expiresAt } = params;
1749
+ await lockDocumentRefs([
1750
+ documentID
1751
+ ]);
1752
+ // A sweep deletes the row under this lock: one committed since the caller looked leaves
1753
+ // nothing to hold.
1754
+ if ((await api.getDocument(documentID))?.data == null) throw new DocumentNotHoldableError();
1755
+ await db.insertInto('kubun_graph_document_holds').values({
1756
+ hold_id: holdID,
1757
+ document_id: documentID,
1758
+ created_by: createdBy,
1759
+ reason: reason ?? null,
1760
+ expires_at: expiresAt ?? null,
1761
+ released_at: null
1762
+ }).execute();
1763
+ await db.insertInto('kubun_graph_document_refs').values({
1764
+ document_id: documentID,
1765
+ ref: `hold:${holdID}`,
1766
+ displays: 0,
1767
+ retains: 1
1768
+ }).onConflict((oc)=>oc.doNothing()).execute();
1769
+ await recordScopeChange(documentID);
1770
+ await syncGCMark(documentID, Date.now());
1771
+ return {
1772
+ id: holdID,
1773
+ documentID,
1774
+ createdBy,
1775
+ reason: reason ?? null,
1776
+ expiresAt: expiresAt ?? null
1777
+ };
1778
+ },
1779
+ async releaseDocumentHold (holdID, nowMS) {
1780
+ if (context.provider === context.root) {
1781
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).releaseDocumentHold(holdID, nowMS));
1782
+ }
1783
+ // A hold never changes document, so its document can be locked before the release decides.
1784
+ const target = await db.selectFrom('kubun_graph_document_holds').select('document_id').where('hold_id', '=', holdID).executeTakeFirst();
1785
+ if (target == null) return null;
1786
+ await lockDocumentRefs([
1787
+ target.document_id
1788
+ ]);
1789
+ const released = await db.updateTable('kubun_graph_document_holds').set({
1790
+ released_at: nowMS
1791
+ }).where('hold_id', '=', holdID).where('released_at', 'is', null).returningAll().executeTakeFirst();
1792
+ if (released == null) return null;
1793
+ await db.deleteFrom('kubun_graph_document_refs').where('document_id', '=', released.document_id).where('ref', '=', `hold:${holdID}`).execute();
1794
+ await recordScopeChange(released.document_id);
1795
+ await syncGCMark(released.document_id, nowMS);
1796
+ return holdFromRow(released);
1797
+ },
1798
+ documentMutationDigest: mutationDigest,
1799
+ async hasAppliedMutations (documentID, mutationIDs) {
1800
+ for (const chunk of chunked(mutationIDs)){
1801
+ const row = await db.selectFrom('kubun_graph_mutation_log').select((eb)=>eb.fn.countAll().as('count')).where('document_id', '=', documentID).where('status', '=', 'applied').where('mutation_hash', 'in', chunk).executeTakeFirst();
1802
+ if (Number(row?.count ?? 0) !== chunk.length) return false;
1803
+ }
1804
+ return true;
1805
+ },
1806
+ async putCustodyNonce (nonce) {
1807
+ await db.deleteFrom('kubun_graph_custody_nonces').where('expires_at', '<=', Date.now()).execute();
1808
+ await db.insertInto('kubun_graph_custody_nonces').values({
1809
+ nonce: nonce.nonce,
1810
+ custodian: nonce.custodian,
1811
+ assignment_digest: nonce.assignmentDigest,
1812
+ expires_at: nonce.expiresAt
1813
+ }).execute();
1814
+ },
1815
+ async consumeCustodyNonce (params) {
1816
+ // One statement, so two concurrent consumers cannot both see the row.
1817
+ const result = await db.deleteFrom('kubun_graph_custody_nonces').where('nonce', '=', params.nonce).where('custodian', '=', params.custodian).where('assignment_digest', '=', params.assignmentDigest).where('expires_at', '>', params.nowMS).executeTakeFirst();
1818
+ return Number(result.numDeletedRows) === 1;
1819
+ },
1820
+ async putCustodyReceipts (receipts) {
1821
+ for (const receipt of receipts){
1822
+ const values = {
1823
+ group_id: receipt.groupID,
1824
+ custody_owner: receipt.custodyOwner,
1825
+ catalog_owner: receipt.catalog.owner,
1826
+ catalog_id: receipt.catalog.id,
1827
+ assignment_digest: receipt.assignmentDigest,
1828
+ head_digest: receipt.headDigest,
1829
+ mutation_digest: receipt.mutationDigest,
1830
+ received_at: receipt.receivedAt
1831
+ };
1832
+ await db.insertInto('kubun_graph_custody_receipts').values({
1833
+ document_id: receipt.documentID,
1834
+ custodian: receipt.custodian,
1835
+ ...values
1836
+ }).onConflict((oc)=>oc.columns([
1837
+ 'document_id',
1838
+ 'custodian'
1839
+ ]).doUpdateSet(values)).execute();
1840
+ }
1841
+ },
1842
+ async listCustodyPinnedDocuments (params) {
1843
+ let query = db.selectFrom('kubun_graph_document_refs').select('document_id').where('ref', '=', params.ref);
1844
+ if (params.after != null) query = query.where('document_id', '>', params.after);
1845
+ const rows = await query.orderBy('document_id').limit(params.limit).execute();
1846
+ return rows.map((row)=>row.document_id);
1847
+ },
1848
+ async listCustodyReceipts (documentID) {
1849
+ const rows = await db.selectFrom('kubun_graph_custody_receipts').selectAll().where('document_id', '=', documentID).orderBy('custodian').execute();
1850
+ return rows.map(custodyReceiptFromRow);
1851
+ },
1852
+ async getGCMark (documentID) {
1853
+ const row = await db.selectFrom('kubun_graph_gc_marks').selectAll().where('document_id', '=', documentID).executeTakeFirst();
1854
+ return row == null ? null : gcMarkFromRow(row);
1855
+ },
1856
+ async dropGCMark (params) {
1857
+ await db.deleteFrom('kubun_graph_gc_marks').where('document_id', '=', params.documentID).where('digest', '=', params.digest).execute();
1858
+ },
1859
+ async listGCMarks (params) {
1860
+ let query = db.selectFrom('kubun_graph_gc_marks').selectAll();
1861
+ // Bytewise order and cursor, so paging agrees across adapters.
1862
+ const postgres = adapter instanceof AbstractPostgresAdapter;
1863
+ if (params.after != null) query = query.where(postgres ? sql`document_id collate "C" > ${params.after}` : sql`document_id > ${params.after}`);
1864
+ const rows = await (postgres ? query.orderBy(sql`document_id collate "C"`) : query.orderBy('document_id')).limit(params.limit).execute();
1865
+ return rows.map(gcMarkFromRow);
1866
+ },
1867
+ async getDocumentHold (holdID) {
1868
+ const row = await db.selectFrom('kubun_graph_document_holds').selectAll().where('hold_id', '=', holdID).executeTakeFirst();
1869
+ return row == null ? null : holdFromRow(row);
1870
+ },
1871
+ async listDocumentHolds (documentID, nowMS) {
1872
+ const rows = await db.selectFrom('kubun_graph_document_holds').selectAll().where('document_id', '=', documentID).where('released_at', 'is', null).where((eb)=>eb.or([
1873
+ eb('expires_at', 'is', null),
1874
+ eb('expires_at', '>', nowMS)
1875
+ ])).orderBy('hold_id').execute();
1876
+ return rows.map(holdFromRow);
1877
+ },
1878
+ async nextDocumentHoldExpiry () {
1879
+ const tick = await db.selectFrom('kubun_graph_catalog_scope_tick').select('last_at').where('id', '=', 1).executeTakeFirst();
1880
+ let query = db.selectFrom('kubun_graph_document_holds').select((eb)=>eb.fn.min('expires_at').as('expires_at')).where('released_at', 'is', null).where('expires_at', 'is not', null);
1881
+ if (tick != null) query = query.where('expires_at', '>', Number(tick.last_at));
1882
+ const row = await query.executeTakeFirst();
1883
+ return row?.expires_at == null ? null : Number(row.expires_at);
1884
+ },
1885
+ async listPendingScopeChanges () {
1886
+ const rows = await db.selectFrom('kubun_graph_scope_changes').select([
1887
+ 'document_id',
1888
+ 'version'
1889
+ ]).orderBy('document_id').execute();
1890
+ return rows.map((row)=>({
1891
+ documentID: row.document_id,
1892
+ version: row.version
1893
+ }));
1894
+ },
1895
+ async ackDocumentScopeChange (documentID, version) {
1896
+ await db.deleteFrom('kubun_graph_scope_changes').where('document_id', '=', documentID).where('version', '=', version).execute();
1897
+ },
1898
+ async getDocumentScope (documentID) {
1899
+ const rows = await db.selectFrom('kubun_graph_document_refs').select([
1900
+ 'ref',
1901
+ 'displays',
1902
+ 'retains'
1903
+ ]).where('document_id', '=', documentID).orderBy('ref').execute();
1904
+ const retained = rows.some((row)=>Number(row.retains) === 1);
1905
+ return {
1906
+ scope: rows.some((row)=>Number(row.displays) === 1) ? 'visible' : retained ? 'retained' : 'outOfScope',
1907
+ retained,
1908
+ refs: rows.map((row)=>row.ref)
1909
+ };
1910
+ },
1911
+ async getDocumentAvailability (documentID, nowMS) {
1912
+ const doc = await api.getDocument(documentID);
1913
+ if (doc != null) return doc.data == null ? null : 'LOCAL';
1914
+ const entry = await api.getSweptDocument(documentID);
1915
+ if (entry == null) return null;
1916
+ const catalogs = await sweptRetainingCatalogs({
1917
+ entry,
1918
+ nowMS,
1919
+ retaining: await retainingRules(nowMS)
1920
+ });
1921
+ return catalogs.length > 0 ? 'UNAVAILABLE' : null;
1922
+ },
1923
+ async nextSweptDocumentTransition (nowMS) {
1924
+ const rows = await db.selectFrom('kubun_graph_swept_documents').selectAll().execute();
1925
+ if (rows.length === 0) return null;
1926
+ const retaining = await retainingRules(nowMS);
1927
+ const windowed = [
1928
+ ...retaining.uses.flatMap((use)=>use.rules),
1929
+ ...retaining.routeRules.flatMap((route)=>route.rules)
1930
+ ].filter((rule)=>rule.maxAge != null || rule.maxAhead != null);
1931
+ if (windowed.length === 0) return null;
1932
+ const interfaces = new Map();
1933
+ let next = null;
1934
+ for (const row of rows){
1935
+ const entry = sweptFromRow(row);
1936
+ let modelInterfaces = interfaces.get(entry.modelID);
1937
+ if (modelInterfaces == null) {
1938
+ modelInterfaces = await api.getModelInterfaces(entry.modelID);
1939
+ interfaces.set(entry.modelID, modelInterfaces);
1940
+ }
1941
+ for (const rule of windowed){
1942
+ // The rule without its window: only the window moves with time.
1943
+ const unbounded = {
1944
+ ...rule,
1945
+ maxAge: undefined,
1946
+ maxAhead: undefined
1947
+ };
1948
+ const facts = {
1949
+ model: entry.modelID,
1950
+ owner: entry.owner,
1951
+ dateOf: ()=>0
1952
+ };
1953
+ if (!scopeRuleMatchesFacts(unbounded, facts, nowMS, modelInterfaces)) continue;
1954
+ const date = rule.dateField == null ? entry.scopeDate ?? entry.createdAt : entry.fieldDates[rule.dateField] ?? entry.createdAt;
1955
+ // `date < now + maxAhead` starts holding just after `date - maxAhead`;
1956
+ // `date >= now - maxAge` stops holding just after `date + maxAge`.
1957
+ const times = [
1958
+ rule.maxAhead == null ? null : date - parseScopeDuration(rule.maxAhead) + 1,
1959
+ rule.maxAge == null ? null : date + parseScopeDuration(rule.maxAge) + 1
1960
+ ];
1961
+ for (const time of times)if (time != null && time > nowMS && (next == null || time < next)) next = time;
1962
+ }
1963
+ }
1964
+ return next;
1965
+ },
1966
+ async getSweptDocument (documentID) {
1967
+ const row = await db.selectFrom('kubun_graph_swept_documents').selectAll().where('document_id', '=', documentID).executeTakeFirst();
1968
+ return row == null ? null : sweptFromRow(row);
1969
+ },
1970
+ async listUnavailableDocuments (params) {
1971
+ const retaining = await retainingRules(params.nowMS);
1972
+ const checks = {
1973
+ mayRead: memoisedReadCheck(),
1974
+ interfaces: memoisedInterfaces()
1975
+ };
1976
+ const found = [];
1977
+ let after = params.after;
1978
+ for(;;){
1979
+ let query = db.selectFrom('kubun_graph_swept_documents').selectAll().orderBy('document_id').limit(SWEPT_PAGE);
1980
+ if (after != null) query = query.where('document_id', '>', after);
1981
+ const rows = await query.execute();
1982
+ for (const row of rows){
1983
+ const entry = sweptFromRow(row);
1984
+ const catalogs = await sweptRetainingCatalogs({
1985
+ entry,
1986
+ nowMS: params.nowMS,
1987
+ retaining,
1988
+ checks
1989
+ });
1990
+ if (catalogs.length === 0) continue;
1991
+ found.push({
1992
+ documentID: entry.documentID,
1993
+ owner: entry.owner,
1994
+ catalogs
1995
+ });
1996
+ if (found.length >= params.limit) return found;
1997
+ }
1998
+ if (rows.length < SWEPT_PAGE) return found;
1999
+ after = rows.at(-1)?.document_id;
2000
+ }
2001
+ },
2002
+ async refreshSweptDocuments (params) {
2003
+ if (context.provider === context.root) {
2004
+ await context.root.withTransaction(async (tx)=>{
2005
+ const graph = await tx.getStore('graph');
2006
+ await graph.refreshSweptDocuments(params);
2007
+ });
2008
+ return;
2009
+ }
2010
+ const { nowMS, ttlMS } = params;
2011
+ let query = db.selectFrom('kubun_graph_swept_documents').selectAll().orderBy('document_id');
2012
+ if (params.modelID != null) query = query.where('model_id', '=', params.modelID);
2013
+ const rows = await query.execute();
2014
+ if (rows.length === 0) return;
2015
+ const retaining = await retainingRules(nowMS);
2016
+ const checks = {
2017
+ mayRead: memoisedReadCheck(),
2018
+ interfaces: memoisedInterfaces()
2019
+ };
2020
+ const entered = [];
2021
+ const left = [];
2022
+ const expired = [];
2023
+ for (const row of rows){
2024
+ const matching = (await sweptRetainingCatalogs({
2025
+ entry: sweptFromRow(row),
2026
+ nowMS,
2027
+ retaining,
2028
+ checks
2029
+ })).length > 0;
2030
+ const wasMatching = Number(row.in_scope) === 1;
2031
+ if (matching !== wasMatching) (matching ? entered : left).push(row.document_id);
2032
+ else if (!matching && ttlMS != null && Number(row.scope_left_at) + ttlMS <= nowMS) expired.push(row.document_id);
2033
+ }
2034
+ for (const chunk of chunked(entered))await db.updateTable('kubun_graph_swept_documents').set({
2035
+ in_scope: 1
2036
+ }).where('document_id', 'in', chunk).execute();
2037
+ for (const chunk of chunked(left))await db.updateTable('kubun_graph_swept_documents').set({
2038
+ in_scope: 0,
2039
+ scope_left_at: nowMS
2040
+ }).where('document_id', 'in', chunk).execute();
2041
+ for (const chunk of chunked(expired))await db.deleteFrom('kubun_graph_swept_documents').where('document_id', 'in', chunk).execute();
2042
+ // Read back as an availability change by whoever emits scope events.
2043
+ await recordScopeChanges([
2044
+ ...entered,
2045
+ ...left
2046
+ ]);
2047
+ if (entered.length + left.length > 0) context.provider.onCommit(()=>notifyScopeListeners(context));
2048
+ },
2049
+ async documentNeedsBytes (documentID) {
2050
+ const doc = await api.getDocument(documentID);
2051
+ if (doc?.data == null) return false;
2052
+ const rows = await db.selectFrom('kubun_graph_document_refs').select([
2053
+ 'ref',
2054
+ 'displays',
2055
+ 'retains'
2056
+ ]).where('document_id', '=', documentID).execute();
2057
+ return await refsNeedBytes(rows);
2058
+ },
2059
+ async countCatalogDocuments (ref) {
2060
+ const holders = await catalogRefHolders(ref);
2061
+ return new Set(holders.map((holder)=>holder.document_id)).size;
2062
+ },
2063
+ async recomputeDocumentRefs (params) {
2064
+ const { documentID, routeRules, nowMS = Date.now(), catalog } = params;
2065
+ if (context.provider === context.root) {
2066
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).recomputeDocumentRefs({
2067
+ ...params,
2068
+ nowMS
2069
+ }));
2070
+ }
2071
+ await lockDocumentRefs([
2072
+ documentID
2073
+ ]);
2074
+ const release = params.releaseCustodyPins;
2075
+ const released = new Set(release != null && release.refs.length > 0 && await release.confirm() ? release.refs : []);
2076
+ const effectiveRouteRules = routeRules.length === 0 ? await context.getCatalogRouteRules(nowMS, catalog) : routeRules;
2077
+ const present = await recomputeRefsWith({
2078
+ documentID,
2079
+ routeRules: effectiveRouteRules,
2080
+ nowMS,
2081
+ catalog,
2082
+ released
2083
+ }, directRecomputeInputs(nowMS, catalog));
2084
+ if (!present) return {
2085
+ scope: 'outOfScope',
2086
+ retained: false,
2087
+ refs: []
2088
+ };
2089
+ return await api.getDocumentScope(documentID);
2090
+ },
2091
+ async sweepDocument (params) {
2092
+ if (context.provider === context.root) {
2093
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).sweepDocument(params));
2094
+ }
2095
+ const { documentID, nowMS } = params;
2096
+ const modelID = DocumentID.fromString(documentID).model.toString();
2097
+ // The row first, as an apply takes it before the ref lock.
2098
+ if (!await api.lockDocumentRow(documentID)) return {
2099
+ swept: false,
2100
+ refusal: 'absent'
2101
+ };
2102
+ await lockDocumentRefs([
2103
+ documentID
2104
+ ]);
2105
+ // Stored refs can be stale (an expired hold, a rule now matching): decide from fresh ones.
2106
+ const scope = await api.recomputeDocumentRefs({
2107
+ documentID,
2108
+ routeRules: [],
2109
+ nowMS
2110
+ });
2111
+ if (scope.retained) return {
2112
+ swept: false,
2113
+ refusal: 'retained'
2114
+ };
2115
+ if ((await api.listDocumentHolds(documentID, nowMS)).length > 0) return {
2116
+ swept: false,
2117
+ refusal: 'held'
2118
+ };
2119
+ const mark = await api.getGCMark(documentID);
2120
+ if (mark == null) return {
2121
+ swept: false,
2122
+ refusal: 'unmarked'
2123
+ };
2124
+ if (mark.digest !== await mutationDigest(documentID)) return {
2125
+ swept: false,
2126
+ refusal: 'changed'
2127
+ };
2128
+ if (!gcMarkPastGrace(mark, params.graceMS, nowMS)) return {
2129
+ swept: false,
2130
+ refusal: 'grace'
2131
+ };
2132
+ // Inbound history no receipt covered.
2133
+ const pending = await db.selectFrom('kubun_graph_mutation_log').select('mutation_hash').where('document_id', '=', documentID).where('status', '=', 'pending').limit(1).executeTakeFirst();
2134
+ if (pending != null) return {
2135
+ swept: false,
2136
+ refusal: 'pending'
2137
+ };
2138
+ if (!await params.confirm(context.provider)) return {
2139
+ swept: false,
2140
+ refusal: 'unconfirmed'
2141
+ };
2142
+ const swept = await api.getDocument(documentID);
2143
+ // A tombstone leaves nothing to restore.
2144
+ if (swept?.data != null) await writeSweptDocument(swept, mark.digest, nowMS);
2145
+ await unindexDocument(modelID, documentID);
2146
+ await db.deleteFrom(`k_${modelID}`).where('id', '=', documentID).execute();
2147
+ for (const table of [
2148
+ 'kubun_graph_mutation_log',
2149
+ 'kubun_graph_document_refs',
2150
+ 'kubun_graph_document_scope_dates',
2151
+ 'kubun_graph_gc_marks',
2152
+ 'kubun_graph_custody_receipts',
2153
+ // No live hold is left: it would have retained the document.
2154
+ 'kubun_graph_document_holds'
2155
+ ]){
2156
+ await db.deleteFrom(table).where('document_id', '=', documentID).execute();
2157
+ }
2158
+ // Bumped, not removed: a copy still displayed leaves the view through the scope event.
2159
+ await recordScopeChange(documentID);
2160
+ context.provider.onCommit(()=>notifyScopeListeners(context));
2161
+ return {
2162
+ swept: true
2163
+ };
2164
+ },
2165
+ async releaseCustodyPinRef (params) {
2166
+ if (context.provider === context.root) {
2167
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).releaseCustodyPinRef(params));
2168
+ }
2169
+ if (!isCustodyPinRef(params.ref)) return 0;
2170
+ // One exclusive key, as bulk ref writers take it: no per-document lock entry per pin.
2171
+ await lockAllDocumentRefs();
2172
+ const documentIDs = await db.selectFrom('kubun_graph_document_refs').select('document_id').where('ref', '=', params.ref).orderBy('document_id').execute();
2173
+ await recomputeDocumentRefsBatch({
2174
+ documentIDs: documentIDs.map((row)=>row.document_id),
2175
+ routeRules: [],
2176
+ nowMS: params.nowMS,
2177
+ releaseCustodyPins: {
2178
+ refs: [
2179
+ params.ref
2180
+ ],
2181
+ confirm: async ()=>true
2182
+ }
2183
+ });
2184
+ return documentIDs.length;
2185
+ },
2186
+ async catalogRulesMatchDocument (params) {
2187
+ const use = await db.selectFrom('kubun_graph_catalog_use').select('rules').where('owner_did', '=', params.catalog.owner).where('catalog_id', '=', params.catalog.id).executeTakeFirst();
2188
+ if (use == null) return false;
2189
+ const rules = typeof use.rules === 'string' ? JSON.parse(use.rules) : use.rules;
2190
+ if (rules.length === 0) return false;
2191
+ const doc = await api.getDocument(params.documentID);
2192
+ if (doc == null) return false;
2193
+ const interfaces = await api.getModelInterfaces(doc.model);
2194
+ return rules.some((rule)=>scopeRuleMatches(rule, doc, params.nowMS, interfaces));
2195
+ },
2196
+ async lockDocumentRow (documentID) {
2197
+ if (context.provider === context.root) throw new DocumentLockRequiresTransactionError();
2198
+ const modelID = DocumentID.fromString(documentID).model.toString();
2199
+ const row = db.selectFrom(`k_${modelID}`).select('id').where('id', '=', documentID);
2200
+ const locked = await (adapter instanceof AbstractPostgresAdapter ? row.forUpdate() : row).executeTakeFirst().catch((err)=>{
2201
+ if (isMissingTableError(err, `k_${modelID}`)) return undefined;
2202
+ throw err;
2203
+ });
2204
+ return locked != null;
2205
+ },
2206
+ async settlePendingCatalogRefs (nowMS) {
2207
+ if (context.provider === context.root) {
2208
+ await context.root.withTransaction(async (tx)=>{
2209
+ await (await tx.getStore('graph')).settlePendingCatalogRefs(nowMS);
2210
+ });
2211
+ return;
2212
+ }
2213
+ await settlePendingScopes(nowMS);
2214
+ },
2215
+ async scheduleCatalogRefReconcile () {
2216
+ scheduleScopeModels(context, await api.listDocumentModelIDs());
2217
+ },
2218
+ async recomputeDocuments (params) {
2219
+ if (context.provider === context.root) {
2220
+ await context.root.withTransaction(async (tx)=>{
2221
+ await (await tx.getStore('graph')).recomputeDocuments(params);
2222
+ });
2223
+ return;
2224
+ }
2225
+ if (params.documentIDs.length === 0) return;
2226
+ await lockAllDocumentRefs();
2227
+ await recomputeDocumentRefsBatch({
2228
+ documentIDs: [
2229
+ ...params.documentIDs
2230
+ ],
2231
+ routeRules: params.routeRules,
2232
+ nowMS: params.nowMS
2233
+ });
2234
+ },
2235
+ async reconcileCatalogRefs (nowMS, routeRules, catalog, options = {}) {
2236
+ if (catalog != null && context.provider === context.root) {
2237
+ await context.root.withTransaction(async (tx)=>{
2238
+ await (await tx.getStore('graph')).reconcileCatalogRefs(nowMS, routeRules, catalog);
2239
+ });
2240
+ return;
2241
+ }
2242
+ const effectiveRouteRules = routeRules.length === 0 ? await context.getCatalogRouteRules(nowMS, catalog) : routeRules;
2243
+ if (catalog != null) {
2244
+ await settlePendingScopes(nowMS, catalog);
2245
+ const use = await db.selectFrom('kubun_graph_catalog_use').select([
2246
+ 'rules',
2247
+ 'level'
2248
+ ]).where('owner_did', '=', catalog.owner).where('catalog_id', '=', catalog.id).executeTakeFirst();
2249
+ const localRules = use?.level == null ? [] : typeof use.rules === 'string' ? JSON.parse(use.rules) : use.rules;
2250
+ const rules = [
2251
+ ...localRules,
2252
+ ...effectiveRouteRules.filter((entry)=>entry.catalog.owner === catalog.owner && entry.catalog.id === catalog.id).flatMap((entry)=>entry.rules)
2253
+ ];
2254
+ for (const task of (await tasksForCatalog(catalog, rules))){
2255
+ await api.reconcileCatalogModelRefs({
2256
+ modelID: task.modelID,
2257
+ routeRules: effectiveRouteRules,
2258
+ nowMS,
2259
+ owners: task.owners,
2260
+ catalog
2261
+ });
2262
+ }
2263
+ return;
2264
+ }
2265
+ const scheduler = scopeSchedulers.get(context.root);
2266
+ if (scheduler != null) {
2267
+ scheduler.generation++;
2268
+ if (scheduler.timer != null) clearTimeout(scheduler.timer);
2269
+ scheduler.timer = null;
2270
+ scheduler.pendingScopes.clear();
2271
+ }
2272
+ if (!await reconcileAll(nowMS, effectiveRouteRules, options.shouldStop ?? (()=>false))) return;
2273
+ await db.insertInto('kubun_graph_catalog_scope_tick').values({
2274
+ id: 1,
2275
+ last_at: nowMS
2276
+ }).onConflict((oc)=>oc.column('id').doUpdateSet({
2277
+ last_at: nowMS
2278
+ })).execute();
2279
+ if (scheduler != null) scheduler.failed = false;
2280
+ if (scheduler != null) scheduler.retryDelayMS = 100;
2281
+ },
2282
+ async reconcileCatalogModelRefs (params) {
2283
+ const { modelID, routeRules, nowMS, owners, catalog } = params;
2284
+ if (context.provider === context.root) {
2285
+ await context.root.withTransaction(async (tx)=>{
2286
+ await (await tx.getStore('graph')).reconcileCatalogModelRefs(params);
2287
+ });
2288
+ return;
2289
+ }
2290
+ const documentIDs = new Set();
2291
+ if (owners == null || owners.length > 0) {
2292
+ let query = db.selectFrom(`k_${modelID}`).select('id');
2293
+ if (owners != null) query = query.where('owner', 'in', owners);
2294
+ const docs = await query.execute().catch((error)=>{
2295
+ if (isMissingTableError(error, `k_${modelID}`)) return [];
2296
+ throw error;
2297
+ });
2298
+ for (const doc of docs)documentIDs.add(doc.id);
2299
+ }
2300
+ if (catalog != null) {
2301
+ for (const holder of (await catalogRefHolders(catalog))){
2302
+ if (DocumentID.fromString(holder.document_id).model.toString() === modelID) {
2303
+ documentIDs.add(holder.document_id);
2304
+ }
2305
+ }
2306
+ }
2307
+ await lockAllDocumentRefs();
2308
+ await recomputeDocumentRefsBatch({
2309
+ documentIDs: [
2310
+ ...documentIDs
2311
+ ].sort(),
2312
+ routeRules,
2313
+ nowMS,
2314
+ catalog
2315
+ });
2316
+ // A swept document has no row to recompute: its kept facts are matched instead.
2317
+ await api.refreshSweptDocuments({
2318
+ nowMS,
2319
+ modelID
2320
+ });
2321
+ },
2322
+ async scheduleCustodyScan (catalog) {
2323
+ await db.insertInto('kubun_graph_custody_scans').values({
2324
+ owner_did: catalog.owner,
2325
+ catalog_id: catalog.id,
2326
+ after_document_id: null
2327
+ }).onConflict((oc)=>oc.columns([
2328
+ 'owner_did',
2329
+ 'catalog_id'
2330
+ ]).doUpdateSet({
2331
+ after_document_id: null
2332
+ })).execute();
2333
+ context.provider.onCommit(()=>context.custody?.scansQueued());
2334
+ },
2335
+ async advanceCustodyScan (params) {
2336
+ if (context.provider === context.root) {
2337
+ return await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).advanceCustodyScan(params));
2338
+ }
2339
+ const query = db.selectFrom('kubun_graph_custody_scans').selectAll().orderBy('owner_did').orderBy('catalog_id').limit(1);
2340
+ // Two runners of one scan would each recompute the same batch.
2341
+ const scan = await (adapter instanceof AbstractPostgresAdapter ? query.forUpdate() : query).executeTakeFirst();
2342
+ if (scan == null) return false;
2343
+ const ref = {
2344
+ owner: scan.owner_did,
2345
+ id: scan.catalog_id
2346
+ };
2347
+ const use = await db.selectFrom('kubun_graph_catalog_use').select('rules').where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).executeTakeFirst();
2348
+ const rules = use == null ? [] : typeof use.rules === 'string' ? JSON.parse(use.rules) : use.rules;
2349
+ const postgres = adapter instanceof AbstractPostgresAdapter;
2350
+ const candidates = [];
2351
+ for (const task of rules.length === 0 ? [] : await tasksForCatalog(ref, rules)){
2352
+ if (task.owners != null && task.owners.length === 0) continue;
2353
+ let docs = db.selectFrom(`k_${task.modelID}`).select('id');
2354
+ if (task.owners != null) docs = docs.where('owner', 'in', task.owners);
2355
+ // Bytewise, so the cursor agrees with the JavaScript order below on both adapters.
2356
+ if (scan.after_document_id != null) docs = docs.where(postgres ? sql`id collate "C" > ${scan.after_document_id}` : sql`id > ${scan.after_document_id}`);
2357
+ const rows = await (postgres ? docs.orderBy(sql`id collate "C"`) : docs.orderBy('id')).limit(params.batchSize).execute().catch((error)=>{
2358
+ if (isMissingTableError(error, `k_${task.modelID}`)) return [];
2359
+ throw error;
2360
+ });
2361
+ for (const row of rows)candidates.push(row.id);
2362
+ }
2363
+ const batch = [
2364
+ ...new Set(candidates)
2365
+ ].sort((a, b)=>a < b ? -1 : a > b ? 1 : 0).slice(0, params.batchSize);
2366
+ await recomputeDocumentRefsBatch({
2367
+ documentIDs: batch,
2368
+ routeRules: [],
2369
+ nowMS: params.nowMS,
2370
+ catalog: ref
2371
+ });
2372
+ const last = batch.at(-1);
2373
+ const scans = db.updateTable('kubun_graph_custody_scans').where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id);
2374
+ if (batch.length < params.batchSize || last == null) await db.deleteFrom('kubun_graph_custody_scans').where('owner_did', '=', ref.owner).where('catalog_id', '=', ref.id).execute();
2375
+ else await scans.set({
2376
+ after_document_id: last
2377
+ }).execute();
2378
+ const remaining = await db.selectFrom('kubun_graph_custody_scans').select('owner_did').limit(1).executeTakeFirst();
2379
+ return remaining != null;
2380
+ },
2381
+ async runCustodyScans (options = {}) {
2382
+ const batchSize = options.batchSize ?? 100;
2383
+ let batches = 0;
2384
+ while(options.maxBatches == null || batches < options.maxBatches){
2385
+ batches++;
2386
+ const more = await context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).advanceCustodyScan({
2387
+ nowMS: options.nowMS ?? Date.now(),
2388
+ batchSize
2389
+ }));
2390
+ if (!more) return true;
2391
+ }
2392
+ return false;
2393
+ },
2394
+ onScopeRecomputed (listener) {
2395
+ const scheduler = getScopeScheduler(context);
2396
+ scheduler.listeners.add(listener);
2397
+ return ()=>scheduler.listeners.delete(listener);
2398
+ },
2399
+ async advanceCatalogAging (nowMS, routeRules) {
2400
+ if (scopeSchedulers.get(context.root)?.failed === true) {
2401
+ await api.reconcileCatalogRefs(nowMS, routeRules);
2402
+ return;
2403
+ }
2404
+ const tick = await db.selectFrom('kubun_graph_catalog_scope_tick').select('last_at').where('id', '=', 1).executeTakeFirst();
2405
+ if (tick == null) {
2406
+ await api.reconcileCatalogRefs(nowMS, routeRules);
2407
+ return;
2408
+ }
2409
+ const previousMS = Number(tick.last_at);
2410
+ if (previousMS === nowMS) return;
2411
+ const catalogs = await db.selectFrom('kubun_graph_catalog_use').select([
2412
+ 'rules'
2413
+ ]).where('level', 'is not', null).execute();
2414
+ const rules = catalogs.flatMap((catalog)=>typeof catalog.rules === 'string' ? JSON.parse(catalog.rules) : catalog.rules);
2415
+ rules.push(...routeRules.flatMap((route)=>route.rules));
2416
+ const windowedRules = rules.filter((rule)=>rule.maxAge != null || rule.maxAhead != null);
2417
+ const modelRows = await db.selectFrom('kubun_graph_document_models').select('id').execute();
2418
+ const interfacesOf = memoisedInterfaces();
2419
+ const affected = new Set();
2420
+ for (const rule of windowedRules){
2421
+ const selectedModels = [];
2422
+ for (const model of modelRows){
2423
+ const interfaces = await interfacesOf(model.id);
2424
+ if (rule.models == null || rule.models.some((id)=>id === model.id || interfaces.includes(id))) selectedModels.push(model.id);
2425
+ }
2426
+ if (selectedModels.length === 0) continue;
2427
+ const collectRange = async (oldBound, newBound)=>{
2428
+ const from = Math.min(oldBound, newBound);
2429
+ const to = Math.max(oldBound, newBound);
2430
+ if (from === to) return;
2431
+ if (rule.dateField != null) {
2432
+ const rows = await db.selectFrom('kubun_graph_document_scope_dates').select('document_id').where('field', '=', rule.dateField).where('scope_date', '>=', from).where('scope_date', '<', to).execute();
2433
+ const selected = new Set(selectedModels);
2434
+ for (const row of rows){
2435
+ if (selected.has(DocumentID.fromString(row.document_id).model.toString())) {
2436
+ affected.add(row.document_id);
2437
+ }
2438
+ }
2439
+ } else {
2440
+ for (const modelID of selectedModels){
2441
+ const rows = await db.selectFrom(`k_${modelID}`).select('id').where('scope_date', '>=', from).where('scope_date', '<', to).execute();
2442
+ for (const row of rows)affected.add(row.id);
2443
+ }
2444
+ }
2445
+ };
2446
+ if (rule.maxAge != null) {
2447
+ const duration = parseScopeDuration(rule.maxAge);
2448
+ await collectRange(previousMS - duration, nowMS - duration);
2449
+ }
2450
+ if (rule.maxAhead != null) {
2451
+ const duration = parseScopeDuration(rule.maxAhead);
2452
+ await collectRange(previousMS + duration, nowMS + duration);
2453
+ }
2454
+ }
2455
+ // A hold that expired keeps its ref until its document is recomputed.
2456
+ const expiredHolds = await db.selectFrom('kubun_graph_document_holds').select('document_id').where('released_at', 'is', null).where('expires_at', '>', previousMS).where('expires_at', '<=', nowMS).execute();
2457
+ for (const row of expiredHolds)affected.add(row.document_id);
2458
+ const ordered = [
2459
+ ...affected
2460
+ ].sort();
2461
+ if (ordered.length > 0) await lockAllDocumentRefs();
2462
+ await recomputeDocumentRefsBatch({
2463
+ documentIDs: ordered,
2464
+ routeRules,
2465
+ nowMS
2466
+ });
2467
+ // A swept document has no row to recompute: a window reaching it is found from its facts.
2468
+ await api.refreshSweptDocuments({
2469
+ nowMS
2470
+ });
2471
+ await db.updateTable('kubun_graph_catalog_scope_tick').set({
2472
+ last_at: nowMS
2473
+ }).where('id', '=', 1).execute();
467
2474
  },
468
2475
  async queryDocuments (params) {
469
- const { catalogID, filter, modelIDs, orderBy, owner, ...pagination } = params;
2476
+ const { catalogRef, filter, modelIDs, orderBy, owner, ...pagination } = params;
470
2477
  const isReverse = params.last != null;
471
- const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
2478
+ const reader = await db.selectFrom('kubun_graph_scope_identity').select('id').executeTakeFirst();
2479
+ const scopeRefs = catalogRef == null ? null : await visibleCatalogRefs(catalogRef);
2480
+ if (catalogRef != null && scopeRefs?.length === 0) {
2481
+ return {
2482
+ entries: [],
2483
+ hasMore: false
2484
+ };
2485
+ }
2486
+ const filterScope = reader != null || catalogRef != null;
2487
+ const effectiveModelIDs = modelIDs;
2488
+ const effectiveOwners = undefined;
472
2489
  if (effectiveModelIDs.length === 0) {
473
2490
  return {
474
2491
  entries: [],
475
2492
  hasMore: false
476
2493
  };
477
2494
  }
478
- const [first, ...rest] = effectiveModelIDs;
2495
+ const first = effectiveModelIDs[0];
2496
+ if (first == null) return {
2497
+ entries: [],
2498
+ hasMore: false
2499
+ };
2500
+ const rest = effectiveModelIDs.slice(1);
479
2501
  // Tombstoned documents (data IS NULL) stay on disk for sync/CRDT but must
480
2502
  // never surface in read queries.
481
2503
  let query = db.selectFrom(`k_${first}`).selectAll().where('data', 'is not', null);
2504
+ if (filterScope) query = query.where(visibleScopePredicate(first, scopeRefs));
482
2505
  if (owner != null) {
483
2506
  query = query.where('owner', '=', owner);
484
2507
  }
@@ -491,6 +2514,7 @@ export function createGraphStore(db, adapter) {
491
2514
  for (const model of rest){
492
2515
  query = query.unionAll((eb)=>{
493
2516
  let q = eb.selectFrom(`k_${model}`).selectAll().where('data', 'is not', null);
2517
+ if (filterScope) q = q.where(visibleScopePredicate(model, scopeRefs));
494
2518
  if (owner != null) {
495
2519
  q = q.where('owner', '=', owner);
496
2520
  }
@@ -503,7 +2527,18 @@ export function createGraphStore(db, adapter) {
503
2527
  return q;
504
2528
  });
505
2529
  }
506
- const [orderedQuery, orderedFieldsPaths] = applyDocumentOrderBy(query, adapter, orderBy, isReverse);
2530
+ // A compound SELECT only accepts result-column references in its ORDER BY (both
2531
+ // SQLite and Postgres reject the JSON-path expressions a data-field order emits),
2532
+ // and a WHERE added after `unionAll` binds to the first branch alone, so a keyset
2533
+ // cursor would only filter that one table. Reading the union as a derived table
2534
+ // lets ordering and the keyset predicate apply the single-model path's expressions
2535
+ // to every row. This relies on every branch selecting the same column set: all
2536
+ // model tables come from the one `createTable` in `addDocumentModel`, so the
2537
+ // derived table has exactly the per-table columns. A field typed differently
2538
+ // across models orders per adapter, as the JSON-extracted value: SQLite sorts
2539
+ // numbers before text, Postgres compares the text form.
2540
+ const source = rest.length > 0 ? db.selectFrom(query.as('k_document')).selectAll() : query;
2541
+ const [orderedQuery, orderedFieldsPaths] = applyDocumentOrderBy(source, adapter, orderBy, isReverse);
507
2542
  const [paginatedQuery, limit] = applyPagination(orderedQuery, pagination, orderBy, coerce);
508
2543
  const results = await paginatedQuery.execute();
509
2544
  const docs = isReverse ? results.slice(0, limit).reverse() : results.slice(0, limit);
@@ -563,12 +2598,17 @@ export function createGraphStore(db, adapter) {
563
2598
  };
564
2599
  },
565
2600
  async aggregateDocuments (params) {
566
- const { aggregates, catalogID, count, filter, modelIDs, owner, readAccess } = params;
2601
+ const { aggregates, catalogRef, count, filter, modelIDs, owner, readAccess } = params;
567
2602
  const empty = emptyAggregateValues(count, aggregates);
568
2603
  // No count and no aggregate ops requested: there is nothing to select, so
569
2604
  // skip the query entirely rather than build an invalid empty SELECT.
570
2605
  if (!count && aggregates.length === 0) return empty;
571
- const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
2606
+ const scopeRefs = catalogRef == null ? null : await visibleCatalogRefs(catalogRef);
2607
+ if (catalogRef != null && scopeRefs?.length === 0) return empty;
2608
+ const reader = await db.selectFrom('kubun_graph_scope_identity').select('id').executeTakeFirst();
2609
+ const filterScope = reader != null || catalogRef != null;
2610
+ const effectiveModelIDs = modelIDs;
2611
+ const effectiveOwners = undefined;
572
2612
  if (effectiveModelIDs.length === 0) return empty;
573
2613
  // Aggregate each model in its own table, then combine at the raw sum/count/min/max
574
2614
  // level so interface-connection results stay correct: averages combine as Σsum /
@@ -582,6 +2622,7 @@ export function createGraphStore(db, adapter) {
582
2622
  return selections;
583
2623
  });
584
2624
  query = query.where('data', 'is not', null);
2625
+ if (filterScope) query = query.where(visibleScopePredicate(modelID, scopeRefs));
585
2626
  if (owner != null) {
586
2627
  query = query.where('owner', '=', owner);
587
2628
  }
@@ -598,8 +2639,7 @@ export function createGraphStore(db, adapter) {
598
2639
  return await query.executeTakeFirst();
599
2640
  } catch (err) {
600
2641
  // A model whose table was never materialised contributes nothing, matching
601
- // the not-yet-materialised-table tolerance used by getDistinctOwnersForModel
602
- // and getDocumentIDsForScope in this file.
2642
+ // the not-yet-materialised-table tolerance used by getDocumentIDsForScope.
603
2643
  if (isMissingTableError(err, `k_${modelID}`)) return undefined;
604
2644
  throw err;
605
2645
  }
@@ -615,9 +2655,14 @@ export function createGraphStore(db, adapter) {
615
2655
  return finalizeAggregateValues(count, aggregates, acc);
616
2656
  },
617
2657
  async groupedAggregateDocuments (params) {
618
- const { aggregates, catalogID, count, filter, groupBy, having, limit, modelIDs, orderBy, owner, readAccess } = params;
619
- const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
2658
+ const { aggregates, catalogRef, count, filter, groupBy, having, limit, modelIDs, orderBy, owner, readAccess } = params;
2659
+ const effectiveModelIDs = modelIDs;
2660
+ const effectiveOwners = undefined;
620
2661
  if (effectiveModelIDs.length === 0) return [];
2662
+ const scopeRefs = catalogRef == null ? null : await visibleCatalogRefs(catalogRef);
2663
+ if (catalogRef != null && scopeRefs?.length === 0) return [];
2664
+ const reader = await db.selectFrom('kubun_graph_scope_identity').select('id').executeTakeFirst();
2665
+ const filterScope = reader != null || catalogRef != null;
621
2666
  // Run the same GROUP BY query per model table in parallel, tolerating tables
622
2667
  // that were never materialised (they contribute nothing), mirroring the
623
2668
  // multi-model fan-out in aggregateDocuments.
@@ -636,6 +2681,7 @@ export function createGraphStore(db, adapter) {
636
2681
  });
637
2682
  query = query.groupBy((eb)=>applyGroupBySelections(eb, groupBy).expressions);
638
2683
  query = query.where('data', 'is not', null);
2684
+ if (filterScope) query = query.where(visibleScopePredicate(modelID, scopeRefs));
639
2685
  if (owner != null) {
640
2686
  query = query.where('owner', '=', owner);
641
2687
  }
@@ -715,31 +2761,169 @@ export function createGraphStore(db, adapter) {
715
2761
  const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
716
2762
  return models.map((m)=>m.id);
717
2763
  },
718
- async getDistinctOwnersForModel (modelID) {
719
- try {
720
- const result = await db.selectFrom(`k_${modelID}`).select('owner').distinct().execute();
721
- return result.map((r)=>r.owner);
722
- } catch (err) {
723
- if (isMissingTableError(err, `k_${modelID}`)) return [];
724
- throw err;
725
- }
726
- },
727
2764
  async queryDocumentsByOwner (modelID, ownerDID) {
728
2765
  const docs = await db.selectFrom(`k_${modelID}`).selectAll().where('owner', '=', ownerDID).execute();
729
2766
  return docs.map((doc)=>documentNodeFromTable(adapter, doc));
730
2767
  },
731
2768
  // --- Mutation log ---
732
2769
  async insertMutationLogEntry (entry) {
733
- await db.insertInto('kubun_graph_mutation_log').values(entry).onConflict((oc)=>oc.doNothing()).execute();
2770
+ if (!context.provider.isTransactionBound) {
2771
+ await context.provider.withTransaction(async (tx)=>{
2772
+ const graph = await tx.getStore('graph');
2773
+ await graph.insertMutationLogEntry(entry);
2774
+ });
2775
+ return;
2776
+ }
2777
+ const inserted = await db.insertInto('kubun_graph_mutation_log').values(entry).onConflict((oc)=>entry.status === 'applied' ? oc.column('mutation_hash').doUpdateSet({
2778
+ status: 'applied',
2779
+ next_attempt_at: null
2780
+ }).where((eb)=>eb(eb.ref('kubun_graph_mutation_log.status'), '!=', 'applied')) : oc.doNothing()).returning('document_id').execute();
2781
+ if (inserted[0] != null && entry.status === 'applied') {
2782
+ await refreshDocumentDates(inserted[0].document_id);
2783
+ await api.recomputeDocumentRefs({
2784
+ documentID: inserted[0].document_id,
2785
+ routeRules: []
2786
+ });
2787
+ }
2788
+ },
2789
+ async parkPushedMutation (entry) {
2790
+ if (!context.provider.isTransactionBound) {
2791
+ return await context.provider.withTransaction(async (tx)=>{
2792
+ const graph = await tx.getStore('graph');
2793
+ return await graph.parkPushedMutation(entry);
2794
+ });
2795
+ }
2796
+ await db.insertInto('kubun_graph_pending_groups').values({
2797
+ group_id: entry.arrival_group_id
2798
+ }).onConflict((oc)=>oc.column('group_id').doUpdateSet({
2799
+ group_id: entry.arrival_group_id
2800
+ })).execute();
2801
+ const parkedAt = Date.now();
2802
+ await expirePushedMutations(parkedAt, entry.arrival_group_id);
2803
+ const inserted = await db.insertInto('kubun_graph_mutation_log').columns([
2804
+ 'mutation_hash',
2805
+ 'model_id',
2806
+ 'document_id',
2807
+ 'author_did',
2808
+ 'hlc',
2809
+ 'mutation_jwt',
2810
+ 'status',
2811
+ 'arrival_group_id',
2812
+ 'sender_peer_did',
2813
+ 'parked_at'
2814
+ ]).expression(db.selectNoFrom((eb)=>[
2815
+ eb.val(entry.mutation_hash).as('mutation_hash'),
2816
+ eb.val(entry.model_id).as('model_id'),
2817
+ eb.val(entry.document_id).as('document_id'),
2818
+ eb.val(entry.author_did).as('author_did'),
2819
+ eb.val(entry.hlc).as('hlc'),
2820
+ eb.val(entry.mutation_jwt).as('mutation_jwt'),
2821
+ eb.val('pending').as('status'),
2822
+ eb.val(entry.arrival_group_id).as('arrival_group_id'),
2823
+ eb.val(entry.sender_peer_did).as('sender_peer_did'),
2824
+ eb.val(parkedAt).as('parked_at')
2825
+ ]).where((eb)=>eb(eb.selectFrom('kubun_graph_mutation_log').select((count)=>count.fn.countAll().as('count')).where('status', '=', 'pending').where('arrival_group_id', '=', entry.arrival_group_id), '<', 1000)).where((eb)=>eb(eb.selectFrom('kubun_graph_mutation_log').select((count)=>count.fn.countAll().as('count')).where('status', '=', 'pending').where('arrival_group_id', '=', entry.arrival_group_id).where('sender_peer_did', '=', entry.sender_peer_did), '<', 100))).onConflict((oc)=>oc.doNothing()).returning('mutation_hash').execute();
2826
+ if (inserted.length > 0) return 'parked';
2827
+ return await api.hasMutationHash(entry.mutation_hash) ? 'duplicate' : 'full';
734
2828
  },
735
2829
  async hasMutationHash (hash) {
736
2830
  const row = await db.selectFrom('kubun_graph_mutation_log').select('mutation_hash').where('mutation_hash', '=', hash).executeTakeFirst();
737
2831
  return row != null;
738
2832
  },
739
2833
  async updateMutationStatus (mutationHash, status) {
740
- await db.updateTable('kubun_graph_mutation_log').set({
2834
+ if (context.provider === context.root) {
2835
+ await context.root.withTransaction(async (tx)=>{
2836
+ await (await tx.getStore('graph')).updateMutationStatus(mutationHash, status);
2837
+ });
2838
+ return;
2839
+ }
2840
+ const updated = await db.updateTable('kubun_graph_mutation_log').set({
741
2841
  status
742
- }).where('mutation_hash', '=', mutationHash).execute();
2842
+ }).where('mutation_hash', '=', mutationHash).returning('document_id').execute();
2843
+ if (updated[0] != null) {
2844
+ await refreshDocumentDates(updated[0].document_id);
2845
+ await api.recomputeDocumentRefs({
2846
+ documentID: updated[0].document_id,
2847
+ routeRules: []
2848
+ });
2849
+ }
2850
+ },
2851
+ async getDuePendingMutations (now) {
2852
+ if (!context.provider.isTransactionBound) {
2853
+ return await context.provider.withTransaction(async (tx)=>{
2854
+ const graph = await tx.getStore('graph');
2855
+ return await graph.getDuePendingMutations(now);
2856
+ });
2857
+ }
2858
+ await expirePushedMutations(now);
2859
+ const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
2860
+ if (models.length === 0) return [];
2861
+ return await db.selectFrom('kubun_graph_mutation_log').selectAll().where('status', '=', 'pending').where((eb)=>eb.or([
2862
+ eb('next_attempt_at', 'is', null),
2863
+ eb('next_attempt_at', '<=', now)
2864
+ ])).where((eb)=>eb.or(models.map(({ id })=>eb.and([
2865
+ eb('model_id', '=', id),
2866
+ eb.exists(eb.selectFrom(`k_${id} as k_`).select('id').whereRef('k_.id', '=', 'kubun_graph_mutation_log.document_id'))
2867
+ ])))).orderBy('hlc', 'asc').limit(100).execute();
2868
+ },
2869
+ async getNextPendingAttemptAt () {
2870
+ if (!context.provider.isTransactionBound) {
2871
+ return await context.provider.withTransaction(async (tx)=>{
2872
+ const graph = await tx.getStore('graph');
2873
+ return await graph.getNextPendingAttemptAt();
2874
+ });
2875
+ }
2876
+ const now = Date.now();
2877
+ await expirePushedMutations(now);
2878
+ const expiry = await db.selectFrom('kubun_graph_mutation_log').select('parked_at').where('status', '=', 'pending').where('arrival_group_id', 'is not', null).where('parked_at', 'is not', null).orderBy('parked_at', 'asc').limit(1).executeTakeFirst();
2879
+ const expiresAt = expiry?.parked_at == null ? null : Number(expiry.parked_at) + pendingLifetimeMS;
2880
+ const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
2881
+ if (models.length === 0) return expiresAt;
2882
+ const row = await db.selectFrom('kubun_graph_mutation_log').select('next_attempt_at').where('status', '=', 'pending').where('next_attempt_at', 'is not', null).where((eb)=>eb.or(models.map(({ id })=>eb.and([
2883
+ eb('model_id', '=', id),
2884
+ eb.exists(eb.selectFrom(`k_${id} as k_`).select('id').whereRef('k_.id', '=', 'kubun_graph_mutation_log.document_id'))
2885
+ ])))).orderBy('next_attempt_at', 'asc').limit(1).executeTakeFirst();
2886
+ const nextAttemptAt = row?.next_attempt_at == null ? null : Number(row.next_attempt_at);
2887
+ if (nextAttemptAt == null) return expiresAt;
2888
+ return expiresAt == null ? nextAttemptAt : Math.min(nextAttemptAt, expiresAt);
2889
+ },
2890
+ async recordPendingMutationAttempt (params) {
2891
+ const { mutationHash, outcome, now, jitter } = params;
2892
+ if (context.provider === context.root) {
2893
+ await context.root.withTransaction(async (tx)=>{
2894
+ const graph = await tx.getStore('graph');
2895
+ await graph.recordPendingMutationAttempt(params);
2896
+ });
2897
+ return;
2898
+ }
2899
+ // Successful apply already promoted the row; stale failures may only touch pending rows.
2900
+ const statuses = outcome === 'applied' ? [
2901
+ 'pending',
2902
+ 'applied'
2903
+ ] : [
2904
+ 'pending'
2905
+ ];
2906
+ const row = await db.selectFrom('kubun_graph_mutation_log').select([
2907
+ 'attempt_count',
2908
+ 'document_id',
2909
+ 'status'
2910
+ ]).where('mutation_hash', '=', mutationHash).where('status', 'in', statuses).executeTakeFirst();
2911
+ if (row == null) return;
2912
+ const count = row.attempt_count + 1;
2913
+ const delay = Math.min(60_000, 100 * 2 ** Math.min(count - 1, 10) * (1 + 0.25 * jitter));
2914
+ const updated = await db.updateTable('kubun_graph_mutation_log').set({
2915
+ attempt_count: count,
2916
+ last_attempt_at: now,
2917
+ next_attempt_at: outcome === 'transient' ? now + Math.round(delay) : null,
2918
+ status: outcome === 'transient' ? 'pending' : outcome
2919
+ }).where('mutation_hash', '=', mutationHash).where('status', 'in', statuses).returning('document_id').execute();
2920
+ if (outcome === 'applied' && row.status !== 'applied' && updated.length > 0) {
2921
+ await refreshDocumentDates(row.document_id);
2922
+ await api.recomputeDocumentRefs({
2923
+ documentID: row.document_id,
2924
+ routeRules: []
2925
+ });
2926
+ }
743
2927
  },
744
2928
  async getMutationLogForDocuments (documentIDs) {
745
2929
  if (documentIDs.length === 0) return [];
@@ -775,6 +2959,31 @@ export function createGraphStore(db, adapter) {
775
2959
  }
776
2960
  return values;
777
2961
  },
2962
+ async getFieldValuesMany (documentIDs) {
2963
+ const byModel = new Map();
2964
+ for (const documentID of documentIDs){
2965
+ const modelID = DocumentID.fromString(documentID).model.toString();
2966
+ const ids = byModel.get(modelID) ?? new Set();
2967
+ ids.add(documentID);
2968
+ byModel.set(modelID, ids);
2969
+ }
2970
+ const found = new Map();
2971
+ for (const [modelID, ids] of byModel){
2972
+ for (const chunk of chunked(ids)){
2973
+ const rows = await db.selectFrom(`k_${modelID}`).select([
2974
+ 'id',
2975
+ 'field_values'
2976
+ ]).where('id', 'in', chunk).execute();
2977
+ for (const row of rows){
2978
+ if (row.field_values == null) continue;
2979
+ const values = row.field_values;
2980
+ // ParseJSONResultsPlugin may have already parsed the value
2981
+ found.set(row.id, typeof values === 'string' ? JSON.parse(values) : values);
2982
+ }
2983
+ }
2984
+ }
2985
+ return found;
2986
+ },
778
2987
  async updateFieldHLCs (docID, fieldHLCs, fieldValues) {
779
2988
  const modelID = docID.model.toString();
780
2989
  await db.updateTable(`k_${modelID}`).set({
@@ -789,6 +2998,8 @@ export function createGraphStore(db, adapter) {
789
2998
  async createGraph (params) {
790
2999
  const graphModelID = params.id;
791
3000
  const name = params.name ?? graphModelID;
3001
+ const recordModelIDs = Object.keys(params.record);
3002
+ const existingModels = recordModelIDs.length === 0 ? [] : await db.selectFrom('kubun_graph_document_models').select('id').where('id', 'in', recordModelIDs).execute();
792
3003
  await db.transaction().setIsolationLevel('read committed').execute(async (trx)=>{
793
3004
  const pluginConfigValue = params.pluginConfig ? adapter.encodeJSON(params.pluginConfig) : null;
794
3005
  await trx.insertInto('kubun_graph_models').values({
@@ -909,6 +3120,7 @@ export function createGraphStore(db, adapter) {
909
3120
  }))).execute();
910
3121
  }
911
3122
  });
3123
+ scheduleScopeModels(context, existingModels.map((model)=>model.id));
912
3124
  return graphModelID;
913
3125
  },
914
3126
  async getGraph (id) {
@@ -964,7 +3176,8 @@ export function createGraphStore(db, adapter) {
964
3176
  async dropSearchIndex (modelID) {
965
3177
  await adapter.dropSearchIndex(db, modelID);
966
3178
  },
967
- async updateSearchEntry (modelID, documentID, data, fields) {
3179
+ async updateSearchEntry (params) {
3180
+ const { modelID, documentID, data, fields } = params;
968
3181
  const fieldValues = {};
969
3182
  for (const field of fields){
970
3183
  const value = extractFieldValue(data, field);
@@ -978,6 +3191,10 @@ export function createGraphStore(db, adapter) {
978
3191
  await adapter.removeSearchEntry(db, modelID, documentID);
979
3192
  },
980
3193
  async searchDocuments (params) {
3194
+ const reader = await db.selectFrom('kubun_graph_scope_identity').select('id').executeTakeFirst();
3195
+ const scopeRefs = params.catalogRef == null ? null : await visibleCatalogRefs(params.catalogRef);
3196
+ if (params.catalogRef != null && scopeRefs?.length === 0) return [];
3197
+ const filterScope = reader != null || params.catalogRef != null;
981
3198
  // Guard against querying a model with no `kubun_graph_search_models`
982
3199
  // registry row: without this, a missing `fts_<model>` table surfaces
983
3200
  // as a raw adapter error (e.g. "no such table") instead of a clean,
@@ -995,17 +3212,32 @@ export function createGraphStore(db, adapter) {
995
3212
  const perModelLimit = params.first != null ? offset + params.first : undefined;
996
3213
  const allResults = [];
997
3214
  for (const modelID of params.modelIDs){
998
- const hits = await adapter.searchIndex(db, {
999
- query: params.query,
1000
- modelID,
1001
- limit: perModelLimit ?? 50
1002
- });
1003
- for (const hit of hits){
1004
- allResults.push({
1005
- documentID: hit.documentID,
3215
+ let limit = Math.max(perModelLimit ?? 50, 50);
3216
+ while(true){
3217
+ const hits = await adapter.searchIndex(db, {
3218
+ query: params.query,
1006
3219
  modelID,
1007
- rank: hit.rank
3220
+ limit
1008
3221
  });
3222
+ let visibleIDs = null;
3223
+ if (filterScope && hits.length > 0) {
3224
+ let refsQuery = db.selectFrom('kubun_graph_document_refs').select('document_id').where('document_id', 'in', hits.map((hit)=>hit.documentID)).where('displays', '=', 1);
3225
+ if (scopeRefs != null) refsQuery = refsQuery.where('ref', 'in', scopeRefs);
3226
+ const rows = await refsQuery.execute();
3227
+ visibleIDs = new Set(rows.map((row)=>row.document_id));
3228
+ }
3229
+ const visibleHits = visibleIDs == null ? hits : hits.filter((hit)=>visibleIDs.has(hit.documentID));
3230
+ if (visibleHits.length >= (perModelLimit ?? 50) || hits.length < limit) {
3231
+ for (const hit of visibleHits){
3232
+ allResults.push({
3233
+ documentID: hit.documentID,
3234
+ modelID,
3235
+ rank: hit.rank
3236
+ });
3237
+ }
3238
+ break;
3239
+ }
3240
+ limit *= 2;
1009
3241
  }
1010
3242
  }
1011
3243
  // Sort by absolute rank descending (SQLite bm25 returns negative, PG ts_rank positive)
@@ -1015,121 +3247,6 @@ export function createGraphStore(db, adapter) {
1015
3247
  }
1016
3248
  return allResults;
1017
3249
  },
1018
- // --- Catalog operations ---
1019
- async createCatalog (catalog) {
1020
- await db.insertInto('kubun_graph_catalogs').values({
1021
- id: catalog.id,
1022
- owner_did: catalog.owner_did,
1023
- name: catalog.name,
1024
- description: catalog.description,
1025
- filter_criteria: adapter.encodeJSON(catalog.filter_criteria),
1026
- hlc: catalog.hlc,
1027
- // Own catalogs scope sync immediately; no discovery provenance.
1028
- active: 1,
1029
- source_group_id: null,
1030
- source_circle_id: null,
1031
- signed_token: catalog.signed_token ?? null
1032
- }).execute();
1033
- },
1034
- async upsertDiscoveredCatalog ({ catalog, sourceGroupID, sourceCircleID }) {
1035
- await db.insertInto('kubun_graph_catalogs').values({
1036
- id: catalog.id,
1037
- owner_did: catalog.owner_did,
1038
- name: catalog.name,
1039
- description: catalog.description,
1040
- filter_criteria: adapter.encodeJSON(catalog.filter_criteria),
1041
- hlc: catalog.hlc,
1042
- // Discovery applies no sync — the catalog arrives known, awaiting an
1043
- // explicit local activation decision.
1044
- active: 0,
1045
- source_group_id: sourceGroupID,
1046
- source_circle_id: sourceCircleID,
1047
- signed_token: catalog.signed_token ?? null
1048
- })// First-discovery-wins: an existing row (already known or locally
1049
- // activated) keeps its `active` flag and source columns untouched.
1050
- .onConflict((oc)=>oc.column('id').doNothing()).execute();
1051
- },
1052
- async setCatalogActive (id, active) {
1053
- await db.updateTable('kubun_graph_catalogs').set({
1054
- active: active ? 1 : 0,
1055
- updated_at: adapter.encodeTimestamp(new Date())
1056
- }).where('id', '=', id).execute();
1057
- },
1058
- async setCatalogsActive (ids, active) {
1059
- if (ids.length === 0) {
1060
- return;
1061
- }
1062
- await db.updateTable('kubun_graph_catalogs').set({
1063
- active: active ? 1 : 0,
1064
- updated_at: adapter.encodeTimestamp(new Date())
1065
- }).where('id', 'in', ids).execute();
1066
- },
1067
- async listStoredCatalogs (filter) {
1068
- let query = db.selectFrom('kubun_graph_catalogs').selectAll();
1069
- if (filter?.active === true) {
1070
- query = query.where('active', '=', 1);
1071
- } else if (filter?.active === false) {
1072
- query = query.where((eb)=>eb.or([
1073
- eb('active', 'is', null),
1074
- eb('active', '=', 0)
1075
- ]));
1076
- }
1077
- return await query.execute();
1078
- },
1079
- async getCatalog (id) {
1080
- return await db.selectFrom('kubun_graph_catalogs').selectAll().where('id', '=', id).executeTakeFirst();
1081
- },
1082
- async getCatalogs (ids) {
1083
- const result = new Map();
1084
- if (ids.length === 0) return result;
1085
- const rows = await db.selectFrom('kubun_graph_catalogs').selectAll().where('id', 'in', ids).execute();
1086
- for (const row of rows){
1087
- result.set(row.id, row);
1088
- }
1089
- return result;
1090
- },
1091
- async updateCatalog (id, update) {
1092
- const values = {};
1093
- if (update.name != null) values.name = update.name;
1094
- if (update.description != null) values.description = update.description;
1095
- if (update.filter_criteria != null) {
1096
- values.filter_criteria = adapter.encodeJSON(update.filter_criteria);
1097
- }
1098
- if (update.hlc != null) values.hlc = update.hlc;
1099
- if (update.signed_token != null) values.signed_token = update.signed_token;
1100
- values.updated_at = adapter.encodeTimestamp(new Date());
1101
- await db.updateTable('kubun_graph_catalogs').set(values).where('id', '=', id).execute();
1102
- },
1103
- async deleteCatalog (id) {
1104
- await db.deleteFrom('kubun_graph_catalogs').where('id', '=', id).execute();
1105
- },
1106
- async listCatalogs (ownerDID) {
1107
- return await db.selectFrom('kubun_graph_catalogs').selectAll().where('owner_did', '=', ownerDID).execute();
1108
- },
1109
- async resolveCatalogScope (catalogID) {
1110
- const catalog = await api.getCatalog(catalogID);
1111
- if (catalog == null) {
1112
- return {
1113
- models: undefined,
1114
- owners: undefined
1115
- };
1116
- }
1117
- const criteria = catalog.filter_criteria;
1118
- const models = criteria.models != null && criteria.models.length > 0 ? criteria.models : undefined;
1119
- let owners;
1120
- if (criteria.owners != null && criteria.owners.length > 0) {
1121
- owners = [
1122
- ...criteria.owners
1123
- ];
1124
- }
1125
- // Note: circle-based owner resolution is not included here because
1126
- // circle tables are in a separate store. Consumers should resolve
1127
- // circle members externally if needed.
1128
- return {
1129
- models,
1130
- owners
1131
- };
1132
- },
1133
3250
  // --- Cluster registration ---
1134
3251
  async registerClusterModels (_clusterID, entries) {
1135
3252
  if (entries.length === 0) return;
@@ -1139,6 +3256,35 @@ export function createGraphStore(db, adapter) {
1139
3256
  }))).execute();
1140
3257
  },
1141
3258
  // --- User model access defaults ---
3259
+ async hasControllerAccessDefaults (ownerDID, modelID) {
3260
+ let query = db.selectFrom('kubun_graph_access_default_records').select('owner').where('type', '=', 'access.default').where('owner', '=', ownerDID);
3261
+ if (modelID != null) {
3262
+ // Prefix ranges depend on collation ordering; type and owner narrow the primary key scan.
3263
+ const prefix = `access.default:[${JSON.stringify(modelID)},`;
3264
+ query = query.where(sql`substr(id, 1, ${Array.from(prefix).length}) = ${prefix}`);
3265
+ }
3266
+ return await query.limit(1).executeTakeFirst() != null;
3267
+ },
3268
+ async invalidateAccessDefaultProjections (ownerDID) {
3269
+ if (context.provider === context.root) {
3270
+ return context.root.withTransaction(async (tx)=>(await tx.getStore('graph')).invalidateAccessDefaultProjections(ownerDID));
3271
+ }
3272
+ let ownersQuery = db.selectFrom('kubun_graph_access_default_records').where('type', '=', 'access.default').select('owner').distinct();
3273
+ if (ownerDID != null) ownersQuery = ownersQuery.where('owner', '=', ownerDID);
3274
+ const owners = (await ownersQuery.execute()).map(({ owner })=>owner);
3275
+ if (owners.length === 0) return;
3276
+ await api.accessDefaultRecords().lock?.(owners.map((owner)=>({
3277
+ type: 'access.default',
3278
+ key: `!projection:${owner}`
3279
+ })));
3280
+ const rows = await db.selectFrom('kubun_graph_user_model_access_defaults').select([
3281
+ 'model_id',
3282
+ 'permission_type'
3283
+ ]).where('owner_did', 'in', owners).execute();
3284
+ // Signed slots survive invalidation so a successful repair can restore the winner.
3285
+ await db.deleteFrom('kubun_graph_user_model_access_defaults').where('owner_did', 'in', owners).execute();
3286
+ scheduleScopeModels(context, rows.filter((row)=>row.permission_type === 'read').map((row)=>row.model_id));
3287
+ },
1142
3288
  async getUserModelAccessDefault (ownerDID, modelID, permissionType) {
1143
3289
  const result = await db.selectFrom('kubun_graph_user_model_access_defaults').select([
1144
3290
  'access_level',
@@ -1216,8 +3362,14 @@ export function createGraphStore(db, adapter) {
1216
3362
  'permission_type'
1217
3363
  ]).doUpdateSet(updateValues);
1218
3364
  }).execute();
3365
+ if (params.permissionType === 'read') {
3366
+ scheduleScopeModels(context, [
3367
+ params.modelID
3368
+ ]);
3369
+ }
1219
3370
  },
1220
- async removeUserModelAccessDefaults (ownerDID, modelID, permissionTypes, hlc) {
3371
+ async removeUserModelAccessDefaults (params) {
3372
+ const { ownerDID, modelID, permissionTypes, hlc } = params;
1221
3373
  // Tombstone rather than delete: retain the row with rule fields nulled,
1222
3374
  // `removed` set, and `hlc` stamped so it stays the LWW anchor. Deleting
1223
3375
  // would erase the anchor and let a stale set revive the rule.
@@ -1259,9 +3411,15 @@ export function createGraphStore(db, adapter) {
1259
3411
  removed: 1,
1260
3412
  updated_at: updatedAt
1261
3413
  })).execute();
3414
+ if (permissionTypes.includes('read')) {
3415
+ scheduleScopeModels(context, [
3416
+ modelID
3417
+ ]);
3418
+ }
1262
3419
  },
1263
3420
  // --- Document scope / metadata helpers ---
1264
- async getDocumentIDsForScope (scopes, excludedDocumentIDs = []) {
3421
+ async getDocumentIDsForScope (params) {
3422
+ const { scopes, excludedDocumentIDs = [], limit, groupRead } = params;
1265
3423
  // Group by model so each model's table is hit once with every owner it was
1266
3424
  // asked for, rather than once per (model, owner) pair.
1267
3425
  const ownersByModel = new Map();
@@ -1275,12 +3433,12 @@ export function createGraphStore(db, adapter) {
1275
3433
  owners.add(scope.ownerDID);
1276
3434
  }
1277
3435
  }
1278
- // Disjoint tables, so the reads go out together. Flattened in
1279
- // `ownersByModel` order rather than completion order — the returned array
1280
- // feeds merkle-tree construction, which must not vary run to run.
1281
- const idsByModel = await Promise.all(Array.from(ownersByModel, async ([modelID, owners])=>{
3436
+ const readModel = async (modelID, owners, modelLimit)=>{
1282
3437
  try {
1283
- const docs = await db.selectFrom(`k_${modelID}`).select('id').where('owner', 'in', Array.from(owners)).execute();
3438
+ let query = db.selectFrom(`k_${modelID}`).select('id').where('owner', 'in', Array.from(owners));
3439
+ if (groupRead != null) query = query.where(adapter.groupReadPredicate(sql.ref('field_values'), groupRead));
3440
+ if (modelLimit != null) query = query.limit(modelLimit);
3441
+ const docs = await query.execute();
1284
3442
  return docs.map((d)=>d.id);
1285
3443
  } catch (err) {
1286
3444
  // A model with no table contributes nothing — one missing table
@@ -1288,7 +3446,24 @@ export function createGraphStore(db, adapter) {
1288
3446
  if (!isMissingTableError(err, `k_${modelID}`)) throw err;
1289
3447
  return [];
1290
3448
  }
1291
- }));
3449
+ };
3450
+ let idsByModel;
3451
+ if (limit == null) {
3452
+ // Disjoint tables, so the reads go out together. Flattened in
3453
+ // `ownersByModel` order rather than completion order — the returned array
3454
+ // feeds merkle-tree construction, which must not vary run to run.
3455
+ idsByModel = await Promise.all(Array.from(ownersByModel, ([modelID, owners])=>readModel(modelID, owners)));
3456
+ } else {
3457
+ // A total limit: each model reads only what is left of it.
3458
+ idsByModel = [];
3459
+ let remaining = limit;
3460
+ for (const [modelID, owners] of ownersByModel){
3461
+ if (remaining <= 0) break;
3462
+ const ids = await readModel(modelID, owners, remaining);
3463
+ idsByModel.push(ids);
3464
+ remaining -= ids.length;
3465
+ }
3466
+ }
1292
3467
  const docIDs = idsByModel.flat();
1293
3468
  if (excludedDocumentIDs.length > 0) {
1294
3469
  const excluded = new Set(excludedDocumentIDs);
@@ -1296,6 +3471,70 @@ export function createGraphStore(db, adapter) {
1296
3471
  }
1297
3472
  return docIDs;
1298
3473
  },
3474
+ async listDocumentLeafPage (params) {
3475
+ if (params.limit <= 0 || params.rules.length === 0) return [];
3476
+ const rulesByModel = new Map();
3477
+ for (const rule of params.rules){
3478
+ const rules = rulesByModel.get(rule.modelID);
3479
+ if (rules == null) rulesByModel.set(rule.modelID, [
3480
+ rule
3481
+ ]);
3482
+ else rules.push(rule);
3483
+ }
3484
+ const postgres = adapter instanceof AbstractPostgresAdapter;
3485
+ const leaves = [];
3486
+ for (const [modelID, rules] of rulesByModel){
3487
+ let rows;
3488
+ try {
3489
+ rows = (await documentLeafPageStatement({
3490
+ modelID,
3491
+ rules,
3492
+ after: params.after,
3493
+ through: params.through ?? null,
3494
+ limit: params.limit,
3495
+ postgres,
3496
+ ...params.groupRead == null ? {} : {
3497
+ groupReadPredicate: adapter.groupReadPredicate(sql.ref('k.field_values'), params.groupRead)
3498
+ }
3499
+ }).execute(db)).rows;
3500
+ } catch (err) {
3501
+ if (!isMissingTableError(err, `k_${modelID}`)) throw err;
3502
+ continue;
3503
+ }
3504
+ let current;
3505
+ for (const row of rows){
3506
+ if (current == null || current.documentID !== row.id) {
3507
+ current = {
3508
+ documentID: row.id,
3509
+ owner: row.owner,
3510
+ authoredHere: row.authored_here === true || Number(row.authored_here) === 1,
3511
+ acceptedMutationIDs: []
3512
+ };
3513
+ leaves.push(current);
3514
+ }
3515
+ if (row.mutation_hash != null) current.acceptedMutationIDs.push(row.mutation_hash);
3516
+ }
3517
+ }
3518
+ leaves.sort((a, b)=>a.documentID < b.documentID ? -1 : a.documentID > b.documentID ? 1 : 0);
3519
+ const page = leaves.slice(0, params.limit);
3520
+ for (const leaf of page)leaf.acceptedMutationIDs.sort();
3521
+ return page;
3522
+ },
3523
+ async getAppliedMutationsForDocuments (documentIDs) {
3524
+ const found = new Map();
3525
+ for (const chunk of chunked(documentIDs)){
3526
+ const rows = await db.selectFrom('kubun_graph_mutation_log').selectAll().where('document_id', 'in', chunk).where('status', '=', 'applied').orderBy('hlc', 'asc').execute();
3527
+ for (const row of rows){
3528
+ const entries = found.get(row.document_id) ?? [];
3529
+ entries.push(row);
3530
+ found.set(row.document_id, entries);
3531
+ }
3532
+ }
3533
+ return found;
3534
+ },
3535
+ async getAppliedMutations (documentID) {
3536
+ return await db.selectFrom('kubun_graph_mutation_log').selectAll().where('document_id', '=', documentID).where('status', '=', 'applied').orderBy('hlc', 'asc').execute();
3537
+ },
1299
3538
  async getDocumentMetadataForSync (modelID, documentIDs) {
1300
3539
  if (documentIDs.length === 0) return [];
1301
3540
  const rows = await db.selectFrom(`k_${modelID}`).select([