@powerhousedao/reactor 6.2.2-dev.8 → 6.2.2-dev.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { A as parsePagingOptions, C as DuplicateManifestError, D as DocumentDeletedError, E as ModuleNotFoundError, O as DocumentNotFoundError, S as CollectionMembershipCache, T as InvalidModuleError, _ as KyselyWriteCache, a as createForwardingPoolInstrumentation, b as createConsistencyToken, c as DuplicateOperationError, d as KyselyKeyframeStore, f as DocumentModelRegistry, g as EventBus, h as KyselyExecutionScope, i as runMigrations, j as throwIfAborted, k as matchesScope, l as OptimisticLockError, m as DriveCollectionId, n as REACTOR_SCHEMA, o as instrumentPgPool, p as SimpleJobExecutor, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as RevisionMismatchError, v as KyselyOperationIndex, w as DuplicateModuleError, x as createEmptyConsistencyToken, y as DocumentMetaCache } from "./drive-container-types-DpJp2AmE.js";
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
2
  import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
3
3
  import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-D3S6Eysd.js";
4
- import { a as ReadModelCoordinator, i as KyselyDocumentView, n as ConsistencyTracker, o as BaseReadModel, r as makeConsistencyKey, t as KyselyDocumentIndexer } from "./document-indexer-FGJmRAdX.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
5
  import { n as errorToInfo, r as sanitizeArg, t as createForwardingLogger } from "./forwarding-logger-BBkMSxuJ.js";
6
- import { t as workerEntryPath } from "./worker-DBJOv8Gp.js";
7
- import { actions, actions as documentActions, createPresignedHeader, generateId, hashDocumentStateForScope, replayDocument } from "@powerhousedao/shared/document-model";
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";
8
8
  import { addFile, addFolder, copyNode, deleteNode, driveCreateDocument, generateNodesCopy, getDescendants, handleTargetNameCollisions, isFileNode, isFolderNode, moveNode, updateNode } from "@powerhousedao/shared/document-drive";
9
9
  import { v4 } from "uuid";
10
10
  import { ConsoleLogger, childLogger } from "document-model";
@@ -190,10 +190,12 @@ function topologicalSort(jobs) {
190
190
  */
191
191
  function toErrorInfo$1(error) {
192
192
  if (error instanceof Error) return {
193
+ name: error.name,
193
194
  message: error.message,
194
195
  stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
195
196
  };
196
197
  return {
198
+ name: "Error",
197
199
  message: error,
198
200
  stack: (/* @__PURE__ */ new Error()).stack || ""
199
201
  };
@@ -363,7 +365,7 @@ var DriveClient = class {
363
365
  documentId: document.header.id,
364
366
  model: document.header.documentType,
365
367
  fromVersion: 0,
366
- toVersion: 1,
368
+ toVersion: normalizeDocumentModelVersion(document.state.document?.version),
367
369
  initialState: document.state
368
370
  }),
369
371
  addRelationshipAction(driveIdentifier, documentId, "child")
@@ -374,24 +376,23 @@ var DriveClient = class {
374
376
  documentType: document.header.documentType,
375
377
  parentFolder
376
378
  })], this.signer, signal);
377
- const batchResult = await this.reactor.executeBatch({ jobs: [{
379
+ await this.runJobs([{
378
380
  key: "document",
379
381
  documentId,
380
382
  scope: getSharedActionScope(documentActions),
381
383
  branch: "main",
382
384
  actions: documentActions,
383
385
  dependsOn: []
384
- }, {
386
+ }], signal);
387
+ await this.runJobs([{
385
388
  key: "drive",
386
389
  documentId: driveIdentifier,
387
390
  scope: getSharedActionScope(driveActions),
388
391
  branch: "main",
389
392
  actions: driveActions,
390
- dependsOn: ["document"]
391
- }] }, signal);
392
- const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.client.waitForJob(job, signal)));
393
- for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
394
- return this.reactor.get(documentId);
393
+ dependsOn: []
394
+ }], signal);
395
+ return this.client.get(documentId, void 0, signal);
395
396
  }
396
397
  async addFolder(driveIdentifier, name, parentFolder, signal) {
397
398
  this.logger.verbose("drives.addFolder(@driveIdentifier, @name, @parentFolder)", driveIdentifier, name, parentFolder);
@@ -400,7 +401,7 @@ var DriveClient = class {
400
401
  id: folderId,
401
402
  name,
402
403
  parentFolder
403
- })], signal)).state.global.nodes.find((n) => n.id === folderId);
404
+ })], signal)).state.global?.nodes.find((n) => n.id === folderId);
404
405
  if (!node || !isFolderNode(node)) throw new Error("Folder creation failed");
405
406
  return node;
406
407
  }
@@ -427,7 +428,7 @@ var DriveClient = class {
427
428
  const node = (await this.client.execute(driveIdentifier, "main", [updateNode({
428
429
  id: nodeId,
429
430
  name
430
- })], signal)).state.global.nodes.find((n) => n.id === nodeId);
431
+ })], signal)).state.global?.nodes.find((n) => n.id === nodeId);
431
432
  if (!node) throw new Error("Node missing from drive after rename");
432
433
  return node;
433
434
  }
@@ -468,8 +469,12 @@ var DriveClient = class {
468
469
  const node = drive.state.global.nodes.find((n) => n.id === entry.srcId);
469
470
  if (!node || !isFileNode(node)) continue;
470
471
  const srcDoc = await this.client.get(entry.srcId, void 0, signal);
471
- const module = await this.client.getDocumentModelModule(srcDoc.header.documentType);
472
- const duplicated = replayDocument(srcDoc.initialState, srcDoc.operations, module.reducer, createPresignedHeader(entry.targetId, srcDoc.header.documentType));
472
+ const duplicated = {
473
+ ...srcDoc,
474
+ header: createPresignedHeader(entry.targetId, srcDoc.header.documentType),
475
+ initialState: srcDoc.state,
476
+ operations: {}
477
+ };
473
478
  const resolvedName = resolvedNamesByTargetId.get(entry.targetId);
474
479
  if (resolvedName) duplicated.header.name = resolvedName;
475
480
  await this.addFile(driveIdentifier, duplicated, entry.targetParentFolder ?? void 0, signal);
@@ -499,6 +504,22 @@ var DriveClient = class {
499
504
  totalCount: filtered.length
500
505
  };
501
506
  }
507
+ /**
508
+ * Runs one batch to completion, throwing the first failure. The thrown error
509
+ * carries the job error's name, which is what callers classify on: an id
510
+ * collision has to be told apart from a transient failure.
511
+ */
512
+ async runJobs(jobs, signal) {
513
+ const batchResult = await this.reactor.executeBatch({ jobs }, signal);
514
+ 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);
516
+ return completedJobs;
517
+ }
518
+ toError(job) {
519
+ const error = new Error(job.error?.message ?? "Job failed");
520
+ if (job.error?.name) error.name = job.error.name;
521
+ return error;
522
+ }
502
523
  async documentExists(documentId, signal) {
503
524
  try {
504
525
  await this.client.get(documentId, void 0, signal);
@@ -680,8 +701,335 @@ let DocumentChangeType = /* @__PURE__ */ function(DocumentChangeType) {
680
701
  return DocumentChangeType;
681
702
  }({});
682
703
  //#endregion
704
+ //#region src/decision/read-gate.ts
705
+ /**
706
+ * Scopes every holder of a document may read, whatever the grants say. Denying
707
+ * the policy itself would let a replica sync a document without it, read the
708
+ * auth scope as uninitialized, and allow every operation it holds, so replicas
709
+ * would diverge permanently. The document scope carries the metadata the same
710
+ * argument covers. Grants gate domain-scope reads only.
711
+ */
712
+ const ALWAYS_READABLE_SCOPES = new Set(["auth", "document"]);
713
+ /** What a gate does with an uninitialized policy when told nothing. */
714
+ const OPEN_BY_DEFAULT = { withholdUninitialized: false };
715
+ /**
716
+ * The branch a group is read on. A group's member list lives on its main
717
+ * branch whatever branch the document naming it is on, and the reference
718
+ * relation records no branch of its own.
719
+ */
720
+ const GROUP_BRANCH = "main";
721
+ /**
722
+ * The scope of a group document that holds its member list. It is the only
723
+ * scope group serving reaches, because it is the only one the audience must
724
+ * fold in order to evaluate auth with the group.
725
+ */
726
+ const GROUP_MEMBERSHIP_SCOPE = "global";
727
+ /**
728
+ * How many of a group's referencing documents are examined before a read of
729
+ * that group gives up and withholds. Each one costs a model build, and the
730
+ * relation is append-only, so an old and briefly-held reference still counts.
731
+ * Truncating denies, which is the direction that withholds. It is the cap a
732
+ * version-1 policy carries on its own grant list, taken from there rather than
733
+ * restated: raising that cap raises the read fan-out here.
734
+ */
735
+ const MAX_EXAMINED_REFERENCERS = MAX_AUTH_GRANTS;
736
+ /**
737
+ * How many referencers are probed at once. The reactor's Postgres pool size is
738
+ * optional and defaults to ten, and a listing resolves one gate per result, so
739
+ * per-gate fan-out multiplies: a handful keeps one group read from claiming the
740
+ * whole pool while still cutting the walk several times over. On PGlite the
741
+ * queries serialize regardless, so a larger number buys nothing in a browser.
742
+ */
743
+ const REFERENCER_PROBE_CONCURRENCY = 4;
744
+ /**
745
+ * The policy carried on a document, if it carries one. A document handed to the
746
+ * gate with no state at all is not policied, so it is not gated.
747
+ */
748
+ function authOf(document) {
749
+ return document.state?.auth;
750
+ }
751
+ /**
752
+ * The model reads enforce. Below `authEnforcement` there is no model to
753
+ * enforce: the document-only model ignores the auth scope entirely, so reading
754
+ * through it would serve every domain scope of a policied document to anyone.
755
+ * Undefined therefore means "evaluate the policy alone", which is what the read
756
+ * surface did before the model existed.
757
+ */
758
+ function readDecisionModel(flags, registry) {
759
+ return flags.authEnforcement ? selectDecisionModel(flags, registry) : void 0;
760
+ }
761
+ /**
762
+ * Evaluates the policy on its own, with no groups map and no condition context.
763
+ * A `{ group }` or conditional grant therefore never applies: an allow that
764
+ * does not apply withholds access, so this cannot widen a policy, but a policy
765
+ * relying on a conditional deny is weaker here than it is written.
766
+ */
767
+ var BareReadGate = class {
768
+ scopePredicate(document, subject) {
769
+ const auth = authOf(document);
770
+ return Promise.resolve((scope) => ALWAYS_READABLE_SCOPES.has(scope) || decide(auth, subject, {
771
+ verb: "read",
772
+ scope
773
+ }) === "allow");
774
+ }
775
+ };
776
+ /**
777
+ * Answers a stream read from the document already fetched, and anything else
778
+ * through the read side.
779
+ *
780
+ * The seed is why routing reads through a decision model costs no extra I/O for
781
+ * the document being read: its `document` and `auth` scopes are the two static
782
+ * projections, and the caller has both in hand. Only a group stream the grant
783
+ * list names is fetched.
784
+ */
785
+ var SeededStateReader = class {
786
+ constructor(documentView, seed, branch) {
787
+ this.documentView = documentView;
788
+ this.seed = seed;
789
+ this.branch = branch;
790
+ }
791
+ /**
792
+ * A stream this replica does not hold has to reach buildDecisionModel as the
793
+ * absence it recognises, or the whole read fails instead of leaving the group
794
+ * out of the model, where its principal does not match and the policy fails
795
+ * closed. The read side reports absence as a plain Error, so the absence is
796
+ * confirmed rather than inferred from the message: a transient failure must
797
+ * surface, not silently deny.
798
+ */
799
+ async getState(documentId, scope, branch, targetRevision, signal) {
800
+ if (targetRevision === void 0 && documentId === this.seed.header.id && branch === this.branch && scope in this.seed.state) return this.seed;
801
+ try {
802
+ return await this.documentView.get(documentId, {
803
+ branch,
804
+ scopes: [scope]
805
+ }, void 0, signal);
806
+ } catch (error) {
807
+ await this.assertAbsent(documentId, error, signal);
808
+ throw new DocumentNotFoundError(documentId);
809
+ }
810
+ }
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
+ };
822
+ /**
823
+ * Evaluates a read against the registered decision model, built at the stream
824
+ * heads. This is what makes `{ group }` principals and conditional grants apply
825
+ * to a read: the model supplies the groups map and the scope's own state, the
826
+ * same two things admission supplies.
827
+ *
828
+ * A read has no action, so a condition on `action.input.*` never holds for one.
829
+ *
830
+ * State is read through the read side rather than the write cache. The write
831
+ * cache is invalidated by whichever process runs the executor, so a reactor
832
+ * running its executors in worker processes would answer reads in the parent
833
+ * from state no commit ever invalidates.
834
+ */
835
+ var ModelReadGate = class {
836
+ constructor(model, documentView, servesGroups, operationIndex, logger, options = OPEN_BY_DEFAULT) {
837
+ this.model = model;
838
+ this.documentView = documentView;
839
+ this.servesGroups = servesGroups;
840
+ this.operationIndex = operationIndex;
841
+ this.logger = logger;
842
+ this.options = options;
843
+ }
844
+ /**
845
+ * A served group yields its member list and nothing else. What the audience
846
+ * is owed is the state it must fold to evaluate auth with the group; a
847
+ * group's other scopes are its own business and stay behind its own grants.
848
+ */
849
+ 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;
854
+ }
855
+ /**
856
+ * Whether the subject is served this group because a policy names it.
857
+ *
858
+ * A replica must fold a group's membership to evaluate auth with it, so a
859
+ * group a grant names is served to the audience of the document that names
860
+ * it, whatever the group's own read grants say. Naming a group in a policy
861
+ * publishes its roster to that policy's audience; a group whose membership
862
+ * must stay confidential does not belong in a grant.
863
+ *
864
+ * The referencing document's own domain scopes are the test. Its `auth` and
865
+ * `document` scopes are readable by every holder, so testing those would
866
+ * serve every referenced group to everybody.
867
+ *
868
+ * One level only. A referencer that is itself a group is skipped, and a
869
+ * referencer's own readability is decided from its policy alone, so a
870
+ * reference cycle terminates. Cycles are reachable: the reference relation
871
+ * is recorded from an operation's input, including one later stored denied,
872
+ * so a refused grant naming a group from inside another group leaves a row
873
+ * behind that validation never saw.
874
+ *
875
+ * The referencers are probed a few at a time and the walk stops at the first
876
+ * that serves, because a subject outside the audience is the case that runs to
877
+ * the bound, and it is the common one. A probe that failed decides only when
878
+ * nothing served: serving rests on a real allow, so this cannot widen, and it
879
+ * stops one unreachable referencer from turning an allow already in hand into
880
+ * a denial. A read records no operation, so replicas differing over a
881
+ * transient failure has no consensus consequence.
882
+ *
883
+ * The probes are awaited together rather than raced, so none is ever left
884
+ * running with nobody awaiting it, which is where unhandled rejections come
885
+ * from.
886
+ */
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`);
892
+ const walk = {
893
+ next: 0,
894
+ served: false,
895
+ failure: void 0
896
+ };
897
+ const probe = async () => {
898
+ while (!walk.served && walk.next < examined.length) {
899
+ const referencerId = examined[walk.next++];
900
+ let serves = false;
901
+ try {
902
+ serves = await this.servesThrough(referencerId, subject, signal);
903
+ } catch (error) {
904
+ walk.failure ??= Error.isError(error) ? error : /* @__PURE__ */ new Error(`Probing referencer ${referencerId} failed`);
905
+ }
906
+ if (serves) walk.served = true;
907
+ }
908
+ };
909
+ await Promise.all(Array.from({ length: Math.min(REFERENCER_PROBE_CONCURRENCY, examined.length) }, probe));
910
+ if (!walk.served && walk.failure) throw walk.failure;
911
+ return walk.served;
912
+ }
913
+ /**
914
+ * Whether one referencing document serves the subject any domain scope. A
915
+ * referencer this replica does not hold serves nothing, which fails closed
916
+ * the same way a group it does not hold does.
917
+ */
918
+ async servesThrough(referencerId, subject, signal) {
919
+ let referencer;
920
+ try {
921
+ referencer = await this.documentView.get(referencerId, { branch: GROUP_BRANCH }, void 0, signal);
922
+ } catch {
923
+ return false;
924
+ }
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));
928
+ }
929
+ servesGroup(document) {
930
+ return this.servesGroups && this.operationIndex !== void 0 && document.header.documentType === groupDocumentType;
931
+ }
932
+ /**
933
+ * What this document's own policy says, with no group serving applied.
934
+ *
935
+ * An unpoliced document is readable in full unless the host closes by default,
936
+ * and either way it is the common case and the one worth answering without
937
+ * building anything. The test is the one `evaluate` makes: a legacy `{}` auth
938
+ * scope and version 0 both mean uninitialized, and "no grants" does not,
939
+ * because a policy with a version and an empty grant list denies everything.
940
+ *
941
+ * Closing here rather than around the gate is what keeps a policy able to
942
+ * publish an unpoliced group it names: the referencer walk asks this question
943
+ * of the referencing document, whose real policy answers it.
944
+ */
945
+ async ownPolicyPredicate(document, subject, branch, signal) {
946
+ const auth = authOf(document);
947
+ if (!auth || !auth.version) return this.options.withholdUninitialized ? (scope) => ALWAYS_READABLE_SCOPES.has(scope) : () => true;
948
+ const target = {
949
+ documentId: document.header.id,
950
+ branch
951
+ };
952
+ const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), this.model, target, signal);
953
+ const definition = this.model(target);
954
+ const scopeStates = document.state ?? {};
955
+ return (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
956
+ verb: "read",
957
+ scope
958
+ }, {
959
+ scopeState: scopeStates[scope],
960
+ actionInput: void 0
961
+ }).decision === "allow";
962
+ }
963
+ };
964
+ //#endregion
965
+ //#region src/client/util.ts
966
+ function authSubjectFromSigner(signer) {
967
+ return {
968
+ address: signer.user?.address,
969
+ key: signer.app?.key
970
+ };
971
+ }
972
+ function withAuthScope(view) {
973
+ if (view?.scopes && view.scopes.length > 0) return {
974
+ ...view,
975
+ scopes: [...new Set([...view.scopes, "auth"])]
976
+ };
977
+ return view;
978
+ }
979
+ /**
980
+ * Drops the scopes the predicate refuses. The predicate is resolved by the read
981
+ * gate, which decides against the whole policy once per document; this only
982
+ * applies the answer, so it stays synchronous.
983
+ */
984
+ function filterReadableScopes(document, readable) {
985
+ const state = document.state;
986
+ if (!state) return document;
987
+ return {
988
+ ...document,
989
+ state: keepReadableScopes(state, readable),
990
+ initialState: keepReadableScopes(document.initialState, readable)
991
+ };
992
+ }
993
+ function keepReadableScopes(scopes, readable) {
994
+ if (!scopes) return scopes;
995
+ const kept = {};
996
+ for (const scope of Object.keys(scopes)) if (readable(scope)) kept[scope] = scopes[scope];
997
+ return kept;
998
+ }
999
+ //#endregion
683
1000
  //#region src/client/reactor-client.ts
684
1001
  /**
1002
+ * The document a candidate is decided against. Routed on the action type alone,
1003
+ * which is what the executor routes on: every action the reactor reduces onto
1004
+ * the document scope carries that scope already, and the gate follows the
1005
+ * action's own target rather than the document the request named.
1006
+ */
1007
+ function evaluationTargetId(candidate, fallback) {
1008
+ return GATED_DOCUMENT_ACTIONS.has(candidate.type) ? targetDocumentId({
1009
+ type: candidate.type,
1010
+ input: candidate.input
1011
+ }, fallback) : fallback;
1012
+ }
1013
+ /**
1014
+ * One candidate's verdict, decided exactly as admission decides it: the same
1015
+ * request, and a condition context populated only while authConditions is on,
1016
+ * so a conditional grant applies here precisely when it would apply there.
1017
+ * Groups need nothing added, because the selected model carries them.
1018
+ */
1019
+ function decideCandidate(config, target, subject, candidate) {
1020
+ return target.definition.decide(target.model, subject, {
1021
+ verb: "execute",
1022
+ scope: candidate.scope,
1023
+ operation: candidate.type
1024
+ }, config.flags.authConditions ? {
1025
+ scopeState: target.scopeStates[candidate.scope],
1026
+ actionInput: candidate.input
1027
+ } : {
1028
+ scopeState: void 0,
1029
+ actionInput: void 0
1030
+ });
1031
+ }
1032
+ /**
685
1033
  * ReactorClient implementation that wraps lower-level APIs to provide
686
1034
  * a simpler interface for document operations.
687
1035
  *
@@ -699,8 +1047,10 @@ var ReactorClient = class {
699
1047
  jobAwaiter;
700
1048
  documentIndexer;
701
1049
  documentView;
1050
+ readGate;
1051
+ actionEvaluation;
702
1052
  drives;
703
- constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView) {
1053
+ constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation) {
704
1054
  this.logger = logger;
705
1055
  this.reactor = reactor;
706
1056
  this.signer = signer;
@@ -708,9 +1058,33 @@ var ReactorClient = class {
708
1058
  this.jobAwaiter = jobAwaiter;
709
1059
  this.documentIndexer = documentIndexer;
710
1060
  this.documentView = documentView;
1061
+ this.readGate = readGate;
1062
+ this.actionEvaluation = actionEvaluation;
711
1063
  this.drives = new DriveClient(this, logger, reactor, signer);
712
1064
  this.logger.verbose("ReactorClient initialized");
713
1065
  }
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
+ }
714
1088
  /**
715
1089
  * Retrieves a list of document model modules.
716
1090
  */
@@ -725,24 +1099,58 @@ var ReactorClient = class {
725
1099
  * @returns The document model module
726
1100
  */
727
1101
  async getDocumentModelModule(documentType) {
728
- const module = (await this.reactor.getDocumentModels()).results.find((m) => m.documentModel.global.id === documentType);
729
- if (!module) throw new Error(`Document model module not found for type: ${documentType}`);
730
- return module;
1102
+ const modules = await this.reactor.getDocumentModels();
1103
+ let latestModule;
1104
+ let latestVersion = -1;
1105
+ for (const module of modules.results) {
1106
+ if (module.documentModel.global.id !== documentType) continue;
1107
+ const version = module.version ?? 1;
1108
+ if (version > latestVersion) {
1109
+ latestVersion = version;
1110
+ latestModule = module;
1111
+ }
1112
+ }
1113
+ if (!latestModule) throw new Error(`Document model module not found for type: ${documentType}`);
1114
+ return latestModule;
1115
+ }
1116
+ /**
1117
+ * Retrieves the document model module matching the version the document is
1118
+ * stamped with, so not-yet-upgraded documents get the reducer their
1119
+ * history was written with rather than the latest.
1120
+ */
1121
+ async getDocumentModelModuleForDocument(document) {
1122
+ const documentType = document.header.documentType;
1123
+ const version = normalizeDocumentModelVersion(document.state.document?.version);
1124
+ const modules = await this.reactor.getDocumentModels();
1125
+ const availableVersions = [];
1126
+ for (const module of modules.results) {
1127
+ if (module.documentModel.global.id !== documentType) continue;
1128
+ const moduleVersion = normalizeDocumentModelVersion(module.version);
1129
+ if (moduleVersion === version) return module;
1130
+ availableVersions.push(moduleVersion);
1131
+ }
1132
+ throw new UnsupportedDocumentModelVersionError(documentType, version, availableVersions.sort((a, b) => a - b));
731
1133
  }
732
1134
  /**
733
1135
  * Retrieves a specific PHDocument
734
1136
  */
735
1137
  async get(identifier, view, signal) {
736
1138
  this.logger.verbose("get(@identifier, @view)", identifier, view);
737
- return await this.reactor.getByIdOrSlug(identifier, view, void 0, signal);
1139
+ const document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
1140
+ return this.gateDocument(document, view, signal);
738
1141
  }
739
1142
  /**
740
1143
  * Resolves an identifier (id or slug) to the canonical document id, using the
741
1144
  * same lookup as the data path. Resolves against the "main" branch. Throws if
742
1145
  * the identifier cannot be resolved or is ambiguous.
743
1146
  */
744
- async resolveIdOrSlug(identifier, signal) {
745
- return this.documentView.resolveIdOrSlug(identifier, void 0, void 0, signal);
1147
+ async resolveIdOrSlug(identifier, view, signal) {
1148
+ return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
1149
+ }
1150
+ /** True when the id is taken; a soft-deleted document's id is still taken. */
1151
+ async isDocumentIdTaken(documentId, signal) {
1152
+ this.logger.verbose("isDocumentIdTaken(@documentId)", documentId);
1153
+ return (await this.documentView.exists([documentId], DocumentExistence.IncludingDeleted, void 0, signal))[0];
746
1154
  }
747
1155
  /**
748
1156
  * Retrieves operations for a document
@@ -750,8 +1158,11 @@ var ReactorClient = class {
750
1158
  async getOperations(documentIdentifier, view, filter, paging, signal) {
751
1159
  this.logger.verbose("getOperations(@documentIdentifier, @view, @filter, @paging)", documentIdentifier, view, filter, paging);
752
1160
  const documentId = await this.documentView.resolveIdOrSlug(documentIdentifier, view, void 0, signal);
753
- if (paging?.cursor && isCompositeCursor(paging.cursor)) return this.getOperationsWithCompositeCursor(documentId, view, filter, paging, signal);
1161
+ const gated = await this.reactor.getByIdOrSlug(documentId, withAuthScope(view), void 0, signal);
1162
+ const canRead = await this.readableScopes(gated, view, signal);
1163
+ if (paging?.cursor && isCompositeCursor(paging.cursor)) return this.getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead);
754
1164
  const operationsByScope = await this.reactor.getOperations(documentId, view, filter, paging, void 0, signal);
1165
+ for (const scope of Object.keys(operationsByScope)) if (!canRead(scope)) delete operationsByScope[scope];
755
1166
  const scopeEntries = Object.entries(operationsByScope);
756
1167
  const effectivePaging = paging || {
757
1168
  cursor: "0",
@@ -779,11 +1190,12 @@ var ReactorClient = class {
779
1190
  nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
780
1191
  };
781
1192
  }
782
- async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal) {
1193
+ async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
783
1194
  const scopeCursors = decodeCompositeCursor(paging.cursor);
784
1195
  const allOperations = [];
785
1196
  const activeCursors = {};
786
1197
  for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1198
+ if (!canRead(scopeName)) continue;
787
1199
  const scopeView = {
788
1200
  ...view,
789
1201
  scopes: [scopeName]
@@ -817,7 +1229,7 @@ var ReactorClient = class {
817
1229
  limit: 0
818
1230
  }
819
1231
  };
820
- return this.reactor.find({ ids: targetIds }, view, paging, void 0, signal);
1232
+ return this.find({ ids: targetIds }, view, paging, signal);
821
1233
  }
822
1234
  /**
823
1235
  * Retrieves incoming relationships of a given type to a target document.
@@ -833,14 +1245,90 @@ var ReactorClient = class {
833
1245
  limit: 0
834
1246
  }
835
1247
  };
836
- return this.reactor.find({ ids: sourceIds }, view, paging, void 0, signal);
1248
+ return this.find({ ids: sourceIds }, view, paging, signal);
837
1249
  }
838
1250
  /**
839
1251
  * Filters documents by criteria and returns a list of them
840
1252
  */
841
1253
  async find(search, view, paging, signal) {
842
1254
  this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
843
- return this.reactor.find(search, view, paging, void 0, signal);
1255
+ 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
+ };
1260
+ }
1261
+ /**
1262
+ * Predicts the admission verdict for each candidate. See
1263
+ * {@link IReactorClient.evaluateActions} for the contract and its caveats.
1264
+ *
1265
+ * Read-only throughout, and never through the write cache: that cache is
1266
+ * invalidated by whichever process runs the executor, so a reactor running
1267
+ * its executors in worker processes would answer here from state no commit
1268
+ * ever invalidates.
1269
+ */
1270
+ async evaluateActions(documentIdentifier, branch, candidates, subject, signal) {
1271
+ this.logger.verbose("evaluateActions(@documentIdentifier, @branch, @count candidates)", documentIdentifier, branch, candidates.length);
1272
+ const config = this.actionEvaluation;
1273
+ if (config === void 0) throw new AuthEnforcementDisabledError();
1274
+ const decidedSubject = this.readSubject(subject);
1275
+ const resolvedId = await this.documentView.resolveIdOrSlug(documentIdentifier, { branch }, void 0, signal);
1276
+ const targets = /* @__PURE__ */ new Map();
1277
+ const evaluations = [];
1278
+ for (const candidate of candidates) {
1279
+ if (candidate.type === "CREATE_DOCUMENT") {
1280
+ evaluations.push({ decision: "allow" });
1281
+ continue;
1282
+ }
1283
+ const targetId = evaluationTargetId(candidate, resolvedId);
1284
+ let target = targets.get(targetId);
1285
+ if (target === void 0) {
1286
+ target = await this.buildEvaluationTarget(config, targetId, branch, signal);
1287
+ targets.set(targetId, target);
1288
+ }
1289
+ evaluations.push(decideCandidate(config, target, decidedSubject, candidate));
1290
+ }
1291
+ const allowed = evaluations.filter((evaluation) => evaluation.decision === "allow").length;
1292
+ return {
1293
+ evaluations,
1294
+ allAllowed: candidates.length > 0 && allowed === candidates.length,
1295
+ anyAllowed: allowed > 0,
1296
+ allDenied: candidates.length > 0 && allowed === 0,
1297
+ anyDenied: allowed < candidates.length
1298
+ };
1299
+ }
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
+ };
844
1332
  }
845
1333
  /**
846
1334
  * Creates a document and waits for completion
@@ -868,7 +1356,7 @@ var ReactorClient = class {
868
1356
  documentId,
869
1357
  model: document.header.documentType,
870
1358
  fromVersion: 0,
871
- toVersion: document.state.document.version,
1359
+ toVersion: normalizeDocumentModelVersion(document.state.document?.version),
872
1360
  initialState: document.state
873
1361
  })], this.signer, signal);
874
1362
  const jobs = [{
@@ -893,7 +1381,8 @@ var ReactorClient = class {
893
1381
  const batchResult = await this.reactor.executeBatch({ jobs }, signal);
894
1382
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
895
1383
  for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
896
- return await this.reactor.get(documentId);
1384
+ const created = await this.reactor.get(documentId);
1385
+ return this.gateDocument(created, void 0, signal);
897
1386
  }
898
1387
  /**
899
1388
  * Creates an empty document and waits for completion
@@ -903,20 +1392,64 @@ var ReactorClient = class {
903
1392
  const matchingModules = (await this.reactor.getDocumentModels(void 0, void 0, signal)).results.filter((m) => m.documentModel.global.id === documentModelType);
904
1393
  let module;
905
1394
  if (options?.documentModelVersion !== void 0) {
906
- module = matchingModules.find((m) => m.version === options.documentModelVersion);
1395
+ const requestedVersion = normalizeDocumentModelVersion(options.documentModelVersion);
1396
+ module = matchingModules.find((m) => normalizeDocumentModelVersion(m.version) === requestedVersion);
907
1397
  if (!module) throw new Error(`Document model not found for type: ${documentModelType} with version: ${options.documentModelVersion}`);
908
1398
  } else {
909
1399
  module = matchingModules.reduce((latest, current) => {
910
1400
  if (latest === void 0) return current;
911
- return (current.version ?? 0) > (latest.version ?? 0) ? current : latest;
1401
+ return normalizeDocumentModelVersion(current.version) > normalizeDocumentModelVersion(latest.version) ? current : latest;
912
1402
  }, void 0);
913
1403
  if (!module) throw new Error(`Document model not found for type: ${documentModelType}`);
914
1404
  }
915
1405
  const document = module.utils.createDocument();
916
- document.state.document.version = module.version ?? 1;
1406
+ document.state.document.version = normalizeDocumentModelVersion(module.version);
917
1407
  return this.create(document, options?.parentIdentifier, signal);
918
1408
  }
919
1409
  /**
1410
+ * Upgrades a document to a newer document model version by dispatching an
1411
+ * UPGRADE_DOCUMENT action. When toVersion is omitted, upgrades to the
1412
+ * latest registered module version for the document's type. Returns the
1413
+ * document unchanged when it is already at the target version.
1414
+ *
1415
+ * The executor validates the action's version and revision snapshot against
1416
+ * the state the migration actually runs on. When a concurrent edit
1417
+ * invalidates the snapshot, the upgrade is rebuilt from a fresh read and
1418
+ * retried up to maxConflictRetries times before the conflict is surfaced.
1419
+ */
1420
+ async upgradeDocument(documentIdentifier, toVersion, options, signal) {
1421
+ this.logger.verbose("upgradeDocument(@documentIdentifier, @toVersion)", documentIdentifier, toVersion);
1422
+ const maxConflictRetries = options?.maxConflictRetries ?? 3;
1423
+ let lastConflictMessage = "";
1424
+ for (let attempt = 0; attempt <= maxConflictRetries; attempt++) {
1425
+ const document = await this.reactor.getByIdOrSlug(documentIdentifier, void 0, void 0, signal);
1426
+ const documentId = document.header.id;
1427
+ const documentType = document.header.documentType;
1428
+ const branch = document.header.branch || "main";
1429
+ const fromVersion = normalizeDocumentModelVersion(document.state.document?.version);
1430
+ let targetVersion = toVersion;
1431
+ if (targetVersion === void 0) targetVersion = normalizeDocumentModelVersion((await this.getDocumentModelModule(documentType)).version);
1432
+ if (targetVersion === fromVersion) return this.gateDocument(document, { branch }, signal);
1433
+ if (targetVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, targetVersion);
1434
+ const signedActions = await signActions([upgradeDocumentAction({
1435
+ documentId,
1436
+ model: documentType,
1437
+ fromVersion,
1438
+ toVersion: targetVersion,
1439
+ revision: { ...document.header.revision }
1440
+ })], this.signer, signal);
1441
+ const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
1442
+ const completedJob = await this.waitForJob(jobInfo, signal);
1443
+ if (completedJob.status !== JobStatus.FAILED) {
1444
+ const upgraded = await this.reactor.getByIdOrSlug(documentId, { branch }, completedJob.consistencyToken, signal);
1445
+ return this.gateDocument(upgraded, { branch }, signal);
1446
+ }
1447
+ if (completedJob.error?.name !== "UpgradePreconditionFailedError") throw new Error(completedJob.error?.message);
1448
+ lastConflictMessage = completedJob.error.message;
1449
+ }
1450
+ throw new Error(`Upgrade of document ${documentIdentifier} conflicted with concurrent edits after ${maxConflictRetries + 1} attempts: ${lastConflictMessage}`);
1451
+ }
1452
+ /**
920
1453
  * Creates an empty document in a drive as a single batched operation.
921
1454
  * Delegates to {@link IDriveClient.addFile}.
922
1455
  *
@@ -936,7 +1469,8 @@ var ReactorClient = class {
936
1469
  const completedJob = await this.waitForJob(jobInfo, signal);
937
1470
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
938
1471
  const view = { branch };
939
- return await this.reactor.getByIdOrSlug(documentIdentifier, view, completedJob.consistencyToken, signal);
1472
+ const result = await this.reactor.getByIdOrSlug(documentIdentifier, view, completedJob.consistencyToken, signal);
1473
+ return this.gateDocument(result, view, signal);
940
1474
  }
941
1475
  /**
942
1476
  * Submits a list of actions to a document
@@ -980,7 +1514,8 @@ var ReactorClient = class {
980
1514
  const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
981
1515
  const completedJob = await this.waitForJob(jobInfo, signal);
982
1516
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
983
- return await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1517
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1518
+ return this.gateDocument(result, { branch }, signal);
984
1519
  }
985
1520
  /**
986
1521
  * Removes a relationship between two documents and waits for completion.
@@ -990,7 +1525,8 @@ var ReactorClient = class {
990
1525
  const jobInfo = await this.reactor.removeRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
991
1526
  const completedJob = await this.waitForJob(jobInfo, signal);
992
1527
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
993
- return await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1528
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1529
+ return this.gateDocument(result, { branch }, signal);
994
1530
  }
995
1531
  /**
996
1532
  * Moves a relationship from one source document to another and waits for completion.
@@ -1003,9 +1539,11 @@ var ReactorClient = class {
1003
1539
  const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1004
1540
  const addCompletedJob = await this.waitForJob(addJobInfo, signal);
1005
1541
  if (addCompletedJob.status === JobStatus.FAILED) throw new Error(addCompletedJob.error?.message);
1542
+ const sourceResult = await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal);
1543
+ const targetResult = await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal);
1006
1544
  return {
1007
- source: await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal),
1008
- target: await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal)
1545
+ source: await this.gateDocument(sourceResult, { branch }, signal),
1546
+ target: await this.gateDocument(targetResult, { branch }, signal)
1009
1547
  };
1010
1548
  }
1011
1549
  async loadBatch(request, signal) {
@@ -1078,32 +1616,56 @@ var ReactorClient = class {
1078
1616
  */
1079
1617
  subscribe(search, callback, view) {
1080
1618
  this.logger.verbose("subscribe(@search, @view)", search, view);
1619
+ const readable = async (document) => filterReadableScopes(document, await this.readableScopes(document, view));
1620
+ let disposed = false;
1621
+ let delivering = Promise.resolve();
1622
+ /**
1623
+ * Queues one event behind those already queued. Gating an event resolves
1624
+ * asynchronously, so an event needing a group fetch would otherwise be
1625
+ * overtaken by the one behind it, and one still in flight would reach a
1626
+ * subscriber that has already unsubscribed. Only delivery is ordered, not
1627
+ * the gating: a single subscription can cover every document in the
1628
+ * reactor, so serializing that work would make its delivery rate the sum
1629
+ * of every gate build rather than the slowest. An event that cannot be
1630
+ * gated is withheld, because serving it unfiltered would leak the scopes
1631
+ * the gate did not clear -- but it is logged rather than swallowed, since
1632
+ * the gate rethrows a transient failure precisely so it is not read as a
1633
+ * denial.
1634
+ */
1635
+ const deliver = (event) => {
1636
+ Promise.resolve(event).catch(() => void 0);
1637
+ delivering = delivering.then(async () => {
1638
+ const built = await event;
1639
+ if (disposed) return;
1640
+ callback(built);
1641
+ }).catch((error) => {
1642
+ this.logger.error("Subscription delivery failed for @search: @Error", { search }, error);
1643
+ });
1644
+ };
1081
1645
  const unsubscribeCreated = this.subscriptionManager.onDocumentCreated((result) => {
1082
- (async () => {
1083
- try {
1084
- const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, view, void 0, void 0)));
1085
- callback({
1086
- type: DocumentChangeType.Created,
1087
- documents
1088
- });
1089
- } catch {}
1090
- })();
1646
+ deliver((async () => {
1647
+ const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
1648
+ return {
1649
+ type: DocumentChangeType.Created,
1650
+ documents: await Promise.all(documents.map(readable))
1651
+ };
1652
+ })());
1091
1653
  }, search);
1092
1654
  const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
1093
- callback({
1655
+ deliver({
1094
1656
  type: DocumentChangeType.Deleted,
1095
1657
  documents: [],
1096
1658
  context: { childId: documentIds[0] }
1097
1659
  });
1098
1660
  }, search);
1099
1661
  const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
1100
- callback({
1662
+ deliver((async () => ({
1101
1663
  type: DocumentChangeType.Updated,
1102
- documents: result.results
1103
- });
1664
+ documents: await Promise.all(result.results.map(readable))
1665
+ }))());
1104
1666
  }, search, view);
1105
1667
  const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
1106
- callback({
1668
+ deliver({
1107
1669
  type: changeType === RelationshipChangeType.Added ? DocumentChangeType.ChildAdded : DocumentChangeType.ChildRemoved,
1108
1670
  documents: [],
1109
1671
  context: {
@@ -1113,6 +1675,7 @@ var ReactorClient = class {
1113
1675
  });
1114
1676
  }, search);
1115
1677
  return () => {
1678
+ disposed = true;
1116
1679
  unsubscribeCreated();
1117
1680
  unsubscribeDeleted();
1118
1681
  unsubscribeUpdated();
@@ -1144,17 +1707,33 @@ let JobQueueState = /* @__PURE__ */ function(JobQueueState) {
1144
1707
  return JobQueueState;
1145
1708
  }({});
1146
1709
  /**
1710
+ * How a retry is accounted against the job's retry limit.
1711
+ * - `CountAgainstLimit` (default): a fault; the job eventually exhausts its
1712
+ * retries and fails terminally.
1713
+ * - `ExemptFromLimit`: not a fault, so the attempt is not charged to the job.
1714
+ * Used for concurrency conflicts, where the retry does new work.
1715
+ */
1716
+ let RetryAccounting = /* @__PURE__ */ function(RetryAccounting) {
1717
+ RetryAccounting["CountAgainstLimit"] = "count-against-limit";
1718
+ RetryAccounting["ExemptFromLimit"] = "exempt-from-limit";
1719
+ return RetryAccounting;
1720
+ }({});
1721
+ /**
1147
1722
  * Event types for the queue system
1148
1723
  */
1149
1724
  const QueueEventTypes = { JOB_AVAILABLE: 1e4 };
1150
1725
  //#endregion
1151
1726
  //#region src/executor/job-result-handler.ts
1727
+ /** Conflict retries a job may take without charging its retry limit. */
1728
+ const MAX_EXEMPT_CONFLICT_RETRIES = 20;
1152
1729
  function toErrorInfo(error) {
1153
1730
  if (error instanceof Error) return {
1731
+ name: error.name,
1154
1732
  message: error.message,
1155
1733
  stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
1156
1734
  };
1157
1735
  return {
1736
+ name: "Error",
1158
1737
  message: error,
1159
1738
  stack: (/* @__PURE__ */ new Error()).stack || ""
1160
1739
  };
@@ -1187,12 +1766,19 @@ var JobResultHandler = class {
1187
1766
  } catch {}
1188
1767
  }
1189
1768
  }
1190
- if (result.error && DocumentNotFoundError.isError(result.error)) {
1769
+ if (result.error && AppendConditionFailedError.isError(result.error) && this.countConflicts(handle.job) < MAX_EXEMPT_CONFLICT_RETRIES) {
1770
+ const errorInfo = toErrorInfo(result.error);
1771
+ try {
1772
+ await this.queue.retryJob(handle.job.id, errorInfo, RetryAccounting.ExemptFromLimit);
1773
+ return;
1774
+ } catch {}
1775
+ }
1776
+ if (result.error && DocumentNotFoundError.isError(result.error) && handle.job.kind === "load") {
1191
1777
  handle.defer();
1192
- callbacks.deferJob(handle.job.documentId, handle.job);
1778
+ callbacks.deferJob(result.error.documentId, handle.job);
1193
1779
  return;
1194
1780
  }
1195
- if (result.error && DocumentDeletedError.isError(result.error)) {
1781
+ 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))) {
1196
1782
  const errorInfo = toErrorInfo(result.error);
1197
1783
  this.jobTracker.markFailed(handle.job.id, errorInfo, handle.job);
1198
1784
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
@@ -1230,6 +1816,12 @@ var JobResultHandler = class {
1230
1816
  handle.fail(fullErrorInfo);
1231
1817
  }
1232
1818
  }
1819
+ /** How many times this job has already lost an append-condition race. */
1820
+ countConflicts(job) {
1821
+ let conflicts = 0;
1822
+ for (const error of job.errorHistory) if (AppendConditionFailedError.isFailureMessage(error.message)) conflicts++;
1823
+ return conflicts;
1824
+ }
1233
1825
  hasCreateDocumentAction(job) {
1234
1826
  for (const action of job.actions) if (action.type === "CREATE_DOCUMENT") return true;
1235
1827
  for (const operation of job.operations) if (operation.action.type === "CREATE_DOCUMENT") return true;
@@ -1245,22 +1837,134 @@ var JobResultHandler = class {
1245
1837
  stackLines.push(`[Attempt ${index + 1}] Stack trace:\n${error.stack}`);
1246
1838
  });
1247
1839
  return {
1840
+ name: currentError.name,
1248
1841
  message: messageLines.join("\n"),
1249
1842
  stack: stackLines.join("\n\n")
1250
1843
  };
1251
1844
  }
1252
1845
  };
1253
1846
  //#endregion
1254
- //#region src/executor/types.ts
1847
+ //#region src/executor/deferred-jobs.ts
1255
1848
  /**
1256
- * Event types for the job executor
1849
+ * The jobs held back because the document they write to was not there yet.
1850
+ *
1851
+ * A job whose document is missing is deferred rather than failed, because the
1852
+ * operations that create that document may still be on their way - out of order
1853
+ * from a peer, or a sibling job in the same batch. Deferring emits no event and
1854
+ * moves no job status, which is what makes it cheap: a job that gets flushed
1855
+ * runs as if it had merely waited its turn.
1856
+ *
1857
+ * That silence is also why the wait has to be bounded. A deferred job is only
1858
+ * released when a job carrying CREATE_DOCUMENT for that exact document id
1859
+ * completes, so a job deferred against an id nothing will ever create is
1860
+ * released by nothing. Its status stays RUNNING, which is not terminal, so a
1861
+ * caller awaiting it waits forever - there is no timeout on that side. The TTL
1862
+ * converts that silence into the failure the caller can act on.
1863
+ *
1864
+ * Failing through the queue rather than only the tracker matters: the queue
1865
+ * records the job as resolved, which is what unblocks anything that declared a
1866
+ * dependency on it. Deferring alone does not, so a dependent of a deferred job
1867
+ * is stranded at PENDING for as long as the deferral lasts.
1257
1868
  */
1258
- const JobExecutorEventTypes = {
1259
- JOB_STARTED: 2e4,
1260
- JOB_COMPLETED: 20001,
1261
- JOB_FAILED: 20002,
1262
- EXECUTOR_STARTED: 20003,
1263
- EXECUTOR_STOPPED: 20004
1869
+ var DeferredJobs = class {
1870
+ #byDocumentId = /* @__PURE__ */ new Map();
1871
+ #timers = /* @__PURE__ */ new Map();
1872
+ constructor(queue, jobTracker, eventBus, logger, ttlMs = DEFAULT_DEFERRED_JOB_TTL_MS, onReleased = () => Promise.resolve()) {
1873
+ this.queue = queue;
1874
+ this.jobTracker = jobTracker;
1875
+ this.eventBus = eventBus;
1876
+ this.logger = logger;
1877
+ this.ttlMs = ttlMs;
1878
+ this.onReleased = onReleased;
1879
+ }
1880
+ /**
1881
+ * Holds a job until its document arrives, or until the TTL elapses.
1882
+ *
1883
+ * A job deferred again after a flush replaces its own timer rather than
1884
+ * accumulating one, so the wait is measured from the most recent attempt.
1885
+ */
1886
+ add(documentId, job) {
1887
+ const existing = this.#byDocumentId.get(documentId) ?? [];
1888
+ existing.push(job);
1889
+ this.#byDocumentId.set(documentId, existing);
1890
+ this.#clearTimer(job.id);
1891
+ const timer = setTimeout(() => {
1892
+ this.#expire(documentId, job);
1893
+ }, this.ttlMs);
1894
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
1895
+ this.#timers.set(job.id, timer);
1896
+ }
1897
+ /** Re-enqueues everything waiting on a document that has now arrived. */
1898
+ async flush(documentId) {
1899
+ const jobs = this.#byDocumentId.get(documentId);
1900
+ if (!jobs || jobs.length === 0) return;
1901
+ this.#byDocumentId.delete(documentId);
1902
+ for (const job of jobs) {
1903
+ this.#clearTimer(job.id);
1904
+ try {
1905
+ await this.queue.enqueue(job);
1906
+ } catch (error) {
1907
+ this.logger.error("Error re-enqueuing deferred job: @Error", error);
1908
+ }
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Fails everything still deferred, for a reactor that is shutting down.
1913
+ *
1914
+ * The queue is deliberately not told: it is going away too, and nothing is
1915
+ * left to unblock.
1916
+ */
1917
+ failAll() {
1918
+ for (const [, jobs] of this.#byDocumentId) for (const job of jobs) {
1919
+ this.#clearTimer(job.id);
1920
+ this.#markFailed(job);
1921
+ }
1922
+ this.#byDocumentId.clear();
1923
+ }
1924
+ /** The jobs still waiting on a document. Exposed for assertions. */
1925
+ waitingOn(documentId) {
1926
+ return this.#byDocumentId.get(documentId) ?? [];
1927
+ }
1928
+ async #expire(documentId, job) {
1929
+ const jobs = this.#byDocumentId.get(documentId);
1930
+ if (!jobs?.includes(job)) return;
1931
+ const remaining = jobs.filter((held) => held !== job);
1932
+ if (remaining.length === 0) this.#byDocumentId.delete(documentId);
1933
+ else this.#byDocumentId.set(documentId, remaining);
1934
+ this.#timers.delete(job.id);
1935
+ this.logger.error("Deferred job @jobId gave up waiting for document @documentId after @ttlMs ms", job.id, documentId, this.ttlMs);
1936
+ const errorInfo = this.#markFailed(job);
1937
+ try {
1938
+ await this.queue.failJob(job.id, errorInfo);
1939
+ } catch (error) {
1940
+ this.logger.error("Error failing an expired deferred job: @Error", error);
1941
+ return;
1942
+ }
1943
+ this.jobTracker.markFailed(job.id, errorInfo, job);
1944
+ try {
1945
+ await this.onReleased();
1946
+ } catch (error) {
1947
+ this.logger.error("Error dispatching after a deferred job expired: @Error", error);
1948
+ }
1949
+ }
1950
+ #markFailed(job) {
1951
+ const error = new DocumentNotFoundError(job.documentId);
1952
+ const errorInfo = toErrorInfo(error);
1953
+ this.jobTracker.markFailed(job.id, errorInfo, job);
1954
+ this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
1955
+ jobId: job.id,
1956
+ error,
1957
+ job
1958
+ }).catch(() => {});
1959
+ return errorInfo;
1960
+ }
1961
+ #clearTimer(jobId) {
1962
+ const timer = this.#timers.get(jobId);
1963
+ if (timer !== void 0) {
1964
+ clearTimeout(timer);
1965
+ this.#timers.delete(jobId);
1966
+ }
1967
+ }
1264
1968
  };
1265
1969
  //#endregion
1266
1970
  //#region src/executor/worker-pool-router.ts
@@ -1297,18 +2001,6 @@ function bucketFor(documentId, numWorkers) {
1297
2001
  //#endregion
1298
2002
  //#region src/executor/worker-pool-job-executor-manager.ts
1299
2003
  /**
1300
- * Action types whose application invalidates the parent's collection
1301
- * membership cache. Mirrors the in-process invalidation pattern in
1302
- * `document-action-handler.ts` (the worker pool relocates that work to
1303
- * the parent because the cache lives there).
1304
- */
1305
- const MEMBERSHIP_INVALIDATING_ACTIONS = new Set([
1306
- "ADD_RELATIONSHIP",
1307
- "REMOVE_RELATIONSHIP",
1308
- "UPDATE_RELATIONSHIP",
1309
- "DELETE_DOCUMENT"
1310
- ]);
1311
- /**
1312
2004
  * Manages a pool of executor workers and dispatches jobs across them with
1313
2005
  * sticky-by-documentId routing. Replaces `SimpleJobExecutorManager` when
1314
2006
  * the worker pool is enabled.
@@ -1318,10 +2010,19 @@ const MEMBERSHIP_INVALIDATING_ACTIONS = new Set([
1318
2010
  * - Emitting `JOB_RUNNING` and `JOB_WRITE_READY` events; the worker's
1319
2011
  * local event bus is a no-op stub.
1320
2012
  * - Maintaining the deferred-jobs map for `DocumentNotFoundError`.
1321
- * - Owning the authoritative `ICollectionMembershipCache` — workers do
1322
- * not query it. Each result enriches the outgoing `JOB_WRITE_READY`
1323
- * with `collectionMemberships` and invalidates targets named by
1324
- * relationship/delete operations before the lookup.
2013
+ * - Enriching the outgoing `JOB_WRITE_READY` with
2014
+ * `collectionMemberships`, read from the operation index after the
2015
+ * worker has committed. It takes the whole index rather than a
2016
+ * narrower read interface so that handing it a cache does not
2017
+ * compile: which documents a commit moves between collections is not
2018
+ * derivable from action shape, because joining a collection also
2019
+ * joins every group the joining document has referenced and that set
2020
+ * comes from selects run inside the commit. A parent-side cache
2021
+ * cannot learn it went stale, and a stale entry silently drops the
2022
+ * document's operations from the outbox of every remote subscribed to
2023
+ * the omitted collection. Costs one primary-key-prefix lookup on
2024
+ * `document_collections` per job that produced operations, on a
2025
+ * fire-and-forget path.
1325
2026
  *
1326
2027
  * @see Executor Worker Pool Design wiki page
1327
2028
  * (Powerhouse board wiki id: d400d711-f07e-4389-a226-4e9fdd4fa8ba)
@@ -1332,18 +2033,19 @@ var WorkerPoolJobExecutorManager = class {
1332
2033
  activeJobs = 0;
1333
2034
  totalJobsProcessed = 0;
1334
2035
  unsubscribe;
1335
- deferredJobs = /* @__PURE__ */ new Map();
2036
+ deferredJobs;
1336
2037
  resultHandler;
1337
2038
  jobTimeoutMs;
1338
- constructor(workerFactory, eventBus, queue, jobTracker, logger, resolver, collectionMembershipCache, jobTimeoutMs = 3e4) {
2039
+ constructor(workerFactory, eventBus, queue, jobTracker, logger, resolver, operationIndex, jobTimeoutMs = 3e4, deferredJobTtlMs = DEFAULT_DEFERRED_JOB_TTL_MS) {
1339
2040
  this.workerFactory = workerFactory;
1340
2041
  this.eventBus = eventBus;
1341
2042
  this.queue = queue;
1342
2043
  this.jobTracker = jobTracker;
1343
2044
  this.logger = logger;
1344
2045
  this.resolver = resolver;
1345
- this.collectionMembershipCache = collectionMembershipCache;
2046
+ this.operationIndex = operationIndex;
1346
2047
  this.jobTimeoutMs = jobTimeoutMs;
2048
+ this.deferredJobs = new DeferredJobs(queue, jobTracker, eventBus, logger, deferredJobTtlMs, () => this.tryDispatchAll());
1347
2049
  this.resultHandler = new JobResultHandler(queue, jobTracker, eventBus, resolver, logger);
1348
2050
  }
1349
2051
  async start(numWorkers) {
@@ -1364,16 +2066,7 @@ var WorkerPoolJobExecutorManager = class {
1364
2066
  this.unsubscribe = void 0;
1365
2067
  }
1366
2068
  if (graceful) while (this.activeJobs > 0) await new Promise((resolve) => setTimeout(resolve, 10));
1367
- for (const [, jobs] of this.deferredJobs) for (const job of jobs) {
1368
- const errorInfo = toErrorInfo(new DocumentNotFoundError(job.documentId));
1369
- this.jobTracker.markFailed(job.id, errorInfo, job);
1370
- this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
1371
- jobId: job.id,
1372
- error: new DocumentNotFoundError(job.documentId),
1373
- job
1374
- }).catch(() => {});
1375
- }
1376
- this.deferredJobs.clear();
2069
+ this.deferredJobs.failAll();
1377
2070
  await Promise.all(this.workers.map((w) => w.shutdown(graceful).catch((err) => {
1378
2071
  this.logger.warn("worker shutdown failed: @Error", err);
1379
2072
  })));
@@ -1495,22 +2188,17 @@ var WorkerPoolJobExecutorManager = class {
1495
2188
  this.logger.error("emitWriteReady failed for job @jobId: @Error", { jobId: handle.job.id }, error);
1496
2189
  });
1497
2190
  await this.resultHandler.handleResult(handle, outcome.result, {
1498
- deferJob: (documentId, job) => {
1499
- const existing = this.deferredJobs.get(documentId) ?? [];
1500
- existing.push(job);
1501
- this.deferredJobs.set(documentId, existing);
1502
- },
1503
- flushDeferredFor: (documentId) => this.flushDeferredJobs(documentId)
2191
+ deferJob: (documentId, job) => this.deferredJobs.add(documentId, job),
2192
+ flushDeferredFor: (documentId) => this.deferredJobs.flush(documentId)
1504
2193
  });
1505
2194
  this.activeJobs--;
1506
2195
  await this.tryDispatchFor(worker);
1507
2196
  }
1508
2197
  async emitWriteReady(job, payload) {
1509
- this.invalidateMembershipsFor(payload.operations);
1510
2198
  const documentIds = [...new Set(payload.operations.map((op) => op.context.documentId))];
1511
2199
  let collectionMemberships = {};
1512
- try {
1513
- collectionMemberships = await this.collectionMembershipCache.getCollectionsForDocuments(documentIds);
2200
+ if (documentIds.length > 0) try {
2201
+ collectionMemberships = fillMissingMemberships(documentIds, await this.operationIndex.getCollectionsForDocuments(documentIds));
1514
2202
  } catch (error) {
1515
2203
  this.logger.error("Failed to load collection memberships for JOB_WRITE_READY: @Error", error);
1516
2204
  }
@@ -1526,14 +2214,6 @@ var WorkerPoolJobExecutorManager = class {
1526
2214
  this.logger.error("Failed to emit JOB_WRITE_READY event: @Error", error);
1527
2215
  }
1528
2216
  }
1529
- invalidateMembershipsFor(operations) {
1530
- for (const op of operations) {
1531
- const actionType = op.operation.action.type;
1532
- if (!MEMBERSHIP_INVALIDATING_ACTIONS.has(actionType)) continue;
1533
- const target = extractMembershipTarget(op);
1534
- if (target) this.collectionMembershipCache.invalidate(target);
1535
- }
1536
- }
1537
2217
  /**
1538
2218
  * Handle a worker-transport failure (worker exited / init failed / abort
1539
2219
  * timed out) detected while `worker.execute` was in flight. Re-enqueues
@@ -1582,16 +2262,6 @@ var WorkerPoolJobExecutorManager = class {
1582
2262
  this.workers[deadIndex] = fresh;
1583
2263
  await this.tryDispatchFor(fresh);
1584
2264
  }
1585
- async flushDeferredJobs(documentId) {
1586
- const jobs = this.deferredJobs.get(documentId);
1587
- if (!jobs || jobs.length === 0) return;
1588
- this.deferredJobs.delete(documentId);
1589
- for (const job of jobs) try {
1590
- await this.queue.enqueue(job);
1591
- } catch (error) {
1592
- this.logger.error("Error re-enqueuing deferred job: @Error", error);
1593
- }
1594
- }
1595
2265
  };
1596
2266
  function isWorkerTransportError(error) {
1597
2267
  return error instanceof WorkerExitedError || error instanceof WorkerInitFailedError || error instanceof WorkerAbortTimeoutError;
@@ -1602,11 +2272,16 @@ function isDuplicateModuleFailure(reason) {
1602
2272
  const cause = reason.cause;
1603
2273
  return cause instanceof Error && cause.name === "DuplicateModuleError";
1604
2274
  }
1605
- function extractMembershipTarget(op) {
1606
- const actionType = op.operation.action.type;
1607
- const input = op.operation.action.input;
1608
- if (actionType === "ADD_RELATIONSHIP" || actionType === "REMOVE_RELATIONSHIP" || actionType === "UPDATE_RELATIONSHIP") return input?.targetId;
1609
- if (actionType === "DELETE_DOCUMENT") return input?.documentId ?? op.context.documentId;
2275
+ /**
2276
+ * Gives every requested document a key, empty when it belongs to no
2277
+ * collection. The operation index omits documents with no rows while the
2278
+ * membership cache defaulted them to `[]`, so this keeps the emitted
2279
+ * `JobWriteReadyEvent.collectionMemberships` shape unchanged.
2280
+ */
2281
+ function fillMissingMemberships(documentIds, found) {
2282
+ const result = {};
2283
+ for (const documentId of documentIds) result[documentId] = found[documentId] ?? [];
2284
+ return result;
1610
2285
  }
1611
2286
  //#endregion
1612
2287
  //#region src/executor/simple-job-executor-manager.ts
@@ -1620,10 +2295,10 @@ var SimpleJobExecutorManager = class {
1620
2295
  activeJobs = 0;
1621
2296
  totalJobsProcessed = 0;
1622
2297
  unsubscribe;
1623
- deferredJobs = /* @__PURE__ */ new Map();
2298
+ deferredJobs;
1624
2299
  resultHandler;
1625
2300
  jobTimeoutMs;
1626
- constructor(executorFactory, eventBus, queue, jobTracker, logger, resolver, jobTimeoutMs = 3e4) {
2301
+ constructor(executorFactory, eventBus, queue, jobTracker, logger, resolver, jobTimeoutMs = 3e4, deferredJobTtlMs = DEFAULT_DEFERRED_JOB_TTL_MS) {
1627
2302
  this.executorFactory = executorFactory;
1628
2303
  this.eventBus = eventBus;
1629
2304
  this.queue = queue;
@@ -1631,6 +2306,7 @@ var SimpleJobExecutorManager = class {
1631
2306
  this.logger = logger;
1632
2307
  this.resolver = resolver;
1633
2308
  this.jobTimeoutMs = jobTimeoutMs;
2309
+ this.deferredJobs = new DeferredJobs(queue, jobTracker, eventBus, logger, deferredJobTtlMs, () => this.checkForMoreJobs());
1634
2310
  this.resultHandler = new JobResultHandler(queue, jobTracker, eventBus, resolver, logger);
1635
2311
  }
1636
2312
  async start(numExecutors) {
@@ -1651,16 +2327,7 @@ var SimpleJobExecutorManager = class {
1651
2327
  this.unsubscribe = void 0;
1652
2328
  }
1653
2329
  if (graceful) while (this.activeJobs > 0) await new Promise((resolve) => setTimeout(resolve, 100));
1654
- for (const [, jobs] of this.deferredJobs) for (const job of jobs) {
1655
- const errorInfo = toErrorInfo(new DocumentNotFoundError(job.documentId));
1656
- this.jobTracker.markFailed(job.id, errorInfo, job);
1657
- this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
1658
- jobId: job.id,
1659
- error: new DocumentNotFoundError(job.documentId),
1660
- job
1661
- }).catch(() => {});
1662
- }
1663
- this.deferredJobs.clear();
2330
+ this.deferredJobs.failAll();
1664
2331
  this.executors = [];
1665
2332
  this.isRunning = false;
1666
2333
  }
@@ -1753,12 +2420,8 @@ var SimpleJobExecutorManager = class {
1753
2420
  this.eventBus.emit(JobExecutorEventTypes.JOB_FAILED, failedEvent).catch(() => {});
1754
2421
  }
1755
2422
  await this.resultHandler.handleResult(handle, result, {
1756
- deferJob: (documentId, job) => {
1757
- const existing = this.deferredJobs.get(documentId) ?? [];
1758
- existing.push(job);
1759
- this.deferredJobs.set(documentId, existing);
1760
- },
1761
- flushDeferredFor: (documentId) => this.flushDeferredJobs(documentId)
2423
+ deferJob: (documentId, job) => this.deferredJobs.add(documentId, job),
2424
+ flushDeferredFor: (documentId) => this.deferredJobs.flush(documentId)
1762
2425
  });
1763
2426
  this.activeJobs--;
1764
2427
  await this.checkForMoreJobs();
@@ -1792,16 +2455,6 @@ var SimpleJobExecutorManager = class {
1792
2455
  }
1793
2456
  }
1794
2457
  }
1795
- async flushDeferredJobs(documentId) {
1796
- const jobs = this.deferredJobs.get(documentId);
1797
- if (!jobs || jobs.length === 0) return;
1798
- this.deferredJobs.delete(documentId);
1799
- for (const job of jobs) try {
1800
- await this.queue.enqueue(job);
1801
- } catch (error) {
1802
- this.logger.error("Error re-enqueuing deferred job: @Error", error);
1803
- }
1804
- }
1805
2458
  };
1806
2459
  //#endregion
1807
2460
  //#region src/job-tracker/in-memory-job-tracker.ts
@@ -1850,6 +2503,7 @@ var InMemoryJobTracker = class {
1850
2503
  }
1851
2504
  handleJobFailed(event) {
1852
2505
  this.markFailed(event.jobId, {
2506
+ name: event.error.name,
1853
2507
  message: event.error.message,
1854
2508
  stack: event.error.stack || ""
1855
2509
  }, event.job);
@@ -1958,6 +2612,26 @@ function matchesFilter(op, filter) {
1958
2612
  }
1959
2613
  return true;
1960
2614
  }
2615
+ function nonEmpty(value) {
2616
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2617
+ }
2618
+ function namespaceOf(processor) {
2619
+ return nonEmpty(processor.namespace);
2620
+ }
2621
+ function classNameOf(processor) {
2622
+ const name = processor.constructor.name;
2623
+ return name && name !== "Object" ? name : void 0;
2624
+ }
2625
+ function resolveProcessorSlots(records, legacy) {
2626
+ if (legacy) return records.map((_, i) => String(i));
2627
+ const seen = /* @__PURE__ */ new Map();
2628
+ return records.map((record, i) => {
2629
+ const base = nonEmpty(record.id) ?? namespaceOf(record.processor) ?? classNameOf(record.processor) ?? String(i);
2630
+ const count = seen.get(base) ?? 0;
2631
+ seen.set(base, count + 1);
2632
+ return count === 0 ? base : `${base}#${count}`;
2633
+ });
2634
+ }
1961
2635
  //#endregion
1962
2636
  //#region src/processors/processor-manager.ts
1963
2637
  /**
@@ -1979,13 +2653,15 @@ var ProcessorManager = class extends BaseReadModel {
1979
2653
  cursorCache = /* @__PURE__ */ new Map();
1980
2654
  logger;
1981
2655
  driveContainerTypes;
1982
- constructor(db, operationIndex, writeCache, consistencyTracker, logger, driveContainerTypes) {
2656
+ legacyProcessorIds;
2657
+ constructor(db, operationIndex, writeCache, consistencyTracker, logger, driveContainerTypes, options = {}) {
1983
2658
  super(db, operationIndex, writeCache, consistencyTracker, {
1984
2659
  readModelId: "processor-manager",
1985
2660
  rebuildStateOnInit: true
1986
2661
  });
1987
2662
  this.logger = logger;
1988
2663
  this.driveContainerTypes = driveContainerTypes;
2664
+ this.legacyProcessorIds = options.legacyProcessorIds ?? true;
1989
2665
  }
1990
2666
  async init() {
1991
2667
  await super.init();
@@ -2072,9 +2748,10 @@ var ProcessorManager = class extends BaseReadModel {
2072
2748
  }
2073
2749
  if (records.length === 0) return;
2074
2750
  const trackedList = [];
2751
+ const slots = resolveProcessorSlots(records, this.legacyProcessorIds);
2075
2752
  for (let i = 0; i < records.length; i++) {
2076
2753
  const record = records[i];
2077
- const processorId = `${identifier}:${driveId}:${i}`;
2754
+ const processorId = `${identifier}:${driveId}:${slots[i]}`;
2078
2755
  const cached = this.cursorCache.get(processorId);
2079
2756
  let lastOrdinal;
2080
2757
  let status;
@@ -2106,8 +2783,9 @@ var ProcessorManager = class extends BaseReadModel {
2106
2783
  trackedList.push(tracked);
2107
2784
  await this.saveProcessorCursor(tracked);
2108
2785
  }
2109
- await this.db.deleteFrom("ProcessorCursor").where("factoryId", "=", identifier).where("driveId", "=", driveId).where("processorIndex", ">=", records.length).execute();
2110
- for (const [id, row] of this.cursorCache) if (row.factoryId === identifier && row.driveId === driveId && row.processorIndex >= records.length) this.cursorCache.delete(id);
2786
+ const liveIds = new Set(trackedList.map((t) => t.processorId));
2787
+ await this.db.deleteFrom("ProcessorCursor").where("factoryId", "=", identifier).where("driveId", "=", driveId).where("processorId", "not in", [...liveIds]).execute();
2788
+ for (const [id, row] of this.cursorCache) if (row.factoryId === identifier && row.driveId === driveId && !liveIds.has(id)) this.cursorCache.delete(id);
2111
2789
  const factoryProcessors = this.factoryToProcessors.get(identifier);
2112
2790
  if (factoryProcessors) factoryProcessors.set(driveId, trackedList);
2113
2791
  const existingDriveProcessors = this.processorsByDrive.get(driveId) ?? [];
@@ -2311,10 +2989,12 @@ var InMemoryQueue = class {
2311
2989
  }
2312
2990
  toErrorInfo(error) {
2313
2991
  if (error instanceof Error) return {
2992
+ name: error.name,
2314
2993
  message: error.message,
2315
2994
  stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
2316
2995
  };
2317
2996
  return {
2997
+ name: "Error",
2318
2998
  message: error,
2319
2999
  stack: (/* @__PURE__ */ new Error()).stack || ""
2320
3000
  };
@@ -2402,6 +3082,7 @@ var InMemoryQueue = class {
2402
3082
  await this.resolver.ensureModelLoaded(documentType);
2403
3083
  } catch {
2404
3084
  await this.failJob(job.id, {
3085
+ name: "Error",
2405
3086
  message: `Failed to load document model for type: ${documentType}`,
2406
3087
  stack: (/* @__PURE__ */ new Error()).stack || ""
2407
3088
  });
@@ -2575,9 +3256,11 @@ var InMemoryQueue = class {
2575
3256
  this.jobIndex.delete(jobId);
2576
3257
  this.completedJobs.add(jobId);
2577
3258
  await this.remove(jobId);
3259
+ const emittedError = new Error(error?.message ?? "Job failed");
3260
+ if (error?.name) emittedError.name = error.name;
2578
3261
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
2579
3262
  jobId,
2580
- error: new Error(error?.message ?? "Job failed"),
3263
+ error: emittedError,
2581
3264
  job
2582
3265
  }).catch(() => {});
2583
3266
  this.checkDrained();
@@ -2587,7 +3270,7 @@ var InMemoryQueue = class {
2587
3270
  if (documentId) this.markJobComplete(jobId, documentId);
2588
3271
  this.jobIndex.delete(jobId);
2589
3272
  }
2590
- async retryJob(jobId, error) {
3273
+ async retryJob(jobId, error, accounting = RetryAccounting.CountAgainstLimit) {
2591
3274
  const job = this.jobIndex.get(jobId);
2592
3275
  if (!job) return;
2593
3276
  job.lastError = error;
@@ -2596,9 +3279,10 @@ var InMemoryQueue = class {
2596
3279
  this.jobIndex.delete(jobId);
2597
3280
  this.jobIdToQueueKey.delete(jobId);
2598
3281
  if (error) job.errorHistory.push(error);
3282
+ const retryCount = job.retryCount || 0;
2599
3283
  const updatedJob = {
2600
3284
  ...job,
2601
- retryCount: (job.retryCount || 0) + 1,
3285
+ retryCount: accounting === RetryAccounting.CountAgainstLimit ? retryCount + 1 : retryCount,
2602
3286
  lastError: error
2603
3287
  };
2604
3288
  await this.enqueue(updatedJob);
@@ -3116,6 +3800,83 @@ var SubscriptionNotificationReadModel = class {
3116
3800
  }
3117
3801
  };
3118
3802
  //#endregion
3803
+ //#region src/core/group-reevaluation-trigger.ts
3804
+ /**
3805
+ * Watches committed writes for group membership changes and enqueues a
3806
+ * re-evaluation job for every document whose auth history references the
3807
+ * changed group, found through the reverse direction of the group-reference
3808
+ * relation. Each affected document is re-judged in its own job, so the work
3809
+ * runs under that document's execution slot rather than the group's.
3810
+ *
3811
+ * The job carries the earliest changed membership timestamp; the executor
3812
+ * skips the pass when everything the document holds sorts before it, which
3813
+ * keeps the common case (a membership write later than all history) free.
3814
+ */
3815
+ var GroupReevaluationTrigger = class {
3816
+ unsubscribe;
3817
+ constructor(logger, eventBus, queue, operationIndex) {
3818
+ this.logger = logger;
3819
+ this.eventBus = eventBus;
3820
+ this.queue = queue;
3821
+ this.operationIndex = operationIndex;
3822
+ }
3823
+ startup() {
3824
+ this.unsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_WRITE_READY, async (_type, event) => this.onWriteReady(event));
3825
+ }
3826
+ shutdown() {
3827
+ this.unsubscribe?.();
3828
+ this.unsubscribe = void 0;
3829
+ }
3830
+ async onWriteReady(event) {
3831
+ const changed = /* @__PURE__ */ new Map();
3832
+ for (const { operation, context } of event.operations) {
3833
+ if (context.documentType !== groupDocumentType || context.scope !== "global" || !groupMembershipActionTypes.includes(operation.action.type)) continue;
3834
+ const existing = changed.get(context.documentId);
3835
+ if (existing === void 0 || Date.parse(operation.timestampUtcMs) < Date.parse(existing)) changed.set(context.documentId, operation.timestampUtcMs);
3836
+ }
3837
+ if (changed.size === 0) return;
3838
+ const affected = /* @__PURE__ */ new Map();
3839
+ for (const [groupId, timestamp] of changed) {
3840
+ let referencers;
3841
+ try {
3842
+ referencers = await this.operationIndex.getGroupReferencers(groupId);
3843
+ } catch (error) {
3844
+ this.logger.error("Failed to resolve referencers of group @groupId: @error", groupId, error);
3845
+ continue;
3846
+ }
3847
+ for (const documentId of referencers) {
3848
+ const existing = affected.get(documentId);
3849
+ if (existing === void 0 || Date.parse(timestamp) < Date.parse(existing)) affected.set(documentId, timestamp);
3850
+ }
3851
+ }
3852
+ for (const [documentId, timestamp] of affected) {
3853
+ const jobId = v4();
3854
+ const job = {
3855
+ id: jobId,
3856
+ kind: "reevaluation",
3857
+ documentId,
3858
+ scope: "global",
3859
+ branch: "main",
3860
+ actions: [],
3861
+ operations: [],
3862
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3863
+ queueHint: [],
3864
+ maxRetries: 3,
3865
+ errorHistory: [],
3866
+ meta: {
3867
+ ...buildSingleJobMeta(jobId),
3868
+ triggerTimestampUtcMs: timestamp
3869
+ }
3870
+ };
3871
+ try {
3872
+ await this.queue.enqueue(job);
3873
+ } catch (error) {
3874
+ this.logger.error("Failed to enqueue re-evaluation of @documentId: @error", documentId, error);
3875
+ }
3876
+ }
3877
+ }
3878
+ };
3879
+ //#endregion
3119
3880
  //#region src/sync/types.ts
3120
3881
  let ChannelScheme = /* @__PURE__ */ function(ChannelScheme) {
3121
3882
  ChannelScheme["CONNECT"] = "connect";
@@ -3407,13 +4168,42 @@ var BufferedMailbox = class {
3407
4168
  var GraphQLRequestError = class extends Error {
3408
4169
  statusCode;
3409
4170
  category;
3410
- constructor(message, category, statusCode) {
4171
+ /**
4172
+ * One entry per error the response carried, in order, holding its
4173
+ * `extensions.code` - undefined where it declared none. Kept per error rather
4174
+ * than as a set, because a response that mixes a classified error with an
4175
+ * unclassified one must not be read as if only the classified one arrived.
4176
+ */
4177
+ codes;
4178
+ constructor(message, category, statusCode, codes = []) {
3411
4179
  super(message);
3412
4180
  this.name = "GraphQLRequestError";
3413
4181
  this.category = category;
3414
4182
  this.statusCode = statusCode;
4183
+ this.codes = codes;
3415
4184
  }
3416
4185
  };
4186
+ /**
4187
+ * Extension codes a remote uses to say a failure is worth polling through.
4188
+ *
4189
+ * Shared with reactor-api so the server throws what this check reads and the two
4190
+ * cannot drift. A `graphql` category error is otherwise permanent: it stops the
4191
+ * poll timer, and nothing restarts it, so a code that lands here is the
4192
+ * difference between a channel that recovers and one that is dead for the
4193
+ * process lifetime.
4194
+ */
4195
+ const RECOVERABLE_GRAPHQL_ERROR_CODES = { malformedStoredOperation: "MALFORMED_STORED_OPERATION" };
4196
+ const RECOVERABLE_CODES = new Set(Object.values(RECOVERABLE_GRAPHQL_ERROR_CODES));
4197
+ /**
4198
+ * True when every error the response carried named a recoverable code.
4199
+ *
4200
+ * Unanimity is the requirement: one unclassified error alongside a recoverable
4201
+ * one means something else also went wrong, and polling through that would be
4202
+ * guessing.
4203
+ */
4204
+ function isRecoverableGraphQLError(error) {
4205
+ return error.codes.length > 0 && error.codes.every((code) => code !== void 0 && RECOVERABLE_CODES.has(code));
4206
+ }
3417
4207
  /** Auth-rejection message fragments the switchboard emits. Shared with
3418
4208
  * reactor-api so server throws and this client check can't drift. */
3419
4209
  const DRIVE_AUTH_ERROR_MESSAGES = {
@@ -3437,11 +4227,18 @@ var PollingChannelError = class extends Error {
3437
4227
  var ChannelError = class extends Error {
3438
4228
  source;
3439
4229
  error;
3440
- constructor(source, error) {
4230
+ /**
4231
+ * The classification when something other than the error carries it. Absent
4232
+ * means derive it from `error.name`; a dead letter mirrored from a peer sets it,
4233
+ * because only the message crosses the wire.
4234
+ */
4235
+ errorType;
4236
+ constructor(source, error, errorType) {
3441
4237
  super(`ChannelError[${source}]: ${error.message}`);
3442
4238
  this.name = "ChannelError";
3443
4239
  this.source = source;
3444
4240
  this.error = error;
4241
+ this.errorType = errorType;
3445
4242
  }
3446
4243
  };
3447
4244
  //#endregion
@@ -3646,6 +4443,7 @@ function toOperationWithContext(entry) {
3646
4443
  skip: entry.skip,
3647
4444
  hash: entry.hash,
3648
4445
  timestampUtcMs: entry.timestampUtcMs,
4446
+ deniedReason: entry.deniedReason,
3649
4447
  action: entry.action
3650
4448
  },
3651
4449
  context: {
@@ -3662,29 +4460,35 @@ function toOperationWithContext(entry) {
3662
4460
  * a single SyncOperation per group. Within each group, operations are sorted
3663
4461
  * by context.ordinal. The merged SyncOperation keeps the first group member's
3664
4462
  * jobId; all other jobIds are remapped so external dependencies still resolve.
4463
+ *
4464
+ * Only CONTIGUOUS runs merge. A document whose scopes interleave arrives as an
4465
+ * alternating chain (document -> auth -> document -> auth) where each entry
4466
+ * depends on the one before it. Merging every occurrence of a
4467
+ * (documentId, scope, branch) collapses that chain into two nodes that each
4468
+ * depend on the other, which validateBatchStructure rejects as a dependency
4469
+ * cycle. Merging only adjacent entries keeps the chain linear.
3665
4470
  */
3666
4471
  function consolidateSyncOperations(syncOps) {
3667
4472
  if (syncOps.length <= 1) return syncOps;
3668
- const groups = /* @__PURE__ */ new Map();
4473
+ const groups = [];
3669
4474
  const jobIdRemap = /* @__PURE__ */ new Map();
3670
- const insertionOrder = [];
4475
+ let prevKey = null;
3671
4476
  for (const syncOp of syncOps) {
3672
4477
  const key = `${syncOp.documentId}|${syncOp.scopes.slice().sort().join(",")}|${syncOp.branch}`;
3673
- const existing = groups.get(key);
3674
- if (existing) {
3675
- existing.ops.push(syncOp);
3676
- if (syncOp.jobId && syncOp.jobId !== existing.canonicalJobId) jobIdRemap.set(syncOp.jobId, existing.canonicalJobId);
4478
+ const current = key === prevKey ? groups[groups.length - 1] : void 0;
4479
+ if (current) {
4480
+ current.ops.push(syncOp);
4481
+ if (syncOp.jobId && syncOp.jobId !== current.canonicalJobId) jobIdRemap.set(syncOp.jobId, current.canonicalJobId);
3677
4482
  } else {
3678
- groups.set(key, {
4483
+ groups.push({
3679
4484
  ops: [syncOp],
3680
4485
  canonicalJobId: syncOp.jobId
3681
4486
  });
3682
- insertionOrder.push(key);
4487
+ prevKey = key;
3683
4488
  }
3684
4489
  }
3685
4490
  const result = [];
3686
- for (const key of insertionOrder) {
3687
- const group = groups.get(key);
4491
+ for (const group of groups) {
3688
4492
  const allOperations = group.ops.flatMap((op) => op.operations).sort((a, b) => a.context.ordinal - b.context.ordinal);
3689
4493
  const allDeps = /* @__PURE__ */ new Set();
3690
4494
  for (const op of group.ops) for (const dep of op.jobDependencies) allDeps.add(dep);
@@ -3811,6 +4615,33 @@ function splitComponent(items, maxSize) {
3811
4615
  for (let i = 0; i < sorted.length; i += maxSize) chunks.push(sorted.slice(i, i + maxSize));
3812
4616
  return chunks;
3813
4617
  }
4618
+ /**
4619
+ * Classifies a failure by error name rather than `instanceof`, because a failure
4620
+ * that crossed the pooled-worker boundary arrives as plain data.
4621
+ */
4622
+ function classifyJobFailure(errorName) {
4623
+ switch (errorName) {
4624
+ case "AuthTimestampNotMonotonicError": return "AUTH_TIMESTAMP_NOT_MONOTONIC";
4625
+ case "InvalidOperationTimestampError": return "INVALID_TIMESTAMP";
4626
+ case "ExcessiveReshuffleError": return "EXCESSIVE_SHUFFLE";
4627
+ case "InvalidSignatureError": return "SIGNATURE_INVALID";
4628
+ case "HashMismatchError": return "HASH_MISMATCH";
4629
+ default: return "UNCLASSIFIED";
4630
+ }
4631
+ }
4632
+ /** The explicit type when something else carried it, else derived by name. */
4633
+ function syncOperationErrorType(error) {
4634
+ return error?.errorType ?? classifyJobFailure(error?.error?.name ?? "Error");
4635
+ }
4636
+ /** Dead-letter types that must not stop the document syncing. */
4637
+ const NON_QUARANTINING_ERROR_TYPES = new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
4638
+ /**
4639
+ * A held auth operation must not quarantine: reconciling the two policies needs
4640
+ * the traffic a quarantine would stop.
4641
+ */
4642
+ function quarantinesDocument(errorType) {
4643
+ return !NON_QUARANTINING_ERROR_TYPES.has(errorType);
4644
+ }
3814
4645
  //#endregion
3815
4646
  //#region src/sync/channels/interval-poll-timer.ts
3816
4647
  const DEFAULT_CONFIG = {
@@ -3924,36 +4755,15 @@ var IntervalPollTimer = class {
3924
4755
  //#region src/sync/channels/utils.ts
3925
4756
  let syncOpCounter = 0;
3926
4757
  /**
3927
- * Serializes an action for GraphQL transport, converting signature tuples to strings.
4758
+ * Serializes an action for GraphQL transport.
3928
4759
  *
3929
- * Only the fields declared by the GraphQL `ActionInput` type are forwarded. This
3930
- * guards against stale runtime-only fields (e.g. a legacy `attachments` array on
3931
- * operations persisted before the attachment-system removal) leaking into the
3932
- * mutation variables, where the tightened schema would reject them.
4760
+ * Projects onto exactly the fields `ActionInput` declares, which is what keeps a
4761
+ * stale runtime-only field - a legacy `attachments` array, say - from riding
4762
+ * along into the mutation variables, where the schema would reject the whole
4763
+ * request rather than the field.
3933
4764
  */
3934
4765
  function serializeAction(action) {
3935
- const base = {
3936
- id: action.id,
3937
- type: action.type,
3938
- timestampUtcMs: action.timestampUtcMs,
3939
- input: action.input,
3940
- scope: action.scope
3941
- };
3942
- const signer = action.context?.signer;
3943
- if (!signer?.signatures) return action.context ? {
3944
- ...base,
3945
- context: action.context
3946
- } : base;
3947
- return {
3948
- ...base,
3949
- context: {
3950
- ...action.context,
3951
- signer: {
3952
- ...signer,
3953
- signatures: signer.signatures.map((sig) => Array.isArray(sig) ? sig.join(", ") : sig)
3954
- }
3955
- }
3956
- };
4766
+ return toTransportAction(action);
3957
4767
  }
3958
4768
  /**
3959
4769
  * Serializes a SyncEnvelope for GraphQL transport.
@@ -3973,6 +4783,7 @@ function serializeEnvelope(envelope) {
3973
4783
  hash: opWithContext.operation.hash,
3974
4784
  skip: opWithContext.operation.skip,
3975
4785
  error: opWithContext.operation.error,
4786
+ ...opWithContext.operation.deniedReason !== void 0 ? { deniedReason: opWithContext.operation.deniedReason } : {},
3976
4787
  id: opWithContext.operation.id,
3977
4788
  action: serializeAction(opWithContext.operation.action)
3978
4789
  },
@@ -3990,31 +4801,33 @@ function serializeEnvelope(envelope) {
3990
4801
  };
3991
4802
  }
3992
4803
  /**
3993
- * Deserializes a signature from a comma-separated string back to a tuple.
3994
- *
3995
- * GraphQL serializes Signature tuples as comma-separated strings for transport.
3996
- * This function converts them back to the expected [string, string, string, string, string] format.
4804
+ * `isDenied` tests strictly against undefined, so a null left in place would mark
4805
+ * every synced operation denied. The key is removed rather than set to undefined,
4806
+ * so an operation that arrived without it stays identical to the one sent.
3997
4807
  */
3998
- function deserializeSignature(sig) {
3999
- if (Array.isArray(sig)) return sig;
4000
- return sig.split(", ");
4808
+ function normalizeAbsentFields(operation) {
4809
+ const wire = operation;
4810
+ if (wire.error !== null && wire.deniedReason !== null) return operation;
4811
+ const normalized = { ...operation };
4812
+ if (wire.error === null) delete normalized.error;
4813
+ if (wire.deniedReason === null) delete normalized.deniedReason;
4814
+ return normalized;
4001
4815
  }
4002
- /**
4003
- * Deserializes signatures in an operation's signer context from strings back to tuples.
4004
- *
4005
- * When operations are transported via GraphQL, signatures are serialized as comma-separated
4006
- * strings. This function restores them to the Signature tuple format required for verification.
4007
- */
4008
- function deserializeOperationSignatures(opWithContext) {
4009
- const signer = opWithContext.operation.action.context?.signer;
4010
- if (!signer?.signatures || signer.signatures.length === 0) return opWithContext;
4816
+ /** Restores signature tuples and null-valued optional fields to undefined. */
4817
+ function deserializeOperation(opWithContext) {
4818
+ const operation = normalizeAbsentFields(opWithContext.operation);
4819
+ const signer = operation.action.context?.signer;
4820
+ if (!signer?.signatures || signer.signatures.length === 0) return {
4821
+ ...opWithContext,
4822
+ operation
4823
+ };
4011
4824
  const deserializedSignatures = signer.signatures.map(deserializeSignature);
4012
4825
  const deserializedOperation = {
4013
- ...opWithContext.operation,
4826
+ ...operation,
4014
4827
  action: {
4015
- ...opWithContext.operation.action,
4828
+ ...operation.action,
4016
4829
  context: {
4017
- ...opWithContext.operation.action.context,
4830
+ ...operation.action.context,
4018
4831
  signer: {
4019
4832
  ...signer,
4020
4833
  signatures: deserializedSignatures
@@ -4041,7 +4854,7 @@ function deserializeOperationSignatures(opWithContext) {
4041
4854
  */
4042
4855
  function envelopesToSyncOperations(envelope, remoteName) {
4043
4856
  if (!envelope.operations || envelope.operations.length === 0) return [];
4044
- return batchOperationsByDocument(envelope.operations.map(deserializeOperationSignatures)).map((batch) => {
4857
+ return batchOperationsByDocument(envelope.operations.map(deserializeOperation)).map((batch) => {
4045
4858
  return new SyncOperation(`syncop-${envelope.channelMeta.id}-${Date.now()}-${syncOpCounter++}`, envelope.key ?? "", (envelope.dependsOn ?? []).filter(Boolean), remoteName, batch.documentId, [batch.scope], batch.branch, batch.operations);
4046
4859
  });
4047
4860
  }
@@ -4053,6 +4866,12 @@ const getLatestAppliedOrdinal = (syncOps) => {
4053
4866
  //#endregion
4054
4867
  //#region src/sync/channels/gql-req-channel.ts
4055
4868
  /**
4869
+ * Fields the auth projection added to the sync schema. A remote that predates
4870
+ * them rejects the whole query for naming one, so they are selected only while
4871
+ * the remote is known to serve them.
4872
+ */
4873
+ const DECISION_FIELDS = ["deniedReason", "errorType"];
4874
+ /**
4056
4875
  * GraphQL-based synchronization channel for network communication between reactors.
4057
4876
  */
4058
4877
  var GqlRequestChannel = class {
@@ -4079,6 +4898,8 @@ var GqlRequestChannel = class {
4079
4898
  isPushing = false;
4080
4899
  pendingDrain = false;
4081
4900
  receivingPages = false;
4901
+ /** Cleared for good the first time the remote rejects {@link DECISION_FIELDS}. */
4902
+ peerServesDecisionFields = true;
4082
4903
  isRecovering = false;
4083
4904
  connectionState = "connecting";
4084
4905
  /** Latest unrecoverable error was an auth rejection; cleared on connect. */
@@ -4257,7 +5078,7 @@ var GqlRequestChannel = class {
4257
5078
  const syncOps = [];
4258
5079
  for (const dl of deadLetters) {
4259
5080
  const syncOp = new SyncOperation(crypto.randomUUID(), dl.jobId, [], this.remoteName, dl.documentId, dl.scopes, dl.branch, []);
4260
- syncOp.failed(new ChannelError(ChannelErrorSource.Outbox, new Error(dl.error)));
5081
+ syncOp.failed(new ChannelError(ChannelErrorSource.Outbox, new Error(dl.error), dl.errorType ?? void 0));
4261
5082
  syncOps.push(syncOp);
4262
5083
  }
4263
5084
  this.deadLetter.add(...syncOps);
@@ -4349,7 +5170,44 @@ var GqlRequestChannel = class {
4349
5170
  * Queries the remote GraphQL endpoint for sync envelopes.
4350
5171
  */
4351
5172
  async pollSyncEnvelopes(ackOrdinal, latestOrdinal) {
4352
- const query = `
5173
+ const variables = {
5174
+ channelId: this.channelId,
5175
+ outboxAck: ackOrdinal,
5176
+ outboxLatest: latestOrdinal
5177
+ };
5178
+ let response;
5179
+ try {
5180
+ response = await this.executeGraphQL(this.pollQuery(this.peerServesDecisionFields), variables);
5181
+ } catch (error) {
5182
+ if (!this.rejectsDecisionFields(error)) throw error;
5183
+ this.logger.warn("Remote @channelId does not serve deniedReason/errorType; polling without them. The remote is on an older schema, so it has neither to report.", this.channelId);
5184
+ this.peerServesDecisionFields = false;
5185
+ response = await this.executeGraphQL(this.pollQuery(false), variables);
5186
+ }
5187
+ return {
5188
+ envelopes: response.pollSyncEnvelopes.envelopes,
5189
+ ackOrdinal: response.pollSyncEnvelopes.ackOrdinal,
5190
+ deadLetters: response.pollSyncEnvelopes.deadLetters ?? [],
5191
+ hasMore: response.pollSyncEnvelopes.hasMore
5192
+ };
5193
+ }
5194
+ /**
5195
+ * True when the remote rejected the query for naming a field it does not
5196
+ * have. Selecting an unknown field fails validation for the whole query, so
5197
+ * an unhandled one takes the channel's polling down until the process
5198
+ * restarts rather than degrading.
5199
+ */
5200
+ rejectsDecisionFields(error) {
5201
+ if (!this.peerServesDecisionFields) return false;
5202
+ if (!(error instanceof GraphQLRequestError) || error.category !== "graphql") return false;
5203
+ return DECISION_FIELDS.some((field) => error.message.includes(field));
5204
+ }
5205
+ /**
5206
+ * The poll query. `withDecisionFields` selects the two fields added with the
5207
+ * auth projection; a remote on the previous schema is polled without them.
5208
+ */
5209
+ pollQuery(withDecisionFields) {
5210
+ return `
4353
5211
  query PollSyncEnvelopes($channelId: String!, $outboxAck: Int!, $outboxLatest: Int!) {
4354
5212
  pollSyncEnvelopes(channelId: $channelId, outboxAck: $outboxAck, outboxLatest: $outboxLatest) {
4355
5213
  envelopes {
@@ -4364,6 +5222,7 @@ var GqlRequestChannel = class {
4364
5222
  hash
4365
5223
  skip
4366
5224
  error
5225
+ ${withDecisionFields ? "deniedReason" : ""}
4367
5226
  id
4368
5227
  action {
4369
5228
  id
@@ -4407,6 +5266,7 @@ var GqlRequestChannel = class {
4407
5266
  deadLetters {
4408
5267
  documentId
4409
5268
  error
5269
+ ${withDecisionFields ? "errorType" : ""}
4410
5270
  jobId
4411
5271
  branch
4412
5272
  scopes
@@ -4416,18 +5276,6 @@ var GqlRequestChannel = class {
4416
5276
  }
4417
5277
  }
4418
5278
  `;
4419
- const variables = {
4420
- channelId: this.channelId,
4421
- outboxAck: ackOrdinal,
4422
- outboxLatest: latestOrdinal
4423
- };
4424
- const response = await this.executeGraphQL(query, variables);
4425
- return {
4426
- envelopes: response.pollSyncEnvelopes.envelopes,
4427
- ackOrdinal: response.pollSyncEnvelopes.ackOrdinal,
4428
- deadLetters: response.pollSyncEnvelopes.deadLetters ?? [],
4429
- hasMore: response.pollSyncEnvelopes.hasMore
4430
- };
4431
5279
  }
4432
5280
  /**
4433
5281
  * Registers or updates this channel on the remote server via GraphQL mutation.
@@ -4546,7 +5394,7 @@ var GqlRequestChannel = class {
4546
5394
  if (error.statusCode !== void 0 && error.statusCode >= 500) return "recoverable";
4547
5395
  return "unrecoverable";
4548
5396
  case "parse": return "recoverable";
4549
- case "graphql": return "unrecoverable";
5397
+ case "graphql": return isRecoverableGraphQLError(error) ? "recoverable" : "unrecoverable";
4550
5398
  case "missing-data": return "unrecoverable";
4551
5399
  }
4552
5400
  }
@@ -4631,7 +5479,7 @@ var GqlRequestChannel = class {
4631
5479
  throw new GraphQLRequestError(`Failed to parse GraphQL response: ${error instanceof Error ? error.message : String(error)}`, "parse");
4632
5480
  }
4633
5481
  this.logger.verbose("GQL response @channelId @operation status=@status data=@data errors=@errors", this.channelId, operationName, response.status, JSON.stringify(result.data), result.errors ? JSON.stringify(result.errors) : "none");
4634
- if (result.errors) throw new GraphQLRequestError(`GraphQL errors: ${JSON.stringify(result.errors, null, 2)}`, "graphql");
5482
+ if (result.errors) throw new GraphQLRequestError(`GraphQL errors: ${JSON.stringify(result.errors, null, 2)}`, "graphql", void 0, result.errors.map((error) => error.extensions?.code));
4635
5483
  if (!result.data) throw new GraphQLRequestError("GraphQL response missing data field", "missing-data");
4636
5484
  return result.data;
4637
5485
  }
@@ -4723,6 +5571,10 @@ var GqlRequestChannelFactory = class {
4723
5571
  };
4724
5572
  //#endregion
4725
5573
  //#region src/sync/channels/gql-res-channel.ts
5574
+ /** Where a sync operation's run of ordinals begins. */
5575
+ function firstOrdinalOf$1(syncOp) {
5576
+ return syncOp.operations.length > 0 ? syncOp.operations[0].context.ordinal : 0;
5577
+ }
4726
5578
  /**
4727
5579
  * This class is used server-side to accumulate inbox + outbox operations.
4728
5580
  *
@@ -4738,6 +5590,8 @@ var GqlResponseChannel = class {
4738
5590
  isShutdown;
4739
5591
  lastPersistedInboxOrdinal = 0;
4740
5592
  lastPersistedOutboxOrdinal = 0;
5593
+ evictedOutboxFloor = Number.POSITIVE_INFINITY;
5594
+ appliedOutboxOrdinal = 0;
4741
5595
  connectionState = "connecting";
4742
5596
  connectionStateCallbacks = /* @__PURE__ */ new Set();
4743
5597
  constructor(logger, channelId, remoteName, cursorStorage) {
@@ -4750,18 +5604,11 @@ var GqlResponseChannel = class {
4750
5604
  this.outbox = new Mailbox();
4751
5605
  this.deadLetter = new Mailbox();
4752
5606
  this.outbox.onRemoved((syncOps) => {
4753
- const maxOrdinal = getLatestAppliedOrdinal(syncOps);
4754
- if (maxOrdinal > this.lastPersistedOutboxOrdinal) {
4755
- this.lastPersistedOutboxOrdinal = maxOrdinal;
4756
- this.cursorStorage.upsert({
4757
- remoteName: this.remoteName,
4758
- cursorType: "outbox",
4759
- cursorOrdinal: maxOrdinal,
4760
- lastSyncedAtUtcMs: Date.now()
4761
- }).catch((error) => {
4762
- this.logger.error("Failed to update outbox cursor for @ChannelId! This means that future application runs may resend duplicate operations. This is recoverable (with deduplication protection), but not-optimal: @Error", this.channelId, error);
4763
- });
4764
- }
5607
+ this.rememberUnserved(syncOps);
5608
+ this.persistOutboxCursor(getLatestAppliedOrdinal(syncOps));
5609
+ });
5610
+ this.outbox.onAdded((syncOps) => {
5611
+ this.forgetEvictedBelow(syncOps);
4765
5612
  });
4766
5613
  this.inbox.onRemoved((syncOps) => {
4767
5614
  const maxOrdinal = getLatestAppliedOrdinal(syncOps);
@@ -4803,6 +5650,17 @@ var GqlResponseChannel = class {
4803
5650
  }
4804
5651
  /** Response channels are push-driven; resolvers populate mailboxes directly. */
4805
5652
  triggerPull() {}
5653
+ async init() {
5654
+ const cursors = await this.cursorStorage.list(this.remoteName);
5655
+ const inboxOrdinal = cursors.find((c) => c.cursorType === "inbox")?.cursorOrdinal ?? 0;
5656
+ const outboxOrdinal = cursors.find((c) => c.cursorType === "outbox")?.cursorOrdinal ?? 0;
5657
+ this.inbox.init(inboxOrdinal);
5658
+ this.outbox.init(outboxOrdinal);
5659
+ this.lastPersistedInboxOrdinal = inboxOrdinal;
5660
+ this.lastPersistedOutboxOrdinal = outboxOrdinal;
5661
+ this.appliedOutboxOrdinal = outboxOrdinal;
5662
+ this.transitionConnectionState("connected");
5663
+ }
4806
5664
  transitionConnectionState(next) {
4807
5665
  if (this.connectionState === next) return;
4808
5666
  this.connectionState = next;
@@ -4813,15 +5671,68 @@ var GqlResponseChannel = class {
4813
5671
  this.logger.error("Connection state change callback error: @Error", error);
4814
5672
  }
4815
5673
  }
4816
- async init() {
4817
- const cursors = await this.cursorStorage.list(this.remoteName);
4818
- const inboxOrdinal = cursors.find((c) => c.cursorType === "inbox")?.cursorOrdinal ?? 0;
4819
- const outboxOrdinal = cursors.find((c) => c.cursorType === "outbox")?.cursorOrdinal ?? 0;
4820
- this.inbox.init(inboxOrdinal);
4821
- this.outbox.init(outboxOrdinal);
4822
- this.lastPersistedInboxOrdinal = inboxOrdinal;
4823
- this.lastPersistedOutboxOrdinal = outboxOrdinal;
4824
- this.transitionConnectionState("connected");
5674
+ /**
5675
+ * Records the ordinals of entries that left the outbox without being served,
5676
+ * so the cursor cannot advance past them.
5677
+ *
5678
+ * An entry can leave unserved because a bound evicted it, and an evicted entry
5679
+ * is exactly one this channel intends to re-derive: it is still owed to the
5680
+ * remote. Remembering the floor across the whole run rather than only while
5681
+ * the entry is present is what makes that true after a later ack would
5682
+ * otherwise have swept the cursor past it.
5683
+ */
5684
+ rememberUnserved(syncOps) {
5685
+ for (const syncOp of syncOps) {
5686
+ if (syncOp.status === SyncOperationStatus.Applied) continue;
5687
+ const first = firstOrdinalOf$1(syncOp);
5688
+ if (first > 0 && first < this.evictedOutboxFloor) this.evictedOutboxFloor = first;
5689
+ }
5690
+ }
5691
+ /**
5692
+ * Persists the outbox cursor, never past an operation this remote has not
5693
+ * been served.
5694
+ *
5695
+ * The cursor is where a restart resumes deriving the outbox from, so an
5696
+ * ordinal persisted past an unserved entry loses that entry for good: the
5697
+ * rebuild starts beyond it and nothing else remembers it was owed. Acks
5698
+ * arrive out of order with respect to what is withheld -- a later entry can
5699
+ * be acknowledged while an earlier one is still being withheld from this
5700
+ * subject -- so the applied high-water mark alone is not a safe cursor.
5701
+ */
5702
+ persistOutboxCursor(appliedOrdinal) {
5703
+ this.appliedOutboxOrdinal = Math.max(this.appliedOutboxOrdinal, appliedOrdinal);
5704
+ const ordinal = Math.min(this.appliedOutboxOrdinal, this.unservedFloor() - 1);
5705
+ if (ordinal <= this.lastPersistedOutboxOrdinal) return;
5706
+ this.lastPersistedOutboxOrdinal = ordinal;
5707
+ this.cursorStorage.upsert({
5708
+ remoteName: this.remoteName,
5709
+ cursorType: "outbox",
5710
+ cursorOrdinal: ordinal,
5711
+ lastSyncedAtUtcMs: Date.now()
5712
+ }).catch((error) => {
5713
+ this.logger.error("Failed to update outbox cursor for @ChannelId! This means that future application runs may resend duplicate operations. This is recoverable (with deduplication protection), but not-optimal: @Error", this.channelId, error);
5714
+ });
5715
+ }
5716
+ /** Drops the evicted floor once the entries it stood for are queued again. */
5717
+ forgetEvictedBelow(syncOps) {
5718
+ if (this.evictedOutboxFloor === Number.POSITIVE_INFINITY) return;
5719
+ for (const syncOp of syncOps) {
5720
+ const first = firstOrdinalOf$1(syncOp);
5721
+ if (first > 0 && first <= this.evictedOutboxFloor) {
5722
+ this.evictedOutboxFloor = Number.POSITIVE_INFINITY;
5723
+ return;
5724
+ }
5725
+ }
5726
+ }
5727
+ /** The lowest ordinal still owed to this remote, evicted or still queued. */
5728
+ unservedFloor() {
5729
+ let floor = this.evictedOutboxFloor;
5730
+ for (const syncOp of this.outbox.items) {
5731
+ if (syncOp.status === SyncOperationStatus.Applied) continue;
5732
+ const first = firstOrdinalOf$1(syncOp);
5733
+ if (first > 0 && first < floor) floor = first;
5734
+ }
5735
+ return floor;
4825
5736
  }
4826
5737
  };
4827
5738
  //#endregion
@@ -4909,7 +5820,8 @@ function rowToDeadLetterRecord(row) {
4909
5820
  branch: row.branch,
4910
5821
  operations: row.operations,
4911
5822
  errorSource: row.error_source,
4912
- errorMessage: row.error_message
5823
+ errorMessage: row.error_message,
5824
+ errorType: row.error_type ?? "UNCLASSIFIED"
4913
5825
  };
4914
5826
  }
4915
5827
  function deadLetterRecordToRow(record) {
@@ -4923,7 +5835,8 @@ function deadLetterRecordToRow(record) {
4923
5835
  branch: record.branch,
4924
5836
  operations: JSON.stringify(record.operations),
4925
5837
  error_source: record.errorSource,
4926
- error_message: record.errorMessage
5838
+ error_message: record.errorMessage,
5839
+ error_type: record.errorType
4927
5840
  };
4928
5841
  }
4929
5842
  /**
@@ -4980,9 +5893,11 @@ var KyselySyncDeadLetterStorage = class {
4980
5893
  }
4981
5894
  async listQuarantinedDocumentIds(signal) {
4982
5895
  if (signal?.aborted) throw new Error("Operation aborted");
4983
- const rows = await this.db.selectFrom("sync_dead_letters").select("document_id").distinct().execute();
5896
+ const rows = await this.db.selectFrom("sync_dead_letters").select(["document_id", "error_type"]).distinct().execute();
4984
5897
  if (signal?.aborted) throw new Error("Operation aborted");
4985
- return rows.map((row) => row.document_id);
5898
+ const quarantined = /* @__PURE__ */ new Set();
5899
+ for (const row of rows) if (quarantinesDocument(row.error_type ?? "UNCLASSIFIED")) quarantined.add(row.document_id);
5900
+ return [...quarantined];
4986
5901
  }
4987
5902
  };
4988
5903
  //#endregion
@@ -5001,7 +5916,10 @@ function rowToRemoteRecord(row) {
5001
5916
  scope: row.filter_scopes ?? [],
5002
5917
  branch: row.filter_branch
5003
5918
  },
5004
- options: { sinceTimestampUtcMs: "0" },
5919
+ options: {
5920
+ sinceTimestampUtcMs: "0",
5921
+ boundAddress: row.bound_address ?? void 0
5922
+ },
5005
5923
  status: {
5006
5924
  push: {
5007
5925
  state: row.push_state,
@@ -5041,7 +5959,8 @@ function remoteRecordToRow(remote) {
5041
5959
  pull_state: remote.status.pull.state,
5042
5960
  pull_last_success_utc_ms: remote.status.pull.lastSuccessUtcMs ? new Date(remote.status.pull.lastSuccessUtcMs).toISOString() : null,
5043
5961
  pull_last_failure_utc_ms: remote.status.pull.lastFailureUtcMs ? new Date(remote.status.pull.lastFailureUtcMs).toISOString() : null,
5044
- pull_failure_count: remote.status.pull.failureCount
5962
+ pull_failure_count: remote.status.pull.failureCount,
5963
+ bound_address: remote.options.boundAddress ?? null
5045
5964
  };
5046
5965
  }
5047
5966
  var KyselySyncRemoteStorage = class {
@@ -5409,9 +6328,14 @@ var OutboxMode = /* @__PURE__ */ function(OutboxMode) {
5409
6328
  }(OutboxMode || {});
5410
6329
  const defaultSyncManagerConfig = {
5411
6330
  maxDeadLettersPerRemote: 100,
5412
- maxInboxBatchSize: 32
6331
+ maxInboxBatchSize: 32,
6332
+ maxHeldOperationsPerRemote: 1e4
5413
6333
  };
5414
6334
  const PLAN_KEY_TO_JOB_UUID_CAP = 1e4;
6335
+ /** Where a sync operation's run of ordinals begins. */
6336
+ function firstOrdinalOf(syncOp) {
6337
+ return syncOp.operations.length > 0 ? syncOp.operations[0].context.ordinal : 0;
6338
+ }
5415
6339
  var SyncManager = class {
5416
6340
  logger;
5417
6341
  remoteStorage;
@@ -5435,6 +6359,7 @@ var SyncManager = class {
5435
6359
  quarantinedDocumentIds = /* @__PURE__ */ new Set();
5436
6360
  backfillAbortControllers = /* @__PURE__ */ new Map();
5437
6361
  planKeyToJobUuid = /* @__PURE__ */ new Map();
6362
+ evictedOutboxFloors = /* @__PURE__ */ new Map();
5438
6363
  lastEnqueuedJobIdByKey = /* @__PURE__ */ new Map();
5439
6364
  inboxChunkChain = Promise.resolve();
5440
6365
  constructor(logger, remoteStorage, cursorStorage, deadLetterStorage, channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, config = {}) {
@@ -5542,6 +6467,28 @@ var SyncManager = class {
5542
6467
  for (const remote of this.remotes.values()) if (remote.meta.id === id) return remote;
5543
6468
  throw new Error(`Remote with id '${id}' does not exist`);
5544
6469
  }
6470
+ async bindRemote(id, boundAddress) {
6471
+ const remote = this.getById(id);
6472
+ const bound = remote.meta.options.boundAddress;
6473
+ if (bound === boundAddress) return;
6474
+ if (bound !== void 0) throw new Error(`Remote with id '${id}' is already bound to another address`);
6475
+ remote.meta.options = {
6476
+ ...remote.meta.options,
6477
+ boundAddress
6478
+ };
6479
+ await this.remoteStorage.upsert({
6480
+ id: remote.meta.id,
6481
+ name: remote.meta.name,
6482
+ collectionId: remote.meta.collectionId,
6483
+ channelConfig: remote.meta.channelConfig,
6484
+ filter: remote.meta.filter,
6485
+ options: remote.meta.options,
6486
+ status: {
6487
+ push: createIdleHealth(),
6488
+ pull: createIdleHealth()
6489
+ }
6490
+ });
6491
+ }
5545
6492
  async add(name, collectionId, channelConfig, filter = {
5546
6493
  documentId: [],
5547
6494
  scope: [],
@@ -5655,7 +6602,8 @@ var SyncManager = class {
5655
6602
  remote.channel.deadLetter.onAdded((syncOps) => {
5656
6603
  for (const syncOp of syncOps) {
5657
6604
  this.logger.error("Dead letter (@remote, @documentId, @jobId, @error, @dependencies)", remote.meta.name, syncOp.documentId, syncOp.jobId, syncOp.error?.message ?? "unknown", syncOp.jobDependencies);
5658
- this.quarantinedDocumentIds.add(syncOp.documentId);
6605
+ const errorType = syncOperationErrorType(syncOp.error);
6606
+ if (quarantinesDocument(errorType)) this.quarantinedDocumentIds.add(syncOp.documentId);
5659
6607
  const record = {
5660
6608
  id: syncOp.id,
5661
6609
  jobId: syncOp.jobId,
@@ -5666,7 +6614,8 @@ var SyncManager = class {
5666
6614
  branch: syncOp.branch,
5667
6615
  operations: syncOp.operations,
5668
6616
  errorSource: syncOp.error?.source ?? ChannelErrorSource.None,
5669
- errorMessage: syncOp.error?.error.message ?? "unknown"
6617
+ errorMessage: syncOp.error?.error.message ?? "unknown",
6618
+ errorType
5670
6619
  };
5671
6620
  this.deadLetterStorage.add(record).catch((err) => {
5672
6621
  this.logger.error("Failed to persist dead letter (@id, @error)", record.id, err instanceof Error ? err.message : String(err));
@@ -5676,7 +6625,8 @@ var SyncManager = class {
5676
6625
  jobId: record.jobId,
5677
6626
  remoteName: record.remoteName,
5678
6627
  documentId: record.documentId,
5679
- errorSource: record.errorSource
6628
+ errorSource: record.errorSource,
6629
+ errorType: record.errorType
5680
6630
  }).catch(() => {});
5681
6631
  }
5682
6632
  const items = remote.channel.deadLetter.items;
@@ -5703,7 +6653,7 @@ var SyncManager = class {
5703
6653
  const syncOps = [];
5704
6654
  for (const record of records) {
5705
6655
  const syncOp = new SyncOperation(record.id, record.jobId, record.jobDependencies, record.remoteName, record.documentId, record.scopes, record.branch, record.operations);
5706
- syncOp.failed(new ChannelError(record.errorSource, new Error(record.errorMessage)));
6656
+ syncOp.failed(new ChannelError(record.errorSource, new Error(record.errorMessage), record.errorType));
5707
6657
  syncOps.push(syncOp);
5708
6658
  }
5709
6659
  remote.channel.deadLetter.add(...syncOps);
@@ -5784,8 +6734,7 @@ var SyncManager = class {
5784
6734
  if (completedJobInfo.status === JobStatus.FAILED) {
5785
6735
  const errorMessage = completedJobInfo.error?.message || "Unknown error";
5786
6736
  this.logger.error("Failed to apply operations from inbox (@remote, @documentId, @jobId, @error)", remote.meta.name, syncOp.documentId, completedJobInfo.id, errorMessage);
5787
- const error = new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${errorMessage}`));
5788
- syncOp.failed(error);
6737
+ syncOp.failed(this.inboxFailure(completedJobInfo.error));
5789
6738
  remote.channel.deadLetter.add(syncOp);
5790
6739
  } else syncOp.executed();
5791
6740
  remote.channel.inbox.remove(syncOp);
@@ -5862,17 +6811,79 @@ var SyncManager = class {
5862
6811
  }
5863
6812
  if (this.isShutdown) return;
5864
6813
  if (completedJobInfo.status === JobStatus.FAILED) {
5865
- const errorMessage = completedJobInfo.error?.message || "Unknown error";
5866
- const channelError = new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${errorMessage}`));
5867
- syncOp.failed(channelError);
6814
+ syncOp.failed(this.inboxFailure(completedJobInfo.error));
5868
6815
  remote.channel.deadLetter.add(syncOp);
5869
6816
  } else syncOp.executed();
5870
6817
  remote.channel.inbox.remove(syncOp);
5871
6818
  }
5872
6819
  }
6820
+ /**
6821
+ * The dead letter for a load job the executor failed.
6822
+ *
6823
+ * The classification is passed explicitly because it cannot be recovered
6824
+ * downstream: the wrapper carries the failure's message, not the failure, so
6825
+ * deriving it from the wrapper's own name would classify every one of these
6826
+ * as unclassified and quarantine the document. A held auth operation must
6827
+ * keep syncing, because reconciling the two policies needs the traffic a
6828
+ * quarantine would stop.
6829
+ */
6830
+ inboxFailure(error) {
6831
+ const message = error?.message || "Unknown error";
6832
+ return new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${message}`), classifyJobFailure(error?.name ?? "Error"));
6833
+ }
6834
+ /**
6835
+ * Where the next outbox derivation starts: at the evicted floor when there is
6836
+ * room to take those entries back, and at the caller's cursor otherwise.
6837
+ *
6838
+ * The floor is forgotten as it is folded in. If the refill overflows again the
6839
+ * eviction records a new one, and if it does not, the entries are queued and
6840
+ * the outbox itself holds the cursor down.
6841
+ */
6842
+ refillOrdinal(remote, ackOrdinal) {
6843
+ const floor = this.evictedOutboxFloors.get(remote.meta.name);
6844
+ if (floor === void 0) return ackOrdinal;
6845
+ if (this.outboxOperationCount(remote) >= this.config.maxHeldOperationsPerRemote) return ackOrdinal;
6846
+ this.evictedOutboxFloors.delete(remote.meta.name);
6847
+ return Math.min(ackOrdinal, floor - 1);
6848
+ }
6849
+ /**
6850
+ * Evicts the newest entries of an outbox that has grown past its bound.
6851
+ *
6852
+ * Only entries that have never been taken up for transport are eligible, which
6853
+ * is what makes this a bound on withheld work rather than a way to drop
6854
+ * something already in flight. One entry is always kept, so an outbox whose
6855
+ * first entry is larger than the whole cap still makes progress.
6856
+ */
6857
+ evictPastOutboxBound(remote) {
6858
+ const cap = this.config.maxHeldOperationsPerRemote;
6859
+ const queued = remote.channel.outbox.items.filter((syncOp) => syncOp.status === SyncOperationStatus.Unknown).sort((a, b) => firstOrdinalOf(a) - firstOrdinalOf(b));
6860
+ let kept = 0;
6861
+ let cut = queued.length;
6862
+ for (let i = 0; i < queued.length; i++) {
6863
+ const size = queued[i].operations.length;
6864
+ if (i > 0 && kept + size > cap) {
6865
+ cut = i;
6866
+ break;
6867
+ }
6868
+ kept += size;
6869
+ }
6870
+ const evicted = queued.slice(cut);
6871
+ if (evicted.length === 0) return;
6872
+ const floor = firstOrdinalOf(evicted[0]);
6873
+ const known = this.evictedOutboxFloors.get(remote.meta.name);
6874
+ this.evictedOutboxFloors.set(remote.meta.name, known === void 0 ? floor : Math.min(known, floor));
6875
+ 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);
6876
+ remote.channel.outbox.remove(...evicted);
6877
+ }
6878
+ outboxOperationCount(remote) {
6879
+ let count = 0;
6880
+ for (const syncOp of remote.channel.outbox.items) count += syncOp.operations.length;
6881
+ return count;
6882
+ }
5873
6883
  async updateOutbox(remote, ackOrdinal, mode = OutboxMode.Backfill, signal) {
5874
6884
  const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
5875
- let maxOrdinal = ackOrdinal;
6885
+ const startOrdinal = this.refillOrdinal(remote, ackOrdinal);
6886
+ let maxOrdinal = startOrdinal;
5876
6887
  const lastJobByDoc = /* @__PURE__ */ new Map();
5877
6888
  let prevChainJobId;
5878
6889
  const sinceTimestamp = remote.meta.options.sinceTimestampUtcMs;
@@ -5892,8 +6903,9 @@ var SyncManager = class {
5892
6903
  if (mode === OutboxMode.BatchTriggered) prevChainJobId = jobId;
5893
6904
  }
5894
6905
  remote.channel.outbox.add(...syncOps);
6906
+ this.evictPastOutboxBound(remote);
5895
6907
  };
5896
- let page = await this.operationIndex.find(remote.meta.collectionId.key, ackOrdinal, { excludeSourceRemote: remote.meta.name }, void 0, composedSignal);
6908
+ let page = await this.operationIndex.find(remote.meta.collectionId.key, startOrdinal, { excludeSourceRemote: remote.meta.name }, void 0, composedSignal);
5897
6909
  let carry = [];
5898
6910
  let hasMore;
5899
6911
  do {
@@ -5909,9 +6921,17 @@ var SyncManager = class {
5909
6921
  operations = operations.filter((op) => !this.quarantinedDocumentIds.has(op.context.documentId));
5910
6922
  hasMore = !!page.next;
5911
6923
  if (operations.length > 0) {
6924
+ const created = /* @__PURE__ */ new Set();
6925
+ for (const entry of operations) if (entry.operation.action.type === "CREATE_DOCUMENT") created.add(entry.context.documentId);
5912
6926
  operations.sort((a, b) => {
5913
6927
  if (a.context.documentId !== b.context.documentId) return a.context.documentId < b.context.documentId ? -1 : 1;
5914
- if (a.context.scope !== b.context.scope) return a.context.scope < b.context.scope ? -1 : 1;
6928
+ if (a.context.scope !== b.context.scope) {
6929
+ if (created.has(a.context.documentId)) {
6930
+ if (a.context.scope === "document") return -1;
6931
+ if (b.context.scope === "document") return 1;
6932
+ }
6933
+ return a.context.scope < b.context.scope ? -1 : 1;
6934
+ }
5915
6935
  return a.context.ordinal - b.context.ordinal;
5916
6936
  });
5917
6937
  if (hasMore) {
@@ -5958,6 +6978,10 @@ var SyncBuilder = class {
5958
6978
  this.config.maxInboxBatchSize = limit;
5959
6979
  return this;
5960
6980
  }
6981
+ withMaxHeldOperationsPerRemote(limit) {
6982
+ this.config.maxHeldOperationsPerRemote = limit;
6983
+ return this;
6984
+ }
5961
6985
  build(reactor, logger, operationIndex, eventBus, db, driveContainerTypes) {
5962
6986
  return this.buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes).syncManager;
5963
6987
  }
@@ -6190,7 +7214,7 @@ var Reactor = class {
6190
7214
  documentId: document.header.id,
6191
7215
  model: document.header.documentType,
6192
7216
  fromVersion: 0,
6193
- toVersion: document.state.document.version,
7217
+ toVersion: normalizeDocumentModelVersion(document.state.document?.version),
6194
7218
  initialState: document.state
6195
7219
  })];
6196
7220
  if (signer) actions = await signActions(actions, signer, signal);
@@ -6572,7 +7596,10 @@ var ReactorBuilder = class {
6572
7596
  logger;
6573
7597
  documentModelSources = [];
6574
7598
  upgradeManifests = [];
6575
- features = { legacyStorageEnabled: false };
7599
+ features = {
7600
+ legacyStorageEnabled: false,
7601
+ legacyProcessorIds: true
7602
+ };
6576
7603
  readModels = [];
6577
7604
  readModelFactories = [];
6578
7605
  executorManager;
@@ -6769,6 +7796,8 @@ var ReactorBuilder = class {
6769
7796
  }
6770
7797
  async buildModule() {
6771
7798
  if (!this.logger) this.logger = new ConsoleLogger(["reactor"]);
7799
+ const featureFlags = resolveFeatureFlags(this.executorConfig.featureFlags);
7800
+ if (this.projectionShardConfig !== void 0 && this.readModelFactories.length > 0) throw new Error("withProjectionShards does not support read models registered through withReadModelFactory; projection workers cannot receive host-only factory dependencies");
6772
7801
  const resolvedSources = await resolveModelSources(this.documentModelSources);
6773
7802
  if (this.workerPool) {
6774
7803
  if (resolvedSources.manifest.length === 0) throw new Error("withWorkerPool requires at least one worker-importable document-model source ({ filePath } or { packageName }).");
@@ -6818,15 +7847,15 @@ var ReactorBuilder = class {
6818
7847
  if (pool.db === void 0) throw new Error("unreachable: worker pool configured without db or factory");
6819
7848
  factory = await this.createDefaultWorkerFactory(pool.numWorkers, pool.db, pool.verifier);
6820
7849
  }
6821
- const poolManager = new WorkerPoolJobExecutorManager(factory, eventBus, queue, jobTracker, this.logger, resolver, collectionMembershipCache, this.executorConfig.jobTimeoutMs);
7850
+ const poolManager = new WorkerPoolJobExecutorManager(factory, eventBus, queue, jobTracker, this.logger, resolver, operationIndex, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
6822
7851
  executorManager = poolManager;
6823
7852
  executorStartCount = pool.numWorkers;
6824
7853
  if (resolver instanceof DocumentModelResolver) resolver.setBroadcastHook((entry) => poolManager.loadModel(entry));
6825
- } 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);
7854
+ } 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);
6826
7855
  await executorManager.start(executorStartCount);
6827
7856
  const readModelInstances = Array.from(new Set([...this.readModels]));
6828
7857
  const documentViewConsistencyTracker = new ConsistencyTracker();
6829
- const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker);
7858
+ const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
6830
7859
  try {
6831
7860
  await documentView.init();
6832
7861
  } catch (error) {
@@ -6844,7 +7873,7 @@ var ReactorBuilder = class {
6844
7873
  const subscriptionManager = new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
6845
7874
  const subscriptionNotificationReadModel = new SubscriptionNotificationReadModel(subscriptionManager, documentView);
6846
7875
  const processorManagerConsistencyTracker = new ConsistencyTracker();
6847
- const processorManager = new ProcessorManager(database, operationIndex, writeCache, processorManagerConsistencyTracker, this.logger, this.driveContainerTypes);
7876
+ const processorManager = new ProcessorManager(database, operationIndex, writeCache, processorManagerConsistencyTracker, this.logger, this.driveContainerTypes, { legacyProcessorIds: this.features.legacyProcessorIds !== false });
6848
7877
  try {
6849
7878
  await processorManager.init();
6850
7879
  } catch (error) {
@@ -6852,6 +7881,7 @@ var ReactorBuilder = class {
6852
7881
  }
6853
7882
  for (const factory of this.readModelFactories) {
6854
7883
  const readModel = await factory({
7884
+ documentModelRegistry,
6855
7885
  operationIndex,
6856
7886
  writeCache,
6857
7887
  processorManagerConsistencyTracker
@@ -6869,9 +7899,15 @@ var ReactorBuilder = class {
6869
7899
  syncModule = this.syncBuilder.buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
6870
7900
  await syncModule.syncManager.startup();
6871
7901
  }
7902
+ let groupReevaluationTrigger;
7903
+ if (featureFlags.authGroups) {
7904
+ groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex);
7905
+ groupReevaluationTrigger.startup();
7906
+ }
6872
7907
  const module = {
6873
7908
  eventBus,
6874
7909
  documentModelRegistry,
7910
+ featureFlags,
6875
7911
  queue,
6876
7912
  jobTracker,
6877
7913
  executorManager,
@@ -6890,6 +7926,7 @@ var ReactorBuilder = class {
6890
7926
  processorManagerConsistencyTracker,
6891
7927
  syncModule,
6892
7928
  reactor,
7929
+ groupReevaluationTrigger,
6893
7930
  pools: this.instrumentedPools
6894
7931
  };
6895
7932
  if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
@@ -6962,9 +7999,9 @@ var ReactorBuilder = class {
6962
7999
  */
6963
8000
  async createDefaultWorkerFactory(numWorkers, db, signatureVerifier) {
6964
8001
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
6965
- import("./worker-handle-B1w03nRA.js"),
8002
+ import("./worker-handle-CrERzl8s.js"),
6966
8003
  import("./transport-ByGviWdZ.js"),
6967
- import("./worker-DBJOv8Gp.js").then((n) => n.n)
8004
+ import("./worker-HPysBfhx.js").then((n) => n.n)
6968
8005
  ]);
6969
8006
  const poolConfig = {
6970
8007
  enabled: true,
@@ -6985,7 +8022,8 @@ var ReactorBuilder = class {
6985
8022
  poolConfig,
6986
8023
  db,
6987
8024
  signatureVerifier,
6988
- models
8025
+ models,
8026
+ executorConfig: this.executorConfig
6989
8027
  },
6990
8028
  logger,
6991
8029
  poolInstrumentation
@@ -7106,6 +8144,7 @@ var ReactorClientBuilder = class {
7106
8144
  subscriptionManager;
7107
8145
  jobAwaiter;
7108
8146
  documentModelLoader;
8147
+ readGate;
7109
8148
  /**
7110
8149
  * Sets the logger for the ReactorClient.
7111
8150
  * @param logger - The logger to use.
@@ -7158,6 +8197,47 @@ var ReactorClientBuilder = class {
7158
8197
  this.documentModelLoader = loader;
7159
8198
  return this;
7160
8199
  }
8200
+ /**
8201
+ * Overrides how reads are gated. A client built from a ReactorBuilder derives
8202
+ * this from that reactor's flags; one built from `withReactor` cannot, because
8203
+ * it is handed no flags and no registry, so it gates on the policy alone
8204
+ * unless a gate is supplied here.
8205
+ */
8206
+ withReadGate(readGate) {
8207
+ this.readGate = readGate;
8208
+ return this;
8209
+ }
8210
+ /**
8211
+ * The gate the resolved model calls for. Below authEnforcement there is no
8212
+ * model to enforce -- the registered one ignores the auth scope -- so the
8213
+ * policy is evaluated on its own, which is what reads did before the model
8214
+ * existed. Group serving turns on with authGroups, because below it a
8215
+ * `{ group }` grant does not match, so a served roster is one no grant can
8216
+ * use.
8217
+ */
8218
+ resolveReadGate(reactorModule, documentView, model) {
8219
+ if (!reactorModule || model === void 0) return new BareReadGate();
8220
+ return new ModelReadGate(model, documentView, reactorModule.featureFlags.authGroups, reactorModule.operationIndex, this.logger);
8221
+ }
8222
+ /**
8223
+ * What the client answers an authorization preflight from, or undefined when
8224
+ * it answers none.
8225
+ *
8226
+ * Resolved from the same model reads enforce, so a preflight and a read can
8227
+ * never decide against different models. Undefined below authEnforcement, and
8228
+ * undefined on the `withReactor` path, where there are no flags and no
8229
+ * registry to select a model with -- a client with no model refuses the
8230
+ * preflight rather than answering it from the legacy host-side permission
8231
+ * tables. Deliberately not derived from the read gate: `withReadGate`
8232
+ * overrides that, so sniffing the gate's type would report enforcement from a
8233
+ * caller's substitution.
8234
+ */
8235
+ resolveActionEvaluation(reactorModule, model) {
8236
+ return reactorModule && model ? {
8237
+ model,
8238
+ flags: reactorModule.featureFlags
8239
+ } : void 0;
8240
+ }
7161
8241
  async build() {
7162
8242
  return (await this.buildModule()).client;
7163
8243
  }
@@ -7186,8 +8266,9 @@ var ReactorClientBuilder = class {
7186
8266
  const signer = this.signer ?? new PassthroughSigner();
7187
8267
  const subscriptionManager = this.subscriptionManager ?? reactorModule?.subscriptionManager ?? new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
7188
8268
  const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
8269
+ const decisionModel = reactorModule ? readDecisionModel(reactorModule.featureFlags, reactorModule.documentModelRegistry) : void 0;
7189
8270
  return {
7190
- client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView),
8271
+ client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel)),
7191
8272
  reactor,
7192
8273
  eventBus,
7193
8274
  documentIndexer,
@@ -7225,6 +8306,107 @@ function driveIdFromUrl(url) {
7225
8306
  return url.split("/").pop() ?? "";
7226
8307
  }
7227
8308
  //#endregion
8309
+ //#region src/decision/sync-scope-gate.ts
8310
+ /** What a subject may read of a document that is not there to be read. */
8311
+ const META_ONLY = (scope) => ALWAYS_READABLE_SCOPES.has(scope);
8312
+ /**
8313
+ * A read gate asked by document id rather than by document.
8314
+ *
8315
+ * Serving works from an id: an outbox entry names a document, a branch and a
8316
+ * scope, and carries no state. Fetching the document is therefore the serving
8317
+ * path's own job, and it is the only thing this adds to the gate it wraps.
8318
+ */
8319
+ var SyncScopeGate = class {
8320
+ constructor(gate, documentView, logger) {
8321
+ this.gate = gate;
8322
+ this.documentView = documentView;
8323
+ this.logger = logger;
8324
+ }
8325
+ /**
8326
+ * Which scopes of one document the subject may be served.
8327
+ *
8328
+ * A document this replica cannot produce yields the metadata scopes and
8329
+ * nothing else. That is the fail-closed direction, and it is safe to fail
8330
+ * closed here precisely because serving withholds rather than consumes: the
8331
+ * entry stays in the outbox and the next poll asks again, so a document that
8332
+ * is merely not indexed yet is delayed rather than lost.
8333
+ *
8334
+ * Any other failure is rethrown. A read side that is down must not read as a
8335
+ * silent, universal denial, because a denial that looks like a policy is one
8336
+ * nobody investigates.
8337
+ */
8338
+ async scopePredicateById(documentId, subject, branch, signal) {
8339
+ let document;
8340
+ try {
8341
+ document = await this.documentView.get(documentId, { branch }, void 0, signal);
8342
+ } catch (error) {
8343
+ if (!DocumentNotFoundError.isError(error)) throw error;
8344
+ this.logger?.verbose(`Withholding ${documentId} on branch ${branch}: the read side does not hold it`);
8345
+ return META_ONLY;
8346
+ }
8347
+ return this.gate.scopePredicate(document, subject, branch, signal);
8348
+ }
8349
+ };
8350
+ //#endregion
8351
+ //#region src/decision/stream-order.ts
8352
+ /**
8353
+ * The first pair of effective operations whose stored order contradicts their
8354
+ * timestamps, or undefined when the stream is in position order.
8355
+ *
8356
+ * Such a stream cannot be walked, and the auth stream is never reshuffled once
8357
+ * the monotonic rule is on, so run this before enabling enforcement on a fleet.
8358
+ *
8359
+ * `requireStrict` additionally rejects a tie, which is what the auth stream's
8360
+ * monotonic rule requires and what the walk alone does not care about.
8361
+ */
8362
+ function firstOutOfOrderPair(operations, options) {
8363
+ const requireStrict = options?.requireStrict ?? false;
8364
+ const effective = garbageCollect(sortOperations([...operations]));
8365
+ for (let i = 1; i < effective.length; i++) {
8366
+ const previous = effective[i - 1];
8367
+ const current = effective[i];
8368
+ const previousAt = Date.parse(previous.timestampUtcMs);
8369
+ const currentAt = Date.parse(current.timestampUtcMs);
8370
+ if (currentAt < previousAt) return {
8371
+ previous,
8372
+ current,
8373
+ kind: "descending"
8374
+ };
8375
+ if (requireStrict && currentAt === previousAt) return {
8376
+ previous,
8377
+ current,
8378
+ kind: "tied"
8379
+ };
8380
+ }
8381
+ }
8382
+ //#endregion
8383
+ //#region src/read-models/interfaces.ts
8384
+ function supportsLiveReadModelRegistration(coordinator) {
8385
+ return "addReadModel" in coordinator && typeof coordinator.addReadModel === "function";
8386
+ }
8387
+ //#endregion
8388
+ //#region src/processors/host-module.ts
8389
+ /** Builds the reactor-level host module; hosts spread it and add their own fields. */
8390
+ function createReactorHostModuleBase(options) {
8391
+ const { client, readModels, ...core } = options;
8392
+ return {
8393
+ ...core,
8394
+ client,
8395
+ dispatch: { async execute(docId, branch, actions, signal) {
8396
+ const jobInfo = await client.executeAsync(docId, branch, actions, signal);
8397
+ return {
8398
+ id: jobInfo.id,
8399
+ status: jobInfo.status
8400
+ };
8401
+ } },
8402
+ getReadModel(name) {
8403
+ const model = readModels.find((m) => m.name === name);
8404
+ if (!model) throw new Error(`Read model "${name}" not found`);
8405
+ return model;
8406
+ }
8407
+ };
8408
+ }
8409
+ //#endregion
7228
8410
  //#region src/admin/passthrough-keyframe-store.ts
7229
8411
  const passthroughKeyframeStore = {
7230
8412
  putKeyframe: () => Promise.resolve(),
@@ -7250,6 +8432,7 @@ var DocumentIntegrityService = class {
7250
8432
  async validateDocument(documentId, branch = "main", signal) {
7251
8433
  const keyframeIssues = [];
7252
8434
  const snapshotIssues = [];
8435
+ const streamOrderIssues = await this.findStreamOrderIssues(documentId, branch, signal);
7253
8436
  const replayCache = new KyselyWriteCache(passthroughKeyframeStore, this.operationStore, this.documentModelRegistry, {
7254
8437
  maxDocuments: 1,
7255
8438
  ringBufferSize: 1,
@@ -7276,9 +8459,10 @@ var DocumentIntegrityService = class {
7276
8459
  } catch {
7277
8460
  return {
7278
8461
  documentId,
7279
- isConsistent: keyframeIssues.length === 0,
8462
+ isConsistent: keyframeIssues.length === 0 && streamOrderIssues.length === 0,
7280
8463
  keyframeIssues,
7281
- snapshotIssues
8464
+ snapshotIssues,
8465
+ streamOrderIssues
7282
8466
  };
7283
8467
  }
7284
8468
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
@@ -7304,9 +8488,10 @@ var DocumentIntegrityService = class {
7304
8488
  }
7305
8489
  return {
7306
8490
  documentId,
7307
- isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0,
8491
+ isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0 && streamOrderIssues.length === 0,
7308
8492
  keyframeIssues,
7309
- snapshotIssues
8493
+ snapshotIssues,
8494
+ streamOrderIssues
7310
8495
  };
7311
8496
  }
7312
8497
  async rebuildKeyframes(documentId, branch = "main", signal) {
@@ -7328,12 +8513,26 @@ var DocumentIntegrityService = class {
7328
8513
  scopesInvalidated: scopes.length
7329
8514
  };
7330
8515
  }
8516
+ async findStreamOrderIssues(documentId, branch, signal) {
8517
+ const scopes = await this.discoverScopes(documentId, branch, signal);
8518
+ const issues = [];
8519
+ for (const scope of scopes) {
8520
+ throwIfAborted(signal);
8521
+ const pair = firstOutOfOrderPair((await this.operationStore.getSince(documentId, scope, branch, -1, void 0, void 0, signal)).results, { requireStrict: scope === "auth" });
8522
+ if (pair !== void 0) issues.push({
8523
+ scope,
8524
+ branch,
8525
+ ...pair
8526
+ });
8527
+ }
8528
+ return issues;
8529
+ }
7331
8530
  async discoverScopes(documentId, branch, signal) {
7332
8531
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
7333
8532
  return Object.keys(revisions.revision);
7334
8533
  }
7335
8534
  };
7336
8535
  //#endregion
7337
- export { BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_DRIVE_CONTAINER_TYPES, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DocumentChangeType, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, IntervalPollTimer, InvalidModuleError, JobAwaiter, JobExecutorEventTypes, JobStatus, KyselyDocumentIndexer, KyselyDocumentView, KyselyKeyframeStore, KyselyOperationStore, KyselySyncCursorStorage, KyselySyncRemoteStorage, KyselyWriteCache, Mailbox, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RevisionMismatchError, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncStatus, SyncStatusTracker, addRelationshipAction, batchOperationsByDocument, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createMutableShutdownStatus, createRelationalDb, deleteDocumentAction, documentActions, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, getMigrationStatus, instrumentPgPool, isDriveAuthError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, removeRelationshipAction, runMigrations, sanitizeArg, trimMailboxFromAckOrdinal, updateRelationshipAction, upgradeDocumentAction, workerEntryPath };
8536
+ 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, 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, 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 };
7338
8537
 
7339
8538
  //# sourceMappingURL=index.js.map