@powerhousedao/reactor 6.2.3-dev.25 → 6.2.3-dev.27

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,6 +1,6 @@
1
- import { $ as DocumentNotFoundError, A as ModuleNotFoundError, B as authDecisionModel, C as DocumentMetaCache, D as DuplicateManifestError, E as JobExecutorEventTypes, F as summarizeSubmittedActions, G as DocumentExistence, H as APPEND_CONDITION_FAILED_PREFIX, I as targetDocumentId, J as RevisionMismatchError, K as DuplicateOperationError, L as decideAtHead, M as createConsistencyToken, N as createEmptyConsistencyToken, O as DuplicateModuleError, P as submittedActionIds, Q as DocumentDeletedError, R as selectDecisionModel, S as KyselyOperationIndex, T as DEFAULT_DEFERRED_JOB_TTL_MS, U as AppendConditionFailedError, V as buildDecisionModel, W as DocumentAlreadyExistsError, X as AuthTimestampNotMonotonicError, Y as AuthEnforcementDisabledError, Z as AuthorizationDeniedError, _ as DriveCollectionId, a as DEFAULT_DRIVE_CONTAINER_TYPES, at as EventBusAggregateError, b as EventBus, c as runMigrations, ct as parsePagingOptions, d as KyselyOperationStore, dt as __exportAll, et as ExcessiveReshuffleError, f as KyselyKeyframeStore, g as PassthroughSigner, h as verifyActionSignature, it as UpgradePreconditionFailedError, j as GATED_DOCUMENT_ACTIONS, k as InvalidModuleError, l as createForwardingPoolInstrumentation, lt as throwIfAborted, m as SimpleJobExecutor, n as errorToInfo, nt as InvalidSignatureError, o as REACTOR_SCHEMA, ot as ReactorEventTypes, p as DocumentModelRegistry, q as OptimisticLockError, r as sanitizeArg, rt as RelationshipNotFoundError, s as getMigrationStatus, st as matchesScope, t as createForwardingLogger, tt as InvalidOperationTimestampError, u as instrumentPgPool, v as KyselyExecutionScope, w as CollectionMembershipCache, x as KyselyWriteCache, y as resolveFeatureFlags, z as documentDecisionModel } from "./forwarding-logger-CSsCJ38f.js";
1
+ import { $ as DocumentNotFoundError, A as ModuleNotFoundError, B as authDecisionModel, C as DocumentMetaCache, D as DuplicateManifestError, E as JobExecutorEventTypes, F as summarizeSubmittedActions, G as DocumentExistence, H as APPEND_CONDITION_FAILED_PREFIX, I as targetDocumentId, J as RevisionMismatchError, K as DuplicateOperationError, L as decideAtHead, M as createConsistencyToken, N as createEmptyConsistencyToken, O as DuplicateModuleError, P as submittedActionIds, Q as DocumentDeletedError, R as selectDecisionModel, S as KyselyOperationIndex, T as DEFAULT_DEFERRED_JOB_TTL_MS, U as AppendConditionFailedError, V as buildDecisionModel, W as DocumentAlreadyExistsError, X as AuthTimestampNotMonotonicError, Y as AuthEnforcementDisabledError, Z as AuthorizationDeniedError, _ as DriveCollectionId, a as DEFAULT_DRIVE_CONTAINER_TYPES, at as EventBusAggregateError, b as EventBus, c as runMigrations, ct as parsePagingOptions, d as KyselyOperationStore, dt as __exportAll, et as ExcessiveReshuffleError, f as KyselyKeyframeStore, g as PassthroughSigner, h as verifyActionSignature, it as UpgradePreconditionFailedError, j as GATED_DOCUMENT_ACTIONS, k as InvalidModuleError, l as createForwardingPoolInstrumentation, lt as throwIfAborted, m as SimpleJobExecutor, n as errorToInfo, nt as InvalidSignatureError, o as REACTOR_SCHEMA, ot as ReactorEventTypes, p as DocumentModelRegistry, q as OptimisticLockError, r as sanitizeArg, rt as RelationshipNotFoundError, s as getMigrationStatus, st as matchesScope, t as createForwardingLogger, tt as InvalidOperationTimestampError, u as instrumentPgPool, v as KyselyExecutionScope, w as CollectionMembershipCache, x as KyselyWriteCache, y as resolveFeatureFlags, z as documentDecisionModel } from "./forwarding-logger-CnuVDVze.js";
2
2
  import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-BM_ULVWd.js";
3
- import { a as KyselyDocumentView, c as ReadModelCoordinator, d as DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, f as defaultReadModelIndexingConfig, i as DeletedDocumentRead, l as BaseReadModel, n as ConsistencyTracker, o as DOCUMENT_INDEXER_READ_MODEL, p as unchunkedReadModelIndexingConfig, r as makeConsistencyKey, s as DOCUMENT_VIEW_READ_MODEL, t as KyselyDocumentIndexer, u as DEFAULT_COMMIT_CHUNK_SIZE } from "./document-indexer-zbdzqZ3K.js";
3
+ import { a as KyselyDocumentView, c as ReadModelCoordinator, d as DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, f as defaultReadModelIndexingConfig, i as DeletedDocumentRead, l as BaseReadModel, n as ConsistencyTracker, o as DOCUMENT_INDEXER_READ_MODEL, p as unchunkedReadModelIndexingConfig, r as makeConsistencyKey, s as DOCUMENT_VIEW_READ_MODEL, t as KyselyDocumentIndexer, u as DEFAULT_COMMIT_CHUNK_SIZE } from "./document-indexer-BVTGbEB0.js";
4
4
  import { DEFAULT_SIGNATURE_POLICY, DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actionSignerIdentity, actionSigningTarget, actions, actions as documentActions, createCopyHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, requestedSignaturePolicy, sortOperations, toTransportAction, withSignaturePolicy } from "@powerhousedao/shared/document-model";
5
5
  import { addFile, addFolder, copyNode, deleteNode, driveCreateDocument, generateNodesCopy, getDescendants, handleTargetNameCollisions, isFileNode, isFolderNode, moveNode, updateNode } from "@powerhousedao/shared/document-drive";
6
6
  import { v4 } from "uuid";
@@ -504,8 +504,7 @@ var DriveClient = class {
504
504
  return {
505
505
  results: filtered.slice(startIndex, endIndex),
506
506
  options: effective,
507
- ...endIndex < filtered.length ? { nextCursor: String(endIndex) } : {},
508
- totalCount: filtered.length
507
+ ...endIndex < filtered.length ? { nextCursor: String(endIndex) } : {}
509
508
  };
510
509
  }
511
510
  /**
@@ -722,6 +721,53 @@ let DocumentChangeType = /* @__PURE__ */ function(DocumentChangeType) {
722
721
  * argument covers. Grants gate domain-scope reads only.
723
722
  */
724
723
  const ALWAYS_READABLE_SCOPES = /* @__PURE__ */ new Set(["auth", "document"]);
724
+ const DECLARED_DOMAIN_SCOPES = ["global", "local"];
725
+ /**
726
+ * The scopes grants gate: the declared ones, and any other the document holds.
727
+ * A document fetched for a narrowed view holds only that view's scopes.
728
+ */
729
+ function domainScopesOf(document) {
730
+ const state = document.state;
731
+ const held = Object.keys(state ?? {}).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope));
732
+ return [.../* @__PURE__ */ new Set([...DECLARED_DOMAIN_SCOPES, ...held])];
733
+ }
734
+ function allowsSomeDomainScope(document, readable) {
735
+ return domainScopesOf(document).some(readable);
736
+ }
737
+ /** A document with no state at all is not policied, so nothing withholds it. */
738
+ function refusesEveryDomainScope(document, readable) {
739
+ return document.state !== void 0 && !allowsSomeDomainScope(document, readable);
740
+ }
741
+ /**
742
+ * The domain scopes the document does not hold whose read decision reads their
743
+ * own state. A condition's `doc.<scope>` path resolves only for that scope, so
744
+ * every other scope is decided on its name and the policy alone.
745
+ */
746
+ function unheldScopesReadOnState(document) {
747
+ const grants = authOf(document)?.grants ?? [];
748
+ const onState = /* @__PURE__ */ new Set();
749
+ for (const grant of grants) {
750
+ collectStateScopes(grant.where, onState);
751
+ collectStateScopes(grant.principal, onState);
752
+ }
753
+ if (onState.size === 0) return [];
754
+ const held = document.state ?? {};
755
+ return domainScopesOf(document).filter((scope) => onState.has(scope) && !(scope in held));
756
+ }
757
+ function collectStateScopes(node, into) {
758
+ if (Array.isArray(node)) {
759
+ for (const child of node) collectStateScopes(child, into);
760
+ return;
761
+ }
762
+ if (typeof node !== "object" || node === null) return;
763
+ const { attr } = node;
764
+ if (typeof attr === "string") {
765
+ const [root, scope] = attr.split(".");
766
+ if (root === "doc" && scope) into.add(scope);
767
+ return;
768
+ }
769
+ for (const child of Object.values(node)) collectStateScopes(child, into);
770
+ }
725
771
  /** What a gate does with an uninitialized policy when told nothing. */
726
772
  const OPEN_BY_DEFAULT = { withholdUninitialized: false };
727
773
  /**
@@ -784,8 +830,26 @@ var BareReadGate = class {
784
830
  scope
785
831
  }) === "allow");
786
832
  }
833
+ prepare(document) {
834
+ return Promise.resolve((subject) => this.scopePredicate(document, subject));
835
+ }
787
836
  };
788
837
  /**
838
+ * Rethrows a failed read unless the document is confirmed not live. The read
839
+ * side reports absence as a plain Error, so absence is checked rather than
840
+ * inferred from the message, and a failed check surfaces the read's own error.
841
+ */
842
+ async function assertAbsent(documentView, documentId, error, signal) {
843
+ if (DocumentNotFoundError.isError(error)) return;
844
+ let exists;
845
+ try {
846
+ exists = await documentView.exists([documentId], "LiveOnly", void 0, signal);
847
+ } catch {
848
+ throw error;
849
+ }
850
+ if (exists[0]) throw error;
851
+ }
852
+ /**
789
853
  * Answers a stream read from the document already fetched, and anything else
790
854
  * through the read side.
791
855
  *
@@ -819,20 +883,10 @@ var SeededStateReader = class {
819
883
  scopes: [scope]
820
884
  }, void 0, signal);
821
885
  } catch (error) {
822
- await this.assertAbsent(documentId, error, signal);
886
+ await assertAbsent(this.documentView, documentId, error, signal);
823
887
  throw new DocumentNotFoundError(documentId);
824
888
  }
825
889
  }
826
- async assertAbsent(documentId, error, signal) {
827
- if (DocumentNotFoundError.isError(error)) return;
828
- let exists;
829
- try {
830
- exists = await this.documentView.exists([documentId], "LiveOnly", void 0, signal);
831
- } catch {
832
- throw error;
833
- }
834
- if (exists[0]) throw error;
835
- }
836
890
  };
837
891
  /**
838
892
  * Evaluates a read against the registered decision model, built at the stream
@@ -868,10 +922,18 @@ var ModelReadGate = class {
868
922
  * group's other scopes are its own business and stay behind its own grants.
869
923
  */
870
924
  async scopePredicate(document, subject, branch, signal) {
871
- const own = await this.ownPolicyPredicate(document, subject, branch, signal);
872
- if (!this.servesGroup(document) || own(GROUP_MEMBERSHIP_SCOPE)) return own;
873
- const audience = await this.servesGroupTo(document.header.id, subject, signal);
874
- return (scope) => own(scope) || audience && scope === GROUP_MEMBERSHIP_SCOPE;
925
+ return (await this.prepare(document, branch, signal))(subject);
926
+ }
927
+ async prepare(document, branch, signal) {
928
+ const policy = await this.ownPolicy(document, branch, signal);
929
+ if (!this.servesGroup(document)) return (subject) => Promise.resolve(policy(subject));
930
+ const referencers = this.referencerReads(document.header.id, signal);
931
+ return async (subject) => {
932
+ const own = policy(subject);
933
+ if (own(GROUP_MEMBERSHIP_SCOPE)) return own;
934
+ const audience = await this.servesGroupTo(subject, referencers);
935
+ return (scope) => own(scope) || audience && scope === GROUP_MEMBERSHIP_SCOPE;
936
+ };
875
937
  }
876
938
  /**
877
939
  * Whether the subject is served this group because a policy names it.
@@ -905,11 +967,8 @@ var ModelReadGate = class {
905
967
  * running with nobody awaiting it, which is where unhandled rejections come
906
968
  * from.
907
969
  */
908
- async servesGroupTo(groupId, subject, signal) {
909
- if (!this.operationIndex) return false;
910
- const referencers = await this.operationIndex.getGroupReferencers(groupId, signal);
911
- const examined = referencers.slice(0, MAX_EXAMINED_REFERENCERS);
912
- if (examined.length < referencers.length) this.logger?.warn(`Group ${groupId} is referenced by ${referencers.length} documents; only the first ${MAX_EXAMINED_REFERENCERS} decide whether it is served`);
970
+ async servesGroupTo(subject, referencers) {
971
+ const examined = await referencers.examined();
913
972
  const walk = {
914
973
  next: 0,
915
974
  served: false,
@@ -920,7 +979,7 @@ var ModelReadGate = class {
920
979
  const referencerId = examined[walk.next++];
921
980
  let serves = false;
922
981
  try {
923
- serves = await this.servesThrough(referencerId, subject, signal);
982
+ serves = (await referencers.serves(referencerId))(subject);
924
983
  } catch (error) {
925
984
  walk.failure ??= Error.isError(error) ? error : /* @__PURE__ */ new Error(`Probing referencer ${referencerId} failed`);
926
985
  }
@@ -931,21 +990,44 @@ var ModelReadGate = class {
931
990
  if (!walk.served && walk.failure) throw walk.failure;
932
991
  return walk.served;
933
992
  }
993
+ /** Each read made once, however many subjects the walk runs for. */
994
+ referencerReads(groupId, signal) {
995
+ let examined;
996
+ const serves = /* @__PURE__ */ new Map();
997
+ return {
998
+ examined: () => examined ??= this.examinedReferencers(groupId, signal),
999
+ serves: (referencerId) => {
1000
+ let read = serves.get(referencerId);
1001
+ if (!read) {
1002
+ read = this.servesThrough(referencerId, signal);
1003
+ serves.set(referencerId, read);
1004
+ }
1005
+ return read;
1006
+ }
1007
+ };
1008
+ }
1009
+ async examinedReferencers(groupId, signal) {
1010
+ if (!this.operationIndex) return [];
1011
+ const referencers = await this.operationIndex.getGroupReferencers(groupId, signal);
1012
+ const examined = referencers.slice(0, MAX_EXAMINED_REFERENCERS);
1013
+ if (examined.length < referencers.length) this.logger?.warn(`Group ${groupId} is referenced by ${referencers.length} documents; only the first ${MAX_EXAMINED_REFERENCERS} decide whether it is served`);
1014
+ return examined;
1015
+ }
934
1016
  /**
935
- * Whether one referencing document serves the subject any domain scope. A
1017
+ * Whether one referencing document serves a subject any domain scope. A
936
1018
  * referencer this replica does not hold serves nothing, which fails closed
937
1019
  * the same way a group it does not hold does.
938
1020
  */
939
- async servesThrough(referencerId, subject, signal) {
1021
+ async servesThrough(referencerId, signal) {
940
1022
  let referencer;
941
1023
  try {
942
1024
  referencer = await this.documentView.get(referencerId, { branch: GROUP_BRANCH }, void 0, signal);
943
1025
  } catch {
944
- return false;
1026
+ return () => false;
945
1027
  }
946
- if (this.servesGroup(referencer)) return false;
947
- const readable = await this.ownPolicyPredicate(referencer, subject, GROUP_BRANCH, signal);
948
- return Object.keys(referencer.state ?? {}).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope)).some((scope) => readable(scope));
1028
+ if (this.servesGroup(referencer)) return () => false;
1029
+ const policy = await this.ownPolicy(referencer, GROUP_BRANCH, signal);
1030
+ return (subject) => allowsSomeDomainScope(referencer, policy(subject));
949
1031
  }
950
1032
  servesGroup(document) {
951
1033
  return this.servesGroups && this.operationIndex !== void 0 && document.header.documentType === groupDocumentType;
@@ -963,9 +1045,12 @@ var ModelReadGate = class {
963
1045
  * publish an unpoliced group it names: the referencer walk asks this question
964
1046
  * of the referencing document, whose real policy answers it.
965
1047
  */
966
- async ownPolicyPredicate(document, subject, branch, signal) {
1048
+ async ownPolicy(document, branch, signal) {
967
1049
  const auth = authOf(document);
968
- if (!auth || !auth.version) return this.options.withholdUninitialized ? (scope) => ALWAYS_READABLE_SCOPES.has(scope) : () => true;
1050
+ if (!auth || !auth.version) {
1051
+ const readable = this.options.withholdUninitialized ? (scope) => ALWAYS_READABLE_SCOPES.has(scope) : () => true;
1052
+ return () => readable;
1053
+ }
969
1054
  const target = {
970
1055
  documentId: document.header.id,
971
1056
  branch
@@ -973,7 +1058,7 @@ var ModelReadGate = class {
973
1058
  const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), this.model, target, signal);
974
1059
  const definition = this.model(target);
975
1060
  const scopeStates = document.state ?? {};
976
- return (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
1061
+ return (subject) => (scope) => ALWAYS_READABLE_SCOPES.has(scope) || definition.decide(built.model, subject, {
977
1062
  verb: "read",
978
1063
  scope
979
1064
  }, {
@@ -983,6 +1068,78 @@ var ModelReadGate = class {
983
1068
  }
984
1069
  };
985
1070
  //#endregion
1071
+ //#region src/client/event-reads.ts
1072
+ /** One change event's reads, shared by every subscription it reaches. */
1073
+ var EventReads = class {
1074
+ reactor;
1075
+ documentView;
1076
+ readGate;
1077
+ documents = /* @__PURE__ */ new Map();
1078
+ existence = /* @__PURE__ */ new Map();
1079
+ prepared = /* @__PURE__ */ new WeakMap();
1080
+ constructor(reactor, documentView, readGate) {
1081
+ this.reactor = reactor;
1082
+ this.documentView = documentView;
1083
+ this.readGate = readGate;
1084
+ }
1085
+ get(id, view) {
1086
+ const key = JSON.stringify([
1087
+ id,
1088
+ view?.branch,
1089
+ view?.scopes
1090
+ ]);
1091
+ let read = this.documents.get(key);
1092
+ if (!read) {
1093
+ read = this.reactor.get(id, view);
1094
+ this.documents.set(key, read);
1095
+ }
1096
+ return read;
1097
+ }
1098
+ exists(ids, existence) {
1099
+ const key = JSON.stringify([ids, existence]);
1100
+ let read = this.existence.get(key);
1101
+ if (!read) {
1102
+ read = this.documentView.exists(ids, existence);
1103
+ this.existence.set(key, read);
1104
+ }
1105
+ return read;
1106
+ }
1107
+ async scopePredicate(document, subject, branch) {
1108
+ if (!this.readGate.prepare) return this.readGate.scopePredicate(document, subject, branch);
1109
+ let byBranch = this.prepared.get(document);
1110
+ if (!byBranch) {
1111
+ byBranch = /* @__PURE__ */ new Map();
1112
+ this.prepared.set(document, byBranch);
1113
+ }
1114
+ let prepared = byBranch.get(branch);
1115
+ if (!prepared) {
1116
+ prepared = this.readGate.prepare(document, branch);
1117
+ byBranch.set(branch, prepared);
1118
+ }
1119
+ return (await prepared)(subject);
1120
+ }
1121
+ };
1122
+ var EventReadsSource = class {
1123
+ reactor;
1124
+ documentView;
1125
+ readGate;
1126
+ current;
1127
+ constructor(reactor, documentView, readGate) {
1128
+ this.reactor = reactor;
1129
+ this.documentView = documentView;
1130
+ this.readGate = readGate;
1131
+ }
1132
+ forEvent() {
1133
+ if (!this.current) {
1134
+ this.current = new EventReads(this.reactor, this.documentView, this.readGate);
1135
+ queueMicrotask(() => {
1136
+ this.current = void 0;
1137
+ });
1138
+ }
1139
+ return this.current;
1140
+ }
1141
+ };
1142
+ //#endregion
986
1143
  //#region src/client/util.ts
987
1144
  function authSubjectFromSigner(signer) {
988
1145
  return {
@@ -997,6 +1154,19 @@ function withAuthScope(view) {
997
1154
  };
998
1155
  return view;
999
1156
  }
1157
+ function withAllScopes(view) {
1158
+ if (!view?.scopes) return view;
1159
+ const { scopes: _narrowed, ...whole } = view;
1160
+ return whole;
1161
+ }
1162
+ function narrowedScopes(view) {
1163
+ if (!view?.scopes || view.scopes.length === 0) return;
1164
+ return /* @__PURE__ */ new Set([
1165
+ "document",
1166
+ "auth",
1167
+ ...view.scopes
1168
+ ]);
1169
+ }
1000
1170
  /**
1001
1171
  * Drops the scopes the predicate refuses. The predicate is resolved by the read
1002
1172
  * gate, which decides against the whole policy once per document; this only
@@ -1069,6 +1239,7 @@ var ReactorClient = class {
1069
1239
  documentIndexer;
1070
1240
  documentView;
1071
1241
  readGate;
1242
+ eventReads;
1072
1243
  actionEvaluation;
1073
1244
  createSignaturePolicy;
1074
1245
  drives;
@@ -1081,33 +1252,12 @@ var ReactorClient = class {
1081
1252
  this.documentIndexer = documentIndexer;
1082
1253
  this.documentView = documentView;
1083
1254
  this.readGate = readGate;
1255
+ this.eventReads = new EventReadsSource(reactor, documentView, readGate);
1084
1256
  this.actionEvaluation = actionEvaluation;
1085
1257
  this.createSignaturePolicy = createSignaturePolicy;
1086
1258
  this.drives = new DriveClient(this, logger, reactor, signer);
1087
1259
  this.logger.verbose("ReactorClient initialized");
1088
1260
  }
1089
- readSubject(subject) {
1090
- return subject ?? authSubjectFromSigner(this.signer);
1091
- }
1092
- /**
1093
- * Which scopes of one document the subject may read. Resolved once per
1094
- * document, so the gate builds its model once however many scopes are then
1095
- * tested, and the filtering itself stays synchronous.
1096
- */
1097
- readableScopes(document, view, signal) {
1098
- return this.readGate.scopePredicate(document, this.readSubject(view?.subject), view?.branch ?? "main", signal);
1099
- }
1100
- /**
1101
- * One document, filtered to the scopes the subject may read. Every method
1102
- * that hands a document back goes through here, including the ones that
1103
- * follow a write: a document returned from a mutation is a read like any
1104
- * other, and returning it whole served scopes the same subject would be
1105
- * refused by `get`. Its author still sees what it wrote, because an allow on
1106
- * execute confers read of that scope.
1107
- */
1108
- async gateDocument(document, view, signal) {
1109
- return filterReadableScopes(document, await this.readableScopes(document, view, signal));
1110
- }
1111
1261
  /**
1112
1262
  * Retrieves a list of document model modules.
1113
1263
  */
@@ -1213,31 +1363,6 @@ var ReactorClient = class {
1213
1363
  nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
1214
1364
  };
1215
1365
  }
1216
- async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
1217
- const scopeCursors = decodeCompositeCursor(paging.cursor);
1218
- const allOperations = [];
1219
- const activeCursors = {};
1220
- for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1221
- if (!canRead(scopeName)) continue;
1222
- const scopeView = {
1223
- ...view,
1224
- scopes: [scopeName]
1225
- };
1226
- const scopePaging = {
1227
- cursor,
1228
- limit: paging.limit
1229
- };
1230
- const scopeResult = (await this.reactor.getOperations(documentId, scopeView, filter, scopePaging, void 0, signal))[scopeName];
1231
- allOperations.push(...scopeResult.results);
1232
- if (scopeResult.nextCursor) activeCursors[scopeName] = scopeResult.nextCursor;
1233
- }
1234
- allOperations.sort((a, b) => a.index - b.index);
1235
- return {
1236
- results: allOperations,
1237
- options: paging,
1238
- nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
1239
- };
1240
- }
1241
1366
  /**
1242
1367
  * Retrieves outgoing relationships of a given type from a source document.
1243
1368
  */
@@ -1291,68 +1416,24 @@ var ReactorClient = class {
1291
1416
  return this.gateEdges(edges, "sourceId", view, signal);
1292
1417
  }
1293
1418
  /**
1294
- * Drops the edges whose far-end document the subject may read no domain scope
1295
- * of. An edge is withheld whole rather than stripped of its metadata: the
1296
- * document-shaped relationship reads already answer with the far end stripped
1297
- * to the scopes the gate allows, so the far end's existence is disclosed
1298
- * either way, but an edge's metadata is content about the pair that the far
1299
- * end's own reads would refuse. An edge to a far end stripped to nothing
1300
- * therefore carries content past a refusal, and there is no useful shell to
1301
- * hand back in its place.
1302
- *
1303
- * `nextCursor` and `options` are left as the underlying stream reported them,
1304
- * because a caller must feed them back to resume from the right position. A
1305
- * gated page can therefore be shorter than the limit it asked for.
1306
- */
1307
- async gateEdges(page, farEnd, view, signal) {
1308
- const ids = [...new Set(page.results.map((edge) => edge[farEnd]))];
1309
- if (ids.length === 0) return page;
1310
- const farEndView = {
1311
- subject: view?.subject,
1312
- branch: view?.branch
1313
- };
1314
- const documents = await this.reactor.find({ ids }, farEndView, {
1315
- cursor: "0",
1316
- limit: ids.length
1317
- }, void 0, signal);
1318
- const readable = new Map(await Promise.all(documents.results.map(async (doc) => {
1319
- const allows = await this.readableScopes(doc, view, signal);
1320
- const domainScopes = Object.keys(doc.state).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope));
1321
- return [doc.header.id, domainScopes.length === 0 || domainScopes.some(allows)];
1322
- })));
1323
- const results = page.results.filter((edge) => readable.get(edge[farEnd]) !== false);
1324
- if (results.length === page.results.length) return page;
1325
- const nextPage = page.next;
1326
- return {
1327
- ...page,
1328
- results,
1329
- next: nextPage ? async () => this.gateEdges(await nextPage(), farEnd, view, signal) : void 0
1330
- };
1331
- }
1332
- /**
1333
- * One relationship edge, or undefined when it does not exist. A point lookup:
1334
- * the pair is filtered in SQL rather than scanned out of the source's edge
1335
- * list, which on a drive with thousands of children is the difference between
1336
- * one query and dozens.
1337
- */
1338
- async readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, view, signal) {
1339
- const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
1340
- const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
1341
- return (await this.documentIndexer.getDirectedRelationships(sourceId, targetId, [relationshipType], {
1342
- cursor: "0",
1343
- limit: 1
1344
- }, void 0, signal)).results[0];
1345
- }
1346
- /**
1347
1419
  * Filters documents by criteria and returns a list of them
1348
1420
  */
1349
1421
  async find(search, view, paging, signal) {
1350
1422
  this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
1351
1423
  const results = await this.reactor.find(search, withAuthScope(view), paging, void 0, signal);
1352
- return {
1353
- ...results,
1354
- results: await Promise.all(results.results.map(async (doc) => filterReadableScopes(doc, await this.readableScopes(doc, view, signal))))
1355
- };
1424
+ return this.gateListing(results, view, signal);
1425
+ }
1426
+ /** Whether `find` would serve the document; false when it is absent. */
1427
+ async isServed(identifier, view, signal) {
1428
+ this.logger.verbose("isServed(@identifier, @view)", identifier, view);
1429
+ let document;
1430
+ try {
1431
+ document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
1432
+ } catch (error) {
1433
+ await assertAbsent(this.documentView, identifier, error, signal);
1434
+ return false;
1435
+ }
1436
+ return await this.gateListed(document, view, signal) !== void 0;
1356
1437
  }
1357
1438
  /**
1358
1439
  * Predicts the admission verdict for each candidate. See
@@ -1393,39 +1474,6 @@ var ReactorClient = class {
1393
1474
  anyDenied: allowed < candidates.length
1394
1475
  };
1395
1476
  }
1396
- /**
1397
- * The decision model for one target document, built at its stream heads.
1398
- *
1399
- * The document is fetched unfiltered, because the policy is what decides:
1400
- * reading it through the read gate would withhold the very scopes the
1401
- * decision is about. A deleted document is served at its deletion boundary,
1402
- * which is what lets the model refuse an execute against it -- authEnforcement
1403
- * requires documentDecisions, so that read is available whenever this runs.
1404
- *
1405
- * Reading past the gate discloses nothing a submit does not. The `auth` and
1406
- * `document` scopes are readable by every holder, so a verdict resting on the
1407
- * policy alone is one the caller could compute unaided; and a verdict resting
1408
- * on a conditional grant reads the executing scope's state exactly as
1409
- * admission reads it, so the answer here is what submitting and being refused
1410
- * would have revealed anyway.
1411
- *
1412
- * The append condition the build records is dropped. It guards a write, and
1413
- * this makes none; reproducing it is also what the preflight cannot do, which
1414
- * is why the answer is a prediction.
1415
- */
1416
- async buildEvaluationTarget(config, documentId, branch, signal) {
1417
- const document = await this.documentView.get(documentId, { branch }, void 0, signal);
1418
- const target = {
1419
- documentId,
1420
- branch
1421
- };
1422
- const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), config.model, target, signal);
1423
- return {
1424
- definition: config.model(target),
1425
- model: built.model,
1426
- scopeStates: document.state ?? {}
1427
- };
1428
- }
1429
1477
  getCreateSignaturePolicy() {
1430
1478
  return Promise.resolve(this.createSignaturePolicy);
1431
1479
  }
@@ -1608,18 +1656,6 @@ var ReactorClient = class {
1608
1656
  }, signal);
1609
1657
  return this.reactor.execute(documentId, branch, signedActions, signal);
1610
1658
  }
1611
- /**
1612
- * The id a write on `identifier` is stored under, which its signatures bind.
1613
- * A create names its own id, and an id no slug maps to is taken as given, so
1614
- * a document still in flight resolves to itself.
1615
- */
1616
- async resolveWriteTarget(identifier, branch, actions, signal) {
1617
- if (actions.some((action) => action.type === "CREATE_DOCUMENT")) return identifier;
1618
- const view = { branch };
1619
- const bySlug = await this.documentView.resolveSlug(identifier, view, void 0, signal);
1620
- if (bySlug === void 0 || bySlug === identifier) return identifier;
1621
- return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
1622
- }
1623
1659
  async executeBatch(request, signal) {
1624
1660
  this.logger.verbose("executeBatch(@count jobs)", request.jobs.length);
1625
1661
  const signedJobs = await Promise.all(request.jobs.map(async (job) => {
@@ -1776,7 +1812,13 @@ var ReactorClient = class {
1776
1812
  */
1777
1813
  subscribe(search, callback, view) {
1778
1814
  this.logger.verbose("subscribe(@search, @view)", search, view);
1779
- const readable = async (document) => filterReadableScopes(document, await this.readableScopes(document, view));
1815
+ const served = async (type, documents, reads) => {
1816
+ const documentsServed = await this.gateServedAll(documents, view, void 0, reads);
1817
+ return documentsServed.length > 0 ? {
1818
+ type,
1819
+ documents: documentsServed
1820
+ } : void 0;
1821
+ };
1780
1822
  let disposed = false;
1781
1823
  let delivering = Promise.resolve();
1782
1824
  /**
@@ -1790,49 +1832,46 @@ var ReactorClient = class {
1790
1832
  * gated is withheld, because serving it unfiltered would leak the scopes
1791
1833
  * the gate did not clear -- but it is logged rather than swallowed, since
1792
1834
  * the gate rethrows a transient failure precisely so it is not read as a
1793
- * denial.
1835
+ * denial. An event that resolves to undefined was withheld.
1794
1836
  */
1795
1837
  const deliver = (event) => {
1796
1838
  Promise.resolve(event).catch(() => void 0);
1797
1839
  delivering = delivering.then(async () => {
1798
1840
  const built = await event;
1799
- if (disposed) return;
1841
+ if (disposed || !built) return;
1800
1842
  callback(built);
1801
1843
  }).catch((error) => {
1802
1844
  this.logger.error("Subscription delivery failed for @search: @Error", { search }, error);
1803
1845
  });
1804
1846
  };
1805
1847
  const unsubscribeCreated = this.subscriptionManager.onDocumentCreated((result) => {
1848
+ const reads = this.eventReads.forEvent();
1806
1849
  deliver((async () => {
1807
- const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
1808
- return {
1809
- type: "created",
1810
- documents: await Promise.all(documents.map(readable))
1811
- };
1850
+ const documents = await Promise.all(result.results.map((id) => reads.get(id, withAllScopes(view))));
1851
+ return served("created", documents, reads);
1812
1852
  })());
1813
1853
  }, search);
1814
1854
  const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
1815
- deliver({
1855
+ const reads = this.eventReads.forEvent();
1856
+ for (const childId of documentIds) deliver((async () => await this.servesEvery([childId], view, reads) ? {
1816
1857
  type: "deleted",
1817
1858
  documents: [],
1818
- context: { childId: documentIds[0] }
1819
- });
1859
+ context: { childId }
1860
+ } : void 0)());
1820
1861
  }, search);
1821
1862
  const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
1822
- deliver((async () => ({
1823
- type: "updated",
1824
- documents: await Promise.all(result.results.map(readable))
1825
- }))());
1863
+ deliver(served("updated", result.results, this.eventReads.forEvent()));
1826
1864
  }, search, view);
1827
1865
  const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
1828
- deliver({
1866
+ const reads = this.eventReads.forEvent();
1867
+ deliver((async () => await this.servesEvery([parentId, childId], view, reads) ? {
1829
1868
  type: changeType === "added" ? "child_added" : "child_removed",
1830
1869
  documents: [],
1831
1870
  context: {
1832
1871
  parentId,
1833
1872
  childId
1834
1873
  }
1835
- });
1874
+ } : void 0)());
1836
1875
  }, search);
1837
1876
  return () => {
1838
1877
  disposed = true;
@@ -1842,6 +1881,225 @@ var ReactorClient = class {
1842
1881
  unsubscribeRelationship();
1843
1882
  };
1844
1883
  }
1884
+ readSubject(subject) {
1885
+ return subject ?? authSubjectFromSigner(this.signer);
1886
+ }
1887
+ /**
1888
+ * Which scopes of one document the subject may read. Resolved once per
1889
+ * document, so the gate builds its model once however many scopes are then
1890
+ * tested, and the filtering itself stays synchronous.
1891
+ */
1892
+ readableScopes(document, view, signal, reads) {
1893
+ const subject = this.readSubject(view?.subject);
1894
+ const branch = view?.branch ?? "main";
1895
+ return reads ? reads.scopePredicate(document, subject, branch) : this.readGate.scopePredicate(document, subject, branch, signal);
1896
+ }
1897
+ /**
1898
+ * One document, filtered to the scopes the subject may read. Every method
1899
+ * that hands a document back goes through here, including the ones that
1900
+ * follow a write: a document returned from a mutation is a read like any
1901
+ * other, and returning it whole served scopes the same subject would be
1902
+ * refused by `get`. Its author still sees what it wrote, because an allow on
1903
+ * execute confers read of that scope.
1904
+ */
1905
+ async gateDocument(document, view, signal) {
1906
+ return filterReadableScopes(document, await this.readableScopes(document, view, signal));
1907
+ }
1908
+ /**
1909
+ * One document as a listing or a feed serves it, or undefined when withheld.
1910
+ * The document must hold every scope, or withholding is decided on a subset;
1911
+ * a narrowing view is applied here instead.
1912
+ */
1913
+ async gateServed(document, view, signal, reads) {
1914
+ const readable = await this.readableScopes(document, view, signal, reads);
1915
+ if (refusesEveryDomainScope(document, readable)) return;
1916
+ const narrowed = narrowedScopes(view);
1917
+ return filterReadableScopes(document, narrowed ? (scope) => readable(scope) && narrowed.has(scope) : readable);
1918
+ }
1919
+ async gateServedAll(documents, view, signal, reads) {
1920
+ return (await Promise.all(documents.map((document) => this.gateServed(document, view, signal, reads)))).filter((document) => document !== void 0);
1921
+ }
1922
+ /**
1923
+ * Gates a page and every page after it. `nextCursor` is left as the stream
1924
+ * reported it.
1925
+ */
1926
+ async gateListing(page, view, signal) {
1927
+ const results = (await Promise.all(page.results.map((document) => this.gateListed(document, view, signal)))).filter((document) => document !== void 0);
1928
+ const next = page.next;
1929
+ return {
1930
+ ...page,
1931
+ results,
1932
+ next: next ? async () => this.gateListing(await next(), view, signal) : void 0
1933
+ };
1934
+ }
1935
+ /**
1936
+ * One document as a listing serves it, or undefined when withheld. It holds
1937
+ * only the view's scopes; a domain scope it lacks is decided on its name, or
1938
+ * on its state read now when a condition needs that.
1939
+ */
1940
+ async gateListed(document, view, signal) {
1941
+ const readable = await this.readableScopes(document, view, signal);
1942
+ const unheld = unheldScopesReadOnState(document);
1943
+ if (refusesEveryDomainScope(document, unheld.length === 0 ? readable : (scope) => !unheld.includes(scope) && readable(scope)) && !await this.servesUnheld(document, unheld, view, signal)) return;
1944
+ return filterReadableScopes(document, readable);
1945
+ }
1946
+ async servesUnheld(document, scopes, view, signal) {
1947
+ if (scopes.length === 0) return false;
1948
+ const id = document.header.id;
1949
+ let held;
1950
+ try {
1951
+ held = await this.reactor.get(id, withAuthScope({
1952
+ ...view,
1953
+ scopes
1954
+ }), void 0, signal);
1955
+ } catch (error) {
1956
+ await assertAbsent(this.documentView, id, error, signal);
1957
+ return false;
1958
+ }
1959
+ const readable = await this.readableScopes(held, view, signal);
1960
+ return scopes.some(readable);
1961
+ }
1962
+ /**
1963
+ * Whether an event naming only these ids may reach the subject. An id with no
1964
+ * live document behind it has no content left to protect, so it does not
1965
+ * withhold; a deleted document is read at its deletion boundary when
1966
+ * deletion is positional, and gated like any other.
1967
+ */
1968
+ async servesEvery(ids, view, reads) {
1969
+ return (await Promise.all(ids.map((id) => this.servesId(id, view, reads)))).every(Boolean);
1970
+ }
1971
+ async servesId(id, view, reads) {
1972
+ let document;
1973
+ try {
1974
+ document = await reads.get(id, withAllScopes(view));
1975
+ } catch (error) {
1976
+ await assertAbsent(reads, id, error);
1977
+ return true;
1978
+ }
1979
+ return !refusesEveryDomainScope(document, await this.readableScopes(document, view, void 0, reads));
1980
+ }
1981
+ async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
1982
+ const scopeCursors = decodeCompositeCursor(paging.cursor);
1983
+ const allOperations = [];
1984
+ const activeCursors = {};
1985
+ for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
1986
+ if (!canRead(scopeName)) continue;
1987
+ const scopeView = {
1988
+ ...view,
1989
+ scopes: [scopeName]
1990
+ };
1991
+ const scopePaging = {
1992
+ cursor,
1993
+ limit: paging.limit
1994
+ };
1995
+ const scopeResult = (await this.reactor.getOperations(documentId, scopeView, filter, scopePaging, void 0, signal))[scopeName];
1996
+ allOperations.push(...scopeResult.results);
1997
+ if (scopeResult.nextCursor) activeCursors[scopeName] = scopeResult.nextCursor;
1998
+ }
1999
+ allOperations.sort((a, b) => a.index - b.index);
2000
+ return {
2001
+ results: allOperations,
2002
+ options: paging,
2003
+ nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
2004
+ };
2005
+ }
2006
+ /**
2007
+ * Drops the edges whose far-end document the subject may read no domain scope
2008
+ * of. An edge is withheld whole rather than stripped of its metadata: the
2009
+ * document-shaped relationship reads already answer with the far end stripped
2010
+ * to the scopes the gate allows, so the far end's existence is disclosed
2011
+ * either way, but an edge's metadata is content about the pair that the far
2012
+ * end's own reads would refuse. An edge to a far end stripped to nothing
2013
+ * therefore carries content past a refusal, and there is no useful shell to
2014
+ * hand back in its place.
2015
+ *
2016
+ * `nextCursor` and `options` are left as the underlying stream reported them,
2017
+ * because a caller must feed them back to resume from the right position. A
2018
+ * gated page can therefore be shorter than the limit it asked for.
2019
+ */
2020
+ async gateEdges(page, farEnd, view, signal) {
2021
+ const ids = [...new Set(page.results.map((edge) => edge[farEnd]))];
2022
+ if (ids.length === 0) return page;
2023
+ const farEndView = {
2024
+ subject: view?.subject,
2025
+ branch: view?.branch
2026
+ };
2027
+ const documents = await this.reactor.find({ ids }, farEndView, {
2028
+ cursor: "0",
2029
+ limit: ids.length
2030
+ }, void 0, signal);
2031
+ const readable = new Map(await Promise.all(documents.results.map(async (doc) => {
2032
+ const allows = await this.readableScopes(doc, view, signal);
2033
+ return [doc.header.id, !refusesEveryDomainScope(doc, allows)];
2034
+ })));
2035
+ const results = page.results.filter((edge) => readable.get(edge[farEnd]) !== false);
2036
+ if (results.length === page.results.length) return page;
2037
+ const nextPage = page.next;
2038
+ return {
2039
+ ...page,
2040
+ results,
2041
+ next: nextPage ? async () => this.gateEdges(await nextPage(), farEnd, view, signal) : void 0
2042
+ };
2043
+ }
2044
+ /**
2045
+ * One relationship edge, or undefined when it does not exist. A point lookup:
2046
+ * the pair is filtered in SQL rather than scanned out of the source's edge
2047
+ * list, which on a drive with thousands of children is the difference between
2048
+ * one query and dozens.
2049
+ */
2050
+ async readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, view, signal) {
2051
+ const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
2052
+ const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
2053
+ return (await this.documentIndexer.getDirectedRelationships(sourceId, targetId, [relationshipType], {
2054
+ cursor: "0",
2055
+ limit: 1
2056
+ }, void 0, signal)).results[0];
2057
+ }
2058
+ /**
2059
+ * The id a write on `identifier` is stored under, which its signatures bind.
2060
+ * A create names its own id, and an id no slug maps to is taken as given, so
2061
+ * a document still in flight resolves to itself.
2062
+ */
2063
+ async resolveWriteTarget(identifier, branch, actions, signal) {
2064
+ if (actions.some((action) => action.type === "CREATE_DOCUMENT")) return identifier;
2065
+ const view = { branch };
2066
+ const bySlug = await this.documentView.resolveSlug(identifier, view, void 0, signal);
2067
+ if (bySlug === void 0 || bySlug === identifier) return identifier;
2068
+ return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
2069
+ }
2070
+ /**
2071
+ * The decision model for one target document, built at its stream heads.
2072
+ *
2073
+ * The document is fetched unfiltered, because the policy is what decides:
2074
+ * reading it through the read gate would withhold the very scopes the
2075
+ * decision is about. A deleted document is served at its deletion boundary,
2076
+ * which is what lets the model refuse an execute against it -- authEnforcement
2077
+ * requires documentDecisions, so that read is available whenever this runs.
2078
+ *
2079
+ * Reading past the gate discloses nothing a submit does not. The `auth` and
2080
+ * `document` scopes are readable by every holder, so a verdict resting on the
2081
+ * policy alone is one the caller could compute unaided; and a verdict resting
2082
+ * on a conditional grant reads the executing scope's state exactly as
2083
+ * admission reads it, so the answer here is what submitting and being refused
2084
+ * would have revealed anyway.
2085
+ *
2086
+ * The append condition the build records is dropped. It guards a write, and
2087
+ * this makes none; reproducing it is also what the preflight cannot do, which
2088
+ * is why the answer is a prediction.
2089
+ */
2090
+ async buildEvaluationTarget(config, documentId, branch, signal) {
2091
+ const document = await this.documentView.get(documentId, { branch }, void 0, signal);
2092
+ const target = {
2093
+ documentId,
2094
+ branch
2095
+ };
2096
+ const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), config.model, target, signal);
2097
+ return {
2098
+ definition: config.model(target),
2099
+ model: built.model,
2100
+ scopeStates: document.state ?? {}
2101
+ };
2102
+ }
1845
2103
  async removeAllIncomingRelationships(documentId, signal) {
1846
2104
  const incoming = await this.documentIndexer.getIncoming(documentId, void 0, void 0, void 0, signal);
1847
2105
  const jobs = [];
@@ -8827,7 +9085,7 @@ var ReactorBuilder = class {
8827
9085
  poolInstrumentations.push(forwarder);
8828
9086
  this.instrumentedPools.push(forwarder);
8829
9087
  }
8830
- const { ProjectionShardManager } = await import("./projection-shard-manager-B-j8VYzA.js");
9088
+ const { ProjectionShardManager } = await import("./projection-shard-manager-BpcqDtnQ.js");
8831
9089
  const manager = new ProjectionShardManager({
8832
9090
  shardCount: config.shardCount,
8833
9091
  db,
@@ -8871,7 +9129,7 @@ var ReactorBuilder = class {
8871
9129
  */
8872
9130
  async createDefaultWorkerFactory(numWorkers, db) {
8873
9131
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
8874
- import("./worker-handle-CwWNTpUg.js"),
9132
+ import("./worker-handle-C2rLUHwv.js"),
8875
9133
  import("./transport-CZDHzfvy.js"),
8876
9134
  Promise.resolve().then(() => worker_exports)
8877
9135
  ]);