@powerhousedao/reactor 6.2.2-dev.5 → 6.2.2-dev.50

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 decideAtHead, B as InvalidOperationTimestampError, C as DuplicateOperationError, D as DuplicateModuleError, E as DuplicateManifestError, F as AuthTimestampNotMonotonicError, H as matchesScope, I as AuthorizationDeniedError, L as DocumentDeletedError, M as documentDecisionModel, N as authDecisionModel, O as InvalidModuleError, P as buildDecisionModel, R as DocumentNotFoundError, S as AppendConditionFailedError, T as RevisionMismatchError, U as parsePagingOptions, V as UpgradePreconditionFailedError, W as throwIfAborted, _ as DocumentMetaCache, a as createForwardingPoolInstrumentation, b as CollectionMembershipCache, c as KyselyKeyframeStore, d as DriveCollectionId, f as KyselyExecutionScope, g as KyselyOperationIndex, h as KyselyWriteCache, i as runMigrations, j as selectDecisionModel, k as ModuleNotFoundError, l as DocumentModelRegistry, m as EventBus, n as REACTOR_SCHEMA, o as instrumentPgPool, p as resolveFeatureFlags, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as createConsistencyToken, w as OptimisticLockError, x as APPEND_CONDITION_FAILED_PREFIX, y as createEmptyConsistencyToken, z as ExcessiveReshuffleError } from "./drive-container-types-BoY5t12r.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 ReadModelCoordinator, i as KyselyDocumentView, n as ConsistencyTracker, o as BaseReadModel, r as makeConsistencyKey, t as KyselyDocumentIndexer } from "./document-indexer-DlpJB8AK.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-BCMw-hBw.js";
7
+ import { DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actions, actions as documentActions, createPresignedHeader, decide, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, sortOperations } 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")
@@ -391,7 +393,7 @@ var DriveClient = class {
391
393
  }] }, signal);
392
394
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.client.waitForJob(job, signal)));
393
395
  for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
394
- return this.reactor.get(documentId);
396
+ return this.client.get(documentId, void 0, signal);
395
397
  }
396
398
  async addFolder(driveIdentifier, name, parentFolder, signal) {
397
399
  this.logger.verbose("drives.addFolder(@driveIdentifier, @name, @parentFolder)", driveIdentifier, name, parentFolder);
@@ -400,7 +402,7 @@ var DriveClient = class {
400
402
  id: folderId,
401
403
  name,
402
404
  parentFolder
403
- })], signal)).state.global.nodes.find((n) => n.id === folderId);
405
+ })], signal)).state.global?.nodes.find((n) => n.id === folderId);
404
406
  if (!node || !isFolderNode(node)) throw new Error("Folder creation failed");
405
407
  return node;
406
408
  }
@@ -427,7 +429,7 @@ var DriveClient = class {
427
429
  const node = (await this.client.execute(driveIdentifier, "main", [updateNode({
428
430
  id: nodeId,
429
431
  name
430
- })], signal)).state.global.nodes.find((n) => n.id === nodeId);
432
+ })], signal)).state.global?.nodes.find((n) => n.id === nodeId);
431
433
  if (!node) throw new Error("Node missing from drive after rename");
432
434
  return node;
433
435
  }
@@ -468,8 +470,12 @@ var DriveClient = class {
468
470
  const node = drive.state.global.nodes.find((n) => n.id === entry.srcId);
469
471
  if (!node || !isFileNode(node)) continue;
470
472
  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));
473
+ const duplicated = {
474
+ ...srcDoc,
475
+ header: createPresignedHeader(entry.targetId, srcDoc.header.documentType),
476
+ initialState: srcDoc.state,
477
+ operations: {}
478
+ };
473
479
  const resolvedName = resolvedNamesByTargetId.get(entry.targetId);
474
480
  if (resolvedName) duplicated.header.name = resolvedName;
475
481
  await this.addFile(driveIdentifier, duplicated, entry.targetParentFolder ?? void 0, signal);
@@ -680,6 +686,295 @@ let DocumentChangeType = /* @__PURE__ */ function(DocumentChangeType) {
680
686
  return DocumentChangeType;
681
687
  }({});
682
688
  //#endregion
689
+ //#region src/decision/read-gate.ts
690
+ /**
691
+ * Scopes every holder of a document may read, whatever the grants say. Denying
692
+ * the policy itself would let a replica sync a document without it, read the
693
+ * auth scope as uninitialized, and allow every operation it holds, so replicas
694
+ * would diverge permanently. The document scope carries the metadata the same
695
+ * argument covers. Grants gate domain-scope reads only.
696
+ */
697
+ const ALWAYS_READABLE_SCOPES = new Set(["auth", "document"]);
698
+ /**
699
+ * The branch a group is read on. A group's member list lives on its main
700
+ * branch whatever branch the document naming it is on, and the reference
701
+ * relation records no branch of its own.
702
+ */
703
+ const GROUP_BRANCH = "main";
704
+ /**
705
+ * The scope of a group document that holds its member list. It is the only
706
+ * scope group serving reaches, because it is the only one the audience must
707
+ * fold in order to evaluate auth with the group.
708
+ */
709
+ const GROUP_MEMBERSHIP_SCOPE = "global";
710
+ /**
711
+ * How many of a group's referencing documents are examined before a read of
712
+ * that group gives up and withholds. Each one costs a model build, and the
713
+ * relation is append-only, so an old and briefly-held reference still counts.
714
+ * Truncating denies, which is the direction that withholds. It is the cap a
715
+ * version-1 policy carries on its own grant list, taken from there rather than
716
+ * restated: raising that cap raises the read fan-out here.
717
+ */
718
+ const MAX_EXAMINED_REFERENCERS = MAX_AUTH_GRANTS;
719
+ /**
720
+ * How many referencers are probed at once. The reactor's Postgres pool size is
721
+ * optional and defaults to ten, and a listing resolves one gate per result, so
722
+ * per-gate fan-out multiplies: a handful keeps one group read from claiming the
723
+ * whole pool while still cutting the walk several times over. On PGlite the
724
+ * queries serialize regardless, so a larger number buys nothing in a browser.
725
+ */
726
+ const REFERENCER_PROBE_CONCURRENCY = 4;
727
+ /**
728
+ * The policy carried on a document, if it carries one. A document handed to the
729
+ * gate with no state at all is not policied, so it is not gated.
730
+ */
731
+ function authOf(document) {
732
+ return document.state?.auth;
733
+ }
734
+ /**
735
+ * The model reads enforce. Below `authEnforcement` there is no model to
736
+ * enforce: the document-only model ignores the auth scope entirely, so reading
737
+ * through it would serve every domain scope of a policied document to anyone.
738
+ * Undefined therefore means "evaluate the policy alone", which is what the read
739
+ * surface did before the model existed.
740
+ */
741
+ function readDecisionModel(flags, registry) {
742
+ return flags.authEnforcement ? selectDecisionModel(flags, registry) : void 0;
743
+ }
744
+ /**
745
+ * Evaluates the policy on its own, with no groups map and no condition context.
746
+ * A `{ group }` or conditional grant therefore never applies: an allow that
747
+ * does not apply withholds access, so this cannot widen a policy, but a policy
748
+ * relying on a conditional deny is weaker here than it is written.
749
+ */
750
+ var BareReadGate = class {
751
+ scopePredicate(document, subject) {
752
+ const auth = authOf(document);
753
+ return Promise.resolve((scope) => ALWAYS_READABLE_SCOPES.has(scope) || decide(auth, subject, {
754
+ verb: "read",
755
+ scope
756
+ }) === "allow");
757
+ }
758
+ };
759
+ /**
760
+ * Answers a stream read from the document already fetched, and anything else
761
+ * through the read side.
762
+ *
763
+ * The seed is why routing reads through a decision model costs no extra I/O for
764
+ * the document being read: its `document` and `auth` scopes are the two static
765
+ * projections, and the caller has both in hand. Only a group stream the grant
766
+ * list names is fetched.
767
+ */
768
+ var SeededStateReader = class {
769
+ constructor(documentView, seed, branch) {
770
+ this.documentView = documentView;
771
+ this.seed = seed;
772
+ this.branch = branch;
773
+ }
774
+ /**
775
+ * A stream this replica does not hold has to reach buildDecisionModel as the
776
+ * absence it recognises, or the whole read fails instead of leaving the group
777
+ * out of the model, where its principal does not match and the policy fails
778
+ * closed. The read side reports absence as a plain Error, so the absence is
779
+ * confirmed rather than inferred from the message: a transient failure must
780
+ * surface, not silently deny.
781
+ */
782
+ async getState(documentId, scope, branch, targetRevision, signal) {
783
+ if (targetRevision === void 0 && documentId === this.seed.header.id && branch === this.branch && scope in this.seed.state) return this.seed;
784
+ try {
785
+ return await this.documentView.get(documentId, {
786
+ branch,
787
+ scopes: [scope]
788
+ }, void 0, signal);
789
+ } catch (error) {
790
+ await this.assertAbsent(documentId, error, signal);
791
+ throw new DocumentNotFoundError(documentId);
792
+ }
793
+ }
794
+ async assertAbsent(documentId, error, signal) {
795
+ if (DocumentNotFoundError.isError(error)) return;
796
+ let exists;
797
+ try {
798
+ exists = await this.documentView.exists([documentId], void 0, signal);
799
+ } catch {
800
+ throw error;
801
+ }
802
+ if (exists[0]) throw error;
803
+ }
804
+ };
805
+ /**
806
+ * Evaluates a read against the registered decision model, built at the stream
807
+ * heads. This is what makes `{ group }` principals and conditional grants apply
808
+ * to a read: the model supplies the groups map and the scope's own state, the
809
+ * same two things admission supplies.
810
+ *
811
+ * A read has no action, so a condition on `action.input.*` never holds for one.
812
+ *
813
+ * State is read through the read side rather than the write cache. The write
814
+ * cache is invalidated by whichever process runs the executor, so a reactor
815
+ * running its executors in worker processes would answer reads in the parent
816
+ * from state no commit ever invalidates.
817
+ */
818
+ var ModelReadGate = class {
819
+ constructor(model, documentView, servesGroups, operationIndex, logger) {
820
+ this.model = model;
821
+ this.documentView = documentView;
822
+ this.servesGroups = servesGroups;
823
+ this.operationIndex = operationIndex;
824
+ this.logger = logger;
825
+ }
826
+ /**
827
+ * A served group yields its member list and nothing else. What the audience
828
+ * is owed is the state it must fold to evaluate auth with the group; a
829
+ * group's other scopes are its own business and stay behind its own grants.
830
+ */
831
+ async scopePredicate(document, subject, branch, signal) {
832
+ const own = await this.ownPolicyPredicate(document, subject, branch, signal);
833
+ if (!this.servesGroup(document) || own(GROUP_MEMBERSHIP_SCOPE)) return own;
834
+ const audience = await this.servesGroupTo(document.header.id, subject, signal);
835
+ return (scope) => own(scope) || audience && scope === GROUP_MEMBERSHIP_SCOPE;
836
+ }
837
+ /**
838
+ * Whether the subject is served this group because a policy names it.
839
+ *
840
+ * A replica must fold a group's membership to evaluate auth with it, so a
841
+ * group a grant names is served to the audience of the document that names
842
+ * it, whatever the group's own read grants say. Naming a group in a policy
843
+ * publishes its roster to that policy's audience; a group whose membership
844
+ * must stay confidential does not belong in a grant.
845
+ *
846
+ * The referencing document's own domain scopes are the test. Its `auth` and
847
+ * `document` scopes are readable by every holder, so testing those would
848
+ * serve every referenced group to everybody.
849
+ *
850
+ * One level only. A referencer that is itself a group is skipped, and a
851
+ * referencer's own readability is decided from its policy alone, so a
852
+ * reference cycle terminates. Cycles are reachable: the reference relation
853
+ * is recorded from an operation's input, including one later stored denied,
854
+ * so a refused grant naming a group from inside another group leaves a row
855
+ * behind that validation never saw.
856
+ *
857
+ * The referencers are probed a few at a time and the walk stops at the first
858
+ * that serves, because a subject outside the audience is the case that runs to
859
+ * the bound, and it is the common one. A probe that failed decides only when
860
+ * nothing served: serving rests on a real allow, so this cannot widen, and it
861
+ * stops one unreachable referencer from turning an allow already in hand into
862
+ * a denial. A read records no operation, so replicas differing over a
863
+ * transient failure has no consensus consequence.
864
+ *
865
+ * The probes are awaited together rather than raced, so none is ever left
866
+ * running with nobody awaiting it, which is where unhandled rejections come
867
+ * from.
868
+ */
869
+ async servesGroupTo(groupId, subject, signal) {
870
+ if (!this.operationIndex) return false;
871
+ const referencers = await this.operationIndex.getGroupReferencers(groupId, signal);
872
+ const examined = referencers.slice(0, MAX_EXAMINED_REFERENCERS);
873
+ 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`);
874
+ const walk = {
875
+ next: 0,
876
+ served: false,
877
+ failure: void 0
878
+ };
879
+ const probe = async () => {
880
+ while (!walk.served && walk.next < examined.length) {
881
+ const referencerId = examined[walk.next++];
882
+ let serves = false;
883
+ try {
884
+ serves = await this.servesThrough(referencerId, subject, signal);
885
+ } catch (error) {
886
+ walk.failure ??= Error.isError(error) ? error : /* @__PURE__ */ new Error(`Probing referencer ${referencerId} failed`);
887
+ }
888
+ if (serves) walk.served = true;
889
+ }
890
+ };
891
+ await Promise.all(Array.from({ length: Math.min(REFERENCER_PROBE_CONCURRENCY, examined.length) }, probe));
892
+ if (!walk.served && walk.failure) throw walk.failure;
893
+ return walk.served;
894
+ }
895
+ /**
896
+ * Whether one referencing document serves the subject any domain scope. A
897
+ * referencer this replica does not hold serves nothing, which fails closed
898
+ * the same way a group it does not hold does.
899
+ */
900
+ async servesThrough(referencerId, subject, signal) {
901
+ let referencer;
902
+ try {
903
+ referencer = await this.documentView.get(referencerId, { branch: GROUP_BRANCH }, void 0, signal);
904
+ } catch {
905
+ return false;
906
+ }
907
+ if (this.servesGroup(referencer)) return false;
908
+ const readable = await this.ownPolicyPredicate(referencer, subject, GROUP_BRANCH, signal);
909
+ return Object.keys(referencer.state ?? {}).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope)).some((scope) => readable(scope));
910
+ }
911
+ servesGroup(document) {
912
+ return this.servesGroups && this.operationIndex !== void 0 && document.header.documentType === groupDocumentType;
913
+ }
914
+ /**
915
+ * What this document's own policy says, with no group serving applied.
916
+ *
917
+ * An unpoliced document is readable in full, which is the common case and the
918
+ * one worth answering without building anything. The test is the one
919
+ * `evaluate` makes: a legacy `{}` auth scope and version 0 both mean
920
+ * uninitialized, and "no grants" does not, because a policy with a version and
921
+ * an empty grant list denies everything.
922
+ */
923
+ async ownPolicyPredicate(document, subject, branch, signal) {
924
+ const auth = authOf(document);
925
+ if (!auth || !auth.version) return () => true;
926
+ const target = {
927
+ documentId: document.header.id,
928
+ branch
929
+ };
930
+ const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), this.model, target, signal);
931
+ const definition = this.model(target);
932
+ const scopeStates = document.state ?? {};
933
+ return (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
934
+ verb: "read",
935
+ scope
936
+ }, {
937
+ scopeState: scopeStates[scope],
938
+ actionInput: void 0
939
+ }).decision === "allow";
940
+ }
941
+ };
942
+ //#endregion
943
+ //#region src/client/util.ts
944
+ function authSubjectFromSigner(signer) {
945
+ return {
946
+ address: signer.user?.address,
947
+ key: signer.app?.key
948
+ };
949
+ }
950
+ function withAuthScope(view) {
951
+ if (view?.scopes && view.scopes.length > 0) return {
952
+ ...view,
953
+ scopes: [...new Set([...view.scopes, "auth"])]
954
+ };
955
+ return view;
956
+ }
957
+ /**
958
+ * Drops the scopes the predicate refuses. The predicate is resolved by the read
959
+ * gate, which decides against the whole policy once per document; this only
960
+ * applies the answer, so it stays synchronous.
961
+ */
962
+ function filterReadableScopes(document, readable) {
963
+ const state = document.state;
964
+ if (!state) return document;
965
+ return {
966
+ ...document,
967
+ state: keepReadableScopes(state, readable),
968
+ initialState: keepReadableScopes(document.initialState, readable)
969
+ };
970
+ }
971
+ function keepReadableScopes(scopes, readable) {
972
+ if (!scopes) return scopes;
973
+ const kept = {};
974
+ for (const scope of Object.keys(scopes)) if (readable(scope)) kept[scope] = scopes[scope];
975
+ return kept;
976
+ }
977
+ //#endregion
683
978
  //#region src/client/reactor-client.ts
684
979
  /**
685
980
  * ReactorClient implementation that wraps lower-level APIs to provide
@@ -699,8 +994,9 @@ var ReactorClient = class {
699
994
  jobAwaiter;
700
995
  documentIndexer;
701
996
  documentView;
997
+ readGate;
702
998
  drives;
703
- constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView) {
999
+ constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate()) {
704
1000
  this.logger = logger;
705
1001
  this.reactor = reactor;
706
1002
  this.signer = signer;
@@ -708,9 +1004,32 @@ var ReactorClient = class {
708
1004
  this.jobAwaiter = jobAwaiter;
709
1005
  this.documentIndexer = documentIndexer;
710
1006
  this.documentView = documentView;
1007
+ this.readGate = readGate;
711
1008
  this.drives = new DriveClient(this, logger, reactor, signer);
712
1009
  this.logger.verbose("ReactorClient initialized");
713
1010
  }
1011
+ readSubject(subject) {
1012
+ return subject ?? authSubjectFromSigner(this.signer);
1013
+ }
1014
+ /**
1015
+ * Which scopes of one document the subject may read. Resolved once per
1016
+ * document, so the gate builds its model once however many scopes are then
1017
+ * tested, and the filtering itself stays synchronous.
1018
+ */
1019
+ readableScopes(document, view, signal) {
1020
+ return this.readGate.scopePredicate(document, this.readSubject(view?.subject), view?.branch ?? "main", signal);
1021
+ }
1022
+ /**
1023
+ * One document, filtered to the scopes the subject may read. Every method
1024
+ * that hands a document back goes through here, including the ones that
1025
+ * follow a write: a document returned from a mutation is a read like any
1026
+ * other, and returning it whole served scopes the same subject would be
1027
+ * refused by `get`. Its author still sees what it wrote, because an allow on
1028
+ * execute confers read of that scope.
1029
+ */
1030
+ async gateDocument(document, view, signal) {
1031
+ return filterReadableScopes(document, await this.readableScopes(document, view, signal));
1032
+ }
714
1033
  /**
715
1034
  * Retrieves a list of document model modules.
716
1035
  */
@@ -725,24 +1044,53 @@ var ReactorClient = class {
725
1044
  * @returns The document model module
726
1045
  */
727
1046
  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;
1047
+ const modules = await this.reactor.getDocumentModels();
1048
+ let latestModule;
1049
+ let latestVersion = -1;
1050
+ for (const module of modules.results) {
1051
+ if (module.documentModel.global.id !== documentType) continue;
1052
+ const version = module.version ?? 1;
1053
+ if (version > latestVersion) {
1054
+ latestVersion = version;
1055
+ latestModule = module;
1056
+ }
1057
+ }
1058
+ if (!latestModule) throw new Error(`Document model module not found for type: ${documentType}`);
1059
+ return latestModule;
1060
+ }
1061
+ /**
1062
+ * Retrieves the document model module matching the version the document is
1063
+ * stamped with, so not-yet-upgraded documents get the reducer their
1064
+ * history was written with rather than the latest.
1065
+ */
1066
+ async getDocumentModelModuleForDocument(document) {
1067
+ const documentType = document.header.documentType;
1068
+ const version = normalizeDocumentModelVersion(document.state.document?.version);
1069
+ const modules = await this.reactor.getDocumentModels();
1070
+ const availableVersions = [];
1071
+ for (const module of modules.results) {
1072
+ if (module.documentModel.global.id !== documentType) continue;
1073
+ const moduleVersion = normalizeDocumentModelVersion(module.version);
1074
+ if (moduleVersion === version) return module;
1075
+ availableVersions.push(moduleVersion);
1076
+ }
1077
+ throw new UnsupportedDocumentModelVersionError(documentType, version, availableVersions.sort((a, b) => a - b));
731
1078
  }
732
1079
  /**
733
1080
  * Retrieves a specific PHDocument
734
1081
  */
735
1082
  async get(identifier, view, signal) {
736
1083
  this.logger.verbose("get(@identifier, @view)", identifier, view);
737
- return await this.reactor.getByIdOrSlug(identifier, view, void 0, signal);
1084
+ const document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
1085
+ return this.gateDocument(document, view, signal);
738
1086
  }
739
1087
  /**
740
1088
  * Resolves an identifier (id or slug) to the canonical document id, using the
741
1089
  * same lookup as the data path. Resolves against the "main" branch. Throws if
742
1090
  * the identifier cannot be resolved or is ambiguous.
743
1091
  */
744
- async resolveIdOrSlug(identifier, signal) {
745
- return this.documentView.resolveIdOrSlug(identifier, void 0, void 0, signal);
1092
+ async resolveIdOrSlug(identifier, view, signal) {
1093
+ return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
746
1094
  }
747
1095
  /**
748
1096
  * Retrieves operations for a document
@@ -750,8 +1098,11 @@ var ReactorClient = class {
750
1098
  async getOperations(documentIdentifier, view, filter, paging, signal) {
751
1099
  this.logger.verbose("getOperations(@documentIdentifier, @view, @filter, @paging)", documentIdentifier, view, filter, paging);
752
1100
  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);
1101
+ const gated = await this.reactor.getByIdOrSlug(documentId, withAuthScope(view), void 0, signal);
1102
+ const canRead = await this.readableScopes(gated, view, signal);
1103
+ if (paging?.cursor && isCompositeCursor(paging.cursor)) return this.getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead);
754
1104
  const operationsByScope = await this.reactor.getOperations(documentId, view, filter, paging, void 0, signal);
1105
+ for (const scope of Object.keys(operationsByScope)) if (!canRead(scope)) delete operationsByScope[scope];
755
1106
  const scopeEntries = Object.entries(operationsByScope);
756
1107
  const effectivePaging = paging || {
757
1108
  cursor: "0",
@@ -779,11 +1130,12 @@ var ReactorClient = class {
779
1130
  nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
780
1131
  };
781
1132
  }
782
- async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal) {
1133
+ async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
783
1134
  const scopeCursors = decodeCompositeCursor(paging.cursor);
784
1135
  const allOperations = [];
785
1136
  const activeCursors = {};
786
1137
  for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1138
+ if (!canRead(scopeName)) continue;
787
1139
  const scopeView = {
788
1140
  ...view,
789
1141
  scopes: [scopeName]
@@ -817,7 +1169,7 @@ var ReactorClient = class {
817
1169
  limit: 0
818
1170
  }
819
1171
  };
820
- return this.reactor.find({ ids: targetIds }, view, paging, void 0, signal);
1172
+ return this.find({ ids: targetIds }, view, paging, signal);
821
1173
  }
822
1174
  /**
823
1175
  * Retrieves incoming relationships of a given type to a target document.
@@ -833,14 +1185,18 @@ var ReactorClient = class {
833
1185
  limit: 0
834
1186
  }
835
1187
  };
836
- return this.reactor.find({ ids: sourceIds }, view, paging, void 0, signal);
1188
+ return this.find({ ids: sourceIds }, view, paging, signal);
837
1189
  }
838
1190
  /**
839
1191
  * Filters documents by criteria and returns a list of them
840
1192
  */
841
1193
  async find(search, view, paging, signal) {
842
1194
  this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
843
- return this.reactor.find(search, view, paging, void 0, signal);
1195
+ const results = await this.reactor.find(search, withAuthScope(view), paging, void 0, signal);
1196
+ return {
1197
+ ...results,
1198
+ results: await Promise.all(results.results.map(async (doc) => filterReadableScopes(doc, await this.readableScopes(doc, view, signal))))
1199
+ };
844
1200
  }
845
1201
  /**
846
1202
  * Creates a document and waits for completion
@@ -868,7 +1224,7 @@ var ReactorClient = class {
868
1224
  documentId,
869
1225
  model: document.header.documentType,
870
1226
  fromVersion: 0,
871
- toVersion: document.state.document.version,
1227
+ toVersion: normalizeDocumentModelVersion(document.state.document?.version),
872
1228
  initialState: document.state
873
1229
  })], this.signer, signal);
874
1230
  const jobs = [{
@@ -893,7 +1249,8 @@ var ReactorClient = class {
893
1249
  const batchResult = await this.reactor.executeBatch({ jobs }, signal);
894
1250
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
895
1251
  for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
896
- return await this.reactor.get(documentId);
1252
+ const created = await this.reactor.get(documentId);
1253
+ return this.gateDocument(created, void 0, signal);
897
1254
  }
898
1255
  /**
899
1256
  * Creates an empty document and waits for completion
@@ -903,20 +1260,64 @@ var ReactorClient = class {
903
1260
  const matchingModules = (await this.reactor.getDocumentModels(void 0, void 0, signal)).results.filter((m) => m.documentModel.global.id === documentModelType);
904
1261
  let module;
905
1262
  if (options?.documentModelVersion !== void 0) {
906
- module = matchingModules.find((m) => m.version === options.documentModelVersion);
1263
+ const requestedVersion = normalizeDocumentModelVersion(options.documentModelVersion);
1264
+ module = matchingModules.find((m) => normalizeDocumentModelVersion(m.version) === requestedVersion);
907
1265
  if (!module) throw new Error(`Document model not found for type: ${documentModelType} with version: ${options.documentModelVersion}`);
908
1266
  } else {
909
1267
  module = matchingModules.reduce((latest, current) => {
910
1268
  if (latest === void 0) return current;
911
- return (current.version ?? 0) > (latest.version ?? 0) ? current : latest;
1269
+ return normalizeDocumentModelVersion(current.version) > normalizeDocumentModelVersion(latest.version) ? current : latest;
912
1270
  }, void 0);
913
1271
  if (!module) throw new Error(`Document model not found for type: ${documentModelType}`);
914
1272
  }
915
1273
  const document = module.utils.createDocument();
916
- document.state.document.version = module.version ?? 1;
1274
+ document.state.document.version = normalizeDocumentModelVersion(module.version);
917
1275
  return this.create(document, options?.parentIdentifier, signal);
918
1276
  }
919
1277
  /**
1278
+ * Upgrades a document to a newer document model version by dispatching an
1279
+ * UPGRADE_DOCUMENT action. When toVersion is omitted, upgrades to the
1280
+ * latest registered module version for the document's type. Returns the
1281
+ * document unchanged when it is already at the target version.
1282
+ *
1283
+ * The executor validates the action's version and revision snapshot against
1284
+ * the state the migration actually runs on. When a concurrent edit
1285
+ * invalidates the snapshot, the upgrade is rebuilt from a fresh read and
1286
+ * retried up to maxConflictRetries times before the conflict is surfaced.
1287
+ */
1288
+ async upgradeDocument(documentIdentifier, toVersion, options, signal) {
1289
+ this.logger.verbose("upgradeDocument(@documentIdentifier, @toVersion)", documentIdentifier, toVersion);
1290
+ const maxConflictRetries = options?.maxConflictRetries ?? 3;
1291
+ let lastConflictMessage = "";
1292
+ for (let attempt = 0; attempt <= maxConflictRetries; attempt++) {
1293
+ const document = await this.reactor.getByIdOrSlug(documentIdentifier, void 0, void 0, signal);
1294
+ const documentId = document.header.id;
1295
+ const documentType = document.header.documentType;
1296
+ const branch = document.header.branch || "main";
1297
+ const fromVersion = normalizeDocumentModelVersion(document.state.document?.version);
1298
+ let targetVersion = toVersion;
1299
+ if (targetVersion === void 0) targetVersion = normalizeDocumentModelVersion((await this.getDocumentModelModule(documentType)).version);
1300
+ if (targetVersion === fromVersion) return this.gateDocument(document, { branch }, signal);
1301
+ if (targetVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, targetVersion);
1302
+ const signedActions = await signActions([upgradeDocumentAction({
1303
+ documentId,
1304
+ model: documentType,
1305
+ fromVersion,
1306
+ toVersion: targetVersion,
1307
+ revision: { ...document.header.revision }
1308
+ })], this.signer, signal);
1309
+ const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
1310
+ const completedJob = await this.waitForJob(jobInfo, signal);
1311
+ if (completedJob.status !== JobStatus.FAILED) {
1312
+ const upgraded = await this.reactor.getByIdOrSlug(documentId, { branch }, completedJob.consistencyToken, signal);
1313
+ return this.gateDocument(upgraded, { branch }, signal);
1314
+ }
1315
+ if (completedJob.error?.name !== "UpgradePreconditionFailedError") throw new Error(completedJob.error?.message);
1316
+ lastConflictMessage = completedJob.error.message;
1317
+ }
1318
+ throw new Error(`Upgrade of document ${documentIdentifier} conflicted with concurrent edits after ${maxConflictRetries + 1} attempts: ${lastConflictMessage}`);
1319
+ }
1320
+ /**
920
1321
  * Creates an empty document in a drive as a single batched operation.
921
1322
  * Delegates to {@link IDriveClient.addFile}.
922
1323
  *
@@ -936,7 +1337,8 @@ var ReactorClient = class {
936
1337
  const completedJob = await this.waitForJob(jobInfo, signal);
937
1338
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
938
1339
  const view = { branch };
939
- return await this.reactor.getByIdOrSlug(documentIdentifier, view, completedJob.consistencyToken, signal);
1340
+ const result = await this.reactor.getByIdOrSlug(documentIdentifier, view, completedJob.consistencyToken, signal);
1341
+ return this.gateDocument(result, view, signal);
940
1342
  }
941
1343
  /**
942
1344
  * Submits a list of actions to a document
@@ -980,7 +1382,8 @@ var ReactorClient = class {
980
1382
  const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
981
1383
  const completedJob = await this.waitForJob(jobInfo, signal);
982
1384
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
983
- return await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1385
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1386
+ return this.gateDocument(result, { branch }, signal);
984
1387
  }
985
1388
  /**
986
1389
  * Removes a relationship between two documents and waits for completion.
@@ -990,7 +1393,8 @@ var ReactorClient = class {
990
1393
  const jobInfo = await this.reactor.removeRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
991
1394
  const completedJob = await this.waitForJob(jobInfo, signal);
992
1395
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
993
- return await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1396
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1397
+ return this.gateDocument(result, { branch }, signal);
994
1398
  }
995
1399
  /**
996
1400
  * Moves a relationship from one source document to another and waits for completion.
@@ -1003,9 +1407,11 @@ var ReactorClient = class {
1003
1407
  const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1004
1408
  const addCompletedJob = await this.waitForJob(addJobInfo, signal);
1005
1409
  if (addCompletedJob.status === JobStatus.FAILED) throw new Error(addCompletedJob.error?.message);
1410
+ const sourceResult = await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal);
1411
+ const targetResult = await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal);
1006
1412
  return {
1007
- source: await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal),
1008
- target: await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal)
1413
+ source: await this.gateDocument(sourceResult, { branch }, signal),
1414
+ target: await this.gateDocument(targetResult, { branch }, signal)
1009
1415
  };
1010
1416
  }
1011
1417
  async loadBatch(request, signal) {
@@ -1078,32 +1484,56 @@ var ReactorClient = class {
1078
1484
  */
1079
1485
  subscribe(search, callback, view) {
1080
1486
  this.logger.verbose("subscribe(@search, @view)", search, view);
1487
+ const readable = async (document) => filterReadableScopes(document, await this.readableScopes(document, view));
1488
+ let disposed = false;
1489
+ let delivering = Promise.resolve();
1490
+ /**
1491
+ * Queues one event behind those already queued. Gating an event resolves
1492
+ * asynchronously, so an event needing a group fetch would otherwise be
1493
+ * overtaken by the one behind it, and one still in flight would reach a
1494
+ * subscriber that has already unsubscribed. Only delivery is ordered, not
1495
+ * the gating: a single subscription can cover every document in the
1496
+ * reactor, so serializing that work would make its delivery rate the sum
1497
+ * of every gate build rather than the slowest. An event that cannot be
1498
+ * gated is withheld, because serving it unfiltered would leak the scopes
1499
+ * the gate did not clear -- but it is logged rather than swallowed, since
1500
+ * the gate rethrows a transient failure precisely so it is not read as a
1501
+ * denial.
1502
+ */
1503
+ const deliver = (event) => {
1504
+ Promise.resolve(event).catch(() => void 0);
1505
+ delivering = delivering.then(async () => {
1506
+ const built = await event;
1507
+ if (disposed) return;
1508
+ callback(built);
1509
+ }).catch((error) => {
1510
+ this.logger.error("Subscription delivery failed for @search: @Error", { search }, error);
1511
+ });
1512
+ };
1081
1513
  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
- })();
1514
+ deliver((async () => {
1515
+ const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
1516
+ return {
1517
+ type: DocumentChangeType.Created,
1518
+ documents: await Promise.all(documents.map(readable))
1519
+ };
1520
+ })());
1091
1521
  }, search);
1092
1522
  const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
1093
- callback({
1523
+ deliver({
1094
1524
  type: DocumentChangeType.Deleted,
1095
1525
  documents: [],
1096
1526
  context: { childId: documentIds[0] }
1097
1527
  });
1098
1528
  }, search);
1099
1529
  const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
1100
- callback({
1530
+ deliver((async () => ({
1101
1531
  type: DocumentChangeType.Updated,
1102
- documents: result.results
1103
- });
1532
+ documents: await Promise.all(result.results.map(readable))
1533
+ }))());
1104
1534
  }, search, view);
1105
1535
  const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
1106
- callback({
1536
+ deliver({
1107
1537
  type: changeType === RelationshipChangeType.Added ? DocumentChangeType.ChildAdded : DocumentChangeType.ChildRemoved,
1108
1538
  documents: [],
1109
1539
  context: {
@@ -1113,6 +1543,7 @@ var ReactorClient = class {
1113
1543
  });
1114
1544
  }, search);
1115
1545
  return () => {
1546
+ disposed = true;
1116
1547
  unsubscribeCreated();
1117
1548
  unsubscribeDeleted();
1118
1549
  unsubscribeUpdated();
@@ -1144,17 +1575,33 @@ let JobQueueState = /* @__PURE__ */ function(JobQueueState) {
1144
1575
  return JobQueueState;
1145
1576
  }({});
1146
1577
  /**
1578
+ * How a retry is accounted against the job's retry limit.
1579
+ * - `CountAgainstLimit` (default): a fault; the job eventually exhausts its
1580
+ * retries and fails terminally.
1581
+ * - `ExemptFromLimit`: not a fault, so the attempt is not charged to the job.
1582
+ * Used for concurrency conflicts, where the retry does new work.
1583
+ */
1584
+ let RetryAccounting = /* @__PURE__ */ function(RetryAccounting) {
1585
+ RetryAccounting["CountAgainstLimit"] = "count-against-limit";
1586
+ RetryAccounting["ExemptFromLimit"] = "exempt-from-limit";
1587
+ return RetryAccounting;
1588
+ }({});
1589
+ /**
1147
1590
  * Event types for the queue system
1148
1591
  */
1149
1592
  const QueueEventTypes = { JOB_AVAILABLE: 1e4 };
1150
1593
  //#endregion
1151
1594
  //#region src/executor/job-result-handler.ts
1595
+ /** Conflict retries a job may take without charging its retry limit. */
1596
+ const MAX_EXEMPT_CONFLICT_RETRIES = 20;
1152
1597
  function toErrorInfo(error) {
1153
1598
  if (error instanceof Error) return {
1599
+ name: error.name,
1154
1600
  message: error.message,
1155
1601
  stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
1156
1602
  };
1157
1603
  return {
1604
+ name: "Error",
1158
1605
  message: error,
1159
1606
  stack: (/* @__PURE__ */ new Error()).stack || ""
1160
1607
  };
@@ -1187,12 +1634,19 @@ var JobResultHandler = class {
1187
1634
  } catch {}
1188
1635
  }
1189
1636
  }
1637
+ if (result.error && AppendConditionFailedError.isError(result.error) && this.countConflicts(handle.job) < MAX_EXEMPT_CONFLICT_RETRIES) {
1638
+ const errorInfo = toErrorInfo(result.error);
1639
+ try {
1640
+ await this.queue.retryJob(handle.job.id, errorInfo, RetryAccounting.ExemptFromLimit);
1641
+ return;
1642
+ } catch {}
1643
+ }
1190
1644
  if (result.error && DocumentNotFoundError.isError(result.error)) {
1191
1645
  handle.defer();
1192
1646
  callbacks.deferJob(handle.job.documentId, handle.job);
1193
1647
  return;
1194
1648
  }
1195
- if (result.error && DocumentDeletedError.isError(result.error)) {
1649
+ if (result.error && (DocumentDeletedError.isError(result.error) || AuthorizationDeniedError.isError(result.error) || AuthTimestampNotMonotonicError.isError(result.error) || InvalidOperationTimestampError.isError(result.error) || ExcessiveReshuffleError.isError(result.error) || UpgradePreconditionFailedError.isError(result.error))) {
1196
1650
  const errorInfo = toErrorInfo(result.error);
1197
1651
  this.jobTracker.markFailed(handle.job.id, errorInfo, handle.job);
1198
1652
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
@@ -1230,6 +1684,12 @@ var JobResultHandler = class {
1230
1684
  handle.fail(fullErrorInfo);
1231
1685
  }
1232
1686
  }
1687
+ /** How many times this job has already lost an append-condition race. */
1688
+ countConflicts(job) {
1689
+ let conflicts = 0;
1690
+ for (const error of job.errorHistory) if (AppendConditionFailedError.isFailureMessage(error.message)) conflicts++;
1691
+ return conflicts;
1692
+ }
1233
1693
  hasCreateDocumentAction(job) {
1234
1694
  for (const action of job.actions) if (action.type === "CREATE_DOCUMENT") return true;
1235
1695
  for (const operation of job.operations) if (operation.action.type === "CREATE_DOCUMENT") return true;
@@ -1245,6 +1705,7 @@ var JobResultHandler = class {
1245
1705
  stackLines.push(`[Attempt ${index + 1}] Stack trace:\n${error.stack}`);
1246
1706
  });
1247
1707
  return {
1708
+ name: currentError.name,
1248
1709
  message: messageLines.join("\n"),
1249
1710
  stack: stackLines.join("\n\n")
1250
1711
  };
@@ -1850,6 +2311,7 @@ var InMemoryJobTracker = class {
1850
2311
  }
1851
2312
  handleJobFailed(event) {
1852
2313
  this.markFailed(event.jobId, {
2314
+ name: event.error.name,
1853
2315
  message: event.error.message,
1854
2316
  stack: event.error.stack || ""
1855
2317
  }, event.job);
@@ -2311,10 +2773,12 @@ var InMemoryQueue = class {
2311
2773
  }
2312
2774
  toErrorInfo(error) {
2313
2775
  if (error instanceof Error) return {
2776
+ name: error.name,
2314
2777
  message: error.message,
2315
2778
  stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
2316
2779
  };
2317
2780
  return {
2781
+ name: "Error",
2318
2782
  message: error,
2319
2783
  stack: (/* @__PURE__ */ new Error()).stack || ""
2320
2784
  };
@@ -2402,6 +2866,7 @@ var InMemoryQueue = class {
2402
2866
  await this.resolver.ensureModelLoaded(documentType);
2403
2867
  } catch {
2404
2868
  await this.failJob(job.id, {
2869
+ name: "Error",
2405
2870
  message: `Failed to load document model for type: ${documentType}`,
2406
2871
  stack: (/* @__PURE__ */ new Error()).stack || ""
2407
2872
  });
@@ -2575,9 +3040,11 @@ var InMemoryQueue = class {
2575
3040
  this.jobIndex.delete(jobId);
2576
3041
  this.completedJobs.add(jobId);
2577
3042
  await this.remove(jobId);
3043
+ const emittedError = new Error(error?.message ?? "Job failed");
3044
+ if (error?.name) emittedError.name = error.name;
2578
3045
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
2579
3046
  jobId,
2580
- error: new Error(error?.message ?? "Job failed"),
3047
+ error: emittedError,
2581
3048
  job
2582
3049
  }).catch(() => {});
2583
3050
  this.checkDrained();
@@ -2587,7 +3054,7 @@ var InMemoryQueue = class {
2587
3054
  if (documentId) this.markJobComplete(jobId, documentId);
2588
3055
  this.jobIndex.delete(jobId);
2589
3056
  }
2590
- async retryJob(jobId, error) {
3057
+ async retryJob(jobId, error, accounting = RetryAccounting.CountAgainstLimit) {
2591
3058
  const job = this.jobIndex.get(jobId);
2592
3059
  if (!job) return;
2593
3060
  job.lastError = error;
@@ -2596,9 +3063,10 @@ var InMemoryQueue = class {
2596
3063
  this.jobIndex.delete(jobId);
2597
3064
  this.jobIdToQueueKey.delete(jobId);
2598
3065
  if (error) job.errorHistory.push(error);
3066
+ const retryCount = job.retryCount || 0;
2599
3067
  const updatedJob = {
2600
3068
  ...job,
2601
- retryCount: (job.retryCount || 0) + 1,
3069
+ retryCount: accounting === RetryAccounting.CountAgainstLimit ? retryCount + 1 : retryCount,
2602
3070
  lastError: error
2603
3071
  };
2604
3072
  await this.enqueue(updatedJob);
@@ -3116,6 +3584,83 @@ var SubscriptionNotificationReadModel = class {
3116
3584
  }
3117
3585
  };
3118
3586
  //#endregion
3587
+ //#region src/core/group-reevaluation-trigger.ts
3588
+ /**
3589
+ * Watches committed writes for group membership changes and enqueues a
3590
+ * re-evaluation job for every document whose auth history references the
3591
+ * changed group, found through the reverse direction of the group-reference
3592
+ * relation. Each affected document is re-judged in its own job, so the work
3593
+ * runs under that document's execution slot rather than the group's.
3594
+ *
3595
+ * The job carries the earliest changed membership timestamp; the executor
3596
+ * skips the pass when everything the document holds sorts before it, which
3597
+ * keeps the common case (a membership write later than all history) free.
3598
+ */
3599
+ var GroupReevaluationTrigger = class {
3600
+ unsubscribe;
3601
+ constructor(logger, eventBus, queue, operationIndex) {
3602
+ this.logger = logger;
3603
+ this.eventBus = eventBus;
3604
+ this.queue = queue;
3605
+ this.operationIndex = operationIndex;
3606
+ }
3607
+ startup() {
3608
+ this.unsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_WRITE_READY, async (_type, event) => this.onWriteReady(event));
3609
+ }
3610
+ shutdown() {
3611
+ this.unsubscribe?.();
3612
+ this.unsubscribe = void 0;
3613
+ }
3614
+ async onWriteReady(event) {
3615
+ const changed = /* @__PURE__ */ new Map();
3616
+ for (const { operation, context } of event.operations) {
3617
+ if (context.documentType !== groupDocumentType || context.scope !== "global" || !groupMembershipActionTypes.includes(operation.action.type)) continue;
3618
+ const existing = changed.get(context.documentId);
3619
+ if (existing === void 0 || Date.parse(operation.timestampUtcMs) < Date.parse(existing)) changed.set(context.documentId, operation.timestampUtcMs);
3620
+ }
3621
+ if (changed.size === 0) return;
3622
+ const affected = /* @__PURE__ */ new Map();
3623
+ for (const [groupId, timestamp] of changed) {
3624
+ let referencers;
3625
+ try {
3626
+ referencers = await this.operationIndex.getGroupReferencers(groupId);
3627
+ } catch (error) {
3628
+ this.logger.error("Failed to resolve referencers of group @groupId: @error", groupId, error);
3629
+ continue;
3630
+ }
3631
+ for (const documentId of referencers) {
3632
+ const existing = affected.get(documentId);
3633
+ if (existing === void 0 || Date.parse(timestamp) < Date.parse(existing)) affected.set(documentId, timestamp);
3634
+ }
3635
+ }
3636
+ for (const [documentId, timestamp] of affected) {
3637
+ const jobId = v4();
3638
+ const job = {
3639
+ id: jobId,
3640
+ kind: "reevaluation",
3641
+ documentId,
3642
+ scope: "global",
3643
+ branch: "main",
3644
+ actions: [],
3645
+ operations: [],
3646
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3647
+ queueHint: [],
3648
+ maxRetries: 3,
3649
+ errorHistory: [],
3650
+ meta: {
3651
+ ...buildSingleJobMeta(jobId),
3652
+ triggerTimestampUtcMs: timestamp
3653
+ }
3654
+ };
3655
+ try {
3656
+ await this.queue.enqueue(job);
3657
+ } catch (error) {
3658
+ this.logger.error("Failed to enqueue re-evaluation of @documentId: @error", documentId, error);
3659
+ }
3660
+ }
3661
+ }
3662
+ };
3663
+ //#endregion
3119
3664
  //#region src/sync/types.ts
3120
3665
  let ChannelScheme = /* @__PURE__ */ function(ChannelScheme) {
3121
3666
  ChannelScheme["CONNECT"] = "connect";
@@ -3437,11 +3982,18 @@ var PollingChannelError = class extends Error {
3437
3982
  var ChannelError = class extends Error {
3438
3983
  source;
3439
3984
  error;
3440
- constructor(source, error) {
3985
+ /**
3986
+ * The classification when something other than the error carries it. Absent
3987
+ * means derive it from `error.name`; a dead letter mirrored from a peer sets it,
3988
+ * because only the message crosses the wire.
3989
+ */
3990
+ errorType;
3991
+ constructor(source, error, errorType) {
3441
3992
  super(`ChannelError[${source}]: ${error.message}`);
3442
3993
  this.name = "ChannelError";
3443
3994
  this.source = source;
3444
3995
  this.error = error;
3996
+ this.errorType = errorType;
3445
3997
  }
3446
3998
  };
3447
3999
  //#endregion
@@ -3646,6 +4198,7 @@ function toOperationWithContext(entry) {
3646
4198
  skip: entry.skip,
3647
4199
  hash: entry.hash,
3648
4200
  timestampUtcMs: entry.timestampUtcMs,
4201
+ deniedReason: entry.deniedReason,
3649
4202
  action: entry.action
3650
4203
  },
3651
4204
  context: {
@@ -3811,6 +4364,33 @@ function splitComponent(items, maxSize) {
3811
4364
  for (let i = 0; i < sorted.length; i += maxSize) chunks.push(sorted.slice(i, i + maxSize));
3812
4365
  return chunks;
3813
4366
  }
4367
+ /**
4368
+ * Classifies a failure by error name rather than `instanceof`, because a failure
4369
+ * that crossed the pooled-worker boundary arrives as plain data.
4370
+ */
4371
+ function classifyJobFailure(errorName) {
4372
+ switch (errorName) {
4373
+ case "AuthTimestampNotMonotonicError": return "AUTH_TIMESTAMP_NOT_MONOTONIC";
4374
+ case "InvalidOperationTimestampError": return "INVALID_TIMESTAMP";
4375
+ case "ExcessiveReshuffleError": return "EXCESSIVE_SHUFFLE";
4376
+ case "InvalidSignatureError": return "SIGNATURE_INVALID";
4377
+ case "HashMismatchError": return "HASH_MISMATCH";
4378
+ default: return "UNCLASSIFIED";
4379
+ }
4380
+ }
4381
+ /** The explicit type when something else carried it, else derived by name. */
4382
+ function syncOperationErrorType(error) {
4383
+ return error?.errorType ?? classifyJobFailure(error?.error?.name ?? "Error");
4384
+ }
4385
+ /** Dead-letter types that must not stop the document syncing. */
4386
+ const NON_QUARANTINING_ERROR_TYPES = new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
4387
+ /**
4388
+ * A held auth operation must not quarantine: reconciling the two policies needs
4389
+ * the traffic a quarantine would stop.
4390
+ */
4391
+ function quarantinesDocument(errorType) {
4392
+ return !NON_QUARANTINING_ERROR_TYPES.has(errorType);
4393
+ }
3814
4394
  //#endregion
3815
4395
  //#region src/sync/channels/interval-poll-timer.ts
3816
4396
  const DEFAULT_CONFIG = {
@@ -3973,6 +4553,7 @@ function serializeEnvelope(envelope) {
3973
4553
  hash: opWithContext.operation.hash,
3974
4554
  skip: opWithContext.operation.skip,
3975
4555
  error: opWithContext.operation.error,
4556
+ ...opWithContext.operation.deniedReason !== void 0 ? { deniedReason: opWithContext.operation.deniedReason } : {},
3976
4557
  id: opWithContext.operation.id,
3977
4558
  action: serializeAction(opWithContext.operation.action)
3978
4559
  },
@@ -4000,21 +4581,33 @@ function deserializeSignature(sig) {
4000
4581
  return sig.split(", ");
4001
4582
  }
4002
4583
  /**
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.
4584
+ * `isDenied` tests strictly against undefined, so a null left in place would mark
4585
+ * every synced operation denied. The key is removed rather than set to undefined,
4586
+ * so an operation that arrived without it stays identical to the one sent.
4007
4587
  */
4008
- function deserializeOperationSignatures(opWithContext) {
4009
- const signer = opWithContext.operation.action.context?.signer;
4010
- if (!signer?.signatures || signer.signatures.length === 0) return opWithContext;
4588
+ function normalizeAbsentFields(operation) {
4589
+ const wire = operation;
4590
+ if (wire.error !== null && wire.deniedReason !== null) return operation;
4591
+ const normalized = { ...operation };
4592
+ if (wire.error === null) delete normalized.error;
4593
+ if (wire.deniedReason === null) delete normalized.deniedReason;
4594
+ return normalized;
4595
+ }
4596
+ /** Restores signature tuples and null-valued optional fields to undefined. */
4597
+ function deserializeOperation(opWithContext) {
4598
+ const operation = normalizeAbsentFields(opWithContext.operation);
4599
+ const signer = operation.action.context?.signer;
4600
+ if (!signer?.signatures || signer.signatures.length === 0) return {
4601
+ ...opWithContext,
4602
+ operation
4603
+ };
4011
4604
  const deserializedSignatures = signer.signatures.map(deserializeSignature);
4012
4605
  const deserializedOperation = {
4013
- ...opWithContext.operation,
4606
+ ...operation,
4014
4607
  action: {
4015
- ...opWithContext.operation.action,
4608
+ ...operation.action,
4016
4609
  context: {
4017
- ...opWithContext.operation.action.context,
4610
+ ...operation.action.context,
4018
4611
  signer: {
4019
4612
  ...signer,
4020
4613
  signatures: deserializedSignatures
@@ -4041,7 +4634,7 @@ function deserializeOperationSignatures(opWithContext) {
4041
4634
  */
4042
4635
  function envelopesToSyncOperations(envelope, remoteName) {
4043
4636
  if (!envelope.operations || envelope.operations.length === 0) return [];
4044
- return batchOperationsByDocument(envelope.operations.map(deserializeOperationSignatures)).map((batch) => {
4637
+ return batchOperationsByDocument(envelope.operations.map(deserializeOperation)).map((batch) => {
4045
4638
  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
4639
  });
4047
4640
  }
@@ -4053,6 +4646,12 @@ const getLatestAppliedOrdinal = (syncOps) => {
4053
4646
  //#endregion
4054
4647
  //#region src/sync/channels/gql-req-channel.ts
4055
4648
  /**
4649
+ * Fields the auth projection added to the sync schema. A remote that predates
4650
+ * them rejects the whole query for naming one, so they are selected only while
4651
+ * the remote is known to serve them.
4652
+ */
4653
+ const DECISION_FIELDS = ["deniedReason", "errorType"];
4654
+ /**
4056
4655
  * GraphQL-based synchronization channel for network communication between reactors.
4057
4656
  */
4058
4657
  var GqlRequestChannel = class {
@@ -4079,6 +4678,8 @@ var GqlRequestChannel = class {
4079
4678
  isPushing = false;
4080
4679
  pendingDrain = false;
4081
4680
  receivingPages = false;
4681
+ /** Cleared for good the first time the remote rejects {@link DECISION_FIELDS}. */
4682
+ peerServesDecisionFields = true;
4082
4683
  isRecovering = false;
4083
4684
  connectionState = "connecting";
4084
4685
  /** Latest unrecoverable error was an auth rejection; cleared on connect. */
@@ -4257,7 +4858,7 @@ var GqlRequestChannel = class {
4257
4858
  const syncOps = [];
4258
4859
  for (const dl of deadLetters) {
4259
4860
  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)));
4861
+ syncOp.failed(new ChannelError(ChannelErrorSource.Outbox, new Error(dl.error), dl.errorType ?? void 0));
4261
4862
  syncOps.push(syncOp);
4262
4863
  }
4263
4864
  this.deadLetter.add(...syncOps);
@@ -4349,7 +4950,44 @@ var GqlRequestChannel = class {
4349
4950
  * Queries the remote GraphQL endpoint for sync envelopes.
4350
4951
  */
4351
4952
  async pollSyncEnvelopes(ackOrdinal, latestOrdinal) {
4352
- const query = `
4953
+ const variables = {
4954
+ channelId: this.channelId,
4955
+ outboxAck: ackOrdinal,
4956
+ outboxLatest: latestOrdinal
4957
+ };
4958
+ let response;
4959
+ try {
4960
+ response = await this.executeGraphQL(this.pollQuery(this.peerServesDecisionFields), variables);
4961
+ } catch (error) {
4962
+ if (!this.rejectsDecisionFields(error)) throw error;
4963
+ 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);
4964
+ this.peerServesDecisionFields = false;
4965
+ response = await this.executeGraphQL(this.pollQuery(false), variables);
4966
+ }
4967
+ return {
4968
+ envelopes: response.pollSyncEnvelopes.envelopes,
4969
+ ackOrdinal: response.pollSyncEnvelopes.ackOrdinal,
4970
+ deadLetters: response.pollSyncEnvelopes.deadLetters ?? [],
4971
+ hasMore: response.pollSyncEnvelopes.hasMore
4972
+ };
4973
+ }
4974
+ /**
4975
+ * True when the remote rejected the query for naming a field it does not
4976
+ * have. Selecting an unknown field fails validation for the whole query, so
4977
+ * an unhandled one takes the channel's polling down until the process
4978
+ * restarts rather than degrading.
4979
+ */
4980
+ rejectsDecisionFields(error) {
4981
+ if (!this.peerServesDecisionFields) return false;
4982
+ if (!(error instanceof GraphQLRequestError) || error.category !== "graphql") return false;
4983
+ return DECISION_FIELDS.some((field) => error.message.includes(field));
4984
+ }
4985
+ /**
4986
+ * The poll query. `withDecisionFields` selects the two fields added with the
4987
+ * auth projection; a remote on the previous schema is polled without them.
4988
+ */
4989
+ pollQuery(withDecisionFields) {
4990
+ return `
4353
4991
  query PollSyncEnvelopes($channelId: String!, $outboxAck: Int!, $outboxLatest: Int!) {
4354
4992
  pollSyncEnvelopes(channelId: $channelId, outboxAck: $outboxAck, outboxLatest: $outboxLatest) {
4355
4993
  envelopes {
@@ -4364,6 +5002,7 @@ var GqlRequestChannel = class {
4364
5002
  hash
4365
5003
  skip
4366
5004
  error
5005
+ ${withDecisionFields ? "deniedReason" : ""}
4367
5006
  id
4368
5007
  action {
4369
5008
  id
@@ -4407,6 +5046,7 @@ var GqlRequestChannel = class {
4407
5046
  deadLetters {
4408
5047
  documentId
4409
5048
  error
5049
+ ${withDecisionFields ? "errorType" : ""}
4410
5050
  jobId
4411
5051
  branch
4412
5052
  scopes
@@ -4416,18 +5056,6 @@ var GqlRequestChannel = class {
4416
5056
  }
4417
5057
  }
4418
5058
  `;
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
5059
  }
4432
5060
  /**
4433
5061
  * Registers or updates this channel on the remote server via GraphQL mutation.
@@ -4909,7 +5537,8 @@ function rowToDeadLetterRecord(row) {
4909
5537
  branch: row.branch,
4910
5538
  operations: row.operations,
4911
5539
  errorSource: row.error_source,
4912
- errorMessage: row.error_message
5540
+ errorMessage: row.error_message,
5541
+ errorType: row.error_type ?? "UNCLASSIFIED"
4913
5542
  };
4914
5543
  }
4915
5544
  function deadLetterRecordToRow(record) {
@@ -4923,7 +5552,8 @@ function deadLetterRecordToRow(record) {
4923
5552
  branch: record.branch,
4924
5553
  operations: JSON.stringify(record.operations),
4925
5554
  error_source: record.errorSource,
4926
- error_message: record.errorMessage
5555
+ error_message: record.errorMessage,
5556
+ error_type: record.errorType
4927
5557
  };
4928
5558
  }
4929
5559
  /**
@@ -4980,9 +5610,11 @@ var KyselySyncDeadLetterStorage = class {
4980
5610
  }
4981
5611
  async listQuarantinedDocumentIds(signal) {
4982
5612
  if (signal?.aborted) throw new Error("Operation aborted");
4983
- const rows = await this.db.selectFrom("sync_dead_letters").select("document_id").distinct().execute();
5613
+ const rows = await this.db.selectFrom("sync_dead_letters").select(["document_id", "error_type"]).distinct().execute();
4984
5614
  if (signal?.aborted) throw new Error("Operation aborted");
4985
- return rows.map((row) => row.document_id);
5615
+ const quarantined = /* @__PURE__ */ new Set();
5616
+ for (const row of rows) if (quarantinesDocument(row.error_type ?? "UNCLASSIFIED")) quarantined.add(row.document_id);
5617
+ return [...quarantined];
4986
5618
  }
4987
5619
  };
4988
5620
  //#endregion
@@ -5655,7 +6287,8 @@ var SyncManager = class {
5655
6287
  remote.channel.deadLetter.onAdded((syncOps) => {
5656
6288
  for (const syncOp of syncOps) {
5657
6289
  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);
6290
+ const errorType = syncOperationErrorType(syncOp.error);
6291
+ if (quarantinesDocument(errorType)) this.quarantinedDocumentIds.add(syncOp.documentId);
5659
6292
  const record = {
5660
6293
  id: syncOp.id,
5661
6294
  jobId: syncOp.jobId,
@@ -5666,7 +6299,8 @@ var SyncManager = class {
5666
6299
  branch: syncOp.branch,
5667
6300
  operations: syncOp.operations,
5668
6301
  errorSource: syncOp.error?.source ?? ChannelErrorSource.None,
5669
- errorMessage: syncOp.error?.error.message ?? "unknown"
6302
+ errorMessage: syncOp.error?.error.message ?? "unknown",
6303
+ errorType
5670
6304
  };
5671
6305
  this.deadLetterStorage.add(record).catch((err) => {
5672
6306
  this.logger.error("Failed to persist dead letter (@id, @error)", record.id, err instanceof Error ? err.message : String(err));
@@ -5676,7 +6310,8 @@ var SyncManager = class {
5676
6310
  jobId: record.jobId,
5677
6311
  remoteName: record.remoteName,
5678
6312
  documentId: record.documentId,
5679
- errorSource: record.errorSource
6313
+ errorSource: record.errorSource,
6314
+ errorType: record.errorType
5680
6315
  }).catch(() => {});
5681
6316
  }
5682
6317
  const items = remote.channel.deadLetter.items;
@@ -5703,7 +6338,7 @@ var SyncManager = class {
5703
6338
  const syncOps = [];
5704
6339
  for (const record of records) {
5705
6340
  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)));
6341
+ syncOp.failed(new ChannelError(record.errorSource, new Error(record.errorMessage), record.errorType));
5707
6342
  syncOps.push(syncOp);
5708
6343
  }
5709
6344
  remote.channel.deadLetter.add(...syncOps);
@@ -5784,8 +6419,7 @@ var SyncManager = class {
5784
6419
  if (completedJobInfo.status === JobStatus.FAILED) {
5785
6420
  const errorMessage = completedJobInfo.error?.message || "Unknown error";
5786
6421
  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);
6422
+ syncOp.failed(this.inboxFailure(completedJobInfo.error));
5789
6423
  remote.channel.deadLetter.add(syncOp);
5790
6424
  } else syncOp.executed();
5791
6425
  remote.channel.inbox.remove(syncOp);
@@ -5862,14 +6496,26 @@ var SyncManager = class {
5862
6496
  }
5863
6497
  if (this.isShutdown) return;
5864
6498
  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);
6499
+ syncOp.failed(this.inboxFailure(completedJobInfo.error));
5868
6500
  remote.channel.deadLetter.add(syncOp);
5869
6501
  } else syncOp.executed();
5870
6502
  remote.channel.inbox.remove(syncOp);
5871
6503
  }
5872
6504
  }
6505
+ /**
6506
+ * The dead letter for a load job the executor failed.
6507
+ *
6508
+ * The classification is passed explicitly because it cannot be recovered
6509
+ * downstream: the wrapper carries the failure's message, not the failure, so
6510
+ * deriving it from the wrapper's own name would classify every one of these
6511
+ * as unclassified and quarantine the document. A held auth operation must
6512
+ * keep syncing, because reconciling the two policies needs the traffic a
6513
+ * quarantine would stop.
6514
+ */
6515
+ inboxFailure(error) {
6516
+ const message = error?.message || "Unknown error";
6517
+ return new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${message}`), classifyJobFailure(error?.name ?? "Error"));
6518
+ }
5873
6519
  async updateOutbox(remote, ackOrdinal, mode = OutboxMode.Backfill, signal) {
5874
6520
  const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
5875
6521
  let maxOrdinal = ackOrdinal;
@@ -6190,7 +6836,7 @@ var Reactor = class {
6190
6836
  documentId: document.header.id,
6191
6837
  model: document.header.documentType,
6192
6838
  fromVersion: 0,
6193
- toVersion: document.state.document.version,
6839
+ toVersion: normalizeDocumentModelVersion(document.state.document?.version),
6194
6840
  initialState: document.state
6195
6841
  })];
6196
6842
  if (signer) actions = await signActions(actions, signer, signal);
@@ -6769,6 +7415,8 @@ var ReactorBuilder = class {
6769
7415
  }
6770
7416
  async buildModule() {
6771
7417
  if (!this.logger) this.logger = new ConsoleLogger(["reactor"]);
7418
+ const featureFlags = resolveFeatureFlags(this.executorConfig.featureFlags);
7419
+ 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
7420
  const resolvedSources = await resolveModelSources(this.documentModelSources);
6773
7421
  if (this.workerPool) {
6774
7422
  if (resolvedSources.manifest.length === 0) throw new Error("withWorkerPool requires at least one worker-importable document-model source ({ filePath } or { packageName }).");
@@ -6826,7 +7474,7 @@ var ReactorBuilder = class {
6826
7474
  await executorManager.start(executorStartCount);
6827
7475
  const readModelInstances = Array.from(new Set([...this.readModels]));
6828
7476
  const documentViewConsistencyTracker = new ConsistencyTracker();
6829
- const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker);
7477
+ const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
6830
7478
  try {
6831
7479
  await documentView.init();
6832
7480
  } catch (error) {
@@ -6852,6 +7500,7 @@ var ReactorBuilder = class {
6852
7500
  }
6853
7501
  for (const factory of this.readModelFactories) {
6854
7502
  const readModel = await factory({
7503
+ documentModelRegistry,
6855
7504
  operationIndex,
6856
7505
  writeCache,
6857
7506
  processorManagerConsistencyTracker
@@ -6869,9 +7518,15 @@ var ReactorBuilder = class {
6869
7518
  syncModule = this.syncBuilder.buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
6870
7519
  await syncModule.syncManager.startup();
6871
7520
  }
7521
+ let groupReevaluationTrigger;
7522
+ if (featureFlags.authGroups) {
7523
+ groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex);
7524
+ groupReevaluationTrigger.startup();
7525
+ }
6872
7526
  const module = {
6873
7527
  eventBus,
6874
7528
  documentModelRegistry,
7529
+ featureFlags,
6875
7530
  queue,
6876
7531
  jobTracker,
6877
7532
  executorManager,
@@ -6890,6 +7545,7 @@ var ReactorBuilder = class {
6890
7545
  processorManagerConsistencyTracker,
6891
7546
  syncModule,
6892
7547
  reactor,
7548
+ groupReevaluationTrigger,
6893
7549
  pools: this.instrumentedPools
6894
7550
  };
6895
7551
  if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
@@ -6962,9 +7618,9 @@ var ReactorBuilder = class {
6962
7618
  */
6963
7619
  async createDefaultWorkerFactory(numWorkers, db, signatureVerifier) {
6964
7620
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
6965
- import("./worker-handle-B1w03nRA.js"),
7621
+ import("./worker-handle-CrERzl8s.js"),
6966
7622
  import("./transport-ByGviWdZ.js"),
6967
- import("./worker-DBJOv8Gp.js").then((n) => n.n)
7623
+ import("./worker-BCMw-hBw.js").then((n) => n.n)
6968
7624
  ]);
6969
7625
  const poolConfig = {
6970
7626
  enabled: true,
@@ -6985,7 +7641,8 @@ var ReactorBuilder = class {
6985
7641
  poolConfig,
6986
7642
  db,
6987
7643
  signatureVerifier,
6988
- models
7644
+ models,
7645
+ executorConfig: this.executorConfig
6989
7646
  },
6990
7647
  logger,
6991
7648
  poolInstrumentation
@@ -7106,6 +7763,7 @@ var ReactorClientBuilder = class {
7106
7763
  subscriptionManager;
7107
7764
  jobAwaiter;
7108
7765
  documentModelLoader;
7766
+ readGate;
7109
7767
  /**
7110
7768
  * Sets the logger for the ReactorClient.
7111
7769
  * @param logger - The logger to use.
@@ -7158,6 +7816,29 @@ var ReactorClientBuilder = class {
7158
7816
  this.documentModelLoader = loader;
7159
7817
  return this;
7160
7818
  }
7819
+ /**
7820
+ * Overrides how reads are gated. A client built from a ReactorBuilder derives
7821
+ * this from that reactor's flags; one built from `withReactor` cannot, because
7822
+ * it is handed no flags and no registry, so it gates on the policy alone
7823
+ * unless a gate is supplied here.
7824
+ */
7825
+ withReadGate(readGate) {
7826
+ this.readGate = readGate;
7827
+ return this;
7828
+ }
7829
+ /**
7830
+ * The gate this reactor's flags call for. Below authEnforcement there is no
7831
+ * model to enforce -- the registered one ignores the auth scope -- so the
7832
+ * policy is evaluated on its own, which is what reads did before the model
7833
+ * existed. Group serving turns on with authGroups, because below it a
7834
+ * `{ group }` grant does not match, so a served roster is one no grant can
7835
+ * use.
7836
+ */
7837
+ resolveReadGate(reactorModule, documentView) {
7838
+ if (!reactorModule) return new BareReadGate();
7839
+ const model = readDecisionModel(reactorModule.featureFlags, reactorModule.documentModelRegistry);
7840
+ return model === void 0 ? new BareReadGate() : new ModelReadGate(model, documentView, reactorModule.featureFlags.authGroups, reactorModule.operationIndex, this.logger);
7841
+ }
7161
7842
  async build() {
7162
7843
  return (await this.buildModule()).client;
7163
7844
  }
@@ -7187,7 +7868,7 @@ var ReactorClientBuilder = class {
7187
7868
  const subscriptionManager = this.subscriptionManager ?? reactorModule?.subscriptionManager ?? new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
7188
7869
  const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
7189
7870
  return {
7190
- client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView),
7871
+ client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView)),
7191
7872
  reactor,
7192
7873
  eventBus,
7193
7874
  documentIndexer,
@@ -7225,6 +7906,43 @@ function driveIdFromUrl(url) {
7225
7906
  return url.split("/").pop() ?? "";
7226
7907
  }
7227
7908
  //#endregion
7909
+ //#region src/decision/stream-order.ts
7910
+ /**
7911
+ * The first pair of effective operations whose stored order contradicts their
7912
+ * timestamps, or undefined when the stream is in position order.
7913
+ *
7914
+ * Such a stream cannot be walked, and the auth stream is never reshuffled once
7915
+ * the monotonic rule is on, so run this before enabling enforcement on a fleet.
7916
+ *
7917
+ * `requireStrict` additionally rejects a tie, which is what the auth stream's
7918
+ * monotonic rule requires and what the walk alone does not care about.
7919
+ */
7920
+ function firstOutOfOrderPair(operations, options) {
7921
+ const requireStrict = options?.requireStrict ?? false;
7922
+ const effective = garbageCollect(sortOperations([...operations]));
7923
+ for (let i = 1; i < effective.length; i++) {
7924
+ const previous = effective[i - 1];
7925
+ const current = effective[i];
7926
+ const previousAt = Date.parse(previous.timestampUtcMs);
7927
+ const currentAt = Date.parse(current.timestampUtcMs);
7928
+ if (currentAt < previousAt) return {
7929
+ previous,
7930
+ current,
7931
+ kind: "descending"
7932
+ };
7933
+ if (requireStrict && currentAt === previousAt) return {
7934
+ previous,
7935
+ current,
7936
+ kind: "tied"
7937
+ };
7938
+ }
7939
+ }
7940
+ //#endregion
7941
+ //#region src/read-models/interfaces.ts
7942
+ function supportsLiveReadModelRegistration(coordinator) {
7943
+ return "addReadModel" in coordinator && typeof coordinator.addReadModel === "function";
7944
+ }
7945
+ //#endregion
7228
7946
  //#region src/admin/passthrough-keyframe-store.ts
7229
7947
  const passthroughKeyframeStore = {
7230
7948
  putKeyframe: () => Promise.resolve(),
@@ -7250,6 +7968,7 @@ var DocumentIntegrityService = class {
7250
7968
  async validateDocument(documentId, branch = "main", signal) {
7251
7969
  const keyframeIssues = [];
7252
7970
  const snapshotIssues = [];
7971
+ const streamOrderIssues = await this.findStreamOrderIssues(documentId, branch, signal);
7253
7972
  const replayCache = new KyselyWriteCache(passthroughKeyframeStore, this.operationStore, this.documentModelRegistry, {
7254
7973
  maxDocuments: 1,
7255
7974
  ringBufferSize: 1,
@@ -7276,9 +7995,10 @@ var DocumentIntegrityService = class {
7276
7995
  } catch {
7277
7996
  return {
7278
7997
  documentId,
7279
- isConsistent: keyframeIssues.length === 0,
7998
+ isConsistent: keyframeIssues.length === 0 && streamOrderIssues.length === 0,
7280
7999
  keyframeIssues,
7281
- snapshotIssues
8000
+ snapshotIssues,
8001
+ streamOrderIssues
7282
8002
  };
7283
8003
  }
7284
8004
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
@@ -7304,9 +8024,10 @@ var DocumentIntegrityService = class {
7304
8024
  }
7305
8025
  return {
7306
8026
  documentId,
7307
- isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0,
8027
+ isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0 && streamOrderIssues.length === 0,
7308
8028
  keyframeIssues,
7309
- snapshotIssues
8029
+ snapshotIssues,
8030
+ streamOrderIssues
7310
8031
  };
7311
8032
  }
7312
8033
  async rebuildKeyframes(documentId, branch = "main", signal) {
@@ -7328,12 +8049,26 @@ var DocumentIntegrityService = class {
7328
8049
  scopesInvalidated: scopes.length
7329
8050
  };
7330
8051
  }
8052
+ async findStreamOrderIssues(documentId, branch, signal) {
8053
+ const scopes = await this.discoverScopes(documentId, branch, signal);
8054
+ const issues = [];
8055
+ for (const scope of scopes) {
8056
+ throwIfAborted(signal);
8057
+ const pair = firstOutOfOrderPair((await this.operationStore.getSince(documentId, scope, branch, -1, void 0, void 0, signal)).results, { requireStrict: scope === "auth" });
8058
+ if (pair !== void 0) issues.push({
8059
+ scope,
8060
+ branch,
8061
+ ...pair
8062
+ });
8063
+ }
8064
+ return issues;
8065
+ }
7331
8066
  async discoverScopes(documentId, branch, signal) {
7332
8067
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
7333
8068
  return Object.keys(revisions.revision);
7334
8069
  }
7335
8070
  };
7336
8071
  //#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 };
8072
+ export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, BareReadGate, 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, ModelReadGate, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RetryAccounting, RevisionMismatchError, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createMutableShutdownStatus, createRelationalDb, decideAtHead, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, updateRelationshipAction, upgradeDocumentAction, workerEntryPath };
7338
8073
 
7339
8074
  //# sourceMappingURL=index.js.map