@powerhousedao/reactor 6.2.3-dev.3 → 6.2.3-dev.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{build-worker-executor-DnU3ZU4H.js → build-worker-executor-Bvoba-AV.js} +24 -8
  2. package/dist/build-worker-executor-Bvoba-AV.js.map +1 -0
  3. package/dist/{document-indexer-C5Gsa1B3.js → document-indexer-BVTGbEB0.js} +190 -40
  4. package/dist/document-indexer-BVTGbEB0.js.map +1 -0
  5. package/dist/entry.d.ts +2 -1
  6. package/dist/entry.js +10 -6
  7. package/dist/entry.js.map +1 -1
  8. package/dist/{errors-D3S6Eysd.js → errors-BM_ULVWd.js} +9 -1
  9. package/dist/{errors-D3S6Eysd.js.map → errors-BM_ULVWd.js.map} +1 -1
  10. package/dist/{drive-container-types-yZrksiJR.js → forwarding-logger-CnuVDVze.js} +1297 -241
  11. package/dist/forwarding-logger-CnuVDVze.js.map +1 -0
  12. package/dist/index.d.ts +2707 -2040
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1708 -648
  15. package/dist/index.js.map +1 -1
  16. package/dist/projection-entry.d.ts +2 -1
  17. package/dist/projection-entry.js +11 -17
  18. package/dist/projection-entry.js.map +1 -1
  19. package/dist/{projection-shard-manager-CPQc5XHL.js → projection-shard-manager-BpcqDtnQ.js} +4 -4
  20. package/dist/projection-shard-manager-BpcqDtnQ.js.map +1 -0
  21. package/dist/{projection-worker-wI4PwcV2.js → projection-worker-D8uNFdwX.js} +1 -1
  22. package/dist/{projection-worker-wI4PwcV2.js.map → projection-worker-D8uNFdwX.js.map} +1 -1
  23. package/dist/{transport-CuogVKN_.js → transport-B3G50KVd.js} +1 -1
  24. package/dist/{transport-CuogVKN_.js.map → transport-B3G50KVd.js.map} +1 -1
  25. package/dist/{transport-ByGviWdZ.js → transport-CZDHzfvy.js} +1 -1
  26. package/dist/{transport-ByGviWdZ.js.map → transport-CZDHzfvy.js.map} +1 -1
  27. package/dist/{worker-handle-CrERzl8s.js → worker-handle-C2rLUHwv.js} +22 -16
  28. package/dist/worker-handle-C2rLUHwv.js.map +1 -0
  29. package/package.json +19 -10
  30. package/dist/build-worker-executor-DnU3ZU4H.js.map +0 -1
  31. package/dist/document-indexer-C5Gsa1B3.js.map +0 -1
  32. package/dist/drive-container-types-yZrksiJR.js.map +0 -1
  33. package/dist/error-info-Cpu4OY3o.js +0 -62
  34. package/dist/error-info-Cpu4OY3o.js.map +0 -1
  35. package/dist/forwarding-logger-BBkMSxuJ.js +0 -85
  36. package/dist/forwarding-logger-BBkMSxuJ.js.map +0 -1
  37. package/dist/projection-shard-manager-CPQc5XHL.js.map +0 -1
  38. package/dist/types-DMKLa0Ok.js +0 -33
  39. package/dist/types-DMKLa0Ok.js.map +0 -1
  40. package/dist/worker-HPysBfhx.js +0 -22
  41. package/dist/worker-HPysBfhx.js.map +0 -1
  42. package/dist/worker-handle-CrERzl8s.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,10 +1,7 @@
1
- import { A as selectDecisionModel, B as RevisionMismatchError, C as InvalidModuleError, D as createEmptyConsistencyToken, E as createConsistencyToken, F as AppendConditionFailedError, G as DocumentNotFoundError, H as AuthTimestampNotMonotonicError, I as DocumentAlreadyExistsError, J as UpgradePreconditionFailedError, K as ExcessiveReshuffleError, L as DocumentExistence, M as authDecisionModel, N as buildDecisionModel, O as targetDocumentId, P as APPEND_CONDITION_FAILED_PREFIX, R as DuplicateOperationError, S as DuplicateModuleError, T as GATED_DOCUMENT_ACTIONS, U as AuthorizationDeniedError, V as AuthEnforcementDisabledError, W as DocumentDeletedError, X as parsePagingOptions, Y as matchesScope, Z as throwIfAborted, _ as DocumentMetaCache, a as createForwardingPoolInstrumentation, b as JobExecutorEventTypes, c as KyselyKeyframeStore, d as DriveCollectionId, f as KyselyExecutionScope, g as KyselyOperationIndex, h as KyselyWriteCache, i as runMigrations, j as documentDecisionModel, k as decideAtHead, l as DocumentModelRegistry, m as EventBus, n as REACTOR_SCHEMA, o as instrumentPgPool, p as resolveFeatureFlags, q as InvalidOperationTimestampError, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as CollectionMembershipCache, w as ModuleNotFoundError, x as DuplicateManifestError, y as DEFAULT_DEFERRED_JOB_TTL_MS, z as OptimisticLockError } from "./drive-container-types-yZrksiJR.js";
2
- import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
3
- import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-D3S6Eysd.js";
4
- import { a as DOCUMENT_INDEXER_READ_MODEL, c as BaseReadModel, i as KyselyDocumentView, n as ConsistencyTracker, o as DOCUMENT_VIEW_READ_MODEL, r as makeConsistencyKey, s as ReadModelCoordinator, t as KyselyDocumentIndexer } from "./document-indexer-C5Gsa1B3.js";
5
- import { n as errorToInfo, r as sanitizeArg, t as createForwardingLogger } from "./forwarding-logger-BBkMSxuJ.js";
6
- import { t as workerEntryPath } from "./worker-HPysBfhx.js";
7
- import { DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actions, actions as documentActions, createPresignedHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, sortOperations, toTransportAction } from "@powerhousedao/shared/document-model";
1
+ import { $ as DocumentNotFoundError, A as ModuleNotFoundError, B as authDecisionModel, C as DocumentMetaCache, D as DuplicateManifestError, E as JobExecutorEventTypes, F as summarizeSubmittedActions, G as DocumentExistence, H as APPEND_CONDITION_FAILED_PREFIX, I as targetDocumentId, J as RevisionMismatchError, K as DuplicateOperationError, L as decideAtHead, M as createConsistencyToken, N as createEmptyConsistencyToken, O as DuplicateModuleError, P as submittedActionIds, Q as DocumentDeletedError, R as selectDecisionModel, S as KyselyOperationIndex, T as DEFAULT_DEFERRED_JOB_TTL_MS, U as AppendConditionFailedError, V as buildDecisionModel, W as DocumentAlreadyExistsError, X as AuthTimestampNotMonotonicError, Y as AuthEnforcementDisabledError, Z as AuthorizationDeniedError, _ as DriveCollectionId, a as DEFAULT_DRIVE_CONTAINER_TYPES, at as EventBusAggregateError, b as EventBus, c as runMigrations, ct as parsePagingOptions, d as KyselyOperationStore, dt as __exportAll, et as ExcessiveReshuffleError, f as KyselyKeyframeStore, g as PassthroughSigner, h as verifyActionSignature, it as UpgradePreconditionFailedError, j as GATED_DOCUMENT_ACTIONS, k as InvalidModuleError, l as createForwardingPoolInstrumentation, lt as throwIfAborted, m as SimpleJobExecutor, n as errorToInfo, nt as InvalidSignatureError, o as REACTOR_SCHEMA, ot as ReactorEventTypes, p as DocumentModelRegistry, q as OptimisticLockError, r as sanitizeArg, rt as RelationshipNotFoundError, s as getMigrationStatus, st as matchesScope, t as createForwardingLogger, tt as InvalidOperationTimestampError, u as instrumentPgPool, v as KyselyExecutionScope, w as CollectionMembershipCache, x as KyselyWriteCache, y as resolveFeatureFlags, z as documentDecisionModel } from "./forwarding-logger-CnuVDVze.js";
2
+ import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-BM_ULVWd.js";
3
+ import { a as KyselyDocumentView, c as ReadModelCoordinator, d as DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, f as defaultReadModelIndexingConfig, i as DeletedDocumentRead, l as BaseReadModel, n as ConsistencyTracker, o as DOCUMENT_INDEXER_READ_MODEL, p as unchunkedReadModelIndexingConfig, r as makeConsistencyKey, s as DOCUMENT_VIEW_READ_MODEL, t as KyselyDocumentIndexer, u as DEFAULT_COMMIT_CHUNK_SIZE } from "./document-indexer-BVTGbEB0.js";
4
+ import { DEFAULT_SIGNATURE_POLICY, DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actionSignerIdentity, actionSigningTarget, actions, actions as documentActions, createCopyHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, requestedSignaturePolicy, sortOperations, toTransportAction, withSignaturePolicy } from "@powerhousedao/shared/document-model";
8
5
  import { addFile, addFolder, copyNode, deleteNode, driveCreateDocument, generateNodesCopy, getDescendants, handleTargetNameCollisions, isFileNode, isFolderNode, moveNode, updateNode } from "@powerhousedao/shared/document-drive";
9
6
  import { v4 } from "uuid";
10
7
  import { ConsoleLogger, childLogger } from "document-model";
@@ -237,37 +234,31 @@ function getSharedActionScope(actions) {
237
234
  return baseScope;
238
235
  }
239
236
  /**
240
- * Signs an action with the provided signer.
241
- * If the action already has valid signatures, it is returned unchanged.
237
+ * Signs an action for `target`, the log it is written to. An action already
238
+ * signed under a key is returned unchanged; one whose signer or last tuple has
239
+ * no key is unsigned, and is signed like any other.
242
240
  */
243
- const signAction = async (action, signer, signal) => {
244
- const existingSignatures = action.context?.signer?.signatures;
245
- if (existingSignatures && existingSignatures.length > 0) return action;
246
- const signature = await signer.signAction(action, signal);
241
+ const signAction = async (action, signer, target, signal) => {
242
+ const existing = action.context?.signer;
243
+ if (existing?.app?.key && existing.signatures.at(-1)?.[1]) return action;
244
+ const signature = await signer.signAction(action, target, signal);
247
245
  return {
248
246
  ...action,
249
247
  context: {
250
248
  ...action.context,
251
249
  signer: {
252
- user: {
253
- address: signer.user?.address || "",
254
- networkId: signer.user?.networkId || "",
255
- chainId: signer.user?.chainId || 0
256
- },
257
- app: {
258
- name: signer.app?.name || "",
259
- key: signer.app?.key || ""
260
- },
250
+ ...actionSignerIdentity(signer),
261
251
  signatures: [signature]
262
252
  }
263
253
  }
264
254
  };
265
255
  };
266
256
  /**
267
- * Signs multiple actions with the provided signer
257
+ * Signs the actions of a job on `job.documentId`, each for the log it is
258
+ * written to: a relationship action lands in its source document.
268
259
  */
269
- const signActions = async (actions, signer, signal) => {
270
- return Promise.all(actions.map((action) => signAction(action, signer, signal)));
260
+ const signActions = async (actions, signer, job, signal) => {
261
+ return Promise.all(actions.map((action) => signAction(action, signer, actionSigningTarget(action, job.documentId, job.branch), signal)));
271
262
  };
272
263
  function buildSingleJobMeta(jobId, callerMeta) {
273
264
  return {
@@ -321,6 +312,10 @@ let JobStatus = /* @__PURE__ */ function(JobStatus) {
321
312
  * during construction, so the partial-`this` hazard does not apply.
322
313
  */
323
314
  var DriveClient = class {
315
+ client;
316
+ logger;
317
+ reactor;
318
+ signer;
324
319
  constructor(client, logger, reactor, signer) {
325
320
  this.client = client;
326
321
  this.logger = logger;
@@ -329,11 +324,11 @@ var DriveClient = class {
329
324
  }
330
325
  async create(input, signal) {
331
326
  this.logger.verbose("drives.create(@input)", input);
332
- const driveDoc = driveCreateDocument({ global: {
327
+ const driveDoc = withSignaturePolicy(driveCreateDocument({ global: {
333
328
  name: input.global.name || "",
334
329
  icon: input.global.icon ?? null,
335
330
  nodes: []
336
- } });
331
+ } }), requestedSignaturePolicy(input, await this.client.getCreateSignaturePolicy()), { protocolVersions: input.protocolVersions });
337
332
  if (input.preferredEditor) driveDoc.header.meta = {
338
333
  ...driveDoc.header.meta,
339
334
  preferredEditor: input.preferredEditor
@@ -343,24 +338,25 @@ var DriveClient = class {
343
338
  async addFile(driveIdentifier, document, parentFolder, signal) {
344
339
  this.logger.verbose("drives.addFile(@driveIdentifier, @document, @parentFolder)", driveIdentifier, document.header.id, parentFolder);
345
340
  const documentId = document.header.id;
341
+ const createInput = {
342
+ model: document.header.documentType,
343
+ version: 0,
344
+ documentId: document.header.id,
345
+ signing: {
346
+ signature: document.header.id,
347
+ publicKey: document.header.sig.publicKey,
348
+ nonce: document.header.sig.nonce,
349
+ createdAtUtcIso: document.header.createdAtUtcIso,
350
+ documentType: document.header.documentType
351
+ },
352
+ slug: document.header.slug,
353
+ name: document.header.name,
354
+ branch: document.header.branch,
355
+ meta: document.header.meta,
356
+ protocolVersions: document.header.protocolVersions ?? { "base-reducer": 2 }
357
+ };
346
358
  const documentActions = await signActions([
347
- createDocumentAction({
348
- model: document.header.documentType,
349
- version: 0,
350
- documentId: document.header.id,
351
- signing: {
352
- signature: document.header.id,
353
- publicKey: document.header.sig.publicKey,
354
- nonce: document.header.sig.nonce,
355
- createdAtUtcIso: document.header.createdAtUtcIso,
356
- documentType: document.header.documentType
357
- },
358
- slug: document.header.slug,
359
- name: document.header.name,
360
- branch: document.header.branch,
361
- meta: document.header.meta,
362
- protocolVersions: document.header.protocolVersions ?? { "base-reducer": 2 }
363
- }),
359
+ createDocumentAction(createInput),
364
360
  upgradeDocumentAction({
365
361
  documentId: document.header.id,
366
362
  model: document.header.documentType,
@@ -369,13 +365,19 @@ var DriveClient = class {
369
365
  initialState: document.state
370
366
  }),
371
367
  addRelationshipAction(driveIdentifier, documentId, "child")
372
- ], this.signer, signal);
368
+ ], this.signer, {
369
+ documentId,
370
+ branch: "main"
371
+ }, signal);
373
372
  const driveActions = await signActions([addFile({
374
373
  id: documentId,
375
374
  name: document.header.name || documentId,
376
375
  documentType: document.header.documentType,
377
376
  parentFolder
378
- })], this.signer, signal);
377
+ })], this.signer, {
378
+ documentId: driveIdentifier,
379
+ branch: "main"
380
+ }, signal);
379
381
  await this.runJobs([{
380
382
  key: "document",
381
383
  documentId,
@@ -465,18 +467,20 @@ var DriveClient = class {
465
467
  });
466
468
  resolvedNamesByTargetId.set(entry.targetId, resolved);
467
469
  }
470
+ const policy = await this.client.getCreateSignaturePolicy();
468
471
  for (const entry of copyPlan) {
469
472
  const node = drive.state.global.nodes.find((n) => n.id === entry.srcId);
470
473
  if (!node || !isFileNode(node)) continue;
471
474
  const srcDoc = await this.client.get(entry.srcId, void 0, signal);
472
475
  const duplicated = {
473
476
  ...srcDoc,
474
- header: createPresignedHeader(entry.targetId, srcDoc.header.documentType),
477
+ header: createCopyHeader(srcDoc.header, entry.targetId, policy),
475
478
  initialState: srcDoc.state,
476
479
  operations: {}
477
480
  };
478
481
  const resolvedName = resolvedNamesByTargetId.get(entry.targetId);
479
482
  if (resolvedName) duplicated.header.name = resolvedName;
483
+ entry.targetId = duplicated.header.id;
480
484
  await this.addFile(driveIdentifier, duplicated, entry.targetParentFolder ?? void 0, signal);
481
485
  }
482
486
  return this.client.execute(driveIdentifier, "main", copyPlan.map((entry) => copyNode(entry)), signal);
@@ -500,8 +504,7 @@ var DriveClient = class {
500
504
  return {
501
505
  results: filtered.slice(startIndex, endIndex),
502
506
  options: effective,
503
- ...endIndex < filtered.length ? { nextCursor: String(endIndex) } : {},
504
- totalCount: filtered.length
507
+ ...endIndex < filtered.length ? { nextCursor: String(endIndex) } : {}
505
508
  };
506
509
  }
507
510
  /**
@@ -512,7 +515,7 @@ var DriveClient = class {
512
515
  async runJobs(jobs, signal) {
513
516
  const batchResult = await this.reactor.executeBatch({ jobs }, signal);
514
517
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.client.waitForJob(job, signal)));
515
- for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw this.toError(job);
518
+ for (const job of completedJobs) if (job.status === "FAILED") throw this.toError(job);
516
519
  return completedJobs;
517
520
  }
518
521
  toError(job) {
@@ -529,8 +532,14 @@ var DriveClient = class {
529
532
  }
530
533
  }
531
534
  async removeFileNode(driveId, fileId, signal) {
532
- const relationshipActions = await signActions([removeRelationshipAction(driveId, fileId, "child")], this.signer, signal);
533
- const driveActions = await signActions([deleteNode({ id: fileId })], this.signer, signal);
535
+ const relationshipActions = await signActions([removeRelationshipAction(driveId, fileId, "child")], this.signer, {
536
+ documentId: driveId,
537
+ branch: "main"
538
+ }, signal);
539
+ const driveActions = await signActions([deleteNode({ id: fileId })], this.signer, {
540
+ documentId: driveId,
541
+ branch: "main"
542
+ }, signal);
534
543
  const batchResult = await this.reactor.executeBatch({ jobs: [{
535
544
  key: "relationship",
536
545
  documentId: driveId,
@@ -547,10 +556,10 @@ var DriveClient = class {
547
556
  dependsOn: ["relationship"]
548
557
  }] }, signal);
549
558
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.client.waitForJob(job, signal)));
550
- for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
559
+ for (const job of completedJobs) if (job.status === "FAILED") throw new Error(job.error?.message);
551
560
  const deleteJob = await this.reactor.deleteDocument(fileId, this.signer, signal);
552
561
  const deleteCompleted = await this.client.waitForJob(deleteJob, signal);
553
- if (deleteCompleted.status === JobStatus.FAILED) throw new Error(deleteCompleted.error?.message);
562
+ if (deleteCompleted.status === "FAILED") throw new Error(deleteCompleted.error?.message);
554
563
  }
555
564
  };
556
565
  //#endregion
@@ -561,13 +570,15 @@ var DriveClient = class {
561
570
  * Only READ_READY and FAILED are truly terminal.
562
571
  */
563
572
  function isTerminalStatus(status) {
564
- return status === JobStatus.READ_READY || status === JobStatus.FAILED;
573
+ return status === "READ_READY" || status === "FAILED";
565
574
  }
566
575
  /**
567
576
  * Event-driven implementation of IJobAwaiter.
568
577
  * Subscribes to operation events to detect job completion without polling.
569
578
  */
570
579
  var JobAwaiter = class {
580
+ eventBus;
581
+ getJobStatus;
571
582
  pendingJobs = /* @__PURE__ */ new Map();
572
583
  unsubscribers = [];
573
584
  constructor(eventBus, getJobStatus) {
@@ -709,7 +720,54 @@ let DocumentChangeType = /* @__PURE__ */ function(DocumentChangeType) {
709
720
  * would diverge permanently. The document scope carries the metadata the same
710
721
  * argument covers. Grants gate domain-scope reads only.
711
722
  */
712
- const ALWAYS_READABLE_SCOPES = new Set(["auth", "document"]);
723
+ const ALWAYS_READABLE_SCOPES = /* @__PURE__ */ new Set(["auth", "document"]);
724
+ const DECLARED_DOMAIN_SCOPES = ["global", "local"];
725
+ /**
726
+ * The scopes grants gate: the declared ones, and any other the document holds.
727
+ * A document fetched for a narrowed view holds only that view's scopes.
728
+ */
729
+ function domainScopesOf(document) {
730
+ const state = document.state;
731
+ const held = Object.keys(state ?? {}).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope));
732
+ return [.../* @__PURE__ */ new Set([...DECLARED_DOMAIN_SCOPES, ...held])];
733
+ }
734
+ function allowsSomeDomainScope(document, readable) {
735
+ return domainScopesOf(document).some(readable);
736
+ }
737
+ /** A document with no state at all is not policied, so nothing withholds it. */
738
+ function refusesEveryDomainScope(document, readable) {
739
+ return document.state !== void 0 && !allowsSomeDomainScope(document, readable);
740
+ }
741
+ /**
742
+ * The domain scopes the document does not hold whose read decision reads their
743
+ * own state. A condition's `doc.<scope>` path resolves only for that scope, so
744
+ * every other scope is decided on its name and the policy alone.
745
+ */
746
+ function unheldScopesReadOnState(document) {
747
+ const grants = authOf(document)?.grants ?? [];
748
+ const onState = /* @__PURE__ */ new Set();
749
+ for (const grant of grants) {
750
+ collectStateScopes(grant.where, onState);
751
+ collectStateScopes(grant.principal, onState);
752
+ }
753
+ if (onState.size === 0) return [];
754
+ const held = document.state ?? {};
755
+ return domainScopesOf(document).filter((scope) => onState.has(scope) && !(scope in held));
756
+ }
757
+ function collectStateScopes(node, into) {
758
+ if (Array.isArray(node)) {
759
+ for (const child of node) collectStateScopes(child, into);
760
+ return;
761
+ }
762
+ if (typeof node !== "object" || node === null) return;
763
+ const { attr } = node;
764
+ if (typeof attr === "string") {
765
+ const [root, scope] = attr.split(".");
766
+ if (root === "doc" && scope) into.add(scope);
767
+ return;
768
+ }
769
+ for (const child of Object.values(node)) collectStateScopes(child, into);
770
+ }
713
771
  /** What a gate does with an uninitialized policy when told nothing. */
714
772
  const OPEN_BY_DEFAULT = { withholdUninitialized: false };
715
773
  /**
@@ -772,8 +830,26 @@ var BareReadGate = class {
772
830
  scope
773
831
  }) === "allow");
774
832
  }
833
+ prepare(document) {
834
+ return Promise.resolve((subject) => this.scopePredicate(document, subject));
835
+ }
775
836
  };
776
837
  /**
838
+ * Rethrows a failed read unless the document is confirmed not live. The read
839
+ * side reports absence as a plain Error, so absence is checked rather than
840
+ * inferred from the message, and a failed check surfaces the read's own error.
841
+ */
842
+ async function assertAbsent(documentView, documentId, error, signal) {
843
+ if (DocumentNotFoundError.isError(error)) return;
844
+ let exists;
845
+ try {
846
+ exists = await documentView.exists([documentId], "LiveOnly", void 0, signal);
847
+ } catch {
848
+ throw error;
849
+ }
850
+ if (exists[0]) throw error;
851
+ }
852
+ /**
777
853
  * Answers a stream read from the document already fetched, and anything else
778
854
  * through the read side.
779
855
  *
@@ -783,6 +859,9 @@ var BareReadGate = class {
783
859
  * list names is fetched.
784
860
  */
785
861
  var SeededStateReader = class {
862
+ documentView;
863
+ seed;
864
+ branch;
786
865
  constructor(documentView, seed, branch) {
787
866
  this.documentView = documentView;
788
867
  this.seed = seed;
@@ -804,20 +883,10 @@ var SeededStateReader = class {
804
883
  scopes: [scope]
805
884
  }, void 0, signal);
806
885
  } catch (error) {
807
- await this.assertAbsent(documentId, error, signal);
886
+ await assertAbsent(this.documentView, documentId, error, signal);
808
887
  throw new DocumentNotFoundError(documentId);
809
888
  }
810
889
  }
811
- async assertAbsent(documentId, error, signal) {
812
- if (DocumentNotFoundError.isError(error)) return;
813
- let exists;
814
- try {
815
- exists = await this.documentView.exists([documentId], DocumentExistence.LiveOnly, void 0, signal);
816
- } catch {
817
- throw error;
818
- }
819
- if (exists[0]) throw error;
820
- }
821
890
  };
822
891
  /**
823
892
  * Evaluates a read against the registered decision model, built at the stream
@@ -833,6 +902,12 @@ var SeededStateReader = class {
833
902
  * from state no commit ever invalidates.
834
903
  */
835
904
  var ModelReadGate = class {
905
+ model;
906
+ documentView;
907
+ servesGroups;
908
+ operationIndex;
909
+ logger;
910
+ options;
836
911
  constructor(model, documentView, servesGroups, operationIndex, logger, options = OPEN_BY_DEFAULT) {
837
912
  this.model = model;
838
913
  this.documentView = documentView;
@@ -847,10 +922,18 @@ var ModelReadGate = class {
847
922
  * group's other scopes are its own business and stay behind its own grants.
848
923
  */
849
924
  async scopePredicate(document, subject, branch, signal) {
850
- const own = await this.ownPolicyPredicate(document, subject, branch, signal);
851
- if (!this.servesGroup(document) || own(GROUP_MEMBERSHIP_SCOPE)) return own;
852
- const audience = await this.servesGroupTo(document.header.id, subject, signal);
853
- return (scope) => own(scope) || audience && scope === GROUP_MEMBERSHIP_SCOPE;
925
+ return (await this.prepare(document, branch, signal))(subject);
926
+ }
927
+ async prepare(document, branch, signal) {
928
+ const policy = await this.ownPolicy(document, branch, signal);
929
+ if (!this.servesGroup(document)) return (subject) => Promise.resolve(policy(subject));
930
+ const referencers = this.referencerReads(document.header.id, signal);
931
+ return async (subject) => {
932
+ const own = policy(subject);
933
+ if (own(GROUP_MEMBERSHIP_SCOPE)) return own;
934
+ const audience = await this.servesGroupTo(subject, referencers);
935
+ return (scope) => own(scope) || audience && scope === GROUP_MEMBERSHIP_SCOPE;
936
+ };
854
937
  }
855
938
  /**
856
939
  * Whether the subject is served this group because a policy names it.
@@ -884,11 +967,8 @@ var ModelReadGate = class {
884
967
  * running with nobody awaiting it, which is where unhandled rejections come
885
968
  * from.
886
969
  */
887
- async servesGroupTo(groupId, subject, signal) {
888
- if (!this.operationIndex) return false;
889
- const referencers = await this.operationIndex.getGroupReferencers(groupId, signal);
890
- const examined = referencers.slice(0, MAX_EXAMINED_REFERENCERS);
891
- if (examined.length < referencers.length) this.logger?.warn(`Group ${groupId} is referenced by ${referencers.length} documents; only the first ${MAX_EXAMINED_REFERENCERS} decide whether it is served`);
970
+ async servesGroupTo(subject, referencers) {
971
+ const examined = await referencers.examined();
892
972
  const walk = {
893
973
  next: 0,
894
974
  served: false,
@@ -899,7 +979,7 @@ var ModelReadGate = class {
899
979
  const referencerId = examined[walk.next++];
900
980
  let serves = false;
901
981
  try {
902
- serves = await this.servesThrough(referencerId, subject, signal);
982
+ serves = (await referencers.serves(referencerId))(subject);
903
983
  } catch (error) {
904
984
  walk.failure ??= Error.isError(error) ? error : /* @__PURE__ */ new Error(`Probing referencer ${referencerId} failed`);
905
985
  }
@@ -910,21 +990,44 @@ var ModelReadGate = class {
910
990
  if (!walk.served && walk.failure) throw walk.failure;
911
991
  return walk.served;
912
992
  }
993
+ /** Each read made once, however many subjects the walk runs for. */
994
+ referencerReads(groupId, signal) {
995
+ let examined;
996
+ const serves = /* @__PURE__ */ new Map();
997
+ return {
998
+ examined: () => examined ??= this.examinedReferencers(groupId, signal),
999
+ serves: (referencerId) => {
1000
+ let read = serves.get(referencerId);
1001
+ if (!read) {
1002
+ read = this.servesThrough(referencerId, signal);
1003
+ serves.set(referencerId, read);
1004
+ }
1005
+ return read;
1006
+ }
1007
+ };
1008
+ }
1009
+ async examinedReferencers(groupId, signal) {
1010
+ if (!this.operationIndex) return [];
1011
+ const referencers = await this.operationIndex.getGroupReferencers(groupId, signal);
1012
+ const examined = referencers.slice(0, MAX_EXAMINED_REFERENCERS);
1013
+ if (examined.length < referencers.length) this.logger?.warn(`Group ${groupId} is referenced by ${referencers.length} documents; only the first ${MAX_EXAMINED_REFERENCERS} decide whether it is served`);
1014
+ return examined;
1015
+ }
913
1016
  /**
914
- * Whether one referencing document serves the subject any domain scope. A
1017
+ * Whether one referencing document serves a subject any domain scope. A
915
1018
  * referencer this replica does not hold serves nothing, which fails closed
916
1019
  * the same way a group it does not hold does.
917
1020
  */
918
- async servesThrough(referencerId, subject, signal) {
1021
+ async servesThrough(referencerId, signal) {
919
1022
  let referencer;
920
1023
  try {
921
1024
  referencer = await this.documentView.get(referencerId, { branch: GROUP_BRANCH }, void 0, signal);
922
1025
  } catch {
923
- return false;
1026
+ return () => false;
924
1027
  }
925
- if (this.servesGroup(referencer)) return false;
926
- const readable = await this.ownPolicyPredicate(referencer, subject, GROUP_BRANCH, signal);
927
- return Object.keys(referencer.state ?? {}).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope)).some((scope) => readable(scope));
1028
+ if (this.servesGroup(referencer)) return () => false;
1029
+ const policy = await this.ownPolicy(referencer, GROUP_BRANCH, signal);
1030
+ return (subject) => allowsSomeDomainScope(referencer, policy(subject));
928
1031
  }
929
1032
  servesGroup(document) {
930
1033
  return this.servesGroups && this.operationIndex !== void 0 && document.header.documentType === groupDocumentType;
@@ -942,9 +1045,12 @@ var ModelReadGate = class {
942
1045
  * publish an unpoliced group it names: the referencer walk asks this question
943
1046
  * of the referencing document, whose real policy answers it.
944
1047
  */
945
- async ownPolicyPredicate(document, subject, branch, signal) {
1048
+ async ownPolicy(document, branch, signal) {
946
1049
  const auth = authOf(document);
947
- if (!auth || !auth.version) return this.options.withholdUninitialized ? (scope) => ALWAYS_READABLE_SCOPES.has(scope) : () => true;
1050
+ if (!auth || !auth.version) {
1051
+ const readable = this.options.withholdUninitialized ? (scope) => ALWAYS_READABLE_SCOPES.has(scope) : () => true;
1052
+ return () => readable;
1053
+ }
948
1054
  const target = {
949
1055
  documentId: document.header.id,
950
1056
  branch
@@ -952,7 +1058,7 @@ var ModelReadGate = class {
952
1058
  const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), this.model, target, signal);
953
1059
  const definition = this.model(target);
954
1060
  const scopeStates = document.state ?? {};
955
- return (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
1061
+ return (subject) => (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
956
1062
  verb: "read",
957
1063
  scope
958
1064
  }, {
@@ -962,6 +1068,78 @@ var ModelReadGate = class {
962
1068
  }
963
1069
  };
964
1070
  //#endregion
1071
+ //#region src/client/event-reads.ts
1072
+ /** One change event's reads, shared by every subscription it reaches. */
1073
+ var EventReads = class {
1074
+ reactor;
1075
+ documentView;
1076
+ readGate;
1077
+ documents = /* @__PURE__ */ new Map();
1078
+ existence = /* @__PURE__ */ new Map();
1079
+ prepared = /* @__PURE__ */ new WeakMap();
1080
+ constructor(reactor, documentView, readGate) {
1081
+ this.reactor = reactor;
1082
+ this.documentView = documentView;
1083
+ this.readGate = readGate;
1084
+ }
1085
+ get(id, view) {
1086
+ const key = JSON.stringify([
1087
+ id,
1088
+ view?.branch,
1089
+ view?.scopes
1090
+ ]);
1091
+ let read = this.documents.get(key);
1092
+ if (!read) {
1093
+ read = this.reactor.get(id, view);
1094
+ this.documents.set(key, read);
1095
+ }
1096
+ return read;
1097
+ }
1098
+ exists(ids, existence) {
1099
+ const key = JSON.stringify([ids, existence]);
1100
+ let read = this.existence.get(key);
1101
+ if (!read) {
1102
+ read = this.documentView.exists(ids, existence);
1103
+ this.existence.set(key, read);
1104
+ }
1105
+ return read;
1106
+ }
1107
+ async scopePredicate(document, subject, branch) {
1108
+ if (!this.readGate.prepare) return this.readGate.scopePredicate(document, subject, branch);
1109
+ let byBranch = this.prepared.get(document);
1110
+ if (!byBranch) {
1111
+ byBranch = /* @__PURE__ */ new Map();
1112
+ this.prepared.set(document, byBranch);
1113
+ }
1114
+ let prepared = byBranch.get(branch);
1115
+ if (!prepared) {
1116
+ prepared = this.readGate.prepare(document, branch);
1117
+ byBranch.set(branch, prepared);
1118
+ }
1119
+ return (await prepared)(subject);
1120
+ }
1121
+ };
1122
+ var EventReadsSource = class {
1123
+ reactor;
1124
+ documentView;
1125
+ readGate;
1126
+ current;
1127
+ constructor(reactor, documentView, readGate) {
1128
+ this.reactor = reactor;
1129
+ this.documentView = documentView;
1130
+ this.readGate = readGate;
1131
+ }
1132
+ forEvent() {
1133
+ if (!this.current) {
1134
+ this.current = new EventReads(this.reactor, this.documentView, this.readGate);
1135
+ queueMicrotask(() => {
1136
+ this.current = void 0;
1137
+ });
1138
+ }
1139
+ return this.current;
1140
+ }
1141
+ };
1142
+ //#endregion
965
1143
  //#region src/client/util.ts
966
1144
  function authSubjectFromSigner(signer) {
967
1145
  return {
@@ -972,10 +1150,23 @@ function authSubjectFromSigner(signer) {
972
1150
  function withAuthScope(view) {
973
1151
  if (view?.scopes && view.scopes.length > 0) return {
974
1152
  ...view,
975
- scopes: [...new Set([...view.scopes, "auth"])]
1153
+ scopes: [.../* @__PURE__ */ new Set([...view.scopes, "auth"])]
976
1154
  };
977
1155
  return view;
978
1156
  }
1157
+ function withAllScopes(view) {
1158
+ if (!view?.scopes) return view;
1159
+ const { scopes: _narrowed, ...whole } = view;
1160
+ return whole;
1161
+ }
1162
+ function narrowedScopes(view) {
1163
+ if (!view?.scopes || view.scopes.length === 0) return;
1164
+ return /* @__PURE__ */ new Set([
1165
+ "document",
1166
+ "auth",
1167
+ ...view.scopes
1168
+ ]);
1169
+ }
979
1170
  /**
980
1171
  * Drops the scopes the predicate refuses. The predicate is resolved by the read
981
1172
  * gate, which decides against the whole policy once per document; this only
@@ -1048,9 +1239,11 @@ var ReactorClient = class {
1048
1239
  documentIndexer;
1049
1240
  documentView;
1050
1241
  readGate;
1242
+ eventReads;
1051
1243
  actionEvaluation;
1244
+ createSignaturePolicy;
1052
1245
  drives;
1053
- constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation) {
1246
+ constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation, createSignaturePolicy = DEFAULT_SIGNATURE_POLICY) {
1054
1247
  this.logger = logger;
1055
1248
  this.reactor = reactor;
1056
1249
  this.signer = signer;
@@ -1059,32 +1252,12 @@ var ReactorClient = class {
1059
1252
  this.documentIndexer = documentIndexer;
1060
1253
  this.documentView = documentView;
1061
1254
  this.readGate = readGate;
1255
+ this.eventReads = new EventReadsSource(reactor, documentView, readGate);
1062
1256
  this.actionEvaluation = actionEvaluation;
1257
+ this.createSignaturePolicy = createSignaturePolicy;
1063
1258
  this.drives = new DriveClient(this, logger, reactor, signer);
1064
1259
  this.logger.verbose("ReactorClient initialized");
1065
1260
  }
1066
- readSubject(subject) {
1067
- return subject ?? authSubjectFromSigner(this.signer);
1068
- }
1069
- /**
1070
- * Which scopes of one document the subject may read. Resolved once per
1071
- * document, so the gate builds its model once however many scopes are then
1072
- * tested, and the filtering itself stays synchronous.
1073
- */
1074
- readableScopes(document, view, signal) {
1075
- return this.readGate.scopePredicate(document, this.readSubject(view?.subject), view?.branch ?? "main", signal);
1076
- }
1077
- /**
1078
- * One document, filtered to the scopes the subject may read. Every method
1079
- * that hands a document back goes through here, including the ones that
1080
- * follow a write: a document returned from a mutation is a read like any
1081
- * other, and returning it whole served scopes the same subject would be
1082
- * refused by `get`. Its author still sees what it wrote, because an allow on
1083
- * execute confers read of that scope.
1084
- */
1085
- async gateDocument(document, view, signal) {
1086
- return filterReadableScopes(document, await this.readableScopes(document, view, signal));
1087
- }
1088
1261
  /**
1089
1262
  * Retrieves a list of document model modules.
1090
1263
  */
@@ -1150,7 +1323,7 @@ var ReactorClient = class {
1150
1323
  /** True when the id is taken; a soft-deleted document's id is still taken. */
1151
1324
  async isDocumentIdTaken(documentId, signal) {
1152
1325
  this.logger.verbose("isDocumentIdTaken(@documentId)", documentId);
1153
- return (await this.documentView.exists([documentId], DocumentExistence.IncludingDeleted, void 0, signal))[0];
1326
+ return (await this.documentView.exists([documentId], "IncludingDeleted", void 0, signal))[0];
1154
1327
  }
1155
1328
  /**
1156
1329
  * Retrieves operations for a document
@@ -1190,31 +1363,6 @@ var ReactorClient = class {
1190
1363
  nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
1191
1364
  };
1192
1365
  }
1193
- async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
1194
- const scopeCursors = decodeCompositeCursor(paging.cursor);
1195
- const allOperations = [];
1196
- const activeCursors = {};
1197
- for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1198
- if (!canRead(scopeName)) continue;
1199
- const scopeView = {
1200
- ...view,
1201
- scopes: [scopeName]
1202
- };
1203
- const scopePaging = {
1204
- cursor,
1205
- limit: paging.limit
1206
- };
1207
- const scopeResult = (await this.reactor.getOperations(documentId, scopeView, filter, scopePaging, void 0, signal))[scopeName];
1208
- allOperations.push(...scopeResult.results);
1209
- if (scopeResult.nextCursor) activeCursors[scopeName] = scopeResult.nextCursor;
1210
- }
1211
- allOperations.sort((a, b) => a.index - b.index);
1212
- return {
1213
- results: allOperations,
1214
- options: paging,
1215
- nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
1216
- };
1217
- }
1218
1366
  /**
1219
1367
  * Retrieves outgoing relationships of a given type from a source document.
1220
1368
  */
@@ -1248,15 +1396,44 @@ var ReactorClient = class {
1248
1396
  return this.find({ ids: sourceIds }, view, paging, signal);
1249
1397
  }
1250
1398
  /**
1399
+ * Retrieves the outgoing relationship edges of a source document, carrying the
1400
+ * metadata and timestamps the far-end documents do not.
1401
+ */
1402
+ async getOutgoingRelationshipEdges(sourceIdentifier, relationshipType, view, paging, signal) {
1403
+ this.logger.verbose("getOutgoingRelationshipEdges(@sourceIdentifier, @relationshipType, @view, @paging)", sourceIdentifier, relationshipType, view, paging);
1404
+ const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
1405
+ const edges = await this.reactor.getOutgoingRelationshipEdges(sourceId, relationshipType, paging, void 0, signal);
1406
+ return this.gateEdges(edges, "targetId", view, signal);
1407
+ }
1408
+ /**
1409
+ * Retrieves the incoming relationship edges of a target document, carrying the
1410
+ * metadata and timestamps the far-end documents do not.
1411
+ */
1412
+ async getIncomingRelationshipEdges(targetIdentifier, relationshipType, view, paging, signal) {
1413
+ this.logger.verbose("getIncomingRelationshipEdges(@targetIdentifier, @relationshipType, @view, @paging)", targetIdentifier, relationshipType, view, paging);
1414
+ const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
1415
+ const edges = await this.reactor.getIncomingRelationshipEdges(targetId, relationshipType, paging, void 0, signal);
1416
+ return this.gateEdges(edges, "sourceId", view, signal);
1417
+ }
1418
+ /**
1251
1419
  * Filters documents by criteria and returns a list of them
1252
1420
  */
1253
1421
  async find(search, view, paging, signal) {
1254
1422
  this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
1255
1423
  const results = await this.reactor.find(search, withAuthScope(view), paging, void 0, signal);
1256
- return {
1257
- ...results,
1258
- results: await Promise.all(results.results.map(async (doc) => filterReadableScopes(doc, await this.readableScopes(doc, view, signal))))
1259
- };
1424
+ return this.gateListing(results, view, signal);
1425
+ }
1426
+ /** Whether `find` would serve the document; false when it is absent. */
1427
+ async isServed(identifier, view, signal) {
1428
+ this.logger.verbose("isServed(@identifier, @view)", identifier, view);
1429
+ let document;
1430
+ try {
1431
+ document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
1432
+ } catch (error) {
1433
+ await assertAbsent(this.documentView, identifier, error, signal);
1434
+ return false;
1435
+ }
1436
+ return await this.gateListed(document, view, signal) !== void 0;
1260
1437
  }
1261
1438
  /**
1262
1439
  * Predicts the admission verdict for each candidate. See
@@ -1297,38 +1474,8 @@ var ReactorClient = class {
1297
1474
  anyDenied: allowed < candidates.length
1298
1475
  };
1299
1476
  }
1300
- /**
1301
- * The decision model for one target document, built at its stream heads.
1302
- *
1303
- * The document is fetched unfiltered, because the policy is what decides:
1304
- * reading it through the read gate would withhold the very scopes the
1305
- * decision is about. A deleted document is served at its deletion boundary,
1306
- * which is what lets the model refuse an execute against it -- authEnforcement
1307
- * requires documentDecisions, so that read is available whenever this runs.
1308
- *
1309
- * Reading past the gate discloses nothing a submit does not. The `auth` and
1310
- * `document` scopes are readable by every holder, so a verdict resting on the
1311
- * policy alone is one the caller could compute unaided; and a verdict resting
1312
- * on a conditional grant reads the executing scope's state exactly as
1313
- * admission reads it, so the answer here is what submitting and being refused
1314
- * would have revealed anyway.
1315
- *
1316
- * The append condition the build records is dropped. It guards a write, and
1317
- * this makes none; reproducing it is also what the preflight cannot do, which
1318
- * is why the answer is a prediction.
1319
- */
1320
- async buildEvaluationTarget(config, documentId, branch, signal) {
1321
- const document = await this.documentView.get(documentId, { branch }, void 0, signal);
1322
- const target = {
1323
- documentId,
1324
- branch
1325
- };
1326
- const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), config.model, target, signal);
1327
- return {
1328
- definition: config.model(target),
1329
- model: built.model,
1330
- scopeStates: document.state ?? {}
1331
- };
1477
+ getCreateSignaturePolicy() {
1478
+ return Promise.resolve(this.createSignaturePolicy);
1332
1479
  }
1333
1480
  /**
1334
1481
  * Creates a document and waits for completion
@@ -1336,7 +1483,8 @@ var ReactorClient = class {
1336
1483
  async create(document, parentIdentifier, signal) {
1337
1484
  this.logger.verbose("create(@id, @parentIdentifier)", document.header.id, parentIdentifier);
1338
1485
  const documentId = document.header.id;
1339
- const createActions = await signActions([createDocumentAction({
1486
+ const branch = document.header.branch || "main";
1487
+ const createInput = {
1340
1488
  model: document.header.documentType,
1341
1489
  version: 0,
1342
1490
  documentId,
@@ -1352,23 +1500,30 @@ var ReactorClient = class {
1352
1500
  branch: document.header.branch,
1353
1501
  meta: document.header.meta,
1354
1502
  protocolVersions: document.header.protocolVersions ?? { "base-reducer": 2 }
1355
- }), upgradeDocumentAction({
1503
+ };
1504
+ const createActions = await signActions([createDocumentAction(createInput), upgradeDocumentAction({
1356
1505
  documentId,
1357
1506
  model: document.header.documentType,
1358
1507
  fromVersion: 0,
1359
1508
  toVersion: normalizeDocumentModelVersion(document.state.document?.version),
1360
1509
  initialState: document.state
1361
- })], this.signer, signal);
1510
+ })], this.signer, {
1511
+ documentId,
1512
+ branch
1513
+ }, signal);
1362
1514
  const jobs = [{
1363
1515
  key: "create",
1364
1516
  documentId,
1365
1517
  scope: getSharedActionScope(createActions),
1366
- branch: "main",
1518
+ branch,
1367
1519
  actions: createActions,
1368
1520
  dependsOn: []
1369
1521
  }];
1370
1522
  if (parentIdentifier) {
1371
- const parentActions = await signActions([addRelationshipAction(parentIdentifier, documentId, "child")], this.signer, signal);
1523
+ const parentActions = await signActions([addRelationshipAction(parentIdentifier, documentId, "child")], this.signer, {
1524
+ documentId: parentIdentifier,
1525
+ branch: "main"
1526
+ }, signal);
1372
1527
  jobs.push({
1373
1528
  key: "parent",
1374
1529
  documentId: parentIdentifier,
@@ -1380,7 +1535,7 @@ var ReactorClient = class {
1380
1535
  }
1381
1536
  const batchResult = await this.reactor.executeBatch({ jobs }, signal);
1382
1537
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
1383
- for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
1538
+ for (const job of completedJobs) if (job.status === "FAILED") throw new Error(job.error?.message);
1384
1539
  const created = await this.reactor.get(documentId);
1385
1540
  return this.gateDocument(created, void 0, signal);
1386
1541
  }
@@ -1402,7 +1557,7 @@ var ReactorClient = class {
1402
1557
  }, void 0);
1403
1558
  if (!module) throw new Error(`Document model not found for type: ${documentModelType}`);
1404
1559
  }
1405
- const document = module.utils.createDocument();
1560
+ const document = withSignaturePolicy(module.utils.createDocument(), requestedSignaturePolicy(options, this.createSignaturePolicy), { protocolVersions: options?.protocolVersions });
1406
1561
  document.state.document.version = normalizeDocumentModelVersion(module.version);
1407
1562
  return this.create(document, options?.parentIdentifier, signal);
1408
1563
  }
@@ -1428,19 +1583,26 @@ var ReactorClient = class {
1428
1583
  const branch = document.header.branch || "main";
1429
1584
  const fromVersion = normalizeDocumentModelVersion(document.state.document?.version);
1430
1585
  let targetVersion = toVersion;
1431
- if (targetVersion === void 0) targetVersion = normalizeDocumentModelVersion((await this.getDocumentModelModule(documentType)).version);
1586
+ if (targetVersion === void 0) {
1587
+ const module = await this.getDocumentModelModule(documentType);
1588
+ targetVersion = normalizeDocumentModelVersion(module.version);
1589
+ }
1432
1590
  if (targetVersion === fromVersion) return this.gateDocument(document, { branch }, signal);
1433
1591
  if (targetVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, targetVersion);
1434
- const signedActions = await signActions([upgradeDocumentAction({
1592
+ const action = upgradeDocumentAction({
1435
1593
  documentId,
1436
1594
  model: documentType,
1437
1595
  fromVersion,
1438
1596
  toVersion: targetVersion,
1439
1597
  revision: { ...document.header.revision }
1440
- })], this.signer, signal);
1598
+ });
1599
+ const signedActions = await signActions([action], this.signer, {
1600
+ documentId,
1601
+ branch
1602
+ }, signal);
1441
1603
  const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
1442
1604
  const completedJob = await this.waitForJob(jobInfo, signal);
1443
- if (completedJob.status !== JobStatus.FAILED) {
1605
+ if (completedJob.status !== "FAILED") {
1444
1606
  const upgraded = await this.reactor.getByIdOrSlug(documentId, { branch }, completedJob.consistencyToken, signal);
1445
1607
  return this.gateDocument(upgraded, { branch }, signal);
1446
1608
  }
@@ -1467,10 +1629,14 @@ var ReactorClient = class {
1467
1629
  */
1468
1630
  async execute(documentIdentifier, branch, actions, signal, subject) {
1469
1631
  this.logger.verbose("execute(@documentIdentifier, @branch, @count actions)", documentIdentifier, branch, actions.length);
1470
- const signedActions = await signActions(actions, this.signer, signal);
1471
- const jobInfo = await this.reactor.execute(documentIdentifier, branch, signedActions, signal);
1632
+ const documentId = await this.resolveWriteTarget(documentIdentifier, branch, actions, signal);
1633
+ const signedActions = await signActions(actions, this.signer, {
1634
+ documentId,
1635
+ branch
1636
+ }, signal);
1637
+ const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
1472
1638
  const completedJob = await this.waitForJob(jobInfo, signal);
1473
- if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1639
+ if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1474
1640
  const view = {
1475
1641
  branch,
1476
1642
  subject
@@ -1483,18 +1649,29 @@ var ReactorClient = class {
1483
1649
  */
1484
1650
  async executeAsync(documentIdentifier, branch, actions, signal) {
1485
1651
  this.logger.verbose("executeAsync(@documentIdentifier, @branch, @count actions)", documentIdentifier, branch, actions.length);
1486
- const signedActions = await signActions(actions, this.signer, signal);
1487
- return this.reactor.execute(documentIdentifier, branch, signedActions, signal);
1652
+ const documentId = await this.resolveWriteTarget(documentIdentifier, branch, actions, signal);
1653
+ const signedActions = await signActions(actions, this.signer, {
1654
+ documentId,
1655
+ branch
1656
+ }, signal);
1657
+ return this.reactor.execute(documentId, branch, signedActions, signal);
1488
1658
  }
1489
1659
  async executeBatch(request, signal) {
1490
1660
  this.logger.verbose("executeBatch(@count jobs)", request.jobs.length);
1491
- const signedJobs = await Promise.all(request.jobs.map(async (job) => ({
1492
- ...job,
1493
- actions: await signActions(job.actions, this.signer, signal)
1494
- })));
1661
+ const signedJobs = await Promise.all(request.jobs.map(async (job) => {
1662
+ const documentId = await this.resolveWriteTarget(job.documentId, job.branch, job.actions, signal);
1663
+ return {
1664
+ ...job,
1665
+ documentId,
1666
+ actions: await signActions(job.actions, this.signer, {
1667
+ documentId,
1668
+ branch: job.branch
1669
+ }, signal)
1670
+ };
1671
+ }));
1495
1672
  const batchResult = await this.reactor.executeBatch({ jobs: signedJobs }, signal);
1496
1673
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
1497
- for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
1674
+ for (const job of completedJobs) if (job.status === "FAILED") throw new Error(job.error?.message);
1498
1675
  return batchResult;
1499
1676
  }
1500
1677
  /**
@@ -1515,11 +1692,23 @@ var ReactorClient = class {
1515
1692
  /**
1516
1693
  * Adds multiple documents as children to another and waits for completion
1517
1694
  */
1518
- async addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch = "main", signal) {
1519
- this.logger.verbose("addRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @branch)", sourceIdentifier, targetIdentifier, relationshipType, branch);
1520
- const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1695
+ async addRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch = "main", signal) {
1696
+ this.logger.verbose("addRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @metadata, @branch)", sourceIdentifier, targetIdentifier, relationshipType, metadata, branch);
1697
+ const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1521
1698
  const completedJob = await this.waitForJob(jobInfo, signal);
1522
- if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1699
+ if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1700
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1701
+ return this.gateDocument(result, { branch }, signal);
1702
+ }
1703
+ /**
1704
+ * Replaces the metadata of an existing relationship and waits for completion.
1705
+ */
1706
+ async updateRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch = "main", signal) {
1707
+ this.logger.verbose("updateRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @metadata, @branch)", sourceIdentifier, targetIdentifier, relationshipType, metadata, branch);
1708
+ if (!await this.readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, { branch }, signal)) throw new RelationshipNotFoundError(sourceIdentifier, targetIdentifier, relationshipType);
1709
+ const jobInfo = await this.reactor.updateRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1710
+ const completedJob = await this.waitForJob(jobInfo, signal);
1711
+ if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1523
1712
  const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1524
1713
  return this.gateDocument(result, { branch }, signal);
1525
1714
  }
@@ -1530,7 +1719,7 @@ var ReactorClient = class {
1530
1719
  this.logger.verbose("removeRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @branch)", sourceIdentifier, targetIdentifier, relationshipType, branch);
1531
1720
  const jobInfo = await this.reactor.removeRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1532
1721
  const completedJob = await this.waitForJob(jobInfo, signal);
1533
- if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1722
+ if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1534
1723
  const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1535
1724
  return this.gateDocument(result, { branch }, signal);
1536
1725
  }
@@ -1539,12 +1728,13 @@ var ReactorClient = class {
1539
1728
  */
1540
1729
  async moveRelationship(sourceParentIdentifier, targetParentIdentifier, targetIdentifier, relationshipType, branch = "main", signal) {
1541
1730
  this.logger.verbose("moveRelationship(@sourceParentIdentifier, @targetParentIdentifier, @targetIdentifier, @relationshipType, @branch)", sourceParentIdentifier, targetParentIdentifier, targetIdentifier, relationshipType, branch);
1731
+ const metadata = (await this.readRelationshipEdge(sourceParentIdentifier, targetIdentifier, relationshipType, { branch }, signal))?.metadata;
1542
1732
  const removeJobInfo = await this.reactor.removeRelationship(sourceParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1543
1733
  const removeCompletedJob = await this.waitForJob(removeJobInfo, signal);
1544
- if (removeCompletedJob.status === JobStatus.FAILED) throw new Error(removeCompletedJob.error?.message);
1545
- const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1734
+ if (removeCompletedJob.status === "FAILED") throw new Error(removeCompletedJob.error?.message);
1735
+ const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1546
1736
  const addCompletedJob = await this.waitForJob(addJobInfo, signal);
1547
- if (addCompletedJob.status === JobStatus.FAILED) throw new Error(addCompletedJob.error?.message);
1737
+ if (addCompletedJob.status === "FAILED") throw new Error(addCompletedJob.error?.message);
1548
1738
  const sourceResult = await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal);
1549
1739
  const targetResult = await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal);
1550
1740
  return {
@@ -1558,7 +1748,7 @@ var ReactorClient = class {
1558
1748
  const completedJobs = await Promise.all(Object.entries(result.jobs).map(async ([key, jobInfo]) => {
1559
1749
  return [key, await this.waitForJob(jobInfo, signal)];
1560
1750
  }));
1561
- for (const [, completedJob] of completedJobs) if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1751
+ for (const [, completedJob] of completedJobs) if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1562
1752
  return { jobs: Object.fromEntries(completedJobs) };
1563
1753
  }
1564
1754
  /**
@@ -1567,8 +1757,8 @@ var ReactorClient = class {
1567
1757
  async deleteDocument(identifier, propagate, signal) {
1568
1758
  this.logger.verbose("deleteDocument(@identifier, @propagate)", identifier, propagate);
1569
1759
  const jobs = [];
1570
- if (propagate === PropagationMode.Cascade) {
1571
- const toDelete = new Set([identifier]);
1760
+ if (propagate === "cascade") {
1761
+ const toDelete = /* @__PURE__ */ new Set([identifier]);
1572
1762
  let changed = true;
1573
1763
  while (changed) {
1574
1764
  if (signal?.aborted) throw new Error("Operation aborted");
@@ -1592,7 +1782,7 @@ var ReactorClient = class {
1592
1782
  const jobInfo = await this.reactor.deleteDocument(identifier, this.signer, signal);
1593
1783
  jobs.push(jobInfo);
1594
1784
  const completedJobs = await Promise.all(jobs.map((job) => this.waitForJob(job, signal)));
1595
- for (const completedJob of completedJobs) if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1785
+ for (const completedJob of completedJobs) if (completedJob.status === "FAILED") throw new Error(completedJob.error?.message);
1596
1786
  }
1597
1787
  /**
1598
1788
  * Deletes documents and waits for completion
@@ -1622,7 +1812,13 @@ var ReactorClient = class {
1622
1812
  */
1623
1813
  subscribe(search, callback, view) {
1624
1814
  this.logger.verbose("subscribe(@search, @view)", search, view);
1625
- const readable = async (document) => filterReadableScopes(document, await this.readableScopes(document, view));
1815
+ const served = async (type, documents, reads) => {
1816
+ const documentsServed = await this.gateServedAll(documents, view, void 0, reads);
1817
+ return documentsServed.length > 0 ? {
1818
+ type,
1819
+ documents: documentsServed
1820
+ } : void 0;
1821
+ };
1626
1822
  let disposed = false;
1627
1823
  let delivering = Promise.resolve();
1628
1824
  /**
@@ -1636,49 +1832,46 @@ var ReactorClient = class {
1636
1832
  * gated is withheld, because serving it unfiltered would leak the scopes
1637
1833
  * the gate did not clear -- but it is logged rather than swallowed, since
1638
1834
  * the gate rethrows a transient failure precisely so it is not read as a
1639
- * denial.
1835
+ * denial. An event that resolves to undefined was withheld.
1640
1836
  */
1641
1837
  const deliver = (event) => {
1642
1838
  Promise.resolve(event).catch(() => void 0);
1643
1839
  delivering = delivering.then(async () => {
1644
1840
  const built = await event;
1645
- if (disposed) return;
1841
+ if (disposed || !built) return;
1646
1842
  callback(built);
1647
1843
  }).catch((error) => {
1648
1844
  this.logger.error("Subscription delivery failed for @search: @Error", { search }, error);
1649
1845
  });
1650
1846
  };
1651
1847
  const unsubscribeCreated = this.subscriptionManager.onDocumentCreated((result) => {
1848
+ const reads = this.eventReads.forEvent();
1652
1849
  deliver((async () => {
1653
- const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
1654
- return {
1655
- type: DocumentChangeType.Created,
1656
- documents: await Promise.all(documents.map(readable))
1657
- };
1850
+ const documents = await Promise.all(result.results.map((id) => reads.get(id, withAllScopes(view))));
1851
+ return served("created", documents, reads);
1658
1852
  })());
1659
1853
  }, search);
1660
1854
  const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
1661
- deliver({
1662
- type: DocumentChangeType.Deleted,
1855
+ const reads = this.eventReads.forEvent();
1856
+ for (const childId of documentIds) deliver((async () => await this.servesEvery([childId], view, reads) ? {
1857
+ type: "deleted",
1663
1858
  documents: [],
1664
- context: { childId: documentIds[0] }
1665
- });
1859
+ context: { childId }
1860
+ } : void 0)());
1666
1861
  }, search);
1667
1862
  const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
1668
- deliver((async () => ({
1669
- type: DocumentChangeType.Updated,
1670
- documents: await Promise.all(result.results.map(readable))
1671
- }))());
1863
+ deliver(served("updated", result.results, this.eventReads.forEvent()));
1672
1864
  }, search, view);
1673
1865
  const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
1674
- deliver({
1675
- type: changeType === RelationshipChangeType.Added ? DocumentChangeType.ChildAdded : DocumentChangeType.ChildRemoved,
1866
+ const reads = this.eventReads.forEvent();
1867
+ deliver((async () => await this.servesEvery([parentId, childId], view, reads) ? {
1868
+ type: changeType === "added" ? "child_added" : "child_removed",
1676
1869
  documents: [],
1677
1870
  context: {
1678
1871
  parentId,
1679
1872
  childId
1680
1873
  }
1681
- });
1874
+ } : void 0)());
1682
1875
  }, search);
1683
1876
  return () => {
1684
1877
  disposed = true;
@@ -1688,6 +1881,225 @@ var ReactorClient = class {
1688
1881
  unsubscribeRelationship();
1689
1882
  };
1690
1883
  }
1884
+ readSubject(subject) {
1885
+ return subject ?? authSubjectFromSigner(this.signer);
1886
+ }
1887
+ /**
1888
+ * Which scopes of one document the subject may read. Resolved once per
1889
+ * document, so the gate builds its model once however many scopes are then
1890
+ * tested, and the filtering itself stays synchronous.
1891
+ */
1892
+ readableScopes(document, view, signal, reads) {
1893
+ const subject = this.readSubject(view?.subject);
1894
+ const branch = view?.branch ?? "main";
1895
+ return reads ? reads.scopePredicate(document, subject, branch) : this.readGate.scopePredicate(document, subject, branch, signal);
1896
+ }
1897
+ /**
1898
+ * One document, filtered to the scopes the subject may read. Every method
1899
+ * that hands a document back goes through here, including the ones that
1900
+ * follow a write: a document returned from a mutation is a read like any
1901
+ * other, and returning it whole served scopes the same subject would be
1902
+ * refused by `get`. Its author still sees what it wrote, because an allow on
1903
+ * execute confers read of that scope.
1904
+ */
1905
+ async gateDocument(document, view, signal) {
1906
+ return filterReadableScopes(document, await this.readableScopes(document, view, signal));
1907
+ }
1908
+ /**
1909
+ * One document as a listing or a feed serves it, or undefined when withheld.
1910
+ * The document must hold every scope, or withholding is decided on a subset;
1911
+ * a narrowing view is applied here instead.
1912
+ */
1913
+ async gateServed(document, view, signal, reads) {
1914
+ const readable = await this.readableScopes(document, view, signal, reads);
1915
+ if (refusesEveryDomainScope(document, readable)) return;
1916
+ const narrowed = narrowedScopes(view);
1917
+ return filterReadableScopes(document, narrowed ? (scope) => readable(scope) && narrowed.has(scope) : readable);
1918
+ }
1919
+ async gateServedAll(documents, view, signal, reads) {
1920
+ return (await Promise.all(documents.map((document) => this.gateServed(document, view, signal, reads)))).filter((document) => document !== void 0);
1921
+ }
1922
+ /**
1923
+ * Gates a page and every page after it. `nextCursor` is left as the stream
1924
+ * reported it.
1925
+ */
1926
+ async gateListing(page, view, signal) {
1927
+ const results = (await Promise.all(page.results.map((document) => this.gateListed(document, view, signal)))).filter((document) => document !== void 0);
1928
+ const next = page.next;
1929
+ return {
1930
+ ...page,
1931
+ results,
1932
+ next: next ? async () => this.gateListing(await next(), view, signal) : void 0
1933
+ };
1934
+ }
1935
+ /**
1936
+ * One document as a listing serves it, or undefined when withheld. It holds
1937
+ * only the view's scopes; a domain scope it lacks is decided on its name, or
1938
+ * on its state read now when a condition needs that.
1939
+ */
1940
+ async gateListed(document, view, signal) {
1941
+ const readable = await this.readableScopes(document, view, signal);
1942
+ const unheld = unheldScopesReadOnState(document);
1943
+ if (refusesEveryDomainScope(document, unheld.length === 0 ? readable : (scope) => !unheld.includes(scope) && readable(scope)) && !await this.servesUnheld(document, unheld, view, signal)) return;
1944
+ return filterReadableScopes(document, readable);
1945
+ }
1946
+ async servesUnheld(document, scopes, view, signal) {
1947
+ if (scopes.length === 0) return false;
1948
+ const id = document.header.id;
1949
+ let held;
1950
+ try {
1951
+ held = await this.reactor.get(id, withAuthScope({
1952
+ ...view,
1953
+ scopes
1954
+ }), void 0, signal);
1955
+ } catch (error) {
1956
+ await assertAbsent(this.documentView, id, error, signal);
1957
+ return false;
1958
+ }
1959
+ const readable = await this.readableScopes(held, view, signal);
1960
+ return scopes.some(readable);
1961
+ }
1962
+ /**
1963
+ * Whether an event naming only these ids may reach the subject. An id with no
1964
+ * live document behind it has no content left to protect, so it does not
1965
+ * withhold; a deleted document is read at its deletion boundary when
1966
+ * deletion is positional, and gated like any other.
1967
+ */
1968
+ async servesEvery(ids, view, reads) {
1969
+ return (await Promise.all(ids.map((id) => this.servesId(id, view, reads)))).every(Boolean);
1970
+ }
1971
+ async servesId(id, view, reads) {
1972
+ let document;
1973
+ try {
1974
+ document = await reads.get(id, withAllScopes(view));
1975
+ } catch (error) {
1976
+ await assertAbsent(reads, id, error);
1977
+ return true;
1978
+ }
1979
+ return !refusesEveryDomainScope(document, await this.readableScopes(document, view, void 0, reads));
1980
+ }
1981
+ async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
1982
+ const scopeCursors = decodeCompositeCursor(paging.cursor);
1983
+ const allOperations = [];
1984
+ const activeCursors = {};
1985
+ for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1986
+ if (!canRead(scopeName)) continue;
1987
+ const scopeView = {
1988
+ ...view,
1989
+ scopes: [scopeName]
1990
+ };
1991
+ const scopePaging = {
1992
+ cursor,
1993
+ limit: paging.limit
1994
+ };
1995
+ const scopeResult = (await this.reactor.getOperations(documentId, scopeView, filter, scopePaging, void 0, signal))[scopeName];
1996
+ allOperations.push(...scopeResult.results);
1997
+ if (scopeResult.nextCursor) activeCursors[scopeName] = scopeResult.nextCursor;
1998
+ }
1999
+ allOperations.sort((a, b) => a.index - b.index);
2000
+ return {
2001
+ results: allOperations,
2002
+ options: paging,
2003
+ nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
2004
+ };
2005
+ }
2006
+ /**
2007
+ * Drops the edges whose far-end document the subject may read no domain scope
2008
+ * of. An edge is withheld whole rather than stripped of its metadata: the
2009
+ * document-shaped relationship reads already answer with the far end stripped
2010
+ * to the scopes the gate allows, so the far end's existence is disclosed
2011
+ * either way, but an edge's metadata is content about the pair that the far
2012
+ * end's own reads would refuse. An edge to a far end stripped to nothing
2013
+ * therefore carries content past a refusal, and there is no useful shell to
2014
+ * hand back in its place.
2015
+ *
2016
+ * `nextCursor` and `options` are left as the underlying stream reported them,
2017
+ * because a caller must feed them back to resume from the right position. A
2018
+ * gated page can therefore be shorter than the limit it asked for.
2019
+ */
2020
+ async gateEdges(page, farEnd, view, signal) {
2021
+ const ids = [...new Set(page.results.map((edge) => edge[farEnd]))];
2022
+ if (ids.length === 0) return page;
2023
+ const farEndView = {
2024
+ subject: view?.subject,
2025
+ branch: view?.branch
2026
+ };
2027
+ const documents = await this.reactor.find({ ids }, farEndView, {
2028
+ cursor: "0",
2029
+ limit: ids.length
2030
+ }, void 0, signal);
2031
+ const readable = new Map(await Promise.all(documents.results.map(async (doc) => {
2032
+ const allows = await this.readableScopes(doc, view, signal);
2033
+ return [doc.header.id, !refusesEveryDomainScope(doc, allows)];
2034
+ })));
2035
+ const results = page.results.filter((edge) => readable.get(edge[farEnd]) !== false);
2036
+ if (results.length === page.results.length) return page;
2037
+ const nextPage = page.next;
2038
+ return {
2039
+ ...page,
2040
+ results,
2041
+ next: nextPage ? async () => this.gateEdges(await nextPage(), farEnd, view, signal) : void 0
2042
+ };
2043
+ }
2044
+ /**
2045
+ * One relationship edge, or undefined when it does not exist. A point lookup:
2046
+ * the pair is filtered in SQL rather than scanned out of the source's edge
2047
+ * list, which on a drive with thousands of children is the difference between
2048
+ * one query and dozens.
2049
+ */
2050
+ async readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, view, signal) {
2051
+ const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
2052
+ const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
2053
+ return (await this.documentIndexer.getDirectedRelationships(sourceId, targetId, [relationshipType], {
2054
+ cursor: "0",
2055
+ limit: 1
2056
+ }, void 0, signal)).results[0];
2057
+ }
2058
+ /**
2059
+ * The id a write on `identifier` is stored under, which its signatures bind.
2060
+ * A create names its own id, and an id no slug maps to is taken as given, so
2061
+ * a document still in flight resolves to itself.
2062
+ */
2063
+ async resolveWriteTarget(identifier, branch, actions, signal) {
2064
+ if (actions.some((action) => action.type === "CREATE_DOCUMENT")) return identifier;
2065
+ const view = { branch };
2066
+ const bySlug = await this.documentView.resolveSlug(identifier, view, void 0, signal);
2067
+ if (bySlug === void 0 || bySlug === identifier) return identifier;
2068
+ return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
2069
+ }
2070
+ /**
2071
+ * The decision model for one target document, built at its stream heads.
2072
+ *
2073
+ * The document is fetched unfiltered, because the policy is what decides:
2074
+ * reading it through the read gate would withhold the very scopes the
2075
+ * decision is about. A deleted document is served at its deletion boundary,
2076
+ * which is what lets the model refuse an execute against it -- authEnforcement
2077
+ * requires documentDecisions, so that read is available whenever this runs.
2078
+ *
2079
+ * Reading past the gate discloses nothing a submit does not. The `auth` and
2080
+ * `document` scopes are readable by every holder, so a verdict resting on the
2081
+ * policy alone is one the caller could compute unaided; and a verdict resting
2082
+ * on a conditional grant reads the executing scope's state exactly as
2083
+ * admission reads it, so the answer here is what submitting and being refused
2084
+ * would have revealed anyway.
2085
+ *
2086
+ * The append condition the build records is dropped. It guards a write, and
2087
+ * this makes none; reproducing it is also what the preflight cannot do, which
2088
+ * is why the answer is a prediction.
2089
+ */
2090
+ async buildEvaluationTarget(config, documentId, branch, signal) {
2091
+ const document = await this.documentView.get(documentId, { branch }, void 0, signal);
2092
+ const target = {
2093
+ documentId,
2094
+ branch
2095
+ };
2096
+ const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), config.model, target, signal);
2097
+ return {
2098
+ definition: config.model(target),
2099
+ model: built.model,
2100
+ scopeStates: document.state ?? {}
2101
+ };
2102
+ }
1691
2103
  async removeAllIncomingRelationships(documentId, signal) {
1692
2104
  const incoming = await this.documentIndexer.getIncoming(documentId, void 0, void 0, void 0, signal);
1693
2105
  const jobs = [];
@@ -1701,18 +2113,6 @@ var ReactorClient = class {
1701
2113
  //#endregion
1702
2114
  //#region src/queue/types.ts
1703
2115
  /**
1704
- * State of a job in the queue
1705
- */
1706
- let JobQueueState = /* @__PURE__ */ function(JobQueueState) {
1707
- JobQueueState[JobQueueState["UNKNOWN"] = -1] = "UNKNOWN";
1708
- JobQueueState[JobQueueState["PREPROCESSING"] = 0] = "PREPROCESSING";
1709
- JobQueueState[JobQueueState["PENDING"] = 1] = "PENDING";
1710
- JobQueueState[JobQueueState["READY"] = 2] = "READY";
1711
- JobQueueState[JobQueueState["RUNNING"] = 3] = "RUNNING";
1712
- JobQueueState[JobQueueState["RESOLVED"] = 4] = "RESOLVED";
1713
- return JobQueueState;
1714
- }({});
1715
- /**
1716
2116
  * How a retry is accounted against the job's retry limit.
1717
2117
  * - `CountAgainstLimit` (default): a fault; the job eventually exhausts its
1718
2118
  * retries and fails terminally.
@@ -1745,6 +2145,11 @@ function toErrorInfo(error) {
1745
2145
  };
1746
2146
  }
1747
2147
  var JobResultHandler = class {
2148
+ queue;
2149
+ jobTracker;
2150
+ eventBus;
2151
+ resolver;
2152
+ logger;
1748
2153
  constructor(queue, jobTracker, eventBus, resolver, logger) {
1749
2154
  this.queue = queue;
1750
2155
  this.jobTracker = jobTracker;
@@ -1775,7 +2180,7 @@ var JobResultHandler = class {
1775
2180
  if (result.error && AppendConditionFailedError.isError(result.error) && this.countConflicts(handle.job) < MAX_EXEMPT_CONFLICT_RETRIES) {
1776
2181
  const errorInfo = toErrorInfo(result.error);
1777
2182
  try {
1778
- await this.queue.retryJob(handle.job.id, errorInfo, RetryAccounting.ExemptFromLimit);
2183
+ await this.queue.retryJob(handle.job.id, errorInfo, "exempt-from-limit");
1779
2184
  return;
1780
2185
  } catch {}
1781
2186
  }
@@ -1784,7 +2189,7 @@ var JobResultHandler = class {
1784
2189
  callbacks.deferJob(result.error.documentId, handle.job);
1785
2190
  return;
1786
2191
  }
1787
- if (result.error && (DocumentDeletedError.isError(result.error) || DocumentAlreadyExistsError.isError(result.error) || DocumentNotFoundError.isError(result.error) || AuthorizationDeniedError.isError(result.error) || AuthTimestampNotMonotonicError.isError(result.error) || InvalidOperationTimestampError.isError(result.error) || ExcessiveReshuffleError.isError(result.error) || UpgradePreconditionFailedError.isError(result.error))) {
2192
+ if (result.error && (DocumentDeletedError.isError(result.error) || DocumentAlreadyExistsError.isError(result.error) || DocumentNotFoundError.isError(result.error) || AuthorizationDeniedError.isError(result.error) || AuthTimestampNotMonotonicError.isError(result.error) || InvalidOperationTimestampError.isError(result.error) || ExcessiveReshuffleError.isError(result.error) || InvalidSignatureError.isError(result.error) || UpgradePreconditionFailedError.isError(result.error))) {
1788
2193
  const errorInfo = toErrorInfo(result.error);
1789
2194
  this.jobTracker.markFailed(handle.job.id, errorInfo, handle.job);
1790
2195
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
@@ -1873,6 +2278,12 @@ var JobResultHandler = class {
1873
2278
  * is stranded at PENDING for as long as the deferral lasts.
1874
2279
  */
1875
2280
  var DeferredJobs = class {
2281
+ queue;
2282
+ jobTracker;
2283
+ eventBus;
2284
+ logger;
2285
+ ttlMs;
2286
+ onReleased;
1876
2287
  #byDocumentId = /* @__PURE__ */ new Map();
1877
2288
  #timers = /* @__PURE__ */ new Map();
1878
2289
  constructor(queue, jobTracker, eventBus, logger, ttlMs = DEFAULT_DEFERRED_JOB_TTL_MS, onReleased = () => Promise.resolve()) {
@@ -2034,6 +2445,13 @@ function bucketFor(documentId, numWorkers) {
2034
2445
  * (Powerhouse board wiki id: d400d711-f07e-4389-a226-4e9fdd4fa8ba)
2035
2446
  */
2036
2447
  var WorkerPoolJobExecutorManager = class {
2448
+ workerFactory;
2449
+ eventBus;
2450
+ queue;
2451
+ jobTracker;
2452
+ logger;
2453
+ resolver;
2454
+ operationIndex;
2037
2455
  workers = [];
2038
2456
  isRunning = false;
2039
2457
  activeJobs = 0;
@@ -2172,6 +2590,7 @@ var WorkerPoolJobExecutorManager = class {
2172
2590
  await this.tryDispatchFor(worker);
2173
2591
  return;
2174
2592
  }
2593
+ for (const refusal of outcome.signatureRefusals ?? []) this.eventBus.emit(ReactorEventTypes.SIGNATURE_REFUSED, refusal).catch(() => {});
2175
2594
  if (outcome.result.success) {
2176
2595
  this.totalJobsProcessed++;
2177
2596
  const completedEvent = {
@@ -2212,6 +2631,7 @@ var WorkerPoolJobExecutorManager = class {
2212
2631
  jobId: job.id,
2213
2632
  operations: payload.operations,
2214
2633
  jobMeta: payload.jobMeta,
2634
+ submittedActionIds: submittedActionIds(job),
2215
2635
  collectionMemberships
2216
2636
  };
2217
2637
  try {
@@ -2296,6 +2716,12 @@ function fillMissingMemberships(documentIds, found) {
2296
2716
  * Listens for job available events and dispatches jobs to executors.
2297
2717
  */
2298
2718
  var SimpleJobExecutorManager = class {
2719
+ executorFactory;
2720
+ eventBus;
2721
+ queue;
2722
+ jobTracker;
2723
+ logger;
2724
+ resolver;
2299
2725
  executors = [];
2300
2726
  isRunning = false;
2301
2727
  activeJobs = 0;
@@ -2470,6 +2896,7 @@ var SimpleJobExecutorManager = class {
2470
2896
  * Subscribes to operation events to update job states.
2471
2897
  */
2472
2898
  var InMemoryJobTracker = class {
2899
+ eventBus;
2473
2900
  jobs = /* @__PURE__ */ new Map();
2474
2901
  unsubscribers = [];
2475
2902
  constructor(eventBus) {
@@ -2490,21 +2917,22 @@ var InMemoryJobTracker = class {
2490
2917
  handleWriteReady(event) {
2491
2918
  const jobId = event.jobId;
2492
2919
  const job = this.jobs.get(jobId);
2493
- if (job && job.status === JobStatus.RUNNING) {
2920
+ if (job && job.status === "RUNNING") {
2494
2921
  const consistencyToken = createConsistencyToken(event.operations);
2495
2922
  this.jobs.set(jobId, {
2496
2923
  ...job,
2497
- status: JobStatus.WRITE_READY,
2498
- consistencyToken
2924
+ status: "WRITE_READY",
2925
+ consistencyToken,
2926
+ result: summarizeSubmittedActions(event.operations, event.submittedActionIds)
2499
2927
  });
2500
2928
  }
2501
2929
  }
2502
2930
  handleReadReady(event) {
2503
2931
  const jobId = event.jobId;
2504
2932
  const job = this.jobs.get(jobId);
2505
- if (job && job.status === JobStatus.WRITE_READY) this.jobs.set(jobId, {
2933
+ if (job && job.status === "WRITE_READY") this.jobs.set(jobId, {
2506
2934
  ...job,
2507
- status: JobStatus.READ_READY
2935
+ status: "READ_READY"
2508
2936
  });
2509
2937
  }
2510
2938
  handleJobFailed(event) {
@@ -2527,7 +2955,7 @@ var InMemoryJobTracker = class {
2527
2955
  this.jobs.set(jobId, {
2528
2956
  id: jobId,
2529
2957
  documentId: "",
2530
- status: JobStatus.RUNNING,
2958
+ status: "RUNNING",
2531
2959
  createdAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
2532
2960
  consistencyToken: createEmptyConsistencyToken(),
2533
2961
  meta: {
@@ -2539,7 +2967,7 @@ var InMemoryJobTracker = class {
2539
2967
  }
2540
2968
  this.jobs.set(jobId, {
2541
2969
  ...job,
2542
- status: JobStatus.RUNNING
2970
+ status: "RUNNING"
2543
2971
  });
2544
2972
  }
2545
2973
  markFailed(jobId, error, job) {
@@ -2548,7 +2976,7 @@ var InMemoryJobTracker = class {
2548
2976
  this.jobs.set(jobId, {
2549
2977
  id: jobId,
2550
2978
  documentId: job?.documentId ?? "",
2551
- status: JobStatus.FAILED,
2979
+ status: "FAILED",
2552
2980
  createdAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
2553
2981
  completedAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
2554
2982
  error,
@@ -2563,7 +2991,7 @@ var InMemoryJobTracker = class {
2563
2991
  }
2564
2992
  this.jobs.set(jobId, {
2565
2993
  ...existing,
2566
- status: JobStatus.FAILED,
2994
+ status: "FAILED",
2567
2995
  completedAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
2568
2996
  error,
2569
2997
  job,
@@ -2610,99 +3038,336 @@ function matchesFilter(op, filter) {
2610
3038
  if (filter.scope && filter.scope.length > 0) {
2611
3039
  if (!filter.scope.includes(op.context.scope)) return false;
2612
3040
  }
2613
- if (filter.branch && filter.branch.length > 0) {
2614
- if (!filter.branch.includes(op.context.branch)) return false;
3041
+ if (filter.branch && filter.branch.length > 0) {
3042
+ if (!filter.branch.includes(op.context.branch)) return false;
3043
+ }
3044
+ if (filter.documentId && filter.documentId.length > 0) {
3045
+ if (!filter.documentId.includes("*") && !filter.documentId.includes(op.context.documentId)) return false;
3046
+ }
3047
+ return true;
3048
+ }
3049
+ function nonEmpty(value) {
3050
+ return typeof value === "string" && value.length > 0 ? value : void 0;
3051
+ }
3052
+ function namespaceOf(processor) {
3053
+ return nonEmpty(processor.namespace);
3054
+ }
3055
+ function classNameOf(processor) {
3056
+ const name = processor.constructor.name;
3057
+ return name && name !== "Object" ? name : void 0;
3058
+ }
3059
+ function resolveProcessorSlots(records, legacy) {
3060
+ if (legacy) return records.map((_, i) => String(i));
3061
+ const seen = /* @__PURE__ */ new Map();
3062
+ return records.map((record, i) => {
3063
+ const base = nonEmpty(record.id) ?? namespaceOf(record.processor) ?? classNameOf(record.processor) ?? String(i);
3064
+ const count = seen.get(base) ?? 0;
3065
+ seen.set(base, count + 1);
3066
+ return count === 0 ? base : `${base}#${count}`;
3067
+ });
3068
+ }
3069
+ //#endregion
3070
+ //#region src/processors/processor-queue.ts
3071
+ function isDelivery(task) {
3072
+ return task?.kind === "live" || task?.kind === "advance";
3073
+ }
3074
+ function errorMessage(error) {
3075
+ return error instanceof Error ? error.message : String(error);
3076
+ }
3077
+ function lowestOf(ops) {
3078
+ let lowest = ops[0].context.ordinal;
3079
+ for (const op of ops) lowest = Math.min(lowest, op.context.ordinal);
3080
+ return lowest;
3081
+ }
3082
+ function highestOf(ops) {
3083
+ let highest = 0;
3084
+ for (const op of ops) highest = Math.max(highest, op.context.ordinal);
3085
+ return highest;
3086
+ }
3087
+ var ProcessorQueue = class {
3088
+ options;
3089
+ tasks = [];
3090
+ running = false;
3091
+ closed = false;
3092
+ replaysAhead = 0;
3093
+ unrouted = /* @__PURE__ */ new Set();
3094
+ constructor(options) {
3095
+ this.options = options;
3096
+ }
3097
+ get isClosed() {
3098
+ return this.closed;
3099
+ }
3100
+ /** Resolves at once behind a replay, so a pass never waits out a backfill. */
3101
+ live(ops) {
3102
+ const delivered = this.push((done) => ({
3103
+ kind: "live",
3104
+ ops,
3105
+ done
3106
+ }));
3107
+ return this.replaysAhead > 0 ? Promise.resolve() : delivered;
3108
+ }
3109
+ /** Raises the cursor past a batch with nothing for this processor. */
3110
+ advance(through) {
3111
+ const last = this.tasks.at(-1);
3112
+ if (last?.kind === "advance") {
3113
+ last.through = Math.max(last.through, through);
3114
+ return Promise.resolve();
3115
+ }
3116
+ return this.push((done) => ({
3117
+ kind: "advance",
3118
+ through,
3119
+ done
3120
+ }));
3121
+ }
3122
+ /** Replays from the cursor as it stands when the task runs. */
3123
+ backfill() {
3124
+ return this.replay("backfill", () => this.runBackfill());
3125
+ }
3126
+ /** Clears an error and replays from the cursor. No-op when active. */
3127
+ retry() {
3128
+ return this.replay("retry", async () => {
3129
+ const { cursor } = this.options;
3130
+ if (cursor.status !== "errored") return;
3131
+ cursor.status = "active";
3132
+ cursor.lastError = void 0;
3133
+ cursor.lastErrorTimestamp = void 0;
3134
+ await this.persist();
3135
+ await this.runBackfill();
3136
+ });
3137
+ }
3138
+ /** Stops cursor writes now; queued tasks still run before the disconnect. */
3139
+ close() {
3140
+ if (this.closed) return Promise.resolve();
3141
+ const disconnect = this.enqueue("disconnect", async () => {
3142
+ try {
3143
+ await this.options.processor.onDisconnect();
3144
+ } catch (error) {
3145
+ this.options.logger.error("Error disconnecting processor '@ProcessorId': @Error", this.options.processorId, error);
3146
+ }
3147
+ });
3148
+ this.closed = true;
3149
+ return disconnect;
3150
+ }
3151
+ replay(kind, run) {
3152
+ if (this.closed) return Promise.resolve();
3153
+ this.replaysAhead++;
3154
+ return this.enqueue(kind, async () => {
3155
+ try {
3156
+ await run();
3157
+ } finally {
3158
+ this.replaysAhead--;
3159
+ }
3160
+ });
3161
+ }
3162
+ enqueue(kind, run) {
3163
+ return this.push((done) => ({
3164
+ kind,
3165
+ run,
3166
+ done
3167
+ }));
3168
+ }
3169
+ push(task) {
3170
+ if (this.closed) return Promise.resolve();
3171
+ return new Promise((resolve) => {
3172
+ this.tasks.push(task(resolve));
3173
+ if (this.running) return;
3174
+ this.running = true;
3175
+ queueMicrotask(() => void this.drain());
3176
+ });
3177
+ }
3178
+ async drain() {
3179
+ while (this.tasks.length > 0) {
3180
+ const task = this.tasks.shift();
3181
+ const batch = [];
3182
+ if (isDelivery(task)) {
3183
+ batch.push(task);
3184
+ let size = task.kind === "live" ? task.ops.length : 0;
3185
+ let next = this.tasks[0];
3186
+ while (isDelivery(next) && size < 500) {
3187
+ if (next.kind === "live") size += next.ops.length;
3188
+ batch.push(next);
3189
+ this.tasks.shift();
3190
+ next = this.tasks[0];
3191
+ }
3192
+ }
3193
+ try {
3194
+ await (isDelivery(task) ? this.deliverLive(batch) : task.run());
3195
+ } catch (error) {
3196
+ this.options.logger.error("Processor '@ProcessorId' task '@Kind' failed: @Error", this.options.processorId, task.kind, error);
3197
+ } finally {
3198
+ task.done();
3199
+ for (const merged of batch.slice(1)) merged.done();
3200
+ }
3201
+ }
3202
+ this.running = false;
3203
+ }
3204
+ async deliverLive(batch) {
3205
+ const { cursor, floor } = this.options;
3206
+ const ops = [];
3207
+ let through = 0;
3208
+ for (const task of batch) if (task.kind === "live") ops.push(...task.ops);
3209
+ else through = Math.max(through, task.through);
3210
+ const fresh = ops.filter((op) => {
3211
+ const ordinal = op.context.ordinal;
3212
+ if (ordinal <= floor) return false;
3213
+ return !this.unrouted.delete(ordinal);
3214
+ });
3215
+ if (fresh.length === 0) {
3216
+ await this.raiseCursor(through);
3217
+ return;
3218
+ }
3219
+ if (cursor.status !== "active") {
3220
+ await this.parkBelow(fresh);
3221
+ return;
3222
+ }
3223
+ if (!await this.deliver(fresh)) {
3224
+ await this.parkBelow(fresh);
3225
+ return;
3226
+ }
3227
+ await this.raiseCursor(Math.max(highestOf(fresh), through));
3228
+ }
3229
+ async runBackfill() {
3230
+ const { cursor, filter, floor } = this.options;
3231
+ if (cursor.status !== "active") return;
3232
+ let page;
3233
+ try {
3234
+ page = await this.options.readSince(cursor.lastOrdinal);
3235
+ } catch (error) {
3236
+ await this.fail(error, "reading backfill");
3237
+ return;
3238
+ }
3239
+ while (page.results.length > 0) {
3240
+ if (this.closed) return;
3241
+ const matching = page.results.filter((op) => op.context.ordinal > floor && matchesFilter(op, filter));
3242
+ if (matching.length > 0) {
3243
+ if (!await this.deliver(matching)) {
3244
+ await this.persist();
3245
+ return;
3246
+ }
3247
+ this.dedupeQueued(matching);
3248
+ }
3249
+ await this.raiseCursor(highestOf(page.results));
3250
+ if (!page.next) break;
3251
+ try {
3252
+ page = await page.next();
3253
+ } catch (error) {
3254
+ await this.fail(error, "reading backfill");
3255
+ return;
3256
+ }
3257
+ }
3258
+ }
3259
+ dedupeQueued(delivered) {
3260
+ const routed = this.options.routedThrough();
3261
+ const queued = /* @__PURE__ */ new Set();
3262
+ for (const op of delivered) {
3263
+ const ordinal = op.context.ordinal;
3264
+ if (ordinal > routed) this.unrouted.add(ordinal);
3265
+ else queued.add(ordinal);
3266
+ }
3267
+ if (queued.size === 0) return;
3268
+ for (const task of this.tasks) {
3269
+ if (task.kind !== "live") continue;
3270
+ task.ops = task.ops.filter((op) => !queued.has(op.context.ordinal));
3271
+ }
3272
+ }
3273
+ async deliver(ops) {
3274
+ try {
3275
+ await this.options.processor.onOperations(ops);
3276
+ return true;
3277
+ } catch (error) {
3278
+ this.markErrored(error);
3279
+ this.options.logger.error("Processor '@ProcessorId' failed at ordinal @Ordinal: @Error", this.options.processorId, lowestOf(ops), error);
3280
+ return false;
3281
+ }
2615
3282
  }
2616
- if (filter.documentId && filter.documentId.length > 0) {
2617
- if (!filter.documentId.includes("*") && !filter.documentId.includes(op.context.documentId)) return false;
3283
+ async fail(error, during) {
3284
+ this.markErrored(error);
3285
+ await this.persist();
3286
+ this.options.logger.error("Processor '@ProcessorId' failed @During: @Error", this.options.processorId, during, error);
3287
+ }
3288
+ markErrored(error) {
3289
+ const { cursor } = this.options;
3290
+ cursor.status = "errored";
3291
+ cursor.lastError = errorMessage(error);
3292
+ cursor.lastErrorTimestamp = /* @__PURE__ */ new Date();
3293
+ }
3294
+ async raiseCursor(through) {
3295
+ const { cursor } = this.options;
3296
+ if (cursor.status !== "active") return;
3297
+ if (through <= cursor.lastOrdinal) return;
3298
+ cursor.lastOrdinal = through;
3299
+ await this.persist();
3300
+ }
3301
+ async parkBelow(missed) {
3302
+ const { cursor } = this.options;
3303
+ cursor.lastOrdinal = Math.min(cursor.lastOrdinal, lowestOf(missed) - 1);
3304
+ await this.persist();
3305
+ }
3306
+ async persist() {
3307
+ if (this.closed) return;
3308
+ const { cursor } = this.options;
3309
+ try {
3310
+ await this.options.persist({
3311
+ lastOrdinal: cursor.lastOrdinal,
3312
+ status: cursor.status,
3313
+ lastError: cursor.lastError,
3314
+ lastErrorTimestamp: cursor.lastErrorTimestamp
3315
+ });
3316
+ } catch (error) {
3317
+ this.options.logger.error("Failed to persist cursor for '@ProcessorId': @Error", this.options.processorId, error);
3318
+ }
2618
3319
  }
2619
- return true;
2620
- }
2621
- function nonEmpty(value) {
2622
- return typeof value === "string" && value.length > 0 ? value : void 0;
2623
- }
2624
- function namespaceOf(processor) {
2625
- return nonEmpty(processor.namespace);
2626
- }
2627
- function classNameOf(processor) {
2628
- const name = processor.constructor.name;
2629
- return name && name !== "Object" ? name : void 0;
2630
- }
2631
- function resolveProcessorSlots(records, legacy) {
2632
- if (legacy) return records.map((_, i) => String(i));
2633
- const seen = /* @__PURE__ */ new Map();
2634
- return records.map((record, i) => {
2635
- const base = nonEmpty(record.id) ?? namespaceOf(record.processor) ?? classNameOf(record.processor) ?? String(i);
2636
- const count = seen.get(base) ?? 0;
2637
- seen.set(base, count + 1);
2638
- return count === 0 ? base : `${base}#${count}`;
2639
- });
2640
- }
3320
+ };
2641
3321
  //#endregion
2642
3322
  //#region src/processors/processor-manager.ts
2643
- /**
2644
- * Manages processor lifecycle based on operations.
2645
- * Extends BaseReadModel to receive operations from ReadModelCoordinator.
2646
- *
2647
- * Responsibilities:
2648
- * 1. Detect drive creation from CREATE_DOCUMENT operations
2649
- * 2. Create processors for each drive using registered factories
2650
- * 3. Route operations to matching processors based on filters
2651
- * 4. Clean up processors when drives are deleted or factories are unregistered
2652
- * 5. Track per-processor cursors for failure recovery and backfill
2653
- */
2654
3323
  var ProcessorManager = class extends BaseReadModel {
2655
3324
  factoryRegistry = /* @__PURE__ */ new Map();
2656
3325
  processorsByDrive = /* @__PURE__ */ new Map();
2657
- factoryToProcessors = /* @__PURE__ */ new Map();
3326
+ pendingSlots = /* @__PURE__ */ new Set();
2658
3327
  knownDrives = /* @__PURE__ */ new Map();
3328
+ highestRoutedOrdinal = 0;
2659
3329
  cursorCache = /* @__PURE__ */ new Map();
3330
+ cursorWrites = /* @__PURE__ */ new Map();
3331
+ draining = /* @__PURE__ */ new Map();
2660
3332
  logger;
2661
3333
  driveContainerTypes;
2662
3334
  legacyProcessorIds;
2663
3335
  constructor(db, operationIndex, writeCache, consistencyTracker, logger, driveContainerTypes, options = {}) {
2664
3336
  super(db, operationIndex, writeCache, consistencyTracker, {
2665
3337
  readModelId: "processor-manager",
2666
- rebuildStateOnInit: true
3338
+ rebuildStateOnInit: true,
3339
+ indexing: unchunkedReadModelIndexingConfig
2667
3340
  });
2668
3341
  this.logger = logger;
2669
3342
  this.driveContainerTypes = driveContainerTypes;
2670
3343
  this.legacyProcessorIds = options.legacyProcessorIds ?? true;
2671
3344
  }
2672
3345
  async init() {
2673
- await super.init();
2674
3346
  await this.loadAllCursors();
3347
+ await super.init();
2675
3348
  await this.discoverExistingDrives();
2676
3349
  }
2677
3350
  async commitOperations(items) {
2678
- await this.detectAndRegisterNewDrives(items);
2679
- await this.detectAndCleanupDeletedDrives(items);
2680
- await this.routeOperationsToProcessors(items);
3351
+ const { runs, reserved } = this.detectNewDrives(items);
3352
+ const disconnects = this.detectDeletedDrives(items);
3353
+ const deliveries = this.enqueueRouted(items, reserved);
3354
+ await Promise.all([
3355
+ ...runs.map((run) => run()),
3356
+ ...disconnects,
3357
+ ...deliveries
3358
+ ]);
2681
3359
  }
2682
3360
  async registerFactory(identifier, factory) {
2683
- if (this.factoryRegistry.has(identifier)) await this.unregisterFactory(identifier);
3361
+ const removals = this.removeFactory(identifier);
2684
3362
  this.factoryRegistry.set(identifier, factory);
2685
- this.factoryToProcessors.set(identifier, /* @__PURE__ */ new Map());
2686
- for (const [driveId, documentType] of this.knownDrives) {
2687
- const driveHeader = createMinimalDriveHeader(driveId, documentType);
2688
- await this.createProcessorsForDrive(driveId, identifier, factory, driveHeader);
2689
- }
3363
+ const previous = this.draining.get(identifier);
3364
+ const creationOrdinal = this.highWater() + 1;
3365
+ const runs = [];
3366
+ for (const [driveId, documentType] of this.knownDrives) runs.push(this.reserveSlot(identifier, factory, driveId, createMinimalDriveHeader(driveId, documentType), creationOrdinal, void 0, false, previous).run);
3367
+ await Promise.all([...removals, ...runs.map((run) => run())]);
2690
3368
  }
2691
3369
  async unregisterFactory(identifier) {
2692
- const factoryProcessors = this.factoryToProcessors.get(identifier);
2693
- if (!factoryProcessors) return;
2694
- for (const [driveId, tracked] of factoryProcessors) {
2695
- for (const t of tracked) await this.safeDisconnect(t.record.processor);
2696
- const driveProcessors = this.processorsByDrive.get(driveId);
2697
- if (driveProcessors) {
2698
- const remaining = driveProcessors.filter((p) => !tracked.includes(p));
2699
- if (remaining.length > 0) this.processorsByDrive.set(driveId, remaining);
2700
- else this.processorsByDrive.delete(driveId);
2701
- }
2702
- }
2703
- await this.deleteProcessorCursors({ factoryId: identifier });
2704
- this.factoryToProcessors.delete(identifier);
2705
- this.factoryRegistry.delete(identifier);
3370
+ await Promise.all(this.removeFactory(identifier));
2706
3371
  }
2707
3372
  get(processorId) {
2708
3373
  for (const tracked of this.allTrackedProcessors()) if (tracked.processorId === processorId) return tracked;
@@ -2710,208 +3375,283 @@ var ProcessorManager = class extends BaseReadModel {
2710
3375
  getAll() {
2711
3376
  return Array.from(this.allTrackedProcessors());
2712
3377
  }
2713
- *allTrackedProcessors() {
2714
- for (const tracked of this.processorsByDrive.values()) yield* tracked;
2715
- }
2716
- async detectAndRegisterNewDrives(operations) {
2717
- for (const op of operations) {
3378
+ /** Synchronous: reserves a slot per registered factory for each new drive. */
3379
+ detectNewDrives(items) {
3380
+ const runs = [];
3381
+ const reserved = /* @__PURE__ */ new Set();
3382
+ for (const op of items) {
2718
3383
  if (!this.isDriveCreation(op)) continue;
2719
3384
  const driveId = op.context.documentId;
2720
3385
  if (this.knownDrives.has(driveId)) continue;
2721
3386
  this.knownDrives.set(driveId, op.context.documentType);
2722
3387
  const driveHeader = extractDriveHeader(op);
2723
3388
  if (!driveHeader) continue;
2724
- for (const [identifier, factory] of this.factoryRegistry) await this.createProcessorsForDrive(driveId, identifier, factory, driveHeader);
3389
+ for (const [identifier, factory] of this.factoryRegistry) {
3390
+ const { slot, run } = this.reserveSlot(identifier, factory, driveId, driveHeader, op.context.ordinal, items, true);
3391
+ reserved.add(slot);
3392
+ runs.push(run);
3393
+ }
2725
3394
  }
3395
+ return {
3396
+ runs,
3397
+ reserved
3398
+ };
2726
3399
  }
2727
- isDriveCreation(op) {
2728
- return op.operation.action.type === "CREATE_DOCUMENT" && this.driveContainerTypes.has(op.context.documentType);
2729
- }
2730
- async detectAndCleanupDeletedDrives(operations) {
2731
- for (const op of operations) {
3400
+ /** Synchronous: drops a deleted drive's processors from the tables. */
3401
+ detectDeletedDrives(items) {
3402
+ const pending = [];
3403
+ for (const op of items) {
2732
3404
  if (!isDriveDeletion(op)) continue;
2733
3405
  const driveId = extractDeletedDocumentId(op);
2734
3406
  if (!driveId || !this.knownDrives.has(driveId)) continue;
2735
- if (!this.isDeletedDocumentADrive(driveId)) continue;
2736
- await this.cleanupDriveProcessors(driveId);
2737
3407
  this.knownDrives.delete(driveId);
3408
+ for (const slot of this.pendingSlots) if (slot.driveId === driveId) this.pendingSlots.delete(slot);
3409
+ for (const { queue } of this.processorsByDrive.get(driveId) ?? []) queue.close();
3410
+ this.processorsByDrive.delete(driveId);
3411
+ pending.push(...this.deleteCursors((row) => row.driveId === driveId));
3412
+ }
3413
+ return pending;
3414
+ }
3415
+ /** Synchronous: puts each processor's share of the batch on its queue. */
3416
+ enqueueRouted(items, reserved) {
3417
+ if (items.length === 0) return [];
3418
+ let lowest = items[0].context.ordinal;
3419
+ let highest = 0;
3420
+ for (const item of items) {
3421
+ lowest = Math.min(lowest, item.context.ordinal);
3422
+ highest = Math.max(highest, item.context.ordinal);
3423
+ }
3424
+ this.highestRoutedOrdinal = Math.max(this.highestRoutedOrdinal, highest);
3425
+ for (const slot of this.pendingSlots) {
3426
+ if (reserved.has(slot)) continue;
3427
+ slot.lowestRoutedOrdinal = Math.min(slot.lowestRoutedOrdinal ?? lowest, lowest);
3428
+ }
3429
+ const deliveries = [];
3430
+ for (const { tracked, queue } of this.allBound()) {
3431
+ const matching = items.filter((op) => matchesFilter(op, tracked.record.filter));
3432
+ if (matching.length > 0) deliveries.push(queue.live(matching));
3433
+ else queue.advance(highest);
3434
+ }
3435
+ return deliveries;
3436
+ }
3437
+ /** Synchronous: binds records, or discards them if the slot was cancelled. */
3438
+ bind(slot, records, creationOrdinal, creationItems) {
3439
+ const released = this.pendingSlots.delete(slot);
3440
+ if (!records || records.length === 0) {
3441
+ const settled = Promise.resolve();
3442
+ return {
3443
+ persisted: settled,
3444
+ delivered: settled
3445
+ };
3446
+ }
3447
+ if (!released) {
3448
+ const discarded = records.map((record) => this.discard(slot, record.processor.onDisconnect.bind(record.processor)));
3449
+ const settled = Promise.all(discarded).then(() => void 0);
3450
+ return {
3451
+ persisted: settled,
3452
+ delivered: settled
3453
+ };
2738
3454
  }
3455
+ const { factoryId, driveId, lowestRoutedOrdinal } = slot;
3456
+ const ids = resolveProcessorSlots(records, this.legacyProcessorIds);
3457
+ const bound = records.map((record, i) => this.track(slot, record, i, `${factoryId}:${driveId}:${ids[i]}`, creationOrdinal, lowestRoutedOrdinal));
3458
+ const drive = this.processorsByDrive.get(driveId) ?? [];
3459
+ this.processorsByDrive.set(driveId, [...drive, ...bound]);
3460
+ const liveIds = new Set(bound.map((b) => b.tracked.processorId));
3461
+ const writes = this.deleteCursors((row) => row.factoryId === factoryId && row.driveId === driveId && !liveIds.has(row.processorId));
3462
+ for (const { tracked } of bound) writes.push(this.writeCursor(tracked, tracked));
3463
+ const deliveries = [];
3464
+ for (const { tracked, queue } of bound) {
3465
+ const missed = tracked.lastOrdinal < slot.reservedAt || lowestRoutedOrdinal !== void 0;
3466
+ if (tracked.status === "active" && missed) deliveries.push(queue.backfill());
3467
+ const matching = creationItems?.filter((op) => matchesFilter(op, tracked.record.filter));
3468
+ if (matching && matching.length > 0) deliveries.push(queue.live(matching));
3469
+ }
3470
+ return {
3471
+ persisted: Promise.all(writes).then(() => void 0),
3472
+ delivered: Promise.all(deliveries).then(() => void 0)
3473
+ };
2739
3474
  }
2740
- async discoverExistingDrives() {
2741
- const drives = await this.db.selectFrom("DocumentSnapshot").select(["documentId", "documentType"]).where("documentType", "in", [...this.driveContainerTypes]).where("isDeleted", "=", false).execute();
2742
- for (const drive of drives) this.knownDrives.set(drive.documentId, drive.documentType);
3475
+ /** Synchronous: removes a factory's slots, processors and cursor rows. */
3476
+ removeFactory(identifier) {
3477
+ if (!this.factoryRegistry.delete(identifier)) return [];
3478
+ const closing = [];
3479
+ for (const slot of this.pendingSlots) {
3480
+ if (slot.factoryId !== identifier) continue;
3481
+ this.pendingSlots.delete(slot);
3482
+ if (slot.settled) closing.push(slot.settled);
3483
+ }
3484
+ for (const [driveId, drive] of this.processorsByDrive) {
3485
+ const remaining = [];
3486
+ for (const b of drive) if (b.tracked.factoryId === identifier) closing.push(b.queue.close());
3487
+ else remaining.push(b);
3488
+ if (remaining.length > 0) this.processorsByDrive.set(driveId, remaining);
3489
+ else this.processorsByDrive.delete(driveId);
3490
+ }
3491
+ if (closing.length > 0) {
3492
+ const drained = Promise.all([this.draining.get(identifier), ...closing]).then(() => void 0);
3493
+ this.draining.set(identifier, drained);
3494
+ drained.then(() => {
3495
+ if (this.draining.get(identifier) === drained) this.draining.delete(identifier);
3496
+ });
3497
+ }
3498
+ return this.deleteCursors((row) => row.factoryId === identifier);
2743
3499
  }
2744
- isDeletedDocumentADrive(documentId) {
2745
- return this.knownDrives.has(documentId);
3500
+ reserveSlot(factoryId, factory, driveId, driveHeader, creationOrdinal, creationItems, awaitDelivery, previous) {
3501
+ const slot = {
3502
+ factoryId,
3503
+ driveId,
3504
+ reservedAt: this.highWater(),
3505
+ lowestRoutedOrdinal: void 0,
3506
+ settled: void 0
3507
+ };
3508
+ this.pendingSlots.add(slot);
3509
+ const run = async () => {
3510
+ const bound = (async () => {
3511
+ await previous;
3512
+ const records = await this.runFactory(slot, factory, driveHeader);
3513
+ return this.bind(slot, records, creationOrdinal, creationItems);
3514
+ })();
3515
+ slot.settled = bound.then(({ persisted }) => persisted);
3516
+ const { persisted, delivered } = await bound;
3517
+ await (awaitDelivery ? Promise.all([persisted, delivered]) : persisted);
3518
+ };
3519
+ return {
3520
+ slot,
3521
+ run
3522
+ };
2746
3523
  }
2747
- async createProcessorsForDrive(driveId, identifier, factory, driveHeader) {
2748
- let records;
3524
+ async runFactory(slot, factory, driveHeader) {
2749
3525
  try {
2750
- records = await factory(driveHeader);
3526
+ return await factory(driveHeader);
2751
3527
  } catch (error) {
2752
- this.logger.error("Factory '@FactoryId' failed for drive '@DriveId': @Error", identifier, driveId, error);
3528
+ this.logger.error("Factory '@FactoryId' failed for drive '@DriveId': @Error", slot.factoryId, slot.driveId, error);
2753
3529
  return;
2754
3530
  }
2755
- if (records.length === 0) return;
2756
- const trackedList = [];
2757
- const slots = resolveProcessorSlots(records, this.legacyProcessorIds);
2758
- for (let i = 0; i < records.length; i++) {
2759
- const record = records[i];
2760
- const processorId = `${identifier}:${driveId}:${slots[i]}`;
2761
- const cached = this.cursorCache.get(processorId);
2762
- let lastOrdinal;
2763
- let status;
2764
- let lastError;
2765
- let lastErrorTimestamp;
2766
- if (cached) {
2767
- lastOrdinal = cached.lastOrdinal;
2768
- status = cached.status;
2769
- lastError = cached.lastError ?? void 0;
2770
- lastErrorTimestamp = cached.lastErrorTimestamp ?? void 0;
2771
- } else {
2772
- lastOrdinal = (record.startFrom ?? "beginning") === "current" ? this.lastOrdinal : 0;
2773
- status = "active";
2774
- lastError = void 0;
2775
- lastErrorTimestamp = void 0;
2776
- }
2777
- const tracked = {
2778
- processorId,
2779
- factoryId: identifier,
2780
- driveId,
2781
- processorIndex: i,
2782
- record,
2783
- lastOrdinal,
2784
- status,
2785
- lastError,
2786
- lastErrorTimestamp,
2787
- retry: () => this.retryProcessor(tracked)
3531
+ }
3532
+ track(slot, record, processorIndex, processorId, creationOrdinal, lowestRoutedOrdinal) {
3533
+ const cached = this.cursorCache.get(processorId);
3534
+ let floor = 0;
3535
+ let cursor;
3536
+ if (cached) cursor = {
3537
+ lastOrdinal: cached.lastOrdinal,
3538
+ status: cached.status,
3539
+ lastError: cached.lastError ?? void 0,
3540
+ lastErrorTimestamp: cached.lastErrorTimestamp ?? void 0
3541
+ };
3542
+ else {
3543
+ if (record.startFrom === "current") floor = creationOrdinal - 1;
3544
+ cursor = {
3545
+ lastOrdinal: floor,
3546
+ status: "active",
3547
+ lastError: void 0,
3548
+ lastErrorTimestamp: void 0
2788
3549
  };
2789
- trackedList.push(tracked);
2790
- await this.saveProcessorCursor(tracked);
2791
- }
2792
- const liveIds = new Set(trackedList.map((t) => t.processorId));
2793
- await this.db.deleteFrom("ProcessorCursor").where("factoryId", "=", identifier).where("driveId", "=", driveId).where("processorId", "not in", [...liveIds]).execute();
2794
- for (const [id, row] of this.cursorCache) if (row.factoryId === identifier && row.driveId === driveId && !liveIds.has(id)) this.cursorCache.delete(id);
2795
- const factoryProcessors = this.factoryToProcessors.get(identifier);
2796
- if (factoryProcessors) factoryProcessors.set(driveId, trackedList);
2797
- const existingDriveProcessors = this.processorsByDrive.get(driveId) ?? [];
2798
- this.processorsByDrive.set(driveId, [...existingDriveProcessors, ...trackedList]);
2799
- for (const tracked of trackedList) if (tracked.status === "active" && tracked.lastOrdinal < this.lastOrdinal) await this.backfillProcessor(tracked);
2800
- }
2801
- async backfillProcessor(tracked) {
2802
- let page = await this.operationIndex.getSinceOrdinal(tracked.lastOrdinal);
2803
- while (page.results.length > 0) {
2804
- const matching = page.results.filter((op) => matchesFilter(op, tracked.record.filter));
2805
- if (matching.length > 0) try {
2806
- await tracked.record.processor.onOperations(matching);
2807
- } catch (error) {
2808
- tracked.status = "errored";
2809
- tracked.lastError = error instanceof Error ? error.message : String(error);
2810
- tracked.lastErrorTimestamp = /* @__PURE__ */ new Date();
2811
- await this.safeSaveProcessorCursor(tracked);
2812
- this.logger.error("Processor '@ProcessorId' failed during backfill at ordinal @Ordinal: @Error", tracked.processorId, tracked.lastOrdinal, error);
2813
- return;
2814
- }
2815
- tracked.lastOrdinal = page.results[page.results.length - 1].context.ordinal;
2816
- await this.safeSaveProcessorCursor(tracked);
2817
- if (!page.next) break;
2818
- page = await page.next();
2819
- }
2820
- }
2821
- async retryProcessor(tracked) {
2822
- if (tracked.status !== "errored") return;
2823
- tracked.status = "active";
2824
- tracked.lastError = void 0;
2825
- tracked.lastErrorTimestamp = void 0;
2826
- await this.saveProcessorCursor(tracked);
2827
- await this.backfillProcessor(tracked);
2828
- }
2829
- async cleanupDriveProcessors(driveId) {
2830
- const processors = this.processorsByDrive.get(driveId);
2831
- if (!processors) return;
2832
- for (const tracked of processors) await this.safeDisconnect(tracked.record.processor);
2833
- this.processorsByDrive.delete(driveId);
2834
- for (const factoryProcessors of this.factoryToProcessors.values()) factoryProcessors.delete(driveId);
2835
- await this.deleteProcessorCursors({ driveId });
2836
- }
2837
- async safeDisconnect(processor) {
3550
+ }
3551
+ if (lowestRoutedOrdinal !== void 0) cursor.lastOrdinal = Math.max(floor, Math.min(cursor.lastOrdinal, lowestRoutedOrdinal - 1));
3552
+ const tracked = {
3553
+ processorId,
3554
+ factoryId: slot.factoryId,
3555
+ driveId: slot.driveId,
3556
+ processorIndex,
3557
+ record,
3558
+ ...cursor,
3559
+ retry: () => queue.retry()
3560
+ };
3561
+ const queue = new ProcessorQueue({
3562
+ processorId,
3563
+ processor: record.processor,
3564
+ filter: record.filter,
3565
+ cursor: tracked,
3566
+ floor,
3567
+ readSince: (ordinal) => this.operationIndex.getSinceOrdinal(ordinal),
3568
+ routedThrough: () => this.highWater(),
3569
+ persist: (state) => this.writeCursor(tracked, state),
3570
+ logger: this.logger
3571
+ });
3572
+ return {
3573
+ tracked,
3574
+ queue
3575
+ };
3576
+ }
3577
+ async discard(slot, disconnect) {
2838
3578
  try {
2839
- await processor.onDisconnect();
3579
+ await disconnect();
2840
3580
  } catch (error) {
2841
- this.logger.error("Error disconnecting processor: @Error", error);
3581
+ this.logger.error("Error disconnecting discarded processor for '@FactoryId' on '@DriveId': @Error", slot.factoryId, slot.driveId, error);
2842
3582
  }
2843
3583
  }
2844
- async routeOperationsToProcessors(operations) {
2845
- let maxOrdinal = 0;
2846
- for (const op of operations) maxOrdinal = Math.max(maxOrdinal, op.context.ordinal);
2847
- const allTracked = Array.from(this.allTrackedProcessors());
2848
- await Promise.all(allTracked.map(async (tracked) => {
2849
- if (tracked.status !== "active") return;
2850
- const matching = operations.filter((op) => op.context.ordinal > tracked.lastOrdinal).filter((op) => matchesFilter(op, tracked.record.filter));
2851
- if (matching.length > 0) try {
2852
- await tracked.record.processor.onOperations(matching);
2853
- } catch (error) {
2854
- tracked.status = "errored";
2855
- tracked.lastError = error instanceof Error ? error.message : String(error);
2856
- tracked.lastErrorTimestamp = /* @__PURE__ */ new Date();
2857
- await this.safeSaveProcessorCursor(tracked);
2858
- this.logger.error("Processor '@ProcessorId' failed at ordinal @Ordinal: @Error", tracked.processorId, tracked.lastOrdinal, error);
2859
- return;
2860
- }
2861
- tracked.lastOrdinal = maxOrdinal;
2862
- await this.safeSaveProcessorCursor(tracked);
2863
- }));
3584
+ highWater() {
3585
+ return Math.max(this.lastOrdinal, this.highestRoutedOrdinal);
3586
+ }
3587
+ *allBound() {
3588
+ for (const drive of this.processorsByDrive.values()) yield* drive;
3589
+ }
3590
+ *allTrackedProcessors() {
3591
+ for (const { tracked } of this.allBound()) yield tracked;
3592
+ }
3593
+ isDriveCreation(op) {
3594
+ return op.operation.action.type === "CREATE_DOCUMENT" && this.driveContainerTypes.has(op.context.documentType);
3595
+ }
3596
+ async discoverExistingDrives() {
3597
+ const drives = await this.db.selectFrom("DocumentSnapshot").select(["documentId", "documentType"]).where("documentType", "in", [...this.driveContainerTypes]).where("isDeleted", "=", false).execute();
3598
+ for (const drive of drives) this.knownDrives.set(drive.documentId, drive.documentType);
2864
3599
  }
2865
3600
  async loadAllCursors() {
2866
3601
  const rows = await this.db.selectFrom("ProcessorCursor").selectAll().execute();
2867
3602
  for (const row of rows) this.cursorCache.set(row.processorId, row);
2868
3603
  }
2869
- async safeSaveProcessorCursor(tracked) {
2870
- try {
2871
- await this.saveProcessorCursor(tracked);
2872
- } catch (error) {
2873
- this.logger.error("Failed to persist cursor for '@ProcessorId': @Error", tracked.processorId, error);
2874
- }
3604
+ lane(processorId, write) {
3605
+ const next = (this.cursorWrites.get(processorId) ?? Promise.resolve()).then(write).then(() => void 0, (error) => {
3606
+ this.logger.error("Failed to write cursor for '@ProcessorId': @Error", processorId, error);
3607
+ });
3608
+ this.cursorWrites.set(processorId, next);
3609
+ next.then(() => {
3610
+ if (this.cursorWrites.get(processorId) === next) this.cursorWrites.delete(processorId);
3611
+ });
3612
+ return next;
2875
3613
  }
2876
- async saveProcessorCursor(tracked) {
2877
- await this.db.insertInto("ProcessorCursor").values({
3614
+ writeCursor(tracked, state) {
3615
+ const now = /* @__PURE__ */ new Date();
3616
+ const row = {
2878
3617
  processorId: tracked.processorId,
2879
3618
  factoryId: tracked.factoryId,
2880
3619
  driveId: tracked.driveId,
2881
3620
  processorIndex: tracked.processorIndex,
2882
- lastOrdinal: tracked.lastOrdinal,
2883
- status: tracked.status,
2884
- lastError: tracked.lastError ?? null,
2885
- lastErrorTimestamp: tracked.lastErrorTimestamp ?? null,
2886
- updatedAt: /* @__PURE__ */ new Date()
3621
+ lastOrdinal: state.lastOrdinal,
3622
+ status: state.status,
3623
+ lastError: state.lastError ?? null,
3624
+ lastErrorTimestamp: state.lastErrorTimestamp ?? null,
3625
+ createdAt: now,
3626
+ updatedAt: now
3627
+ };
3628
+ this.cursorCache.set(row.processorId, row);
3629
+ return this.lane(row.processorId, () => this.db.insertInto("ProcessorCursor").values({
3630
+ processorId: row.processorId,
3631
+ factoryId: row.factoryId,
3632
+ driveId: row.driveId,
3633
+ processorIndex: row.processorIndex,
3634
+ lastOrdinal: row.lastOrdinal,
3635
+ status: row.status,
3636
+ lastError: row.lastError,
3637
+ lastErrorTimestamp: row.lastErrorTimestamp,
3638
+ updatedAt: now
2887
3639
  }).onConflict((oc) => oc.column("processorId").doUpdateSet({
2888
- lastOrdinal: tracked.lastOrdinal,
2889
- status: tracked.status,
2890
- lastError: tracked.lastError ?? null,
2891
- lastErrorTimestamp: tracked.lastErrorTimestamp ?? null,
2892
- updatedAt: /* @__PURE__ */ new Date()
2893
- })).execute();
2894
- this.cursorCache.set(tracked.processorId, {
2895
- processorId: tracked.processorId,
2896
- factoryId: tracked.factoryId,
2897
- driveId: tracked.driveId,
2898
- processorIndex: tracked.processorIndex,
2899
- lastOrdinal: tracked.lastOrdinal,
2900
- status: tracked.status,
2901
- lastError: tracked.lastError ?? null,
2902
- lastErrorTimestamp: tracked.lastErrorTimestamp ?? null,
2903
- createdAt: /* @__PURE__ */ new Date(),
2904
- updatedAt: /* @__PURE__ */ new Date()
2905
- });
2906
- }
2907
- async deleteProcessorCursors(filter) {
2908
- if ("factoryId" in filter) {
2909
- await this.db.deleteFrom("ProcessorCursor").where("factoryId", "=", filter.factoryId).execute();
2910
- for (const [id, row] of this.cursorCache) if (row.factoryId === filter.factoryId) this.cursorCache.delete(id);
2911
- } else {
2912
- await this.db.deleteFrom("ProcessorCursor").where("driveId", "=", filter.driveId).execute();
2913
- for (const [id, row] of this.cursorCache) if (row.driveId === filter.driveId) this.cursorCache.delete(id);
2914
- }
3640
+ lastOrdinal: row.lastOrdinal,
3641
+ status: row.status,
3642
+ lastError: row.lastError,
3643
+ lastErrorTimestamp: row.lastErrorTimestamp,
3644
+ updatedAt: now
3645
+ })).execute());
3646
+ }
3647
+ deleteCursors(matches) {
3648
+ const deletes = [];
3649
+ for (const [processorId, row] of this.cursorCache) {
3650
+ if (!matches(row)) continue;
3651
+ this.cursorCache.delete(processorId);
3652
+ deletes.push(this.lane(processorId, () => this.db.deleteFrom("ProcessorCursor").where("processorId", "=", processorId).execute()));
3653
+ }
3654
+ return deletes;
2915
3655
  }
2916
3656
  };
2917
3657
  //#endregion
@@ -2928,11 +3668,11 @@ var JobExecutionHandle = class {
2928
3668
  onDefer;
2929
3669
  getStateName(state) {
2930
3670
  switch (state) {
2931
- case JobQueueState.PREPROCESSING: return "PREPROCESSING";
2932
- case JobQueueState.PENDING: return "PENDING";
2933
- case JobQueueState.READY: return "READY";
2934
- case JobQueueState.RUNNING: return "RUNNING";
2935
- case JobQueueState.RESOLVED: return "RESOLVED";
3671
+ case 0: return "PREPROCESSING";
3672
+ case 1: return "PENDING";
3673
+ case 2: return "READY";
3674
+ case 3: return "RUNNING";
3675
+ case 4: return "RESOLVED";
2936
3676
  default: return `UNKNOWN`;
2937
3677
  }
2938
3678
  }
@@ -2951,23 +3691,23 @@ var JobExecutionHandle = class {
2951
3691
  return this._state;
2952
3692
  }
2953
3693
  start() {
2954
- if (this._state !== JobQueueState.READY) throw new Error(`Cannot start job in state ${this.getStateName(this._state)}`);
2955
- this._state = JobQueueState.RUNNING;
3694
+ if (this._state !== 2) throw new Error(`Cannot start job in state ${this.getStateName(this._state)}`);
3695
+ this._state = 3;
2956
3696
  this.onStart?.();
2957
3697
  }
2958
3698
  complete() {
2959
- if (this._state !== JobQueueState.RUNNING) throw new Error(`Cannot complete job in state ${this.getStateName(this._state)}`);
2960
- this._state = JobQueueState.RESOLVED;
3699
+ if (this._state !== 3) throw new Error(`Cannot complete job in state ${this.getStateName(this._state)}`);
3700
+ this._state = 4;
2961
3701
  this.onComplete?.();
2962
3702
  }
2963
3703
  fail(error) {
2964
- if (this._state !== JobQueueState.RUNNING) throw new Error(`Cannot fail job in state ${this.getStateName(this._state)}`);
2965
- this._state = JobQueueState.RESOLVED;
3704
+ if (this._state !== 3) throw new Error(`Cannot fail job in state ${this.getStateName(this._state)}`);
3705
+ this._state = 4;
2966
3706
  this.onFail?.(error);
2967
3707
  }
2968
3708
  defer() {
2969
- if (this._state !== JobQueueState.RUNNING) throw new Error(`Cannot defer job in state ${this.getStateName(this._state)}`);
2970
- this._state = JobQueueState.RESOLVED;
3709
+ if (this._state !== 3) throw new Error(`Cannot defer job in state ${this.getStateName(this._state)}`);
3710
+ this._state = 4;
2971
3711
  this.onDefer?.();
2972
3712
  }
2973
3713
  };
@@ -2980,6 +3720,8 @@ var JobExecutionHandle = class {
2980
3720
  * Implements dependency management through queue hints.
2981
3721
  */
2982
3722
  var InMemoryQueue = class {
3723
+ eventBus;
3724
+ resolver;
2983
3725
  queues = /* @__PURE__ */ new Map();
2984
3726
  jobIdToQueueKey = /* @__PURE__ */ new Map();
2985
3727
  docIdToJobId = /* @__PURE__ */ new Map();
@@ -3114,7 +3856,7 @@ var InMemoryQueue = class {
3114
3856
  this.jobIdToQueueKey.delete(job.id);
3115
3857
  this.markJobExecuting(job);
3116
3858
  if (queue.length === 0) this.queues.delete(queueKey);
3117
- const handle = new JobExecutionHandle(job, JobQueueState.READY, {
3859
+ const handle = new JobExecutionHandle(job, 2, {
3118
3860
  onStart: () => {},
3119
3861
  onComplete: () => {
3120
3862
  this.completeJob(job.id);
@@ -3140,7 +3882,7 @@ var InMemoryQueue = class {
3140
3882
  this.jobIdToQueueKey.delete(job.id);
3141
3883
  this.markJobExecuting(job);
3142
3884
  if (queue.length === 0) this.queues.delete(queueKey);
3143
- const handle = new JobExecutionHandle(job, JobQueueState.READY, {
3885
+ const handle = new JobExecutionHandle(job, 2, {
3144
3886
  onStart: () => {},
3145
3887
  onComplete: () => {
3146
3888
  this.completeJob(job.id);
@@ -3174,7 +3916,7 @@ var InMemoryQueue = class {
3174
3916
  this.jobIdToQueueKey.delete(job.id);
3175
3917
  this.markJobExecuting(job);
3176
3918
  if (queue.length === 0) this.queues.delete(queueKey);
3177
- const handle = new JobExecutionHandle(job, JobQueueState.READY, {
3919
+ const handle = new JobExecutionHandle(job, 2, {
3178
3920
  onStart: () => {},
3179
3921
  onComplete: () => {
3180
3922
  this.completeJob(job.id);
@@ -3276,7 +4018,7 @@ var InMemoryQueue = class {
3276
4018
  if (documentId) this.markJobComplete(jobId, documentId);
3277
4019
  this.jobIndex.delete(jobId);
3278
4020
  }
3279
- async retryJob(jobId, error, accounting = RetryAccounting.CountAgainstLimit) {
4021
+ async retryJob(jobId, error, accounting = "count-against-limit") {
3280
4022
  const job = this.jobIndex.get(jobId);
3281
4023
  if (!job) return;
3282
4024
  job.lastError = error;
@@ -3288,7 +4030,7 @@ var InMemoryQueue = class {
3288
4030
  const retryCount = job.retryCount || 0;
3289
4031
  const updatedJob = {
3290
4032
  ...job,
3291
- retryCount: accounting === RetryAccounting.CountAgainstLimit ? retryCount + 1 : retryCount,
4033
+ retryCount: accounting === "count-against-limit" ? retryCount + 1 : retryCount,
3292
4034
  lastError: error
3293
4035
  };
3294
4036
  await this.enqueue(updatedJob);
@@ -3489,6 +4231,8 @@ function describeSource(source) {
3489
4231
  * and failure-caching state.
3490
4232
  */
3491
4233
  var DocumentModelResolver = class {
4234
+ registry;
4235
+ loader;
3492
4236
  loadingModels = /* @__PURE__ */ new Map();
3493
4237
  failedModelTypes = /* @__PURE__ */ new Set();
3494
4238
  broadcastHook = null;
@@ -3559,6 +4303,7 @@ var DocumentModelResolver = class {
3559
4303
  * Since there is no loader, missing models cannot be recovered.
3560
4304
  */
3561
4305
  var NullDocumentModelResolver = class {
4306
+ registry;
3562
4307
  constructor(registry) {
3563
4308
  this.registry = registry;
3564
4309
  }
@@ -3782,6 +4527,8 @@ var ReactorSubscriptionManager = class {
3782
4527
  * is emitted, so that reactor.get() returns fresh data when callbacks fire.
3783
4528
  */
3784
4529
  var SubscriptionNotificationReadModel = class {
4530
+ subscriptionManager;
4531
+ documentView;
3785
4532
  name = "subscription-notification";
3786
4533
  constructor(subscriptionManager, documentView) {
3787
4534
  this.subscriptionManager = subscriptionManager;
@@ -3804,10 +4551,10 @@ var SubscriptionNotificationReadModel = class {
3804
4551
  deleted.push(deletedId);
3805
4552
  } else if (actionType === "ADD_RELATIONSHIP") {
3806
4553
  const input = operation.action.input;
3807
- this.subscriptionManager.notifyRelationshipChanged(input.sourceId, input.targetId, RelationshipChangeType.Added, input.childType);
4554
+ this.subscriptionManager.notifyRelationshipChanged(input.sourceId, input.targetId, "added", input.childType);
3808
4555
  } else if (actionType === "REMOVE_RELATIONSHIP") {
3809
4556
  const input = operation.action.input;
3810
- this.subscriptionManager.notifyRelationshipChanged(input.sourceId, input.targetId, RelationshipChangeType.Removed, input.childType);
4557
+ this.subscriptionManager.notifyRelationshipChanged(input.sourceId, input.targetId, "removed", input.childType);
3811
4558
  } else if (!created.includes(context.documentId)) updatedIds.add(context.documentId);
3812
4559
  }
3813
4560
  if (created.length > 0) this.subscriptionManager.notifyDocumentsCreated(created, documentTypes, parentIds);
@@ -3832,6 +4579,10 @@ var SubscriptionNotificationReadModel = class {
3832
4579
  * keeps the common case (a membership write later than all history) free.
3833
4580
  */
3834
4581
  var GroupReevaluationTrigger = class {
4582
+ logger;
4583
+ eventBus;
4584
+ queue;
4585
+ operationIndex;
3835
4586
  unsubscribe;
3836
4587
  constructor(logger, eventBus, queue, operationIndex) {
3837
4588
  this.logger = logger;
@@ -3983,7 +4734,7 @@ var Mailbox = class {
3983
4734
  this.itemsMap.set(item.id, item);
3984
4735
  for (const op of item.operations) this._latestOrdinal = Math.max(this._latestOrdinal, op.context.ordinal);
3985
4736
  item.on((syncOp, _, next) => {
3986
- if (next === SyncOperationStatus.Applied) for (const op of syncOp.operations) this._ack = Math.max(this._ack, op.context.ordinal);
4737
+ if (next === 2) for (const op of syncOp.operations) this._ack = Math.max(this._ack, op.context.ordinal);
3987
4738
  });
3988
4739
  }
3989
4740
  if (this.paused) {
@@ -4097,7 +4848,7 @@ var BufferedMailbox = class {
4097
4848
  this.itemsMap.set(item.id, item);
4098
4849
  for (const op of item.operations) this._latestOrdinal = Math.max(this._latestOrdinal, op.context.ordinal);
4099
4850
  item.on((syncOp, _, next) => {
4100
- if (next === SyncOperationStatus.Applied) for (const op of syncOp.operations) this._ack = Math.max(this._ack, op.context.ordinal);
4851
+ if (next === 2) for (const op of syncOp.operations) this._ack = Math.max(this._ack, op.context.ordinal);
4101
4852
  });
4102
4853
  }
4103
4854
  this.addedBuffer.push(...items);
@@ -4211,7 +4962,14 @@ var GraphQLRequestError = class extends Error {
4211
4962
  * difference between a channel that recovers and one that is dead for the
4212
4963
  * process lifetime.
4213
4964
  */
4214
- const RECOVERABLE_GRAPHQL_ERROR_CODES = { malformedStoredOperation: "MALFORMED_STORED_OPERATION" };
4965
+ const RECOVERABLE_GRAPHQL_ERROR_CODES = {
4966
+ /**
4967
+ * A stored operation cannot be represented in the schema - an action with no
4968
+ * id, say. The document holding it needs repairing, but the channel serves
4969
+ * every other document, and a peer that stopped polling would stop receiving
4970
+ * those too.
4971
+ */
4972
+ malformedStoredOperation: "MALFORMED_STORED_OPERATION" };
4215
4973
  const RECOVERABLE_CODES = new Set(Object.values(RECOVERABLE_GRAPHQL_ERROR_CODES));
4216
4974
  /**
4217
4975
  * True when every error the response carried named a recoverable code.
@@ -4229,9 +4987,35 @@ const DRIVE_AUTH_ERROR_MESSAGES = {
4229
4987
  forbidden: "Forbidden: insufficient permissions",
4230
4988
  authenticationRequired: "Forbidden: authentication required"
4231
4989
  };
4990
+ /**
4991
+ * A non-GraphQL HTTP failure against a drive endpoint.
4992
+ *
4993
+ * Drive discovery (`GET <base>/d/:drive`) is REST, not GraphQL, so its
4994
+ * failures cannot be a `GraphQLRequestError` without the name lying about
4995
+ * what was called. It carries the status for the same reason that one does:
4996
+ * `isDriveAuthError` is what decides whether a failure prompts a login, and a
4997
+ * bare `Error` tells it nothing.
4998
+ */
4999
+ var DriveRequestError = class extends Error {
5000
+ statusCode;
5001
+ constructor(message, statusCode) {
5002
+ super(message);
5003
+ this.name = "DriveRequestError";
5004
+ this.statusCode = statusCode;
5005
+ }
5006
+ };
4232
5007
  /** True when the remote rejected the caller as unauthenticated/unauthorized:
4233
- * an HTTP 401/403, or a Forbidden/Unauthorized GraphQL error. */
5008
+ * an HTTP 401/403, or a Forbidden/Unauthorized GraphQL error.
5009
+ *
5010
+ * 403 and 401 only — NOT 404. The drive info endpoint answers a drive the
5011
+ * caller may not read with the same 404 it gives a drive that does not exist,
5012
+ * so that an unauthorized caller cannot enumerate drives by probing slugs.
5013
+ * That is deliberate, and it costs exactly this: a protected drive is
5014
+ * indistinguishable from a typo, and prompting for a login on every 404 would
5015
+ * fire on every mistyped URL. See the `WWW-Authenticate` note on the endpoint
5016
+ * for the signal that would let a client tell the two apart. */
4234
5017
  function isDriveAuthError(error) {
5018
+ if (error instanceof DriveRequestError) return error.statusCode === 401 || error.statusCode === 403;
4235
5019
  if (!(error instanceof GraphQLRequestError)) return false;
4236
5020
  if (error.category === "http") return error.statusCode === 401 || error.statusCode === 403;
4237
5021
  if (error.category === "graphql") return Object.values(DRIVE_AUTH_ERROR_MESSAGES).some((m) => error.message.includes(m));
@@ -4294,23 +5078,23 @@ var SyncOperation = class {
4294
5078
  this.scopes = scopes;
4295
5079
  this.branch = branch;
4296
5080
  this.operations = operations;
4297
- this.status = SyncOperationStatus.Unknown;
5081
+ this.status = -1;
4298
5082
  }
4299
5083
  on(callback) {
4300
5084
  this.callbacks.push(callback);
4301
5085
  }
4302
5086
  started() {
4303
- this.transition(SyncOperationStatus.TransportPending);
5087
+ this.transition(0);
4304
5088
  }
4305
5089
  transported() {
4306
- this.transition(SyncOperationStatus.ExecutionPending);
5090
+ this.transition(1);
4307
5091
  }
4308
5092
  executed() {
4309
- this.transition(SyncOperationStatus.Applied);
5093
+ this.transition(2);
4310
5094
  }
4311
5095
  failed(error) {
4312
5096
  this.error = error;
4313
- this.transition(SyncOperationStatus.Error);
5097
+ this.transition(3);
4314
5098
  }
4315
5099
  transition(next) {
4316
5100
  const prev = this.status;
@@ -4520,9 +5304,11 @@ function consolidateSyncOperations(syncOps) {
4520
5304
  }
4521
5305
  const first = group.ops[0];
4522
5306
  const merged = new SyncOperation(first.id, first.jobId, remappedDeps, first.remoteName, first.documentId, first.scopes, first.branch, allOperations);
4523
- if (first.status > SyncOperationStatus.TransportPending) if (first.status >= SyncOperationStatus.Error) merged.executed();
4524
- else if (first.status >= SyncOperationStatus.Applied) merged.transported();
4525
- else merged.started();
5307
+ if (first.status > 0) {
5308
+ if (first.status >= 3) merged.executed();
5309
+ else if (first.status >= 2) merged.transported();
5310
+ else merged.started();
5311
+ }
4526
5312
  result.push(merged);
4527
5313
  }
4528
5314
  return result;
@@ -4653,7 +5439,7 @@ function syncOperationErrorType(error) {
4653
5439
  return error?.errorType ?? classifyJobFailure(error?.error?.name ?? "Error");
4654
5440
  }
4655
5441
  /** Dead-letter types that must not stop the document syncing. */
4656
- const NON_QUARANTINING_ERROR_TYPES = new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
5442
+ const NON_QUARANTINING_ERROR_TYPES = /* @__PURE__ */ new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
4657
5443
  /**
4658
5444
  * A held auth operation must not quarantine: reconciling the two policies needs
4659
5445
  * the traffic a quarantine would stop.
@@ -4879,7 +5665,7 @@ function envelopesToSyncOperations(envelope, remoteName) {
4879
5665
  }
4880
5666
  const getLatestAppliedOrdinal = (syncOps) => {
4881
5667
  let maxOrdinal = 0;
4882
- for (const syncOp of syncOps) if (syncOp.status === SyncOperationStatus.Applied) for (const op of syncOp.operations) maxOrdinal = Math.max(maxOrdinal, op.context.ordinal);
5668
+ for (const syncOp of syncOps) if (syncOp.status === 2) for (const op of syncOp.operations) maxOrdinal = Math.max(maxOrdinal, op.context.ordinal);
4883
5669
  return maxOrdinal;
4884
5670
  };
4885
5671
  //#endregion
@@ -4894,6 +5680,7 @@ const DECISION_FIELDS = ["deniedReason", "errorType"];
4894
5680
  * GraphQL-based synchronization channel for network communication between reactors.
4895
5681
  */
4896
5682
  var GqlRequestChannel = class {
5683
+ logger;
4897
5684
  inbox;
4898
5685
  outbox;
4899
5686
  deadLetter;
@@ -5028,6 +5815,10 @@ var GqlRequestChannel = class {
5028
5815
  if (this.isShutdown) return;
5029
5816
  this.pollTimer.triggerNow();
5030
5817
  }
5818
+ /** This channel polls a remote itself; it has no holder to hear from. */
5819
+ notePoll() {}
5820
+ /** No holder, so nothing this channel reports may strand one. */
5821
+ lastHolderPollUtcMs() {}
5031
5822
  /**
5032
5823
  * Initializes the channel by registering it on the remote server and starting polling.
5033
5824
  */
@@ -5097,7 +5888,7 @@ var GqlRequestChannel = class {
5097
5888
  const syncOps = [];
5098
5889
  for (const dl of deadLetters) {
5099
5890
  const syncOp = new SyncOperation(crypto.randomUUID(), dl.jobId, [], this.remoteName, dl.documentId, dl.scopes, dl.branch, []);
5100
- syncOp.failed(new ChannelError(ChannelErrorSource.Outbox, new Error(dl.error), dl.errorType ?? void 0));
5891
+ syncOp.failed(new ChannelError("outbox", new Error(dl.error), dl.errorType ?? void 0));
5101
5892
  syncOps.push(syncOp);
5102
5893
  }
5103
5894
  this.deadLetter.add(...syncOps);
@@ -5117,7 +5908,7 @@ var GqlRequestChannel = class {
5117
5908
  const classification = this.classifyError(err);
5118
5909
  this.failureCount++;
5119
5910
  this.lastFailureUtcMs = Date.now();
5120
- const channelError = new ChannelError(ChannelErrorSource.Inbox, err);
5911
+ const channelError = new ChannelError("inbox", err);
5121
5912
  this.logger.error("GqlChannel poll error (@FailureCount, @Classification): @Error", this.failureCount, classification, channelError);
5122
5913
  if (classification === "unrecoverable") {
5123
5914
  this.pollTimer.stop();
@@ -5361,7 +6152,7 @@ var GqlRequestChannel = class {
5361
6152
  this.transitionConnectionState("reconnecting");
5362
6153
  this.schedulePushRetry();
5363
6154
  } else {
5364
- const channelError = new ChannelError(ChannelErrorSource.Outbox, err);
6155
+ const channelError = new ChannelError("outbox", err);
5365
6156
  for (const syncOp of syncOps) syncOp.failed(channelError);
5366
6157
  this.deadLetter.add(...syncOps);
5367
6158
  this.outbox.remove(...syncOps);
@@ -5583,7 +6374,7 @@ var GqlRequestChannelFactory = class {
5583
6374
  ...retryMaxDelayMs !== void 0 && { retryMaxDelayMs },
5584
6375
  ...maxQueueDepth !== void 0 && { maxQueueDepth },
5585
6376
  ...backpressureCheckIntervalMs !== void 0 && { backpressureCheckIntervalMs },
5586
- startPaused: options?.pollBehavior === PollBehavior.Manual
6377
+ startPaused: options?.pollBehavior === "manual"
5587
6378
  });
5588
6379
  return new GqlRequestChannel(this.logger, remoteId, remoteName, cursorStorage, gqlConfig, operationIndex, pollTimer);
5589
6380
  }
@@ -5600,6 +6391,7 @@ function firstOrdinalOf$1(syncOp) {
5600
6391
  * In general, the resolvers are responsible for updating mailboxes.
5601
6392
  */
5602
6393
  var GqlResponseChannel = class {
6394
+ logger;
5603
6395
  inbox;
5604
6396
  outbox;
5605
6397
  deadLetter;
@@ -5611,6 +6403,7 @@ var GqlResponseChannel = class {
5611
6403
  lastPersistedOutboxOrdinal = 0;
5612
6404
  evictedOutboxFloor = Number.POSITIVE_INFINITY;
5613
6405
  appliedOutboxOrdinal = 0;
6406
+ lastPollUtcMs = Date.now();
5614
6407
  connectionState = "connecting";
5615
6408
  connectionStateCallbacks = /* @__PURE__ */ new Set();
5616
6409
  constructor(logger, channelId, remoteName, cursorStorage) {
@@ -5653,7 +6446,7 @@ var GqlResponseChannel = class {
5653
6446
  return {
5654
6447
  state: this.connectionState,
5655
6448
  failureCount: 0,
5656
- lastSuccessUtcMs: 0,
6449
+ lastSuccessUtcMs: this.lastPollUtcMs,
5657
6450
  lastFailureUtcMs: 0,
5658
6451
  pushBlocked: false,
5659
6452
  pushFailureCount: 0,
@@ -5669,6 +6462,13 @@ var GqlResponseChannel = class {
5669
6462
  }
5670
6463
  /** Response channels are push-driven; resolvers populate mailboxes directly. */
5671
6464
  triggerPull() {}
6465
+ notePoll() {
6466
+ this.lastPollUtcMs = Date.now();
6467
+ }
6468
+ /** This channel is served: its holder's polls are the liveness it reports. */
6469
+ lastHolderPollUtcMs() {
6470
+ return this.lastPollUtcMs;
6471
+ }
5672
6472
  async init() {
5673
6473
  const cursors = await this.cursorStorage.list(this.remoteName);
5674
6474
  const inboxOrdinal = cursors.find((c) => c.cursorType === "inbox")?.cursorOrdinal ?? 0;
@@ -5702,7 +6502,7 @@ var GqlResponseChannel = class {
5702
6502
  */
5703
6503
  rememberUnserved(syncOps) {
5704
6504
  for (const syncOp of syncOps) {
5705
- if (syncOp.status === SyncOperationStatus.Applied) continue;
6505
+ if (syncOp.status === 2) continue;
5706
6506
  const first = firstOrdinalOf$1(syncOp);
5707
6507
  if (first > 0 && first < this.evictedOutboxFloor) this.evictedOutboxFloor = first;
5708
6508
  }
@@ -5747,7 +6547,7 @@ var GqlResponseChannel = class {
5747
6547
  unservedFloor() {
5748
6548
  let floor = this.evictedOutboxFloor;
5749
6549
  for (const syncOp of this.outbox.items) {
5750
- if (syncOp.status === SyncOperationStatus.Applied) continue;
6550
+ if (syncOp.status === 2) continue;
5751
6551
  const first = firstOrdinalOf$1(syncOp);
5752
6552
  if (first > 0 && first < floor) floor = first;
5753
6553
  }
@@ -5787,6 +6587,7 @@ function remoteCursorToRow(cursor) {
5787
6587
  };
5788
6588
  }
5789
6589
  var KyselySyncCursorStorage = class {
6590
+ db;
5790
6591
  constructor(db) {
5791
6592
  this.db = db;
5792
6593
  }
@@ -5862,6 +6663,7 @@ function deadLetterRecordToRow(record) {
5862
6663
  * PGlite/Kysely-backed implementation of {@link ISyncDeadLetterStorage}.
5863
6664
  */
5864
6665
  var KyselySyncDeadLetterStorage = class {
6666
+ db;
5865
6667
  constructor(db) {
5866
6668
  this.db = db;
5867
6669
  }
@@ -5983,6 +6785,7 @@ function remoteRecordToRow(remote) {
5983
6785
  };
5984
6786
  }
5985
6787
  var KyselySyncRemoteStorage = class {
6788
+ db;
5986
6789
  constructor(db) {
5987
6790
  this.db = db;
5988
6791
  }
@@ -6133,6 +6936,7 @@ var BatchAggregator = class {
6133
6936
  * to provide a fast path for jobs that have already synced.
6134
6937
  */
6135
6938
  var SyncAwaiter = class {
6939
+ eventBus;
6136
6940
  completedResults = /* @__PURE__ */ new Map();
6137
6941
  pendingWaiters = /* @__PURE__ */ new Map();
6138
6942
  unsubscribers = [];
@@ -6332,25 +7136,30 @@ var SyncStatusTracker = class {
6332
7136
  }
6333
7137
  };
6334
7138
  function deriveStatus(inboxCount, outboxCount, errorCount) {
6335
- if (errorCount > 0) return SyncStatus.Error;
6336
- if (inboxCount > 0 && outboxCount > 0) return SyncStatus.OutgoingAndIncoming;
6337
- if (inboxCount > 0) return SyncStatus.Incoming;
6338
- if (outboxCount > 0) return SyncStatus.Outgoing;
6339
- return SyncStatus.Synced;
7139
+ if (errorCount > 0) return "ERROR";
7140
+ if (inboxCount > 0 && outboxCount > 0) return "OUTGOING_AND_INCOMING";
7141
+ if (inboxCount > 0) return "INCOMING";
7142
+ if (outboxCount > 0) return "OUTGOING";
7143
+ return "SYNCED";
6340
7144
  }
6341
7145
  //#endregion
6342
7146
  //#region src/sync/sync-manager.ts
6343
- var OutboxMode = /* @__PURE__ */ function(OutboxMode) {
6344
- OutboxMode["Backfill"] = "backfill";
6345
- OutboxMode["BatchTriggered"] = "batch-triggered";
6346
- return OutboxMode;
6347
- }(OutboxMode || {});
6348
7147
  const defaultSyncManagerConfig = {
6349
7148
  maxDeadLettersPerRemote: 100,
6350
7149
  maxInboxBatchSize: 32,
6351
- maxHeldOperationsPerRemote: 1e4
7150
+ maxHeldOperationsPerRemote: 1e4,
7151
+ staleRemotePollWindowMs: 3e5
6352
7152
  };
6353
7153
  const PLAN_KEY_TO_JOB_UUID_CAP = 1e4;
7154
+ /**
7155
+ * Whether a channel failure says the caller could not authenticate or could not
7156
+ * reach the remote, rather than that the remote itself is misconfigured. The
7157
+ * remote record stays on disk for these so a retry after sign-in can re-add it.
7158
+ */
7159
+ function isCredentialOrNetworkError(error) {
7160
+ if (isDriveAuthError(error)) return true;
7161
+ return error instanceof GraphQLRequestError && error.category === "network";
7162
+ }
6354
7163
  /** Where a sync operation's run of ordinals begins. */
6355
7164
  function firstOrdinalOf(syncOp) {
6356
7165
  return syncOp.operations.length > 0 ? syncOp.operations[0].context.ordinal : 0;
@@ -6379,6 +7188,11 @@ var SyncManager = class {
6379
7188
  backfillAbortControllers = /* @__PURE__ */ new Map();
6380
7189
  planKeyToJobUuid = /* @__PURE__ */ new Map();
6381
7190
  evictedOutboxFloors = /* @__PURE__ */ new Map();
7191
+ prunePending = /* @__PURE__ */ new Set();
7192
+ pruneChain = Promise.resolve();
7193
+ derivingOutboxes = 0;
7194
+ pruneDrainDeferred = false;
7195
+ removing = /* @__PURE__ */ new Set();
6382
7196
  lastEnqueuedJobIdByKey = /* @__PURE__ */ new Map();
6383
7197
  inboxChunkChain = Promise.resolve();
6384
7198
  constructor(logger, remoteStorage, cursorStorage, deadLetterStorage, channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, config = {}) {
@@ -6430,18 +7244,19 @@ var SyncManager = class {
6430
7244
  await channel.init();
6431
7245
  } catch (error) {
6432
7246
  this.logger.error("Error initializing channel for remote (@name, @error)", record.name, error instanceof Error ? error.message : String(error));
6433
- this.remotes.delete(record.name);
7247
+ await this.dropRemoteAfterFailedInit(remote, false);
6434
7248
  continue;
6435
7249
  }
6436
7250
  const outboxAckOrdinal = remote.channel.outbox.ackOrdinal;
6437
7251
  if (outboxAckOrdinal > 0) {
6438
7252
  const backfillController = new AbortController();
6439
7253
  this.backfillAbortControllers.set(record.name, backfillController);
6440
- this.updateOutbox(remote, outboxAckOrdinal, OutboxMode.Backfill, backfillController.signal).catch((error) => {
7254
+ this.updateOutbox(remote, outboxAckOrdinal, "backfill", backfillController.signal).catch((error) => {
6441
7255
  if (backfillController.signal.aborted) return;
6442
7256
  this.logger.error("Backfill failed for remote @RemoteName: @Error", remote.meta.name, error instanceof Error ? error : new Error(String(error)));
6443
7257
  }).finally(() => {
6444
7258
  this.backfillAbortControllers.delete(record.name);
7259
+ this.drainPrunes();
6445
7260
  });
6446
7261
  }
6447
7262
  }
@@ -6455,6 +7270,8 @@ var SyncManager = class {
6455
7270
  this.backfillAbortControllers.clear();
6456
7271
  this.planKeyToJobUuid.clear();
6457
7272
  this.lastEnqueuedJobIdByKey.clear();
7273
+ this.prunePending.clear();
7274
+ this.pruneDrainDeferred = false;
6458
7275
  this.batchAggregator.clear();
6459
7276
  if (this.eventUnsubscribe) {
6460
7277
  this.eventUnsubscribe();
@@ -6548,17 +7365,17 @@ var SyncManager = class {
6548
7365
  try {
6549
7366
  await channel.init();
6550
7367
  } catch (error) {
6551
- this.remotes.delete(name);
6552
- await this.remoteStorage.remove(name);
7368
+ await this.dropRemoteAfterFailedInit(remote, !isCredentialOrNetworkError(error));
6553
7369
  throw error;
6554
7370
  }
6555
7371
  const backfillController = new AbortController();
6556
7372
  this.backfillAbortControllers.set(name, backfillController);
6557
- this.updateOutbox(remote, 0, OutboxMode.Backfill, backfillController.signal).catch((error) => {
7373
+ this.updateOutbox(remote, 0, "backfill", backfillController.signal).catch((error) => {
6558
7374
  if (backfillController.signal.aborted) return;
6559
7375
  this.logger.error("Backfill failed for remote @RemoteName: @Error", remote.meta.name, error instanceof Error ? error : new Error(String(error)));
6560
7376
  }).finally(() => {
6561
7377
  this.backfillAbortControllers.delete(name);
7378
+ this.drainPrunes();
6562
7379
  });
6563
7380
  return remote;
6564
7381
  }
@@ -6570,21 +7387,62 @@ var SyncManager = class {
6570
7387
  async remove(name) {
6571
7388
  const remote = this.remotes.get(name);
6572
7389
  if (!remote) throw new Error(`Remote with name '${name}' does not exist`);
7390
+ this.removing.add(name);
7391
+ try {
7392
+ await this.teardownRemoteResources(remote);
7393
+ await this.remoteStorage.remove(name);
7394
+ await this.cursorStorage.remove(name);
7395
+ } finally {
7396
+ this.remotes.delete(name);
7397
+ this.removing.delete(name);
7398
+ }
7399
+ }
7400
+ /**
7401
+ * Drops a remote whose channel.init() rejected, optionally removing its
7402
+ * stored record. Ordered like remove(): the registry slot is released last,
7403
+ * so a concurrent add of the same name cannot slip in and have its record
7404
+ * deleted by the removal below. A failure to tear down must not replace the
7405
+ * init error the caller has to classify.
7406
+ */
7407
+ async dropRemoteAfterFailedInit(remote, removeStorageRecord) {
7408
+ const name = remote.meta.name;
7409
+ this.removing.add(name);
7410
+ try {
7411
+ await this.teardownRemoteResources(remote);
7412
+ if (removeStorageRecord) await this.remoteStorage.remove(name);
7413
+ } catch (error) {
7414
+ this.logger.error("Error tearing down remote after failed init (@name, @error)", name, error instanceof Error ? error.message : String(error));
7415
+ } finally {
7416
+ this.remotes.delete(name);
7417
+ this.removing.delete(name);
7418
+ }
7419
+ }
7420
+ /**
7421
+ * Releases everything wiring a remote up holds: the in-flight backfill, the
7422
+ * channel, the status tracker entry and the connection-state subscription.
7423
+ * The registry slot is deliberately NOT released here -- the caller holds it
7424
+ * until any storage removal has finished, so a concurrent add of the same
7425
+ * name is refused for the whole teardown.
7426
+ */
7427
+ async teardownRemoteResources(remote) {
7428
+ const name = remote.meta.name;
6573
7429
  const backfillController = this.backfillAbortControllers.get(name);
6574
7430
  if (backfillController) {
6575
7431
  backfillController.abort();
6576
7432
  this.backfillAbortControllers.delete(name);
6577
7433
  }
6578
- await remote.channel.shutdown();
6579
- await this.remoteStorage.remove(name);
6580
- await this.cursorStorage.remove(name);
6581
- this.syncStatusTracker.untrackRemote(name);
6582
- const unsub = this.connectionStateUnsubscribes.get(name);
6583
- if (unsub) {
6584
- unsub();
6585
- this.connectionStateUnsubscribes.delete(name);
7434
+ try {
7435
+ await remote.channel.shutdown();
7436
+ } finally {
7437
+ this.syncStatusTracker.untrackRemote(name);
7438
+ const unsub = this.connectionStateUnsubscribes.get(name);
7439
+ if (unsub) {
7440
+ unsub();
7441
+ this.connectionStateUnsubscribes.delete(name);
7442
+ }
7443
+ this.evictedOutboxFloors.delete(name);
7444
+ this.prunePending.delete(name);
6586
7445
  }
6587
- this.remotes.delete(name);
6588
7446
  }
6589
7447
  list() {
6590
7448
  return Array.from(this.remotes.values());
@@ -6632,7 +7490,7 @@ var SyncManager = class {
6632
7490
  scopes: syncOp.scopes,
6633
7491
  branch: syncOp.branch,
6634
7492
  operations: syncOp.operations,
6635
- errorSource: syncOp.error?.source ?? ChannelErrorSource.None,
7493
+ errorSource: syncOp.error?.source ?? "none",
6636
7494
  errorMessage: syncOp.error?.error.message ?? "unknown",
6637
7495
  errorType
6638
7496
  };
@@ -6679,7 +7537,7 @@ var SyncManager = class {
6679
7537
  this.logger.debug("Loaded @count persisted dead letters for remote @name", records.length, remote.meta.name);
6680
7538
  }
6681
7539
  getRemotesForCollection(collectionId) {
6682
- return Array.from(this.remotes.values()).filter((remote) => remote.meta.collectionId.key === collectionId);
7540
+ return Array.from(this.remotes.values()).filter((remote) => remote.meta.collectionId.key === collectionId && !this.removing.has(remote.meta.name));
6683
7541
  }
6684
7542
  async processCompleteBatch(batch) {
6685
7543
  if (this.isShutdown) return;
@@ -6690,7 +7548,11 @@ var SyncManager = class {
6690
7548
  for (const remote of remotes) if (!affectedRemotes.includes(remote)) affectedRemotes.push(remote);
6691
7549
  }
6692
7550
  for (const remote of affectedRemotes) trimMailboxFromBatch(remote.channel.inbox, batch);
6693
- for (const remote of affectedRemotes) await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, OutboxMode.BatchTriggered);
7551
+ for (const remote of affectedRemotes) {
7552
+ if (!this.remotes.has(remote.meta.name) || this.removing.has(remote.meta.name)) continue;
7553
+ await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, "batch-triggered");
7554
+ }
7555
+ await this.drainPrunes();
6694
7556
  }
6695
7557
  handleInboxAdded(remote, syncOps) {
6696
7558
  if (this.isShutdown) return;
@@ -6730,7 +7592,7 @@ var SyncManager = class {
6730
7592
  if (this.isShutdown) return;
6731
7593
  const err = error instanceof Error ? error : new Error(String(error));
6732
7594
  this.logger.error("Failed to load operations from inbox (@remote, @documentId, @error)", remote.meta.name, syncOp.documentId, err.message);
6733
- const channelError = new ChannelError(ChannelErrorSource.Inbox, err);
7595
+ const channelError = new ChannelError("inbox", err);
6734
7596
  syncOp.failed(channelError);
6735
7597
  remote.channel.deadLetter.add(syncOp);
6736
7598
  remote.channel.inbox.remove(syncOp);
@@ -6743,14 +7605,14 @@ var SyncManager = class {
6743
7605
  if (this.isShutdown) return;
6744
7606
  const err = error instanceof Error ? error : new Error(String(error));
6745
7607
  this.logger.error("Failed to wait for job completion (@remote, @documentId, @jobId, @error)", remote.meta.name, syncOp.documentId, jobInfo.id, err.message);
6746
- const channelError = new ChannelError(ChannelErrorSource.Inbox, err);
7608
+ const channelError = new ChannelError("inbox", err);
6747
7609
  syncOp.failed(channelError);
6748
7610
  remote.channel.deadLetter.add(syncOp);
6749
7611
  remote.channel.inbox.remove(syncOp);
6750
7612
  return;
6751
7613
  }
6752
7614
  if (this.isShutdown) return;
6753
- if (completedJobInfo.status === JobStatus.FAILED) {
7615
+ if (completedJobInfo.status === "FAILED") {
6754
7616
  const errorMessage = completedJobInfo.error?.message || "Unknown error";
6755
7617
  this.logger.error("Failed to apply operations from inbox (@remote, @documentId, @jobId, @error)", remote.meta.name, syncOp.documentId, completedJobInfo.id, errorMessage);
6756
7618
  syncOp.failed(this.inboxFailure(completedJobInfo.error));
@@ -6793,7 +7655,7 @@ var SyncManager = class {
6793
7655
  if (this.isShutdown) return;
6794
7656
  for (const { remote, syncOp } of items) {
6795
7657
  const err = error instanceof Error ? error : new Error(String(error));
6796
- syncOp.failed(new ChannelError(ChannelErrorSource.Inbox, err));
7658
+ syncOp.failed(new ChannelError("inbox", err));
6797
7659
  remote.channel.deadLetter.add(syncOp);
6798
7660
  remote.channel.inbox.remove(syncOp);
6799
7661
  }
@@ -6810,7 +7672,7 @@ var SyncManager = class {
6810
7672
  for (const { remote, syncOp } of items) {
6811
7673
  if (!(syncOp.jobId in result.jobs)) {
6812
7674
  this.logger.error("Job key missing from batch load result (@remote, @documentId, @jobId)", remote.meta.name, syncOp.documentId, syncOp.jobId);
6813
- const error = new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Job key '${syncOp.jobId}' missing from batch load result`));
7675
+ const error = new ChannelError("inbox", /* @__PURE__ */ new Error(`Job key '${syncOp.jobId}' missing from batch load result`));
6814
7676
  syncOp.failed(error);
6815
7677
  remote.channel.deadLetter.add(syncOp);
6816
7678
  remote.channel.inbox.remove(syncOp);
@@ -6823,13 +7685,13 @@ var SyncManager = class {
6823
7685
  } catch (error) {
6824
7686
  if (this.isShutdown) continue;
6825
7687
  const err = error instanceof Error ? error : new Error(String(error));
6826
- syncOp.failed(new ChannelError(ChannelErrorSource.Inbox, err));
7688
+ syncOp.failed(new ChannelError("inbox", err));
6827
7689
  remote.channel.deadLetter.add(syncOp);
6828
7690
  remote.channel.inbox.remove(syncOp);
6829
7691
  continue;
6830
7692
  }
6831
7693
  if (this.isShutdown) return;
6832
- if (completedJobInfo.status === JobStatus.FAILED) {
7694
+ if (completedJobInfo.status === "FAILED") {
6833
7695
  syncOp.failed(this.inboxFailure(completedJobInfo.error));
6834
7696
  remote.channel.deadLetter.add(syncOp);
6835
7697
  } else syncOp.executed();
@@ -6848,7 +7710,7 @@ var SyncManager = class {
6848
7710
  */
6849
7711
  inboxFailure(error) {
6850
7712
  const message = error?.message || "Unknown error";
6851
- return new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${message}`), classifyJobFailure(error?.name ?? "Error"));
7713
+ return new ChannelError("inbox", /* @__PURE__ */ new Error(`Failed to apply operations: ${message}`), classifyJobFailure(error?.name ?? "Error"));
6852
7714
  }
6853
7715
  /**
6854
7716
  * Where the next outbox derivation starts: at the evicted floor when there is
@@ -6875,7 +7737,7 @@ var SyncManager = class {
6875
7737
  */
6876
7738
  evictPastOutboxBound(remote) {
6877
7739
  const cap = this.config.maxHeldOperationsPerRemote;
6878
- const queued = remote.channel.outbox.items.filter((syncOp) => syncOp.status === SyncOperationStatus.Unknown).sort((a, b) => firstOrdinalOf(a) - firstOrdinalOf(b));
7740
+ const queued = remote.channel.outbox.items.filter((syncOp) => syncOp.status === -1).sort((a, b) => firstOrdinalOf(a) - firstOrdinalOf(b));
6879
7741
  let kept = 0;
6880
7742
  let cut = queued.length;
6881
7743
  for (let i = 0; i < queued.length; i++) {
@@ -6891,15 +7753,95 @@ var SyncManager = class {
6891
7753
  const floor = firstOrdinalOf(evicted[0]);
6892
7754
  const known = this.evictedOutboxFloors.get(remote.meta.name);
6893
7755
  this.evictedOutboxFloors.set(remote.meta.name, known === void 0 ? floor : Math.min(known, floor));
6894
- this.logger.warn("Outbox for @RemoteName is past its bound of @Cap operations; evicting @Count entries from ordinal @Floor, to be derived again once it drains", remote.meta.name, cap, evicted.length, floor);
7756
+ const staleMs = this.stalePollAgeMs(remote);
7757
+ if (staleMs !== void 0) {
7758
+ let held = kept;
7759
+ for (const syncOp of evicted) held += syncOp.operations.length;
7760
+ if (!this.prunePending.has(remote.meta.name)) {
7761
+ this.prunePending.add(remote.meta.name);
7762
+ this.logger.warn("Outbox for @RemoteName (@Collection) is past its bound of @Cap operations holding @Held, and it has not been polled for @StaleMs ms; marking the channel for removal once this derivation ends", remote.meta.name, remote.meta.collectionId.key, cap, held, staleMs);
7763
+ }
7764
+ } else this.logger.warn("Outbox for @RemoteName is past its bound of @Cap operations; evicting @Count entries from ordinal @Floor, to be derived again once it drains", remote.meta.name, cap, evicted.length, floor);
6895
7765
  remote.channel.outbox.remove(...evicted);
6896
7766
  }
7767
+ /**
7768
+ * How long a served remote's holder has been silent, if past the window.
7769
+ *
7770
+ * The channel is asked, rather than its config inspected: SyncManager serves
7771
+ * and subscribes with the same interface, both kinds report lastSuccessUtcMs,
7772
+ * and the caller-supplied channelConfig.type is a free-form string the
7773
+ * factories do not read -- so neither could tell a dead served channel from a
7774
+ * client whose switchboard is merely unreachable. Only a channel that claims
7775
+ * a holder by reporting when it last heard from one can be removed for that
7776
+ * holder's silence; a channel that reports nothing (or 0) is never pruned.
7777
+ */
7778
+ stalePollAgeMs(remote) {
7779
+ const last = remote.channel.lastHolderPollUtcMs();
7780
+ if (last === void 0 || last <= 0) return;
7781
+ const age = Date.now() - last;
7782
+ return age >= this.config.staleRemotePollWindowMs ? age : void 0;
7783
+ }
7784
+ /**
7785
+ * Removes the remotes marked stale during eviction.
7786
+ *
7787
+ * Removing a remote that a derivation is still iterating would pull its
7788
+ * mailboxes out from under it, so a drain that arrives during one is deferred
7789
+ * rather than run; the last derivation to finish re-arms it.
7790
+ */
7791
+ drainPrunes() {
7792
+ if (this.derivingOutboxes > 0) {
7793
+ this.pruneDrainDeferred = true;
7794
+ return Promise.resolve();
7795
+ }
7796
+ const next = this.pruneChain.then(async () => {
7797
+ if (this.isShutdown) return;
7798
+ for (const name of [...this.prunePending]) {
7799
+ if (this.derivingOutboxes > 0) {
7800
+ this.pruneDrainDeferred = true;
7801
+ return;
7802
+ }
7803
+ this.prunePending.delete(name);
7804
+ const remote = this.remotes.get(name);
7805
+ if (!remote) continue;
7806
+ if (this.stalePollAgeMs(remote) === void 0) {
7807
+ this.logger.info("Stale removal of @name revoked: its holder polled while the outbox was being derived", name);
7808
+ continue;
7809
+ }
7810
+ try {
7811
+ await this.remove(name);
7812
+ } catch (error) {
7813
+ this.logger.error("Failed to remove stale remote (@name, @error)", name, error instanceof Error ? error.message : String(error));
7814
+ }
7815
+ }
7816
+ });
7817
+ this.pruneChain = next.catch(() => {});
7818
+ return next;
7819
+ }
6897
7820
  outboxOperationCount(remote) {
6898
7821
  let count = 0;
6899
7822
  for (const syncOp of remote.channel.outbox.items) count += syncOp.operations.length;
6900
7823
  return count;
6901
7824
  }
6902
- async updateOutbox(remote, ackOrdinal, mode = OutboxMode.Backfill, signal) {
7825
+ /**
7826
+ * Derives this remote's outbox, holding off prunes for the duration.
7827
+ *
7828
+ * A backfill elsewhere can finish at any await in here and drain the prunes
7829
+ * it marked; the count is what keeps that drain from removing the remote this
7830
+ * call is still adding to.
7831
+ */
7832
+ async updateOutbox(remote, ackOrdinal, mode = "backfill", signal) {
7833
+ this.derivingOutboxes++;
7834
+ try {
7835
+ await this.deriveOutbox(remote, ackOrdinal, mode, signal);
7836
+ } finally {
7837
+ this.derivingOutboxes--;
7838
+ if (this.derivingOutboxes === 0 && this.pruneDrainDeferred) {
7839
+ this.pruneDrainDeferred = false;
7840
+ this.drainPrunes();
7841
+ }
7842
+ }
7843
+ }
7844
+ async deriveOutbox(remote, ackOrdinal, mode, signal) {
6903
7845
  const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
6904
7846
  const startOrdinal = this.refillOrdinal(remote, ackOrdinal);
6905
7847
  let maxOrdinal = startOrdinal;
@@ -6915,11 +7857,11 @@ var SyncManager = class {
6915
7857
  const prevJobId = lastJobByDoc.get(batch.documentId);
6916
7858
  const deps = [];
6917
7859
  if (prevJobId) deps.push(prevJobId);
6918
- if (mode === OutboxMode.BatchTriggered && prevChainJobId && prevChainJobId !== prevJobId) deps.push(prevChainJobId);
7860
+ if (mode === "batch-triggered" && prevChainJobId && prevChainJobId !== prevJobId) deps.push(prevChainJobId);
6919
7861
  const syncOp = new SyncOperation(crypto.randomUUID(), jobId, deps, remote.meta.name, batch.documentId, [batch.scope], batch.branch, batch.operations);
6920
7862
  syncOps.push(syncOp);
6921
7863
  lastJobByDoc.set(batch.documentId, jobId);
6922
- if (mode === OutboxMode.BatchTriggered) prevChainJobId = jobId;
7864
+ if (mode === "batch-triggered") prevChainJobId = jobId;
6923
7865
  }
6924
7866
  remote.channel.outbox.add(...syncOps);
6925
7867
  this.evictPastOutboxBound(remote);
@@ -7001,6 +7943,10 @@ var SyncBuilder = class {
7001
7943
  this.config.maxHeldOperationsPerRemote = limit;
7002
7944
  return this;
7003
7945
  }
7946
+ withStaleRemotePollWindowMs(windowMs) {
7947
+ this.config.staleRemotePollWindowMs = windowMs;
7948
+ return this;
7949
+ }
7004
7950
  build(reactor, logger, operationIndex, eventBus, db, driveContainerTypes) {
7005
7951
  return this.buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes).syncManager;
7006
7952
  }
@@ -7166,6 +8112,16 @@ var Reactor = class {
7166
8112
  throwIfAborted(signal, () => new AbortError());
7167
8113
  return relationships.results.map((rel) => rel.sourceId);
7168
8114
  }
8115
+ async getOutgoingRelationshipEdges(sourceId, relationshipType, paging, consistencyToken, signal) {
8116
+ const relationships = await this.documentIndexer.getOutgoing(sourceId, relationshipType ? [relationshipType] : void 0, paging, consistencyToken, signal);
8117
+ throwIfAborted(signal, () => new AbortError());
8118
+ return relationships;
8119
+ }
8120
+ async getIncomingRelationshipEdges(targetId, relationshipType, paging, consistencyToken, signal) {
8121
+ const relationships = await this.documentIndexer.getIncoming(targetId, relationshipType ? [relationshipType] : void 0, paging, consistencyToken, signal);
8122
+ throwIfAborted(signal, () => new AbortError());
8123
+ return relationships;
8124
+ }
7169
8125
  async getOperations(documentId, view, filter, paging, consistencyToken, signal) {
7170
8126
  this.logger.verbose("getOperations(@documentId, @view, @filter, @paging)", documentId, view, filter, paging);
7171
8127
  const branch = view?.branch || "main";
@@ -7213,7 +8169,7 @@ var Reactor = class {
7213
8169
  this.logger.verbose("create(@id, @type, @slug)", document.header.id, document.header.documentType, document.header.slug);
7214
8170
  const createdAtUtcIso = (/* @__PURE__ */ new Date()).toISOString();
7215
8171
  throwIfAborted(signal, () => new AbortError());
7216
- let actions = [createDocumentAction({
8172
+ const createAction = createDocumentAction({
7217
8173
  model: document.header.documentType,
7218
8174
  version: 0,
7219
8175
  documentId: document.header.id,
@@ -7229,14 +8185,20 @@ var Reactor = class {
7229
8185
  branch: document.header.branch,
7230
8186
  meta: document.header.meta,
7231
8187
  protocolVersions: document.header.protocolVersions ?? { "base-reducer": 2 }
7232
- }), upgradeDocumentAction({
8188
+ });
8189
+ const upgradeAction = upgradeDocumentAction({
7233
8190
  documentId: document.header.id,
7234
8191
  model: document.header.documentType,
7235
8192
  fromVersion: 0,
7236
8193
  toVersion: normalizeDocumentModelVersion(document.state.document?.version),
7237
8194
  initialState: document.state
7238
- })];
7239
- if (signer) actions = await signActions(actions, signer, signal);
8195
+ });
8196
+ const branch = document.header.branch || "main";
8197
+ let actions = [createAction, upgradeAction];
8198
+ if (signer) actions = await signActions(actions, signer, {
8199
+ documentId: document.header.id,
8200
+ branch
8201
+ }, signal);
7240
8202
  const jobId = v4();
7241
8203
  const jobMeta = buildSingleJobMeta(jobId, meta);
7242
8204
  const job = {
@@ -7244,7 +8206,7 @@ var Reactor = class {
7244
8206
  kind: "mutation",
7245
8207
  documentId: document.header.id,
7246
8208
  scope: "document",
7247
- branch: "main",
8209
+ branch,
7248
8210
  actions,
7249
8211
  operations: [],
7250
8212
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -7256,7 +8218,7 @@ var Reactor = class {
7256
8218
  const jobInfo = {
7257
8219
  id: jobId,
7258
8220
  documentId: job.documentId,
7259
- status: JobStatus.PENDING,
8221
+ status: "PENDING",
7260
8222
  createdAtUtcIso,
7261
8223
  consistencyToken: {
7262
8224
  version: 1,
@@ -7275,7 +8237,10 @@ var Reactor = class {
7275
8237
  const createdAtUtcIso = (/* @__PURE__ */ new Date()).toISOString();
7276
8238
  throwIfAborted(signal, () => new AbortError());
7277
8239
  let action = deleteDocumentAction(id);
7278
- if (signer) action = await signAction(action, signer, signal);
8240
+ if (signer) action = await signAction(action, signer, {
8241
+ documentId: id,
8242
+ branch: "main"
8243
+ }, signal);
7279
8244
  const jobId = v4();
7280
8245
  const jobMeta = buildSingleJobMeta(jobId, meta);
7281
8246
  const job = {
@@ -7295,7 +8260,7 @@ var Reactor = class {
7295
8260
  const jobInfo = {
7296
8261
  id: jobId,
7297
8262
  documentId: job.documentId,
7298
- status: JobStatus.PENDING,
8263
+ status: "PENDING",
7299
8264
  createdAtUtcIso,
7300
8265
  consistencyToken: {
7301
8266
  version: 1,
@@ -7333,7 +8298,7 @@ var Reactor = class {
7333
8298
  const jobInfo = {
7334
8299
  id: jobId,
7335
8300
  documentId: job.documentId,
7336
- status: JobStatus.PENDING,
8301
+ status: "PENDING",
7337
8302
  createdAtUtcIso,
7338
8303
  consistencyToken: {
7339
8304
  version: 1,
@@ -7373,7 +8338,7 @@ var Reactor = class {
7373
8338
  const jobInfo = {
7374
8339
  id: jobId,
7375
8340
  documentId: job.documentId,
7376
- status: JobStatus.PENDING,
8341
+ status: "PENDING",
7377
8342
  createdAtUtcIso,
7378
8343
  consistencyToken: {
7379
8344
  version: 1,
@@ -7408,7 +8373,7 @@ var Reactor = class {
7408
8373
  const jobInfo = {
7409
8374
  id: planKeyToJobId.get(jobPlan.key),
7410
8375
  documentId: jobPlan.documentId,
7411
- status: JobStatus.PENDING,
8376
+ status: "PENDING",
7412
8377
  createdAtUtcIso,
7413
8378
  consistencyToken: {
7414
8379
  version: 1,
@@ -7478,7 +8443,7 @@ var Reactor = class {
7478
8443
  const jobInfo = {
7479
8444
  id: planKeyToJobId.get(jobPlan.key),
7480
8445
  documentId: jobPlan.documentId,
7481
- status: JobStatus.PENDING,
8446
+ status: "PENDING",
7482
8447
  createdAtUtcIso,
7483
8448
  consistencyToken: {
7484
8449
  version: 1,
@@ -7528,18 +8493,34 @@ var Reactor = class {
7528
8493
  }
7529
8494
  return { jobs: Object.fromEntries(jobInfos) };
7530
8495
  }
7531
- async addRelationship(sourceId, targetId, relationshipType, branch = "main", signer, signal) {
7532
- this.logger.verbose("addRelationship(@sourceId, @targetId, @relationshipType, @branch)", sourceId, targetId, relationshipType, branch);
8496
+ async addRelationship(sourceId, targetId, relationshipType, metadata, branch = "main", signer, signal) {
8497
+ this.logger.verbose("addRelationship(@sourceId, @targetId, @relationshipType, @metadata, @branch)", sourceId, targetId, relationshipType, metadata, branch);
8498
+ throwIfAborted(signal, () => new AbortError());
8499
+ let actions = [addRelationshipAction(sourceId, targetId, relationshipType, metadata)];
8500
+ if (signer) actions = await signActions(actions, signer, {
8501
+ documentId: sourceId,
8502
+ branch
8503
+ }, signal);
8504
+ return await this.execute(sourceId, branch, actions, signal);
8505
+ }
8506
+ async updateRelationship(sourceId, targetId, relationshipType, metadata, branch = "main", signer, signal) {
8507
+ this.logger.verbose("updateRelationship(@sourceId, @targetId, @relationshipType, @metadata, @branch)", sourceId, targetId, relationshipType, metadata, branch);
7533
8508
  throwIfAborted(signal, () => new AbortError());
7534
- let actions = [addRelationshipAction(sourceId, targetId, relationshipType)];
7535
- if (signer) actions = await signActions(actions, signer, signal);
8509
+ let actions = [updateRelationshipAction(sourceId, targetId, relationshipType, metadata)];
8510
+ if (signer) actions = await signActions(actions, signer, {
8511
+ documentId: sourceId,
8512
+ branch
8513
+ }, signal);
7536
8514
  return await this.execute(sourceId, branch, actions, signal);
7537
8515
  }
7538
8516
  async removeRelationship(sourceId, targetId, relationshipType, branch = "main", signer, signal) {
7539
8517
  this.logger.verbose("removeRelationship(@sourceId, @targetId, @relationshipType, @branch)", sourceId, targetId, relationshipType, branch);
7540
8518
  throwIfAborted(signal, () => new AbortError());
7541
8519
  let actions = [removeRelationshipAction(sourceId, targetId, relationshipType)];
7542
- if (signer) actions = await signActions(actions, signer, signal);
8520
+ if (signer) actions = await signActions(actions, signer, {
8521
+ documentId: sourceId,
8522
+ branch
8523
+ }, signal);
7543
8524
  return await this.execute(sourceId, branch, actions, signal);
7544
8525
  }
7545
8526
  getJobStatus(jobId, signal) {
@@ -7551,7 +8532,7 @@ var Reactor = class {
7551
8532
  return Promise.resolve({
7552
8533
  id: jobId,
7553
8534
  documentId: "",
7554
- status: JobStatus.FAILED,
8535
+ status: "FAILED",
7555
8536
  createdAtUtcIso: now,
7556
8537
  completedAtUtcIso: now,
7557
8538
  error: toErrorInfo$1("Job not found"),
@@ -7643,8 +8624,11 @@ var ReactorBuilder = class {
7643
8624
  eventBus;
7644
8625
  readModelCoordinator;
7645
8626
  readModelCoordinatorFactory;
7646
- signatureVerifier;
7647
8627
  kyselyInstance;
8628
+ signer;
8629
+ workerSigner;
8630
+ trustPolicy;
8631
+ workerTrustPolicy;
7648
8632
  signalHandlersEnabled = false;
7649
8633
  queueInstance;
7650
8634
  channelScheme;
@@ -7743,10 +8727,34 @@ var ReactorBuilder = class {
7743
8727
  this.eventBus = eventBus;
7744
8728
  return this;
7745
8729
  }
7746
- withSignatureVerifier(verifier) {
7747
- this.signatureVerifier = verifier;
8730
+ /**
8731
+ * Signs the operations the executor synthesizes: the NOOP an UNDO becomes and
8732
+ * the action a REDO rebuilds. Without one they are stored unsigned. Pooled
8733
+ * workers import `workerSigner` to build the same signer.
8734
+ */
8735
+ withSigner(signer, workerSigner) {
8736
+ this.signer = signer;
8737
+ this.workerSigner = workerSigner;
8738
+ return this;
8739
+ }
8740
+ /** Whether {@link withSigner} was called. */
8741
+ hasSigner() {
8742
+ return this.signer !== void 0;
8743
+ }
8744
+ /**
8745
+ * Decides at admission whether a key may sign as the user it claims; see
8746
+ * {@link SignatureTrustPolicy} for the contract and the default. Pooled
8747
+ * workers import `workerTrustPolicy` to build the same policy.
8748
+ */
8749
+ withTrustPolicy(trustPolicy, workerTrustPolicy) {
8750
+ this.trustPolicy = trustPolicy;
8751
+ this.workerTrustPolicy = workerTrustPolicy;
7748
8752
  return this;
7749
8753
  }
8754
+ /** Whether {@link withTrustPolicy} was called. */
8755
+ hasTrustPolicy() {
8756
+ return this.trustPolicy !== void 0;
8757
+ }
7750
8758
  withKysely(kysely) {
7751
8759
  this.kyselyInstance = kysely;
7752
8760
  return this;
@@ -7800,8 +8808,7 @@ var ReactorBuilder = class {
7800
8808
  * this enables the pool — there is no `enabled` flag. Provide `db`
7801
8809
  * (each worker opens its own Postgres pool; the parent database is built
7802
8810
  * from it too unless {@link withKysely} is set) or a custom `factory`
7803
- * transport. `verifier` is imported by the default transport's workers;
7804
- * omitted = no executor-side signature verification.
8811
+ * transport.
7805
8812
  */
7806
8813
  withWorkerPool(options) {
7807
8814
  this.workerPool = options;
@@ -7876,11 +8883,12 @@ var ReactorBuilder = class {
7876
8883
  if (resolver instanceof DocumentModelResolver) resolver.setModelLoadedHook((documentType) => eventBus.emit(ReactorEventTypes.MODEL_LOADED, { documentType }));
7877
8884
  const queue = this.queueInstance ?? new InMemoryQueue(eventBus, resolver);
7878
8885
  const jobTracker = new InMemoryJobTracker(eventBus);
7879
- const writeCache = new KyselyWriteCache(keyframeStore, operationStore, documentModelRegistry, {
8886
+ const cacheConfig = {
7880
8887
  maxDocuments: this.writeCacheConfig?.maxDocuments ?? 100,
7881
8888
  ringBufferSize: this.writeCacheConfig?.ringBufferSize ?? 10,
7882
8889
  keyframeInterval: this.writeCacheConfig?.keyframeInterval ?? 10
7883
- });
8890
+ };
8891
+ const writeCache = new KyselyWriteCache(keyframeStore, operationStore, documentModelRegistry, cacheConfig);
7884
8892
  await writeCache.startup();
7885
8893
  const operationIndex = new KyselyOperationIndex(database);
7886
8894
  const documentMetaCache = new DocumentMetaCache(operationStore, { maxDocuments: 1e3 });
@@ -7889,33 +8897,49 @@ var ReactorBuilder = class {
7889
8897
  const executionScope = new KyselyExecutionScope(database, operationStore, operationIndex, keyframeStore, writeCache, documentMetaCache, collectionMembershipCache);
7890
8898
  let executorManager = this.executorManager;
7891
8899
  let executorStartCount = this.executorConfig.maxConcurrency ?? 1;
7892
- if (!executorManager) if (this.workerPool) {
7893
- const pool = this.workerPool;
7894
- let factory = pool.factory;
7895
- if (!factory) {
7896
- if (pool.db === void 0) throw new Error("unreachable: worker pool configured without db or factory");
7897
- factory = await this.createDefaultWorkerFactory(pool.numWorkers, pool.db, pool.verifier);
7898
- }
7899
- const poolManager = new WorkerPoolJobExecutorManager(factory, eventBus, queue, jobTracker, this.logger, resolver, operationIndex, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
7900
- executorManager = poolManager;
7901
- executorStartCount = pool.numWorkers;
7902
- if (resolver instanceof DocumentModelResolver) resolver.setBroadcastHook((entry) => poolManager.loadModel(entry));
7903
- } else executorManager = new SimpleJobExecutorManager(() => new SimpleJobExecutor(this.logger, documentModelRegistry, operationStore, eventBus, writeCache, operationIndex, documentMetaCache, collectionMembershipCache, this.driveContainerTypes, this.executorConfig, this.signatureVerifier, executionScope), eventBus, queue, jobTracker, this.logger, resolver, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
8900
+ if (!executorManager) {
8901
+ if (this.workerPool) {
8902
+ const pool = this.workerPool;
8903
+ let factory = pool.factory;
8904
+ if (!factory) {
8905
+ if (pool.db === void 0) throw new Error("unreachable: worker pool configured without db or factory");
8906
+ if (this.signer && !this.workerSigner) this.logger.warn("Worker pool has no signer spec; pooled workers store synthesized operations unsigned");
8907
+ if (this.trustPolicy && !this.workerTrustPolicy) this.logger.warn("Worker pool has no trust policy spec; pooled workers apply the default trust policy");
8908
+ factory = await this.createDefaultWorkerFactory(pool.numWorkers, pool.db);
8909
+ }
8910
+ const poolManager = new WorkerPoolJobExecutorManager(factory, eventBus, queue, jobTracker, this.logger, resolver, operationIndex, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
8911
+ executorManager = poolManager;
8912
+ executorStartCount = pool.numWorkers;
8913
+ if (resolver instanceof DocumentModelResolver) resolver.setBroadcastHook((entry) => poolManager.loadModel(entry));
8914
+ } else executorManager = new SimpleJobExecutorManager(() => new SimpleJobExecutor(this.logger, documentModelRegistry, operationStore, eventBus, writeCache, operationIndex, documentMetaCache, collectionMembershipCache, this.driveContainerTypes, this.executorConfig, executionScope, this.signer, this.trustPolicy), eventBus, queue, jobTracker, this.logger, resolver, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
8915
+ }
7904
8916
  await executorManager.start(executorStartCount);
7905
- const callerReadModels = Array.from(new Set([...this.readModels]));
8917
+ const callerReadModels = Array.from(/* @__PURE__ */ new Set([...this.readModels]));
8918
+ const degradedComponents = [];
8919
+ const startDegraded = (component, error) => {
8920
+ degradedComponents.push({
8921
+ component,
8922
+ error: error instanceof Error ? error : new Error(String(error))
8923
+ });
8924
+ this.logger?.error("Reactor component started degraded: @component", component, error);
8925
+ };
8926
+ const readModelIndexing = {
8927
+ commitChunkSize: 50,
8928
+ yieldDeadlineMs: this.executorConfig.yieldDeadlineMs ?? 50
8929
+ };
7906
8930
  const documentViewConsistencyTracker = new ConsistencyTracker();
7907
- const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
8931
+ const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions ? "StateAtDeletion" : "NotFound", readModelIndexing);
7908
8932
  try {
7909
8933
  await documentView.init();
7910
8934
  } catch (error) {
7911
- console.error("Error initializing document view", error);
8935
+ startDegraded("document view", error);
7912
8936
  }
7913
8937
  const documentIndexerConsistencyTracker = new ConsistencyTracker();
7914
- const documentIndexer = new KyselyDocumentIndexer(database, operationIndex, writeCache, documentIndexerConsistencyTracker);
8938
+ const documentIndexer = new KyselyDocumentIndexer(database, operationIndex, writeCache, documentIndexerConsistencyTracker, readModelIndexing);
7915
8939
  try {
7916
8940
  await documentIndexer.init();
7917
8941
  } catch (error) {
7918
- console.error("Error initializing document indexer", error);
8942
+ startDegraded("document indexer", error);
7919
8943
  }
7920
8944
  const subscriptionManager = new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
7921
8945
  const subscriptionNotificationReadModel = new SubscriptionNotificationReadModel(subscriptionManager, documentView);
@@ -7924,9 +8948,9 @@ var ReactorBuilder = class {
7924
8948
  try {
7925
8949
  await processorManager.init();
7926
8950
  } catch (error) {
7927
- console.error("Error initializing processor manager", error);
8951
+ startDegraded("processor manager", error);
7928
8952
  }
7929
- for (const factory of this.readModelFactories) {
8953
+ for (const [index, factory] of this.readModelFactories.entries()) try {
7930
8954
  const readModel = await factory({
7931
8955
  documentModelRegistry,
7932
8956
  operationIndex,
@@ -7934,6 +8958,8 @@ var ReactorBuilder = class {
7934
8958
  processorManagerConsistencyTracker
7935
8959
  });
7936
8960
  callerReadModels.push(readModel);
8961
+ } catch (error) {
8962
+ startDegraded(`read model ${index}${factory.name ? ` (${factory.name})` : ""}`, error);
7937
8963
  }
7938
8964
  const readModelInstances = [
7939
8965
  ...callerReadModels,
@@ -7952,13 +8978,13 @@ var ReactorBuilder = class {
7952
8978
  processorManager,
7953
8979
  documentView,
7954
8980
  documentIndexer,
7955
- createProjectionShardManager: (config) => this.createProjectionShardManager(config, eventBus, hostTrackers, false),
8981
+ createProjectionShardManager: (config) => this.createProjectionShardManager(config, eventBus, hostTrackers, readModelIndexing, false),
7956
8982
  registerShutdownHook: (hook) => this.shutdownHooks.push(hook)
7957
- }) : this.projectionShardConfig ? await this.createProjectionShardManager(this.projectionShardConfig, eventBus, hostTrackers, true) : new ReadModelCoordinator(eventBus, readModelInstances, [subscriptionNotificationReadModel, processorManager]);
8983
+ }) : this.projectionShardConfig ? await this.createProjectionShardManager(this.projectionShardConfig, eventBus, hostTrackers, readModelIndexing, true) : new ReadModelCoordinator(eventBus, readModelInstances, [subscriptionNotificationReadModel, processorManager]);
7958
8984
  const reactor = new Reactor(this.logger, documentModelRegistry, queue, jobTracker, readModelCoordinator, this.features, documentView, documentIndexer, operationStore, eventBus, executorManager);
7959
8985
  let syncModule = void 0;
7960
8986
  if (this.channelScheme) {
7961
- const factory = this.channelScheme === ChannelScheme.CONNECT ? new GqlRequestChannelFactory(this.logger, this.jwtHandler, queue) : new GqlResponseChannelFactory(this.logger);
8987
+ const factory = this.channelScheme === "connect" ? new GqlRequestChannelFactory(this.logger, this.jwtHandler, queue) : new GqlResponseChannelFactory(this.logger);
7962
8988
  syncModule = new SyncBuilder().withChannelFactory(factory).buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
7963
8989
  await syncModule.syncManager.startup();
7964
8990
  } else if (this.syncBuilder) {
@@ -7993,8 +9019,10 @@ var ReactorBuilder = class {
7993
9019
  syncModule,
7994
9020
  reactor,
7995
9021
  groupReevaluationTrigger,
7996
- pools: this.instrumentedPools
9022
+ pools: this.instrumentedPools,
9023
+ degradedComponents
7997
9024
  };
9025
+ if (degradedComponents.length > 0) this.logger.warn("Reactor started with @count degraded component(s): @components", degradedComponents.length, degradedComponents.map(({ component }) => component).join(", "));
7998
9026
  if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
7999
9027
  return module;
8000
9028
  }
@@ -8023,11 +9051,15 @@ var ReactorBuilder = class {
8023
9051
  * models never index an operation, so the manager advances these from the
8024
9052
  * shards' relayed indexing reports; without them every read carrying a
8025
9053
  * consistency token waits forever.
9054
+ * @param indexing The chunking bounds the host's own read models index
9055
+ * under. The shards' read models are built inside the worker, so without
9056
+ * this they would fall back to the library default and a host that tuned
9057
+ * the cadence would silently get it on the in-process path only.
8026
9058
  * @param registerShutdownHook Whether the builder owns `manager.shutdown()`
8027
9059
  * at signal time. False for the coordinator-factory path, whose factory
8028
9060
  * registers its own hook so host chains drain before the worker stops.
8029
9061
  */
8030
- async createProjectionShardManager(config, eventBus, consistencyTrackers, registerShutdownHook) {
9062
+ async createProjectionShardManager(config, eventBus, consistencyTrackers, indexing, registerShutdownHook) {
8031
9063
  const parentDb = this.resolveReactorDbConfig();
8032
9064
  const baseDb = config.db ?? parentDb;
8033
9065
  const caller = this.projectionShardConfig !== void 0 ? "withProjectionShards" : "withReadModelCoordinatorFactory";
@@ -8053,13 +9085,14 @@ var ReactorBuilder = class {
8053
9085
  poolInstrumentations.push(forwarder);
8054
9086
  this.instrumentedPools.push(forwarder);
8055
9087
  }
8056
- const { ProjectionShardManager } = await import("./projection-shard-manager-CPQc5XHL.js");
9088
+ const { ProjectionShardManager } = await import("./projection-shard-manager-BpcqDtnQ.js");
8057
9089
  const manager = new ProjectionShardManager({
8058
9090
  shardCount: config.shardCount,
8059
9091
  db,
8060
9092
  models,
8061
9093
  preReadyKinds: config.preReadyKinds,
8062
9094
  postReadyKinds: config.postReadyKinds,
9095
+ indexing,
8063
9096
  factory,
8064
9097
  logger: this.logger,
8065
9098
  hostBus: eventBus,
@@ -8086,7 +9119,7 @@ var ReactorBuilder = class {
8086
9119
  return manager;
8087
9120
  }
8088
9121
  async createDefaultProjectionWorkerFactory() {
8089
- const [{ createProjectionThreadTransport }, { projectionWorkerEntryPath }] = await Promise.all([import("./transport-CuogVKN_.js"), import("./projection-worker-wI4PwcV2.js")]);
9122
+ const [{ createProjectionThreadTransport }, { projectionWorkerEntryPath }] = await Promise.all([import("./transport-B3G50KVd.js"), import("./projection-worker-D8uNFdwX.js")]);
8090
9123
  return () => createProjectionThreadTransport(projectionWorkerEntryPath);
8091
9124
  }
8092
9125
  /**
@@ -8094,11 +9127,11 @@ var ReactorBuilder = class {
8094
9127
  * custom `factory`. Each worker spawns a real `node:worker_threads`
8095
9128
  * Worker pointing at the compiled `worker/entry.js`.
8096
9129
  */
8097
- async createDefaultWorkerFactory(numWorkers, db, signatureVerifier) {
9130
+ async createDefaultWorkerFactory(numWorkers, db) {
8098
9131
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
8099
- import("./worker-handle-CrERzl8s.js"),
8100
- import("./transport-ByGviWdZ.js"),
8101
- import("./worker-HPysBfhx.js").then((n) => n.n)
9132
+ import("./worker-handle-C2rLUHwv.js"),
9133
+ import("./transport-CZDHzfvy.js"),
9134
+ Promise.resolve().then(() => worker_exports)
8102
9135
  ]);
8103
9136
  const poolConfig = {
8104
9137
  enabled: true,
@@ -8118,9 +9151,10 @@ var ReactorBuilder = class {
8118
9151
  initPayload: {
8119
9152
  poolConfig,
8120
9153
  db,
8121
- signatureVerifier,
8122
9154
  models,
8123
- executorConfig: this.executorConfig
9155
+ executorConfig: this.executorConfig,
9156
+ signer: this.workerSigner,
9157
+ trustPolicy: this.workerTrustPolicy
8124
9158
  },
8125
9159
  logger,
8126
9160
  poolInstrumentation
@@ -8194,7 +9228,7 @@ var ReactorBuilder = class {
8194
9228
  }
8195
9229
  this.logger.info("Shutdown complete");
8196
9230
  nodeProcess.exit = realExit;
8197
- realExit(pendingExitCode ?? 0);
9231
+ realExit(pendingExitCode || Number(nodeProcess.exitCode ?? 0));
8198
9232
  };
8199
9233
  nodeProcess.prependListener("SIGINT", () => void handler("SIGINT"));
8200
9234
  nodeProcess.prependListener("SIGTERM", () => void handler("SIGTERM"));
@@ -8363,30 +9397,6 @@ function createHybridProjectionCoordinatorFactory(options = {}) {
8363
9397
  };
8364
9398
  }
8365
9399
  //#endregion
8366
- //#region src/signer/passthrough-signer.ts
8367
- /**
8368
- * A no-op signer that returns empty values for all methods.
8369
- * Used when signing is not required.
8370
- */
8371
- var PassthroughSigner = class {
8372
- publicKey = {};
8373
- sign() {
8374
- return Promise.resolve(new Uint8Array(0));
8375
- }
8376
- verify() {
8377
- return Promise.resolve();
8378
- }
8379
- signAction() {
8380
- return Promise.resolve([
8381
- "",
8382
- "",
8383
- "",
8384
- "",
8385
- ""
8386
- ]);
8387
- }
8388
- };
8389
- //#endregion
8390
9400
  //#region src/core/reactor-client-builder.ts
8391
9401
  /**
8392
9402
  * Builder class for constructing ReactorClient instances with proper configuration
@@ -8399,7 +9409,10 @@ var ReactorClientBuilder = class {
8399
9409
  documentIndexer;
8400
9410
  documentView;
8401
9411
  signer;
8402
- signatureVerifier;
9412
+ workerSigner;
9413
+ trustPolicy;
9414
+ workerTrustPolicy;
9415
+ createSignaturePolicy = DEFAULT_SIGNATURE_POLICY;
8403
9416
  subscriptionManager;
8404
9417
  jobAwaiter;
8405
9418
  documentModelLoader;
@@ -8433,17 +9446,28 @@ var ReactorClientBuilder = class {
8433
9446
  return this;
8434
9447
  }
8435
9448
  /**
8436
- * Sets the signer configuration for signing and verifying actions.
8437
- *
8438
- * @param config - Either an ISigner for signing only, or a SignerConfig for both signing and verification
9449
+ * Signs submitted actions, and the operations a reactor built from
9450
+ * `withReactorBuilder` synthesizes unless that builder has its own signer.
9451
+ * A `trustPolicy` reaches that builder unless it has its own.
8439
9452
  */
8440
9453
  withSigner(config) {
8441
9454
  if ("signer" in config) {
8442
9455
  this.signer = config.signer;
8443
- this.signatureVerifier = config.verifier;
9456
+ this.workerSigner = config.workerSigner;
9457
+ this.trustPolicy = config.trustPolicy;
9458
+ this.workerTrustPolicy = config.workerTrustPolicy;
8444
9459
  } else this.signer = config;
8445
9460
  return this;
8446
9461
  }
9462
+ /**
9463
+ * What the client creates new documents as when a call does not say:
9464
+ * `v2-required` (the default) or `legacy`, for a fleet with peers that
9465
+ * predate v2-required documents. Existing documents keep their policy.
9466
+ */
9467
+ withCreateSignaturePolicy(policy) {
9468
+ this.createSignaturePolicy = policy;
9469
+ return this;
9470
+ }
8447
9471
  withSubscriptionManager(subscriptionManager) {
8448
9472
  this.subscriptionManager = subscriptionManager;
8449
9473
  return this;
@@ -8508,8 +9532,9 @@ var ReactorClientBuilder = class {
8508
9532
  let documentView;
8509
9533
  let reactorModule;
8510
9534
  if (this.reactorBuilder) {
8511
- if (this.signatureVerifier) this.reactorBuilder.withSignatureVerifier(this.signatureVerifier);
8512
9535
  if (this.documentModelLoader) this.reactorBuilder.withDocumentModelLoader(this.documentModelLoader);
9536
+ if (this.signer && !this.reactorBuilder.hasSigner()) this.reactorBuilder.withSigner(this.signer, this.workerSigner);
9537
+ if (this.trustPolicy && !this.reactorBuilder.hasTrustPolicy()) this.reactorBuilder.withTrustPolicy(this.trustPolicy, this.workerTrustPolicy);
8513
9538
  reactorModule = await this.reactorBuilder.buildModule();
8514
9539
  reactor = reactorModule.reactor;
8515
9540
  eventBus = reactorModule.eventBus;
@@ -8527,7 +9552,7 @@ var ReactorClientBuilder = class {
8527
9552
  const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
8528
9553
  const decisionModel = reactorModule ? readDecisionModel(reactorModule.featureFlags, reactorModule.documentModelRegistry) : void 0;
8529
9554
  return {
8530
- client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel)),
9555
+ client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel), this.createSignaturePolicy),
8531
9556
  reactor,
8532
9557
  eventBus,
8533
9558
  documentIndexer,
@@ -8565,6 +9590,38 @@ function driveIdFromUrl(url) {
8565
9590
  return url.split("/").pop() ?? "";
8566
9591
  }
8567
9592
  //#endregion
9593
+ //#region src/signer/types.ts
9594
+ const SIGNATURE_REFUSAL_CODES = [
9595
+ "UNSIGNED_REQUIRED",
9596
+ "UNSIGNED_IDENTITY",
9597
+ "KEY_MISMATCH",
9598
+ "MALFORMED_TUPLE",
9599
+ "TIMESTAMP_MISMATCH",
9600
+ "HASH_MISMATCH",
9601
+ "BAD_SIGNATURE",
9602
+ "SCHEME_BELOW_POLICY",
9603
+ "ID_MISMATCH",
9604
+ "DUPLICATE_ACTION",
9605
+ "SIGNER_UNAUTHORIZED"
9606
+ ];
9607
+ //#endregion
9608
+ //#region src/executor/worker/index.ts
9609
+ var worker_exports = /* @__PURE__ */ __exportAll({
9610
+ createForwardingLogger: () => createForwardingLogger,
9611
+ errorToInfo: () => errorToInfo,
9612
+ sanitizeArg: () => sanitizeArg,
9613
+ workerEntryPath: () => workerEntryPath
9614
+ });
9615
+ function resolveWorkerEntryPath() {
9616
+ const url = new URL("./entry.js", import.meta.url);
9617
+ if (url.protocol !== "file:") return url.href;
9618
+ let pathname = decodeURIComponent(url.pathname);
9619
+ if (/^\/[A-Za-z]:[/\\]/.test(pathname)) pathname = pathname.slice(1);
9620
+ return pathname;
9621
+ }
9622
+ /** Absolute path to the worker entry script for use with `new Worker(workerEntryPath)`. */
9623
+ const workerEntryPath = resolveWorkerEntryPath();
9624
+ //#endregion
8568
9625
  //#region src/decision/sync-scope-gate.ts
8569
9626
  /** What a subject may read of a document that is not there to be read. */
8570
9627
  const META_ONLY = (scope) => ALWAYS_READABLE_SCOPES.has(scope);
@@ -8576,6 +9633,9 @@ const META_ONLY = (scope) => ALWAYS_READABLE_SCOPES.has(scope);
8576
9633
  * path's own job, and it is the only thing this adds to the gate it wraps.
8577
9634
  */
8578
9635
  var SyncScopeGate = class {
9636
+ gate;
9637
+ documentView;
9638
+ logger;
8579
9639
  constructor(gate, documentView, logger) {
8580
9640
  this.gate = gate;
8581
9641
  this.documentView = documentView;
@@ -8792,6 +9852,6 @@ var DocumentIntegrityService = class {
8792
9852
  }
8793
9853
  };
8794
9854
  //#endregion
8795
- export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_DRIVE_CONTAINER_TYPES, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DocumentAlreadyExistsError, DocumentChangeType, DocumentExistence, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, HybridProjectionCoordinator, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, IntervalPollTimer, InvalidModuleError, JobAwaiter, JobExecutorEventTypes, JobStatus, KyselyDocumentIndexer, KyselyDocumentView, KyselyKeyframeStore, KyselyOperationStore, KyselySyncCursorStorage, KyselySyncRemoteStorage, KyselyWriteCache, Mailbox, ModelReadGate, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, RECOVERABLE_GRAPHQL_ERROR_CODES, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RetryAccounting, RevisionMismatchError, SeededStateReader, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createMutableShutdownStatus, createReactorHostModuleBase, createRelationalDb, decideAtHead, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, updateRelationshipAction, upgradeDocumentAction, workerEntryPath };
9855
+ export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_COMMIT_CHUNK_SIZE, DEFAULT_DRIVE_CONTAINER_TYPES, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DeletedDocumentRead, DocumentAlreadyExistsError, DocumentChangeType, DocumentExistence, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DriveRequestError, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, HybridProjectionCoordinator, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, IntervalPollTimer, InvalidModuleError, InvalidSignatureError, JobAwaiter, JobExecutorEventTypes, JobStatus, KyselyDocumentIndexer, KyselyDocumentView, KyselyKeyframeStore, KyselyOperationStore, KyselySyncCursorStorage, KyselySyncRemoteStorage, KyselyWriteCache, Mailbox, ModelReadGate, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, RECOVERABLE_GRAPHQL_ERROR_CODES, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RelationshipNotFoundError, RetryAccounting, RevisionMismatchError, SIGNATURE_REFUSAL_CODES, SeededStateReader, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createMutableShutdownStatus, createReactorHostModuleBase, createRelationalDb, decideAtHead, defaultReadModelIndexingConfig, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, unchunkedReadModelIndexingConfig, updateRelationshipAction, upgradeDocumentAction, verifyActionSignature, workerEntryPath };
8796
9856
 
8797
9857
  //# sourceMappingURL=index.js.map