@powerhousedao/reactor 6.2.2-dev.49 → 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 OptimisticLockError, B as ExcessiveReshuffleError, C as DocumentMetaCache, D as APPEND_CONDITION_FAILED_PREFIX, E as CollectionMembershipCache, F as ModuleNotFoundError, G as throwIfAborted, H as UpgradePreconditionFailedError, I as AuthTimestampNotMonotonicError, L as AuthorizationDeniedError, M as DuplicateManifestError, N as DuplicateModuleError, O as AppendConditionFailedError, P as InvalidModuleError, R as DocumentDeletedError, S as KyselyOperationIndex, T as createEmptyConsistencyToken, U as matchesScope, V as InvalidOperationTimestampError, W as parsePagingOptions, _ as KyselyExecutionScope, a as createForwardingPoolInstrumentation, b as EventBus, c as KyselyKeyframeStore, d as DriveCollectionId, f as decideAtHead, g as authDecisionModel, h as buildDecisionModel, i as runMigrations, j as RevisionMismatchError, k as DuplicateOperationError, l as DocumentModelRegistry, m as documentDecisionModel, n as REACTOR_SCHEMA, o as instrumentPgPool, p as selectDecisionModel, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as FLAG_PREREQUISITES, w as createConsistencyToken, x as KyselyWriteCache, y as validateFeatureFlags, z as DocumentNotFoundError } from "./drive-container-types-h3M1AK3K.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-BfdCgjG-.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-CbtYMIju.js";
7
- import { DowngradeNotSupportedError, UnsupportedDocumentModelVersionError, actions, actions as documentActions, createPresignedHeader, decide, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, sortOperations } 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";
@@ -393,7 +393,7 @@ var DriveClient = class {
393
393
  }] }, signal);
394
394
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.client.waitForJob(job, signal)));
395
395
  for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
396
- return this.reactor.get(documentId);
396
+ return this.client.get(documentId, void 0, signal);
397
397
  }
398
398
  async addFolder(driveIdentifier, name, parentFolder, signal) {
399
399
  this.logger.verbose("drives.addFolder(@driveIdentifier, @name, @parentFolder)", driveIdentifier, name, parentFolder);
@@ -402,7 +402,7 @@ var DriveClient = class {
402
402
  id: folderId,
403
403
  name,
404
404
  parentFolder
405
- })], signal)).state.global.nodes.find((n) => n.id === folderId);
405
+ })], signal)).state.global?.nodes.find((n) => n.id === folderId);
406
406
  if (!node || !isFolderNode(node)) throw new Error("Folder creation failed");
407
407
  return node;
408
408
  }
@@ -429,7 +429,7 @@ var DriveClient = class {
429
429
  const node = (await this.client.execute(driveIdentifier, "main", [updateNode({
430
430
  id: nodeId,
431
431
  name
432
- })], signal)).state.global.nodes.find((n) => n.id === nodeId);
432
+ })], signal)).state.global?.nodes.find((n) => n.id === nodeId);
433
433
  if (!node) throw new Error("Node missing from drive after rename");
434
434
  return node;
435
435
  }
@@ -686,20 +686,267 @@ let DocumentChangeType = /* @__PURE__ */ function(DocumentChangeType) {
686
686
  return DocumentChangeType;
687
687
  }({});
688
688
  //#endregion
689
- //#region src/client/util.ts
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
+ */
690
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
691
944
  function authSubjectFromSigner(signer) {
692
945
  return {
693
946
  address: signer.user?.address,
694
947
  key: signer.app?.key
695
948
  };
696
949
  }
697
- function canReadScope(auth, subject, scope) {
698
- return ALWAYS_READABLE_SCOPES.has(scope) || decide(auth, subject, {
699
- verb: "read",
700
- scope
701
- }) === "allow";
702
- }
703
950
  function withAuthScope(view) {
704
951
  if (view?.scopes && view.scopes.length > 0) return {
705
952
  ...view,
@@ -707,11 +954,14 @@ function withAuthScope(view) {
707
954
  };
708
955
  return view;
709
956
  }
710
- function filterReadableScopes(document, subject) {
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) {
711
963
  const state = document.state;
712
964
  if (!state) return document;
713
- const auth = document.state.auth;
714
- const readable = (scope) => canReadScope(auth, subject, scope);
715
965
  return {
716
966
  ...document,
717
967
  state: keepReadableScopes(state, readable),
@@ -744,8 +994,9 @@ var ReactorClient = class {
744
994
  jobAwaiter;
745
995
  documentIndexer;
746
996
  documentView;
997
+ readGate;
747
998
  drives;
748
- constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView) {
999
+ constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate()) {
749
1000
  this.logger = logger;
750
1001
  this.reactor = reactor;
751
1002
  this.signer = signer;
@@ -753,6 +1004,7 @@ var ReactorClient = class {
753
1004
  this.jobAwaiter = jobAwaiter;
754
1005
  this.documentIndexer = documentIndexer;
755
1006
  this.documentView = documentView;
1007
+ this.readGate = readGate;
756
1008
  this.drives = new DriveClient(this, logger, reactor, signer);
757
1009
  this.logger.verbose("ReactorClient initialized");
758
1010
  }
@@ -760,6 +1012,25 @@ var ReactorClient = class {
760
1012
  return subject ?? authSubjectFromSigner(this.signer);
761
1013
  }
762
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
+ }
1033
+ /**
763
1034
  * Retrieves a list of document model modules.
764
1035
  */
765
1036
  async getDocumentModelModules(namespace, paging, signal) {
@@ -810,7 +1081,8 @@ var ReactorClient = class {
810
1081
  */
811
1082
  async get(identifier, view, signal) {
812
1083
  this.logger.verbose("get(@identifier, @view)", identifier, view);
813
- return filterReadableScopes(await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal), this.readSubject(view?.subject));
1084
+ const document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
1085
+ return this.gateDocument(document, view, signal);
814
1086
  }
815
1087
  /**
816
1088
  * Resolves an identifier (id or slug) to the canonical document id, using the
@@ -826,12 +1098,8 @@ var ReactorClient = class {
826
1098
  async getOperations(documentIdentifier, view, filter, paging, signal) {
827
1099
  this.logger.verbose("getOperations(@documentIdentifier, @view, @filter, @paging)", documentIdentifier, view, filter, paging);
828
1100
  const documentId = await this.documentView.resolveIdOrSlug(documentIdentifier, view, void 0, signal);
829
- const authDoc = await this.reactor.getByIdOrSlug(documentId, {
830
- scopes: ["auth"],
831
- branch: view?.branch
832
- }, void 0, signal);
833
- const subject = this.readSubject(view?.subject);
834
- const canRead = (scope) => canReadScope(authDoc?.state.auth, subject, scope);
1101
+ const gated = await this.reactor.getByIdOrSlug(documentId, withAuthScope(view), void 0, signal);
1102
+ const canRead = await this.readableScopes(gated, view, signal);
835
1103
  if (paging?.cursor && isCompositeCursor(paging.cursor)) return this.getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead);
836
1104
  const operationsByScope = await this.reactor.getOperations(documentId, view, filter, paging, void 0, signal);
837
1105
  for (const scope of Object.keys(operationsByScope)) if (!canRead(scope)) delete operationsByScope[scope];
@@ -925,10 +1193,9 @@ var ReactorClient = class {
925
1193
  async find(search, view, paging, signal) {
926
1194
  this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
927
1195
  const results = await this.reactor.find(search, withAuthScope(view), paging, void 0, signal);
928
- const readSubject = this.readSubject(view?.subject);
929
1196
  return {
930
1197
  ...results,
931
- results: results.results.map((doc) => filterReadableScopes(doc, readSubject))
1198
+ results: await Promise.all(results.results.map(async (doc) => filterReadableScopes(doc, await this.readableScopes(doc, view, signal))))
932
1199
  };
933
1200
  }
934
1201
  /**
@@ -982,7 +1249,8 @@ var ReactorClient = class {
982
1249
  const batchResult = await this.reactor.executeBatch({ jobs }, signal);
983
1250
  const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
984
1251
  for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
985
- return await this.reactor.get(documentId);
1252
+ const created = await this.reactor.get(documentId);
1253
+ return this.gateDocument(created, void 0, signal);
986
1254
  }
987
1255
  /**
988
1256
  * Creates an empty document and waits for completion
@@ -1029,7 +1297,7 @@ var ReactorClient = class {
1029
1297
  const fromVersion = normalizeDocumentModelVersion(document.state.document?.version);
1030
1298
  let targetVersion = toVersion;
1031
1299
  if (targetVersion === void 0) targetVersion = normalizeDocumentModelVersion((await this.getDocumentModelModule(documentType)).version);
1032
- if (targetVersion === fromVersion) return document;
1300
+ if (targetVersion === fromVersion) return this.gateDocument(document, { branch }, signal);
1033
1301
  if (targetVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, targetVersion);
1034
1302
  const signedActions = await signActions([upgradeDocumentAction({
1035
1303
  documentId,
@@ -1040,7 +1308,10 @@ var ReactorClient = class {
1040
1308
  })], this.signer, signal);
1041
1309
  const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
1042
1310
  const completedJob = await this.waitForJob(jobInfo, signal);
1043
- if (completedJob.status !== JobStatus.FAILED) return await this.reactor.getByIdOrSlug(documentId, { branch }, completedJob.consistencyToken, 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
+ }
1044
1315
  if (completedJob.error?.name !== "UpgradePreconditionFailedError") throw new Error(completedJob.error?.message);
1045
1316
  lastConflictMessage = completedJob.error.message;
1046
1317
  }
@@ -1066,7 +1337,8 @@ var ReactorClient = class {
1066
1337
  const completedJob = await this.waitForJob(jobInfo, signal);
1067
1338
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1068
1339
  const view = { branch };
1069
- 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);
1070
1342
  }
1071
1343
  /**
1072
1344
  * Submits a list of actions to a document
@@ -1110,7 +1382,8 @@ var ReactorClient = class {
1110
1382
  const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1111
1383
  const completedJob = await this.waitForJob(jobInfo, signal);
1112
1384
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1113
- 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);
1114
1387
  }
1115
1388
  /**
1116
1389
  * Removes a relationship between two documents and waits for completion.
@@ -1120,7 +1393,8 @@ var ReactorClient = class {
1120
1393
  const jobInfo = await this.reactor.removeRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1121
1394
  const completedJob = await this.waitForJob(jobInfo, signal);
1122
1395
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1123
- 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);
1124
1398
  }
1125
1399
  /**
1126
1400
  * Moves a relationship from one source document to another and waits for completion.
@@ -1133,9 +1407,11 @@ var ReactorClient = class {
1133
1407
  const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1134
1408
  const addCompletedJob = await this.waitForJob(addJobInfo, signal);
1135
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);
1136
1412
  return {
1137
- source: await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal),
1138
- 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)
1139
1415
  };
1140
1416
  }
1141
1417
  async loadBatch(request, signal) {
@@ -1208,34 +1484,56 @@ var ReactorClient = class {
1208
1484
  */
1209
1485
  subscribe(search, callback, view) {
1210
1486
  this.logger.verbose("subscribe(@search, @view)", search, view);
1211
- const subject = this.readSubject(view?.subject);
1212
- const readable = (document) => filterReadableScopes(document, subject);
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
+ };
1213
1513
  const unsubscribeCreated = this.subscriptionManager.onDocumentCreated((result) => {
1214
- (async () => {
1215
- try {
1216
- const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
1217
- callback({
1218
- type: DocumentChangeType.Created,
1219
- documents: documents.map(readable)
1220
- });
1221
- } catch {}
1222
- })();
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
+ })());
1223
1521
  }, search);
1224
1522
  const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
1225
- callback({
1523
+ deliver({
1226
1524
  type: DocumentChangeType.Deleted,
1227
1525
  documents: [],
1228
1526
  context: { childId: documentIds[0] }
1229
1527
  });
1230
1528
  }, search);
1231
1529
  const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
1232
- callback({
1530
+ deliver((async () => ({
1233
1531
  type: DocumentChangeType.Updated,
1234
- documents: result.results.map(readable)
1235
- });
1532
+ documents: await Promise.all(result.results.map(readable))
1533
+ }))());
1236
1534
  }, search, view);
1237
1535
  const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
1238
- callback({
1536
+ deliver({
1239
1537
  type: changeType === RelationshipChangeType.Added ? DocumentChangeType.ChildAdded : DocumentChangeType.ChildRemoved,
1240
1538
  documents: [],
1241
1539
  context: {
@@ -1245,6 +1543,7 @@ var ReactorClient = class {
1245
1543
  });
1246
1544
  }, search);
1247
1545
  return () => {
1546
+ disposed = true;
1248
1547
  unsubscribeCreated();
1249
1548
  unsubscribeDeleted();
1250
1549
  unsubscribeUpdated();
@@ -7116,7 +7415,7 @@ var ReactorBuilder = class {
7116
7415
  }
7117
7416
  async buildModule() {
7118
7417
  if (!this.logger) this.logger = new ConsoleLogger(["reactor"]);
7119
- validateFeatureFlags(this.executorConfig.featureFlags ?? {}, FLAG_PREREQUISITES);
7418
+ const featureFlags = resolveFeatureFlags(this.executorConfig.featureFlags);
7120
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");
7121
7420
  const resolvedSources = await resolveModelSources(this.documentModelSources);
7122
7421
  if (this.workerPool) {
@@ -7175,7 +7474,7 @@ var ReactorBuilder = class {
7175
7474
  await executorManager.start(executorStartCount);
7176
7475
  const readModelInstances = Array.from(new Set([...this.readModels]));
7177
7476
  const documentViewConsistencyTracker = new ConsistencyTracker();
7178
- const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, this.executorConfig.featureFlags?.documentDecisions ?? false);
7477
+ const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
7179
7478
  try {
7180
7479
  await documentView.init();
7181
7480
  } catch (error) {
@@ -7220,13 +7519,14 @@ var ReactorBuilder = class {
7220
7519
  await syncModule.syncManager.startup();
7221
7520
  }
7222
7521
  let groupReevaluationTrigger;
7223
- if (this.executorConfig.featureFlags?.authGroups) {
7522
+ if (featureFlags.authGroups) {
7224
7523
  groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex);
7225
7524
  groupReevaluationTrigger.startup();
7226
7525
  }
7227
7526
  const module = {
7228
7527
  eventBus,
7229
7528
  documentModelRegistry,
7529
+ featureFlags,
7230
7530
  queue,
7231
7531
  jobTracker,
7232
7532
  executorManager,
@@ -7320,7 +7620,7 @@ var ReactorBuilder = class {
7320
7620
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
7321
7621
  import("./worker-handle-CrERzl8s.js"),
7322
7622
  import("./transport-ByGviWdZ.js"),
7323
- import("./worker-CbtYMIju.js").then((n) => n.n)
7623
+ import("./worker-BCMw-hBw.js").then((n) => n.n)
7324
7624
  ]);
7325
7625
  const poolConfig = {
7326
7626
  enabled: true,
@@ -7463,6 +7763,7 @@ var ReactorClientBuilder = class {
7463
7763
  subscriptionManager;
7464
7764
  jobAwaiter;
7465
7765
  documentModelLoader;
7766
+ readGate;
7466
7767
  /**
7467
7768
  * Sets the logger for the ReactorClient.
7468
7769
  * @param logger - The logger to use.
@@ -7515,6 +7816,29 @@ var ReactorClientBuilder = class {
7515
7816
  this.documentModelLoader = loader;
7516
7817
  return this;
7517
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
+ }
7518
7842
  async build() {
7519
7843
  return (await this.buildModule()).client;
7520
7844
  }
@@ -7544,7 +7868,7 @@ var ReactorClientBuilder = class {
7544
7868
  const subscriptionManager = this.subscriptionManager ?? reactorModule?.subscriptionManager ?? new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
7545
7869
  const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
7546
7870
  return {
7547
- 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)),
7548
7872
  reactor,
7549
7873
  eventBus,
7550
7874
  documentIndexer,
@@ -7745,6 +8069,6 @@ var DocumentIntegrityService = class {
7745
8069
  }
7746
8070
  };
7747
8071
  //#endregion
7748
- export { APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, 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, 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, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, 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 };
7749
8073
 
7750
8074
  //# sourceMappingURL=index.js.map