@powerhousedao/reactor 6.2.2-dev.5 → 6.2.2-dev.51
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/{build-worker-executor--nhFRF47.js → build-worker-executor-NT9b3rNm.js} +2 -2
- package/dist/{build-worker-executor--nhFRF47.js.map → build-worker-executor-NT9b3rNm.js.map} +1 -1
- package/dist/{document-indexer-FGJmRAdX.js → document-indexer-DlpJB8AK.js} +37 -22
- package/dist/document-indexer-DlpJB8AK.js.map +1 -0
- package/dist/{drive-container-types-DpJp2AmE.js → drive-container-types-RZa1wukO.js} +2182 -314
- package/dist/drive-container-types-RZa1wukO.js.map +1 -0
- package/dist/entry.js +3 -2
- package/dist/entry.js.map +1 -1
- package/dist/index.d.ts +2302 -1313
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +959 -100
- package/dist/index.js.map +1 -1
- package/dist/projection-entry.js +4 -4
- package/dist/projection-entry.js.map +1 -1
- package/dist/{worker-handle-B1w03nRA.js → worker-handle-CrERzl8s.js} +3 -2
- package/dist/worker-handle-CrERzl8s.js.map +1 -0
- package/dist/{worker-DBJOv8Gp.js → worker-sw2vjrd3.js} +2 -2
- package/dist/{worker-DBJOv8Gp.js.map → worker-sw2vjrd3.js.map} +1 -1
- package/package.json +5 -4
- package/dist/document-indexer-FGJmRAdX.js.map +0 -1
- package/dist/drive-container-types-DpJp2AmE.js.map +0 -1
- package/dist/worker-handle-B1w03nRA.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as createEmptyConsistencyToken, B as DocumentDeletedError, C as RevisionMismatchError, D as ModuleNotFoundError, E as InvalidModuleError, F as authDecisionModel, G as matchesScope, H as ExcessiveReshuffleError, I as buildDecisionModel, K as parsePagingOptions, L as AuthEnforcementDisabledError, M as decideAtHead, N as selectDecisionModel, O as GATED_DOCUMENT_ACTIONS, P as documentDecisionModel, R as AuthTimestampNotMonotonicError, S as OptimisticLockError, T as DuplicateModuleError, U as InvalidOperationTimestampError, V as DocumentNotFoundError, W as UpgradePreconditionFailedError, _ as DocumentMetaCache, a as createForwardingPoolInstrumentation, b as AppendConditionFailedError, c as KyselyKeyframeStore, d as DriveCollectionId, f as KyselyExecutionScope, g as KyselyOperationIndex, h as KyselyWriteCache, i as runMigrations, j as targetDocumentId, k as createConsistencyToken, l as DocumentModelRegistry, m as EventBus, n as REACTOR_SCHEMA, o as instrumentPgPool, p as resolveFeatureFlags, q as throwIfAborted, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as CollectionMembershipCache, w as DuplicateManifestError, x as DuplicateOperationError, y as APPEND_CONDITION_FAILED_PREFIX, z as AuthorizationDeniedError } from "./drive-container-types-RZa1wukO.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-
|
|
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-
|
|
7
|
-
import { actions, actions as documentActions, createPresignedHeader, generateId, hashDocumentStateForScope,
|
|
6
|
+
import { t as workerEntryPath } from "./worker-sw2vjrd3.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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
472
|
-
|
|
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,8 +686,328 @@ 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
|
/**
|
|
980
|
+
* The document a candidate is decided against. Routed on the action type alone,
|
|
981
|
+
* which is what the executor routes on: every action the reactor reduces onto
|
|
982
|
+
* the document scope carries that scope already, and the gate follows the
|
|
983
|
+
* action's own target rather than the document the request named.
|
|
984
|
+
*/
|
|
985
|
+
function evaluationTargetId(candidate, fallback) {
|
|
986
|
+
return GATED_DOCUMENT_ACTIONS.has(candidate.type) ? targetDocumentId({
|
|
987
|
+
type: candidate.type,
|
|
988
|
+
input: candidate.input
|
|
989
|
+
}, fallback) : fallback;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* One candidate's verdict, decided exactly as admission decides it: the same
|
|
993
|
+
* request, and a condition context populated only while authConditions is on,
|
|
994
|
+
* so a conditional grant applies here precisely when it would apply there.
|
|
995
|
+
* Groups need nothing added, because the selected model carries them.
|
|
996
|
+
*/
|
|
997
|
+
function decideCandidate(config, target, subject, candidate) {
|
|
998
|
+
return target.definition.decide(target.model, subject, {
|
|
999
|
+
verb: "execute",
|
|
1000
|
+
scope: candidate.scope,
|
|
1001
|
+
operation: candidate.type
|
|
1002
|
+
}, config.flags.authConditions ? {
|
|
1003
|
+
scopeState: target.scopeStates[candidate.scope],
|
|
1004
|
+
actionInput: candidate.input
|
|
1005
|
+
} : {
|
|
1006
|
+
scopeState: void 0,
|
|
1007
|
+
actionInput: void 0
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
685
1011
|
* ReactorClient implementation that wraps lower-level APIs to provide
|
|
686
1012
|
* a simpler interface for document operations.
|
|
687
1013
|
*
|
|
@@ -699,8 +1025,10 @@ var ReactorClient = class {
|
|
|
699
1025
|
jobAwaiter;
|
|
700
1026
|
documentIndexer;
|
|
701
1027
|
documentView;
|
|
1028
|
+
readGate;
|
|
1029
|
+
actionEvaluation;
|
|
702
1030
|
drives;
|
|
703
|
-
constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView) {
|
|
1031
|
+
constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation) {
|
|
704
1032
|
this.logger = logger;
|
|
705
1033
|
this.reactor = reactor;
|
|
706
1034
|
this.signer = signer;
|
|
@@ -708,9 +1036,33 @@ var ReactorClient = class {
|
|
|
708
1036
|
this.jobAwaiter = jobAwaiter;
|
|
709
1037
|
this.documentIndexer = documentIndexer;
|
|
710
1038
|
this.documentView = documentView;
|
|
1039
|
+
this.readGate = readGate;
|
|
1040
|
+
this.actionEvaluation = actionEvaluation;
|
|
711
1041
|
this.drives = new DriveClient(this, logger, reactor, signer);
|
|
712
1042
|
this.logger.verbose("ReactorClient initialized");
|
|
713
1043
|
}
|
|
1044
|
+
readSubject(subject) {
|
|
1045
|
+
return subject ?? authSubjectFromSigner(this.signer);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Which scopes of one document the subject may read. Resolved once per
|
|
1049
|
+
* document, so the gate builds its model once however many scopes are then
|
|
1050
|
+
* tested, and the filtering itself stays synchronous.
|
|
1051
|
+
*/
|
|
1052
|
+
readableScopes(document, view, signal) {
|
|
1053
|
+
return this.readGate.scopePredicate(document, this.readSubject(view?.subject), view?.branch ?? "main", signal);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* One document, filtered to the scopes the subject may read. Every method
|
|
1057
|
+
* that hands a document back goes through here, including the ones that
|
|
1058
|
+
* follow a write: a document returned from a mutation is a read like any
|
|
1059
|
+
* other, and returning it whole served scopes the same subject would be
|
|
1060
|
+
* refused by `get`. Its author still sees what it wrote, because an allow on
|
|
1061
|
+
* execute confers read of that scope.
|
|
1062
|
+
*/
|
|
1063
|
+
async gateDocument(document, view, signal) {
|
|
1064
|
+
return filterReadableScopes(document, await this.readableScopes(document, view, signal));
|
|
1065
|
+
}
|
|
714
1066
|
/**
|
|
715
1067
|
* Retrieves a list of document model modules.
|
|
716
1068
|
*/
|
|
@@ -725,24 +1077,53 @@ var ReactorClient = class {
|
|
|
725
1077
|
* @returns The document model module
|
|
726
1078
|
*/
|
|
727
1079
|
async getDocumentModelModule(documentType) {
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
1080
|
+
const modules = await this.reactor.getDocumentModels();
|
|
1081
|
+
let latestModule;
|
|
1082
|
+
let latestVersion = -1;
|
|
1083
|
+
for (const module of modules.results) {
|
|
1084
|
+
if (module.documentModel.global.id !== documentType) continue;
|
|
1085
|
+
const version = module.version ?? 1;
|
|
1086
|
+
if (version > latestVersion) {
|
|
1087
|
+
latestVersion = version;
|
|
1088
|
+
latestModule = module;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (!latestModule) throw new Error(`Document model module not found for type: ${documentType}`);
|
|
1092
|
+
return latestModule;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Retrieves the document model module matching the version the document is
|
|
1096
|
+
* stamped with, so not-yet-upgraded documents get the reducer their
|
|
1097
|
+
* history was written with rather than the latest.
|
|
1098
|
+
*/
|
|
1099
|
+
async getDocumentModelModuleForDocument(document) {
|
|
1100
|
+
const documentType = document.header.documentType;
|
|
1101
|
+
const version = normalizeDocumentModelVersion(document.state.document?.version);
|
|
1102
|
+
const modules = await this.reactor.getDocumentModels();
|
|
1103
|
+
const availableVersions = [];
|
|
1104
|
+
for (const module of modules.results) {
|
|
1105
|
+
if (module.documentModel.global.id !== documentType) continue;
|
|
1106
|
+
const moduleVersion = normalizeDocumentModelVersion(module.version);
|
|
1107
|
+
if (moduleVersion === version) return module;
|
|
1108
|
+
availableVersions.push(moduleVersion);
|
|
1109
|
+
}
|
|
1110
|
+
throw new UnsupportedDocumentModelVersionError(documentType, version, availableVersions.sort((a, b) => a - b));
|
|
731
1111
|
}
|
|
732
1112
|
/**
|
|
733
1113
|
* Retrieves a specific PHDocument
|
|
734
1114
|
*/
|
|
735
1115
|
async get(identifier, view, signal) {
|
|
736
1116
|
this.logger.verbose("get(@identifier, @view)", identifier, view);
|
|
737
|
-
|
|
1117
|
+
const document = await this.reactor.getByIdOrSlug(identifier, withAuthScope(view), void 0, signal);
|
|
1118
|
+
return this.gateDocument(document, view, signal);
|
|
738
1119
|
}
|
|
739
1120
|
/**
|
|
740
1121
|
* Resolves an identifier (id or slug) to the canonical document id, using the
|
|
741
1122
|
* same lookup as the data path. Resolves against the "main" branch. Throws if
|
|
742
1123
|
* the identifier cannot be resolved or is ambiguous.
|
|
743
1124
|
*/
|
|
744
|
-
async resolveIdOrSlug(identifier, signal) {
|
|
745
|
-
return this.documentView.resolveIdOrSlug(identifier,
|
|
1125
|
+
async resolveIdOrSlug(identifier, view, signal) {
|
|
1126
|
+
return this.documentView.resolveIdOrSlug(identifier, view, void 0, signal);
|
|
746
1127
|
}
|
|
747
1128
|
/**
|
|
748
1129
|
* Retrieves operations for a document
|
|
@@ -750,8 +1131,11 @@ var ReactorClient = class {
|
|
|
750
1131
|
async getOperations(documentIdentifier, view, filter, paging, signal) {
|
|
751
1132
|
this.logger.verbose("getOperations(@documentIdentifier, @view, @filter, @paging)", documentIdentifier, view, filter, paging);
|
|
752
1133
|
const documentId = await this.documentView.resolveIdOrSlug(documentIdentifier, view, void 0, signal);
|
|
753
|
-
|
|
1134
|
+
const gated = await this.reactor.getByIdOrSlug(documentId, withAuthScope(view), void 0, signal);
|
|
1135
|
+
const canRead = await this.readableScopes(gated, view, signal);
|
|
1136
|
+
if (paging?.cursor && isCompositeCursor(paging.cursor)) return this.getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead);
|
|
754
1137
|
const operationsByScope = await this.reactor.getOperations(documentId, view, filter, paging, void 0, signal);
|
|
1138
|
+
for (const scope of Object.keys(operationsByScope)) if (!canRead(scope)) delete operationsByScope[scope];
|
|
755
1139
|
const scopeEntries = Object.entries(operationsByScope);
|
|
756
1140
|
const effectivePaging = paging || {
|
|
757
1141
|
cursor: "0",
|
|
@@ -779,11 +1163,12 @@ var ReactorClient = class {
|
|
|
779
1163
|
nextCursor: Object.keys(activeCursors).length > 0 ? encodeCompositeCursor(activeCursors) : void 0
|
|
780
1164
|
};
|
|
781
1165
|
}
|
|
782
|
-
async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal) {
|
|
1166
|
+
async getOperationsWithCompositeCursor(documentId, view, filter, paging, signal, canRead) {
|
|
783
1167
|
const scopeCursors = decodeCompositeCursor(paging.cursor);
|
|
784
1168
|
const allOperations = [];
|
|
785
1169
|
const activeCursors = {};
|
|
786
1170
|
for (const [scopeName, cursor] of Object.entries(scopeCursors)) {
|
|
1171
|
+
if (!canRead(scopeName)) continue;
|
|
787
1172
|
const scopeView = {
|
|
788
1173
|
...view,
|
|
789
1174
|
scopes: [scopeName]
|
|
@@ -817,7 +1202,7 @@ var ReactorClient = class {
|
|
|
817
1202
|
limit: 0
|
|
818
1203
|
}
|
|
819
1204
|
};
|
|
820
|
-
return this.
|
|
1205
|
+
return this.find({ ids: targetIds }, view, paging, signal);
|
|
821
1206
|
}
|
|
822
1207
|
/**
|
|
823
1208
|
* Retrieves incoming relationships of a given type to a target document.
|
|
@@ -833,14 +1218,90 @@ var ReactorClient = class {
|
|
|
833
1218
|
limit: 0
|
|
834
1219
|
}
|
|
835
1220
|
};
|
|
836
|
-
return this.
|
|
1221
|
+
return this.find({ ids: sourceIds }, view, paging, signal);
|
|
837
1222
|
}
|
|
838
1223
|
/**
|
|
839
1224
|
* Filters documents by criteria and returns a list of them
|
|
840
1225
|
*/
|
|
841
1226
|
async find(search, view, paging, signal) {
|
|
842
1227
|
this.logger.verbose("find(@search, @view, @paging)", search, view, paging);
|
|
843
|
-
|
|
1228
|
+
const results = await this.reactor.find(search, withAuthScope(view), paging, void 0, signal);
|
|
1229
|
+
return {
|
|
1230
|
+
...results,
|
|
1231
|
+
results: await Promise.all(results.results.map(async (doc) => filterReadableScopes(doc, await this.readableScopes(doc, view, signal))))
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Predicts the admission verdict for each candidate. See
|
|
1236
|
+
* {@link IReactorClient.evaluateActions} for the contract and its caveats.
|
|
1237
|
+
*
|
|
1238
|
+
* Read-only throughout, and never through the write cache: that cache is
|
|
1239
|
+
* invalidated by whichever process runs the executor, so a reactor running
|
|
1240
|
+
* its executors in worker processes would answer here from state no commit
|
|
1241
|
+
* ever invalidates.
|
|
1242
|
+
*/
|
|
1243
|
+
async evaluateActions(documentIdentifier, branch, candidates, subject, signal) {
|
|
1244
|
+
this.logger.verbose("evaluateActions(@documentIdentifier, @branch, @count candidates)", documentIdentifier, branch, candidates.length);
|
|
1245
|
+
const config = this.actionEvaluation;
|
|
1246
|
+
if (config === void 0) throw new AuthEnforcementDisabledError();
|
|
1247
|
+
const decidedSubject = this.readSubject(subject);
|
|
1248
|
+
const resolvedId = await this.documentView.resolveIdOrSlug(documentIdentifier, { branch }, void 0, signal);
|
|
1249
|
+
const targets = /* @__PURE__ */ new Map();
|
|
1250
|
+
const evaluations = [];
|
|
1251
|
+
for (const candidate of candidates) {
|
|
1252
|
+
if (candidate.type === "CREATE_DOCUMENT") {
|
|
1253
|
+
evaluations.push({ decision: "allow" });
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const targetId = evaluationTargetId(candidate, resolvedId);
|
|
1257
|
+
let target = targets.get(targetId);
|
|
1258
|
+
if (target === void 0) {
|
|
1259
|
+
target = await this.buildEvaluationTarget(config, targetId, branch, signal);
|
|
1260
|
+
targets.set(targetId, target);
|
|
1261
|
+
}
|
|
1262
|
+
evaluations.push(decideCandidate(config, target, decidedSubject, candidate));
|
|
1263
|
+
}
|
|
1264
|
+
const allowed = evaluations.filter((evaluation) => evaluation.decision === "allow").length;
|
|
1265
|
+
return {
|
|
1266
|
+
evaluations,
|
|
1267
|
+
allAllowed: candidates.length > 0 && allowed === candidates.length,
|
|
1268
|
+
anyAllowed: allowed > 0,
|
|
1269
|
+
allDenied: candidates.length > 0 && allowed === 0,
|
|
1270
|
+
anyDenied: allowed < candidates.length
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* The decision model for one target document, built at its stream heads.
|
|
1275
|
+
*
|
|
1276
|
+
* The document is fetched unfiltered, because the policy is what decides:
|
|
1277
|
+
* reading it through the read gate would withhold the very scopes the
|
|
1278
|
+
* decision is about. A deleted document is served at its deletion boundary,
|
|
1279
|
+
* which is what lets the model refuse an execute against it -- authEnforcement
|
|
1280
|
+
* requires documentDecisions, so that read is available whenever this runs.
|
|
1281
|
+
*
|
|
1282
|
+
* Reading past the gate discloses nothing a submit does not. The `auth` and
|
|
1283
|
+
* `document` scopes are readable by every holder, so a verdict resting on the
|
|
1284
|
+
* policy alone is one the caller could compute unaided; and a verdict resting
|
|
1285
|
+
* on a conditional grant reads the executing scope's state exactly as
|
|
1286
|
+
* admission reads it, so the answer here is what submitting and being refused
|
|
1287
|
+
* would have revealed anyway.
|
|
1288
|
+
*
|
|
1289
|
+
* The append condition the build records is dropped. It guards a write, and
|
|
1290
|
+
* this makes none; reproducing it is also what the preflight cannot do, which
|
|
1291
|
+
* is why the answer is a prediction.
|
|
1292
|
+
*/
|
|
1293
|
+
async buildEvaluationTarget(config, documentId, branch, signal) {
|
|
1294
|
+
const document = await this.documentView.get(documentId, { branch }, void 0, signal);
|
|
1295
|
+
const target = {
|
|
1296
|
+
documentId,
|
|
1297
|
+
branch
|
|
1298
|
+
};
|
|
1299
|
+
const built = await buildDecisionModel(new SeededStateReader(this.documentView, document, branch), config.model, target, signal);
|
|
1300
|
+
return {
|
|
1301
|
+
definition: config.model(target),
|
|
1302
|
+
model: built.model,
|
|
1303
|
+
scopeStates: document.state ?? {}
|
|
1304
|
+
};
|
|
844
1305
|
}
|
|
845
1306
|
/**
|
|
846
1307
|
* Creates a document and waits for completion
|
|
@@ -868,7 +1329,7 @@ var ReactorClient = class {
|
|
|
868
1329
|
documentId,
|
|
869
1330
|
model: document.header.documentType,
|
|
870
1331
|
fromVersion: 0,
|
|
871
|
-
toVersion: document.state.document
|
|
1332
|
+
toVersion: normalizeDocumentModelVersion(document.state.document?.version),
|
|
872
1333
|
initialState: document.state
|
|
873
1334
|
})], this.signer, signal);
|
|
874
1335
|
const jobs = [{
|
|
@@ -893,7 +1354,8 @@ var ReactorClient = class {
|
|
|
893
1354
|
const batchResult = await this.reactor.executeBatch({ jobs }, signal);
|
|
894
1355
|
const completedJobs = await Promise.all(Object.values(batchResult.jobs).map((job) => this.waitForJob(job, signal)));
|
|
895
1356
|
for (const job of completedJobs) if (job.status === JobStatus.FAILED) throw new Error(job.error?.message);
|
|
896
|
-
|
|
1357
|
+
const created = await this.reactor.get(documentId);
|
|
1358
|
+
return this.gateDocument(created, void 0, signal);
|
|
897
1359
|
}
|
|
898
1360
|
/**
|
|
899
1361
|
* Creates an empty document and waits for completion
|
|
@@ -903,20 +1365,64 @@ var ReactorClient = class {
|
|
|
903
1365
|
const matchingModules = (await this.reactor.getDocumentModels(void 0, void 0, signal)).results.filter((m) => m.documentModel.global.id === documentModelType);
|
|
904
1366
|
let module;
|
|
905
1367
|
if (options?.documentModelVersion !== void 0) {
|
|
906
|
-
|
|
1368
|
+
const requestedVersion = normalizeDocumentModelVersion(options.documentModelVersion);
|
|
1369
|
+
module = matchingModules.find((m) => normalizeDocumentModelVersion(m.version) === requestedVersion);
|
|
907
1370
|
if (!module) throw new Error(`Document model not found for type: ${documentModelType} with version: ${options.documentModelVersion}`);
|
|
908
1371
|
} else {
|
|
909
1372
|
module = matchingModules.reduce((latest, current) => {
|
|
910
1373
|
if (latest === void 0) return current;
|
|
911
|
-
return (current.version
|
|
1374
|
+
return normalizeDocumentModelVersion(current.version) > normalizeDocumentModelVersion(latest.version) ? current : latest;
|
|
912
1375
|
}, void 0);
|
|
913
1376
|
if (!module) throw new Error(`Document model not found for type: ${documentModelType}`);
|
|
914
1377
|
}
|
|
915
1378
|
const document = module.utils.createDocument();
|
|
916
|
-
document.state.document.version = module.version
|
|
1379
|
+
document.state.document.version = normalizeDocumentModelVersion(module.version);
|
|
917
1380
|
return this.create(document, options?.parentIdentifier, signal);
|
|
918
1381
|
}
|
|
919
1382
|
/**
|
|
1383
|
+
* Upgrades a document to a newer document model version by dispatching an
|
|
1384
|
+
* UPGRADE_DOCUMENT action. When toVersion is omitted, upgrades to the
|
|
1385
|
+
* latest registered module version for the document's type. Returns the
|
|
1386
|
+
* document unchanged when it is already at the target version.
|
|
1387
|
+
*
|
|
1388
|
+
* The executor validates the action's version and revision snapshot against
|
|
1389
|
+
* the state the migration actually runs on. When a concurrent edit
|
|
1390
|
+
* invalidates the snapshot, the upgrade is rebuilt from a fresh read and
|
|
1391
|
+
* retried up to maxConflictRetries times before the conflict is surfaced.
|
|
1392
|
+
*/
|
|
1393
|
+
async upgradeDocument(documentIdentifier, toVersion, options, signal) {
|
|
1394
|
+
this.logger.verbose("upgradeDocument(@documentIdentifier, @toVersion)", documentIdentifier, toVersion);
|
|
1395
|
+
const maxConflictRetries = options?.maxConflictRetries ?? 3;
|
|
1396
|
+
let lastConflictMessage = "";
|
|
1397
|
+
for (let attempt = 0; attempt <= maxConflictRetries; attempt++) {
|
|
1398
|
+
const document = await this.reactor.getByIdOrSlug(documentIdentifier, void 0, void 0, signal);
|
|
1399
|
+
const documentId = document.header.id;
|
|
1400
|
+
const documentType = document.header.documentType;
|
|
1401
|
+
const branch = document.header.branch || "main";
|
|
1402
|
+
const fromVersion = normalizeDocumentModelVersion(document.state.document?.version);
|
|
1403
|
+
let targetVersion = toVersion;
|
|
1404
|
+
if (targetVersion === void 0) targetVersion = normalizeDocumentModelVersion((await this.getDocumentModelModule(documentType)).version);
|
|
1405
|
+
if (targetVersion === fromVersion) return this.gateDocument(document, { branch }, signal);
|
|
1406
|
+
if (targetVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, targetVersion);
|
|
1407
|
+
const signedActions = await signActions([upgradeDocumentAction({
|
|
1408
|
+
documentId,
|
|
1409
|
+
model: documentType,
|
|
1410
|
+
fromVersion,
|
|
1411
|
+
toVersion: targetVersion,
|
|
1412
|
+
revision: { ...document.header.revision }
|
|
1413
|
+
})], this.signer, signal);
|
|
1414
|
+
const jobInfo = await this.reactor.execute(documentId, branch, signedActions, signal);
|
|
1415
|
+
const completedJob = await this.waitForJob(jobInfo, signal);
|
|
1416
|
+
if (completedJob.status !== JobStatus.FAILED) {
|
|
1417
|
+
const upgraded = await this.reactor.getByIdOrSlug(documentId, { branch }, completedJob.consistencyToken, signal);
|
|
1418
|
+
return this.gateDocument(upgraded, { branch }, signal);
|
|
1419
|
+
}
|
|
1420
|
+
if (completedJob.error?.name !== "UpgradePreconditionFailedError") throw new Error(completedJob.error?.message);
|
|
1421
|
+
lastConflictMessage = completedJob.error.message;
|
|
1422
|
+
}
|
|
1423
|
+
throw new Error(`Upgrade of document ${documentIdentifier} conflicted with concurrent edits after ${maxConflictRetries + 1} attempts: ${lastConflictMessage}`);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
920
1426
|
* Creates an empty document in a drive as a single batched operation.
|
|
921
1427
|
* Delegates to {@link IDriveClient.addFile}.
|
|
922
1428
|
*
|
|
@@ -936,7 +1442,8 @@ var ReactorClient = class {
|
|
|
936
1442
|
const completedJob = await this.waitForJob(jobInfo, signal);
|
|
937
1443
|
if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
|
|
938
1444
|
const view = { branch };
|
|
939
|
-
|
|
1445
|
+
const result = await this.reactor.getByIdOrSlug(documentIdentifier, view, completedJob.consistencyToken, signal);
|
|
1446
|
+
return this.gateDocument(result, view, signal);
|
|
940
1447
|
}
|
|
941
1448
|
/**
|
|
942
1449
|
* Submits a list of actions to a document
|
|
@@ -980,7 +1487,8 @@ var ReactorClient = class {
|
|
|
980
1487
|
const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
|
|
981
1488
|
const completedJob = await this.waitForJob(jobInfo, signal);
|
|
982
1489
|
if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
|
|
983
|
-
|
|
1490
|
+
const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
|
|
1491
|
+
return this.gateDocument(result, { branch }, signal);
|
|
984
1492
|
}
|
|
985
1493
|
/**
|
|
986
1494
|
* Removes a relationship between two documents and waits for completion.
|
|
@@ -990,7 +1498,8 @@ var ReactorClient = class {
|
|
|
990
1498
|
const jobInfo = await this.reactor.removeRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
|
|
991
1499
|
const completedJob = await this.waitForJob(jobInfo, signal);
|
|
992
1500
|
if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
|
|
993
|
-
|
|
1501
|
+
const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
|
|
1502
|
+
return this.gateDocument(result, { branch }, signal);
|
|
994
1503
|
}
|
|
995
1504
|
/**
|
|
996
1505
|
* Moves a relationship from one source document to another and waits for completion.
|
|
@@ -1003,9 +1512,11 @@ var ReactorClient = class {
|
|
|
1003
1512
|
const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
|
|
1004
1513
|
const addCompletedJob = await this.waitForJob(addJobInfo, signal);
|
|
1005
1514
|
if (addCompletedJob.status === JobStatus.FAILED) throw new Error(addCompletedJob.error?.message);
|
|
1515
|
+
const sourceResult = await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal);
|
|
1516
|
+
const targetResult = await this.reactor.getByIdOrSlug(targetParentIdentifier, { branch }, addCompletedJob.consistencyToken, signal);
|
|
1006
1517
|
return {
|
|
1007
|
-
source: await this.
|
|
1008
|
-
target: await this.
|
|
1518
|
+
source: await this.gateDocument(sourceResult, { branch }, signal),
|
|
1519
|
+
target: await this.gateDocument(targetResult, { branch }, signal)
|
|
1009
1520
|
};
|
|
1010
1521
|
}
|
|
1011
1522
|
async loadBatch(request, signal) {
|
|
@@ -1078,32 +1589,56 @@ var ReactorClient = class {
|
|
|
1078
1589
|
*/
|
|
1079
1590
|
subscribe(search, callback, view) {
|
|
1080
1591
|
this.logger.verbose("subscribe(@search, @view)", search, view);
|
|
1592
|
+
const readable = async (document) => filterReadableScopes(document, await this.readableScopes(document, view));
|
|
1593
|
+
let disposed = false;
|
|
1594
|
+
let delivering = Promise.resolve();
|
|
1595
|
+
/**
|
|
1596
|
+
* Queues one event behind those already queued. Gating an event resolves
|
|
1597
|
+
* asynchronously, so an event needing a group fetch would otherwise be
|
|
1598
|
+
* overtaken by the one behind it, and one still in flight would reach a
|
|
1599
|
+
* subscriber that has already unsubscribed. Only delivery is ordered, not
|
|
1600
|
+
* the gating: a single subscription can cover every document in the
|
|
1601
|
+
* reactor, so serializing that work would make its delivery rate the sum
|
|
1602
|
+
* of every gate build rather than the slowest. An event that cannot be
|
|
1603
|
+
* gated is withheld, because serving it unfiltered would leak the scopes
|
|
1604
|
+
* the gate did not clear -- but it is logged rather than swallowed, since
|
|
1605
|
+
* the gate rethrows a transient failure precisely so it is not read as a
|
|
1606
|
+
* denial.
|
|
1607
|
+
*/
|
|
1608
|
+
const deliver = (event) => {
|
|
1609
|
+
Promise.resolve(event).catch(() => void 0);
|
|
1610
|
+
delivering = delivering.then(async () => {
|
|
1611
|
+
const built = await event;
|
|
1612
|
+
if (disposed) return;
|
|
1613
|
+
callback(built);
|
|
1614
|
+
}).catch((error) => {
|
|
1615
|
+
this.logger.error("Subscription delivery failed for @search: @Error", { search }, error);
|
|
1616
|
+
});
|
|
1617
|
+
};
|
|
1081
1618
|
const unsubscribeCreated = this.subscriptionManager.onDocumentCreated((result) => {
|
|
1082
|
-
(async () => {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
} catch {}
|
|
1090
|
-
})();
|
|
1619
|
+
deliver((async () => {
|
|
1620
|
+
const documents = await Promise.all(result.results.map((id) => this.reactor.get(id, withAuthScope(view), void 0, void 0)));
|
|
1621
|
+
return {
|
|
1622
|
+
type: DocumentChangeType.Created,
|
|
1623
|
+
documents: await Promise.all(documents.map(readable))
|
|
1624
|
+
};
|
|
1625
|
+
})());
|
|
1091
1626
|
}, search);
|
|
1092
1627
|
const unsubscribeDeleted = this.subscriptionManager.onDocumentDeleted((documentIds) => {
|
|
1093
|
-
|
|
1628
|
+
deliver({
|
|
1094
1629
|
type: DocumentChangeType.Deleted,
|
|
1095
1630
|
documents: [],
|
|
1096
1631
|
context: { childId: documentIds[0] }
|
|
1097
1632
|
});
|
|
1098
1633
|
}, search);
|
|
1099
1634
|
const unsubscribeUpdated = this.subscriptionManager.onDocumentStateUpdated((result) => {
|
|
1100
|
-
|
|
1635
|
+
deliver((async () => ({
|
|
1101
1636
|
type: DocumentChangeType.Updated,
|
|
1102
|
-
documents: result.results
|
|
1103
|
-
});
|
|
1637
|
+
documents: await Promise.all(result.results.map(readable))
|
|
1638
|
+
}))());
|
|
1104
1639
|
}, search, view);
|
|
1105
1640
|
const unsubscribeRelationship = this.subscriptionManager.onRelationshipChanged((parentId, childId, changeType) => {
|
|
1106
|
-
|
|
1641
|
+
deliver({
|
|
1107
1642
|
type: changeType === RelationshipChangeType.Added ? DocumentChangeType.ChildAdded : DocumentChangeType.ChildRemoved,
|
|
1108
1643
|
documents: [],
|
|
1109
1644
|
context: {
|
|
@@ -1113,6 +1648,7 @@ var ReactorClient = class {
|
|
|
1113
1648
|
});
|
|
1114
1649
|
}, search);
|
|
1115
1650
|
return () => {
|
|
1651
|
+
disposed = true;
|
|
1116
1652
|
unsubscribeCreated();
|
|
1117
1653
|
unsubscribeDeleted();
|
|
1118
1654
|
unsubscribeUpdated();
|
|
@@ -1144,17 +1680,33 @@ let JobQueueState = /* @__PURE__ */ function(JobQueueState) {
|
|
|
1144
1680
|
return JobQueueState;
|
|
1145
1681
|
}({});
|
|
1146
1682
|
/**
|
|
1683
|
+
* How a retry is accounted against the job's retry limit.
|
|
1684
|
+
* - `CountAgainstLimit` (default): a fault; the job eventually exhausts its
|
|
1685
|
+
* retries and fails terminally.
|
|
1686
|
+
* - `ExemptFromLimit`: not a fault, so the attempt is not charged to the job.
|
|
1687
|
+
* Used for concurrency conflicts, where the retry does new work.
|
|
1688
|
+
*/
|
|
1689
|
+
let RetryAccounting = /* @__PURE__ */ function(RetryAccounting) {
|
|
1690
|
+
RetryAccounting["CountAgainstLimit"] = "count-against-limit";
|
|
1691
|
+
RetryAccounting["ExemptFromLimit"] = "exempt-from-limit";
|
|
1692
|
+
return RetryAccounting;
|
|
1693
|
+
}({});
|
|
1694
|
+
/**
|
|
1147
1695
|
* Event types for the queue system
|
|
1148
1696
|
*/
|
|
1149
1697
|
const QueueEventTypes = { JOB_AVAILABLE: 1e4 };
|
|
1150
1698
|
//#endregion
|
|
1151
1699
|
//#region src/executor/job-result-handler.ts
|
|
1700
|
+
/** Conflict retries a job may take without charging its retry limit. */
|
|
1701
|
+
const MAX_EXEMPT_CONFLICT_RETRIES = 20;
|
|
1152
1702
|
function toErrorInfo(error) {
|
|
1153
1703
|
if (error instanceof Error) return {
|
|
1704
|
+
name: error.name,
|
|
1154
1705
|
message: error.message,
|
|
1155
1706
|
stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
|
|
1156
1707
|
};
|
|
1157
1708
|
return {
|
|
1709
|
+
name: "Error",
|
|
1158
1710
|
message: error,
|
|
1159
1711
|
stack: (/* @__PURE__ */ new Error()).stack || ""
|
|
1160
1712
|
};
|
|
@@ -1187,12 +1739,19 @@ var JobResultHandler = class {
|
|
|
1187
1739
|
} catch {}
|
|
1188
1740
|
}
|
|
1189
1741
|
}
|
|
1742
|
+
if (result.error && AppendConditionFailedError.isError(result.error) && this.countConflicts(handle.job) < MAX_EXEMPT_CONFLICT_RETRIES) {
|
|
1743
|
+
const errorInfo = toErrorInfo(result.error);
|
|
1744
|
+
try {
|
|
1745
|
+
await this.queue.retryJob(handle.job.id, errorInfo, RetryAccounting.ExemptFromLimit);
|
|
1746
|
+
return;
|
|
1747
|
+
} catch {}
|
|
1748
|
+
}
|
|
1190
1749
|
if (result.error && DocumentNotFoundError.isError(result.error)) {
|
|
1191
1750
|
handle.defer();
|
|
1192
1751
|
callbacks.deferJob(handle.job.documentId, handle.job);
|
|
1193
1752
|
return;
|
|
1194
1753
|
}
|
|
1195
|
-
if (result.error && DocumentDeletedError.isError(result.error)) {
|
|
1754
|
+
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
1755
|
const errorInfo = toErrorInfo(result.error);
|
|
1197
1756
|
this.jobTracker.markFailed(handle.job.id, errorInfo, handle.job);
|
|
1198
1757
|
this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
|
|
@@ -1230,6 +1789,12 @@ var JobResultHandler = class {
|
|
|
1230
1789
|
handle.fail(fullErrorInfo);
|
|
1231
1790
|
}
|
|
1232
1791
|
}
|
|
1792
|
+
/** How many times this job has already lost an append-condition race. */
|
|
1793
|
+
countConflicts(job) {
|
|
1794
|
+
let conflicts = 0;
|
|
1795
|
+
for (const error of job.errorHistory) if (AppendConditionFailedError.isFailureMessage(error.message)) conflicts++;
|
|
1796
|
+
return conflicts;
|
|
1797
|
+
}
|
|
1233
1798
|
hasCreateDocumentAction(job) {
|
|
1234
1799
|
for (const action of job.actions) if (action.type === "CREATE_DOCUMENT") return true;
|
|
1235
1800
|
for (const operation of job.operations) if (operation.action.type === "CREATE_DOCUMENT") return true;
|
|
@@ -1245,6 +1810,7 @@ var JobResultHandler = class {
|
|
|
1245
1810
|
stackLines.push(`[Attempt ${index + 1}] Stack trace:\n${error.stack}`);
|
|
1246
1811
|
});
|
|
1247
1812
|
return {
|
|
1813
|
+
name: currentError.name,
|
|
1248
1814
|
message: messageLines.join("\n"),
|
|
1249
1815
|
stack: stackLines.join("\n\n")
|
|
1250
1816
|
};
|
|
@@ -1850,6 +2416,7 @@ var InMemoryJobTracker = class {
|
|
|
1850
2416
|
}
|
|
1851
2417
|
handleJobFailed(event) {
|
|
1852
2418
|
this.markFailed(event.jobId, {
|
|
2419
|
+
name: event.error.name,
|
|
1853
2420
|
message: event.error.message,
|
|
1854
2421
|
stack: event.error.stack || ""
|
|
1855
2422
|
}, event.job);
|
|
@@ -2311,10 +2878,12 @@ var InMemoryQueue = class {
|
|
|
2311
2878
|
}
|
|
2312
2879
|
toErrorInfo(error) {
|
|
2313
2880
|
if (error instanceof Error) return {
|
|
2881
|
+
name: error.name,
|
|
2314
2882
|
message: error.message,
|
|
2315
2883
|
stack: error.stack || (/* @__PURE__ */ new Error()).stack || ""
|
|
2316
2884
|
};
|
|
2317
2885
|
return {
|
|
2886
|
+
name: "Error",
|
|
2318
2887
|
message: error,
|
|
2319
2888
|
stack: (/* @__PURE__ */ new Error()).stack || ""
|
|
2320
2889
|
};
|
|
@@ -2402,6 +2971,7 @@ var InMemoryQueue = class {
|
|
|
2402
2971
|
await this.resolver.ensureModelLoaded(documentType);
|
|
2403
2972
|
} catch {
|
|
2404
2973
|
await this.failJob(job.id, {
|
|
2974
|
+
name: "Error",
|
|
2405
2975
|
message: `Failed to load document model for type: ${documentType}`,
|
|
2406
2976
|
stack: (/* @__PURE__ */ new Error()).stack || ""
|
|
2407
2977
|
});
|
|
@@ -2575,9 +3145,11 @@ var InMemoryQueue = class {
|
|
|
2575
3145
|
this.jobIndex.delete(jobId);
|
|
2576
3146
|
this.completedJobs.add(jobId);
|
|
2577
3147
|
await this.remove(jobId);
|
|
3148
|
+
const emittedError = new Error(error?.message ?? "Job failed");
|
|
3149
|
+
if (error?.name) emittedError.name = error.name;
|
|
2578
3150
|
this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
|
|
2579
3151
|
jobId,
|
|
2580
|
-
error:
|
|
3152
|
+
error: emittedError,
|
|
2581
3153
|
job
|
|
2582
3154
|
}).catch(() => {});
|
|
2583
3155
|
this.checkDrained();
|
|
@@ -2587,7 +3159,7 @@ var InMemoryQueue = class {
|
|
|
2587
3159
|
if (documentId) this.markJobComplete(jobId, documentId);
|
|
2588
3160
|
this.jobIndex.delete(jobId);
|
|
2589
3161
|
}
|
|
2590
|
-
async retryJob(jobId, error) {
|
|
3162
|
+
async retryJob(jobId, error, accounting = RetryAccounting.CountAgainstLimit) {
|
|
2591
3163
|
const job = this.jobIndex.get(jobId);
|
|
2592
3164
|
if (!job) return;
|
|
2593
3165
|
job.lastError = error;
|
|
@@ -2596,9 +3168,10 @@ var InMemoryQueue = class {
|
|
|
2596
3168
|
this.jobIndex.delete(jobId);
|
|
2597
3169
|
this.jobIdToQueueKey.delete(jobId);
|
|
2598
3170
|
if (error) job.errorHistory.push(error);
|
|
3171
|
+
const retryCount = job.retryCount || 0;
|
|
2599
3172
|
const updatedJob = {
|
|
2600
3173
|
...job,
|
|
2601
|
-
retryCount:
|
|
3174
|
+
retryCount: accounting === RetryAccounting.CountAgainstLimit ? retryCount + 1 : retryCount,
|
|
2602
3175
|
lastError: error
|
|
2603
3176
|
};
|
|
2604
3177
|
await this.enqueue(updatedJob);
|
|
@@ -3116,6 +3689,83 @@ var SubscriptionNotificationReadModel = class {
|
|
|
3116
3689
|
}
|
|
3117
3690
|
};
|
|
3118
3691
|
//#endregion
|
|
3692
|
+
//#region src/core/group-reevaluation-trigger.ts
|
|
3693
|
+
/**
|
|
3694
|
+
* Watches committed writes for group membership changes and enqueues a
|
|
3695
|
+
* re-evaluation job for every document whose auth history references the
|
|
3696
|
+
* changed group, found through the reverse direction of the group-reference
|
|
3697
|
+
* relation. Each affected document is re-judged in its own job, so the work
|
|
3698
|
+
* runs under that document's execution slot rather than the group's.
|
|
3699
|
+
*
|
|
3700
|
+
* The job carries the earliest changed membership timestamp; the executor
|
|
3701
|
+
* skips the pass when everything the document holds sorts before it, which
|
|
3702
|
+
* keeps the common case (a membership write later than all history) free.
|
|
3703
|
+
*/
|
|
3704
|
+
var GroupReevaluationTrigger = class {
|
|
3705
|
+
unsubscribe;
|
|
3706
|
+
constructor(logger, eventBus, queue, operationIndex) {
|
|
3707
|
+
this.logger = logger;
|
|
3708
|
+
this.eventBus = eventBus;
|
|
3709
|
+
this.queue = queue;
|
|
3710
|
+
this.operationIndex = operationIndex;
|
|
3711
|
+
}
|
|
3712
|
+
startup() {
|
|
3713
|
+
this.unsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_WRITE_READY, async (_type, event) => this.onWriteReady(event));
|
|
3714
|
+
}
|
|
3715
|
+
shutdown() {
|
|
3716
|
+
this.unsubscribe?.();
|
|
3717
|
+
this.unsubscribe = void 0;
|
|
3718
|
+
}
|
|
3719
|
+
async onWriteReady(event) {
|
|
3720
|
+
const changed = /* @__PURE__ */ new Map();
|
|
3721
|
+
for (const { operation, context } of event.operations) {
|
|
3722
|
+
if (context.documentType !== groupDocumentType || context.scope !== "global" || !groupMembershipActionTypes.includes(operation.action.type)) continue;
|
|
3723
|
+
const existing = changed.get(context.documentId);
|
|
3724
|
+
if (existing === void 0 || Date.parse(operation.timestampUtcMs) < Date.parse(existing)) changed.set(context.documentId, operation.timestampUtcMs);
|
|
3725
|
+
}
|
|
3726
|
+
if (changed.size === 0) return;
|
|
3727
|
+
const affected = /* @__PURE__ */ new Map();
|
|
3728
|
+
for (const [groupId, timestamp] of changed) {
|
|
3729
|
+
let referencers;
|
|
3730
|
+
try {
|
|
3731
|
+
referencers = await this.operationIndex.getGroupReferencers(groupId);
|
|
3732
|
+
} catch (error) {
|
|
3733
|
+
this.logger.error("Failed to resolve referencers of group @groupId: @error", groupId, error);
|
|
3734
|
+
continue;
|
|
3735
|
+
}
|
|
3736
|
+
for (const documentId of referencers) {
|
|
3737
|
+
const existing = affected.get(documentId);
|
|
3738
|
+
if (existing === void 0 || Date.parse(timestamp) < Date.parse(existing)) affected.set(documentId, timestamp);
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
for (const [documentId, timestamp] of affected) {
|
|
3742
|
+
const jobId = v4();
|
|
3743
|
+
const job = {
|
|
3744
|
+
id: jobId,
|
|
3745
|
+
kind: "reevaluation",
|
|
3746
|
+
documentId,
|
|
3747
|
+
scope: "global",
|
|
3748
|
+
branch: "main",
|
|
3749
|
+
actions: [],
|
|
3750
|
+
operations: [],
|
|
3751
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3752
|
+
queueHint: [],
|
|
3753
|
+
maxRetries: 3,
|
|
3754
|
+
errorHistory: [],
|
|
3755
|
+
meta: {
|
|
3756
|
+
...buildSingleJobMeta(jobId),
|
|
3757
|
+
triggerTimestampUtcMs: timestamp
|
|
3758
|
+
}
|
|
3759
|
+
};
|
|
3760
|
+
try {
|
|
3761
|
+
await this.queue.enqueue(job);
|
|
3762
|
+
} catch (error) {
|
|
3763
|
+
this.logger.error("Failed to enqueue re-evaluation of @documentId: @error", documentId, error);
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
};
|
|
3768
|
+
//#endregion
|
|
3119
3769
|
//#region src/sync/types.ts
|
|
3120
3770
|
let ChannelScheme = /* @__PURE__ */ function(ChannelScheme) {
|
|
3121
3771
|
ChannelScheme["CONNECT"] = "connect";
|
|
@@ -3437,11 +4087,18 @@ var PollingChannelError = class extends Error {
|
|
|
3437
4087
|
var ChannelError = class extends Error {
|
|
3438
4088
|
source;
|
|
3439
4089
|
error;
|
|
3440
|
-
|
|
4090
|
+
/**
|
|
4091
|
+
* The classification when something other than the error carries it. Absent
|
|
4092
|
+
* means derive it from `error.name`; a dead letter mirrored from a peer sets it,
|
|
4093
|
+
* because only the message crosses the wire.
|
|
4094
|
+
*/
|
|
4095
|
+
errorType;
|
|
4096
|
+
constructor(source, error, errorType) {
|
|
3441
4097
|
super(`ChannelError[${source}]: ${error.message}`);
|
|
3442
4098
|
this.name = "ChannelError";
|
|
3443
4099
|
this.source = source;
|
|
3444
4100
|
this.error = error;
|
|
4101
|
+
this.errorType = errorType;
|
|
3445
4102
|
}
|
|
3446
4103
|
};
|
|
3447
4104
|
//#endregion
|
|
@@ -3646,6 +4303,7 @@ function toOperationWithContext(entry) {
|
|
|
3646
4303
|
skip: entry.skip,
|
|
3647
4304
|
hash: entry.hash,
|
|
3648
4305
|
timestampUtcMs: entry.timestampUtcMs,
|
|
4306
|
+
deniedReason: entry.deniedReason,
|
|
3649
4307
|
action: entry.action
|
|
3650
4308
|
},
|
|
3651
4309
|
context: {
|
|
@@ -3811,6 +4469,33 @@ function splitComponent(items, maxSize) {
|
|
|
3811
4469
|
for (let i = 0; i < sorted.length; i += maxSize) chunks.push(sorted.slice(i, i + maxSize));
|
|
3812
4470
|
return chunks;
|
|
3813
4471
|
}
|
|
4472
|
+
/**
|
|
4473
|
+
* Classifies a failure by error name rather than `instanceof`, because a failure
|
|
4474
|
+
* that crossed the pooled-worker boundary arrives as plain data.
|
|
4475
|
+
*/
|
|
4476
|
+
function classifyJobFailure(errorName) {
|
|
4477
|
+
switch (errorName) {
|
|
4478
|
+
case "AuthTimestampNotMonotonicError": return "AUTH_TIMESTAMP_NOT_MONOTONIC";
|
|
4479
|
+
case "InvalidOperationTimestampError": return "INVALID_TIMESTAMP";
|
|
4480
|
+
case "ExcessiveReshuffleError": return "EXCESSIVE_SHUFFLE";
|
|
4481
|
+
case "InvalidSignatureError": return "SIGNATURE_INVALID";
|
|
4482
|
+
case "HashMismatchError": return "HASH_MISMATCH";
|
|
4483
|
+
default: return "UNCLASSIFIED";
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
/** The explicit type when something else carried it, else derived by name. */
|
|
4487
|
+
function syncOperationErrorType(error) {
|
|
4488
|
+
return error?.errorType ?? classifyJobFailure(error?.error?.name ?? "Error");
|
|
4489
|
+
}
|
|
4490
|
+
/** Dead-letter types that must not stop the document syncing. */
|
|
4491
|
+
const NON_QUARANTINING_ERROR_TYPES = new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
|
|
4492
|
+
/**
|
|
4493
|
+
* A held auth operation must not quarantine: reconciling the two policies needs
|
|
4494
|
+
* the traffic a quarantine would stop.
|
|
4495
|
+
*/
|
|
4496
|
+
function quarantinesDocument(errorType) {
|
|
4497
|
+
return !NON_QUARANTINING_ERROR_TYPES.has(errorType);
|
|
4498
|
+
}
|
|
3814
4499
|
//#endregion
|
|
3815
4500
|
//#region src/sync/channels/interval-poll-timer.ts
|
|
3816
4501
|
const DEFAULT_CONFIG = {
|
|
@@ -3973,6 +4658,7 @@ function serializeEnvelope(envelope) {
|
|
|
3973
4658
|
hash: opWithContext.operation.hash,
|
|
3974
4659
|
skip: opWithContext.operation.skip,
|
|
3975
4660
|
error: opWithContext.operation.error,
|
|
4661
|
+
...opWithContext.operation.deniedReason !== void 0 ? { deniedReason: opWithContext.operation.deniedReason } : {},
|
|
3976
4662
|
id: opWithContext.operation.id,
|
|
3977
4663
|
action: serializeAction(opWithContext.operation.action)
|
|
3978
4664
|
},
|
|
@@ -4000,21 +4686,33 @@ function deserializeSignature(sig) {
|
|
|
4000
4686
|
return sig.split(", ");
|
|
4001
4687
|
}
|
|
4002
4688
|
/**
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
* strings. This function restores them to the Signature tuple format required for verification.
|
|
4689
|
+
* `isDenied` tests strictly against undefined, so a null left in place would mark
|
|
4690
|
+
* every synced operation denied. The key is removed rather than set to undefined,
|
|
4691
|
+
* so an operation that arrived without it stays identical to the one sent.
|
|
4007
4692
|
*/
|
|
4008
|
-
function
|
|
4009
|
-
const
|
|
4010
|
-
if (
|
|
4693
|
+
function normalizeAbsentFields(operation) {
|
|
4694
|
+
const wire = operation;
|
|
4695
|
+
if (wire.error !== null && wire.deniedReason !== null) return operation;
|
|
4696
|
+
const normalized = { ...operation };
|
|
4697
|
+
if (wire.error === null) delete normalized.error;
|
|
4698
|
+
if (wire.deniedReason === null) delete normalized.deniedReason;
|
|
4699
|
+
return normalized;
|
|
4700
|
+
}
|
|
4701
|
+
/** Restores signature tuples and null-valued optional fields to undefined. */
|
|
4702
|
+
function deserializeOperation(opWithContext) {
|
|
4703
|
+
const operation = normalizeAbsentFields(opWithContext.operation);
|
|
4704
|
+
const signer = operation.action.context?.signer;
|
|
4705
|
+
if (!signer?.signatures || signer.signatures.length === 0) return {
|
|
4706
|
+
...opWithContext,
|
|
4707
|
+
operation
|
|
4708
|
+
};
|
|
4011
4709
|
const deserializedSignatures = signer.signatures.map(deserializeSignature);
|
|
4012
4710
|
const deserializedOperation = {
|
|
4013
|
-
...
|
|
4711
|
+
...operation,
|
|
4014
4712
|
action: {
|
|
4015
|
-
...
|
|
4713
|
+
...operation.action,
|
|
4016
4714
|
context: {
|
|
4017
|
-
...
|
|
4715
|
+
...operation.action.context,
|
|
4018
4716
|
signer: {
|
|
4019
4717
|
...signer,
|
|
4020
4718
|
signatures: deserializedSignatures
|
|
@@ -4041,7 +4739,7 @@ function deserializeOperationSignatures(opWithContext) {
|
|
|
4041
4739
|
*/
|
|
4042
4740
|
function envelopesToSyncOperations(envelope, remoteName) {
|
|
4043
4741
|
if (!envelope.operations || envelope.operations.length === 0) return [];
|
|
4044
|
-
return batchOperationsByDocument(envelope.operations.map(
|
|
4742
|
+
return batchOperationsByDocument(envelope.operations.map(deserializeOperation)).map((batch) => {
|
|
4045
4743
|
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
4744
|
});
|
|
4047
4745
|
}
|
|
@@ -4053,6 +4751,12 @@ const getLatestAppliedOrdinal = (syncOps) => {
|
|
|
4053
4751
|
//#endregion
|
|
4054
4752
|
//#region src/sync/channels/gql-req-channel.ts
|
|
4055
4753
|
/**
|
|
4754
|
+
* Fields the auth projection added to the sync schema. A remote that predates
|
|
4755
|
+
* them rejects the whole query for naming one, so they are selected only while
|
|
4756
|
+
* the remote is known to serve them.
|
|
4757
|
+
*/
|
|
4758
|
+
const DECISION_FIELDS = ["deniedReason", "errorType"];
|
|
4759
|
+
/**
|
|
4056
4760
|
* GraphQL-based synchronization channel for network communication between reactors.
|
|
4057
4761
|
*/
|
|
4058
4762
|
var GqlRequestChannel = class {
|
|
@@ -4079,6 +4783,8 @@ var GqlRequestChannel = class {
|
|
|
4079
4783
|
isPushing = false;
|
|
4080
4784
|
pendingDrain = false;
|
|
4081
4785
|
receivingPages = false;
|
|
4786
|
+
/** Cleared for good the first time the remote rejects {@link DECISION_FIELDS}. */
|
|
4787
|
+
peerServesDecisionFields = true;
|
|
4082
4788
|
isRecovering = false;
|
|
4083
4789
|
connectionState = "connecting";
|
|
4084
4790
|
/** Latest unrecoverable error was an auth rejection; cleared on connect. */
|
|
@@ -4257,7 +4963,7 @@ var GqlRequestChannel = class {
|
|
|
4257
4963
|
const syncOps = [];
|
|
4258
4964
|
for (const dl of deadLetters) {
|
|
4259
4965
|
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)));
|
|
4966
|
+
syncOp.failed(new ChannelError(ChannelErrorSource.Outbox, new Error(dl.error), dl.errorType ?? void 0));
|
|
4261
4967
|
syncOps.push(syncOp);
|
|
4262
4968
|
}
|
|
4263
4969
|
this.deadLetter.add(...syncOps);
|
|
@@ -4349,7 +5055,44 @@ var GqlRequestChannel = class {
|
|
|
4349
5055
|
* Queries the remote GraphQL endpoint for sync envelopes.
|
|
4350
5056
|
*/
|
|
4351
5057
|
async pollSyncEnvelopes(ackOrdinal, latestOrdinal) {
|
|
4352
|
-
const
|
|
5058
|
+
const variables = {
|
|
5059
|
+
channelId: this.channelId,
|
|
5060
|
+
outboxAck: ackOrdinal,
|
|
5061
|
+
outboxLatest: latestOrdinal
|
|
5062
|
+
};
|
|
5063
|
+
let response;
|
|
5064
|
+
try {
|
|
5065
|
+
response = await this.executeGraphQL(this.pollQuery(this.peerServesDecisionFields), variables);
|
|
5066
|
+
} catch (error) {
|
|
5067
|
+
if (!this.rejectsDecisionFields(error)) throw error;
|
|
5068
|
+
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);
|
|
5069
|
+
this.peerServesDecisionFields = false;
|
|
5070
|
+
response = await this.executeGraphQL(this.pollQuery(false), variables);
|
|
5071
|
+
}
|
|
5072
|
+
return {
|
|
5073
|
+
envelopes: response.pollSyncEnvelopes.envelopes,
|
|
5074
|
+
ackOrdinal: response.pollSyncEnvelopes.ackOrdinal,
|
|
5075
|
+
deadLetters: response.pollSyncEnvelopes.deadLetters ?? [],
|
|
5076
|
+
hasMore: response.pollSyncEnvelopes.hasMore
|
|
5077
|
+
};
|
|
5078
|
+
}
|
|
5079
|
+
/**
|
|
5080
|
+
* True when the remote rejected the query for naming a field it does not
|
|
5081
|
+
* have. Selecting an unknown field fails validation for the whole query, so
|
|
5082
|
+
* an unhandled one takes the channel's polling down until the process
|
|
5083
|
+
* restarts rather than degrading.
|
|
5084
|
+
*/
|
|
5085
|
+
rejectsDecisionFields(error) {
|
|
5086
|
+
if (!this.peerServesDecisionFields) return false;
|
|
5087
|
+
if (!(error instanceof GraphQLRequestError) || error.category !== "graphql") return false;
|
|
5088
|
+
return DECISION_FIELDS.some((field) => error.message.includes(field));
|
|
5089
|
+
}
|
|
5090
|
+
/**
|
|
5091
|
+
* The poll query. `withDecisionFields` selects the two fields added with the
|
|
5092
|
+
* auth projection; a remote on the previous schema is polled without them.
|
|
5093
|
+
*/
|
|
5094
|
+
pollQuery(withDecisionFields) {
|
|
5095
|
+
return `
|
|
4353
5096
|
query PollSyncEnvelopes($channelId: String!, $outboxAck: Int!, $outboxLatest: Int!) {
|
|
4354
5097
|
pollSyncEnvelopes(channelId: $channelId, outboxAck: $outboxAck, outboxLatest: $outboxLatest) {
|
|
4355
5098
|
envelopes {
|
|
@@ -4364,6 +5107,7 @@ var GqlRequestChannel = class {
|
|
|
4364
5107
|
hash
|
|
4365
5108
|
skip
|
|
4366
5109
|
error
|
|
5110
|
+
${withDecisionFields ? "deniedReason" : ""}
|
|
4367
5111
|
id
|
|
4368
5112
|
action {
|
|
4369
5113
|
id
|
|
@@ -4407,6 +5151,7 @@ var GqlRequestChannel = class {
|
|
|
4407
5151
|
deadLetters {
|
|
4408
5152
|
documentId
|
|
4409
5153
|
error
|
|
5154
|
+
${withDecisionFields ? "errorType" : ""}
|
|
4410
5155
|
jobId
|
|
4411
5156
|
branch
|
|
4412
5157
|
scopes
|
|
@@ -4416,18 +5161,6 @@ var GqlRequestChannel = class {
|
|
|
4416
5161
|
}
|
|
4417
5162
|
}
|
|
4418
5163
|
`;
|
|
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
5164
|
}
|
|
4432
5165
|
/**
|
|
4433
5166
|
* Registers or updates this channel on the remote server via GraphQL mutation.
|
|
@@ -4909,7 +5642,8 @@ function rowToDeadLetterRecord(row) {
|
|
|
4909
5642
|
branch: row.branch,
|
|
4910
5643
|
operations: row.operations,
|
|
4911
5644
|
errorSource: row.error_source,
|
|
4912
|
-
errorMessage: row.error_message
|
|
5645
|
+
errorMessage: row.error_message,
|
|
5646
|
+
errorType: row.error_type ?? "UNCLASSIFIED"
|
|
4913
5647
|
};
|
|
4914
5648
|
}
|
|
4915
5649
|
function deadLetterRecordToRow(record) {
|
|
@@ -4923,7 +5657,8 @@ function deadLetterRecordToRow(record) {
|
|
|
4923
5657
|
branch: record.branch,
|
|
4924
5658
|
operations: JSON.stringify(record.operations),
|
|
4925
5659
|
error_source: record.errorSource,
|
|
4926
|
-
error_message: record.errorMessage
|
|
5660
|
+
error_message: record.errorMessage,
|
|
5661
|
+
error_type: record.errorType
|
|
4927
5662
|
};
|
|
4928
5663
|
}
|
|
4929
5664
|
/**
|
|
@@ -4980,9 +5715,11 @@ var KyselySyncDeadLetterStorage = class {
|
|
|
4980
5715
|
}
|
|
4981
5716
|
async listQuarantinedDocumentIds(signal) {
|
|
4982
5717
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
4983
|
-
const rows = await this.db.selectFrom("sync_dead_letters").select("document_id").distinct().execute();
|
|
5718
|
+
const rows = await this.db.selectFrom("sync_dead_letters").select(["document_id", "error_type"]).distinct().execute();
|
|
4984
5719
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
4985
|
-
|
|
5720
|
+
const quarantined = /* @__PURE__ */ new Set();
|
|
5721
|
+
for (const row of rows) if (quarantinesDocument(row.error_type ?? "UNCLASSIFIED")) quarantined.add(row.document_id);
|
|
5722
|
+
return [...quarantined];
|
|
4986
5723
|
}
|
|
4987
5724
|
};
|
|
4988
5725
|
//#endregion
|
|
@@ -5655,7 +6392,8 @@ var SyncManager = class {
|
|
|
5655
6392
|
remote.channel.deadLetter.onAdded((syncOps) => {
|
|
5656
6393
|
for (const syncOp of syncOps) {
|
|
5657
6394
|
this.logger.error("Dead letter (@remote, @documentId, @jobId, @error, @dependencies)", remote.meta.name, syncOp.documentId, syncOp.jobId, syncOp.error?.message ?? "unknown", syncOp.jobDependencies);
|
|
5658
|
-
|
|
6395
|
+
const errorType = syncOperationErrorType(syncOp.error);
|
|
6396
|
+
if (quarantinesDocument(errorType)) this.quarantinedDocumentIds.add(syncOp.documentId);
|
|
5659
6397
|
const record = {
|
|
5660
6398
|
id: syncOp.id,
|
|
5661
6399
|
jobId: syncOp.jobId,
|
|
@@ -5666,7 +6404,8 @@ var SyncManager = class {
|
|
|
5666
6404
|
branch: syncOp.branch,
|
|
5667
6405
|
operations: syncOp.operations,
|
|
5668
6406
|
errorSource: syncOp.error?.source ?? ChannelErrorSource.None,
|
|
5669
|
-
errorMessage: syncOp.error?.error.message ?? "unknown"
|
|
6407
|
+
errorMessage: syncOp.error?.error.message ?? "unknown",
|
|
6408
|
+
errorType
|
|
5670
6409
|
};
|
|
5671
6410
|
this.deadLetterStorage.add(record).catch((err) => {
|
|
5672
6411
|
this.logger.error("Failed to persist dead letter (@id, @error)", record.id, err instanceof Error ? err.message : String(err));
|
|
@@ -5676,7 +6415,8 @@ var SyncManager = class {
|
|
|
5676
6415
|
jobId: record.jobId,
|
|
5677
6416
|
remoteName: record.remoteName,
|
|
5678
6417
|
documentId: record.documentId,
|
|
5679
|
-
errorSource: record.errorSource
|
|
6418
|
+
errorSource: record.errorSource,
|
|
6419
|
+
errorType: record.errorType
|
|
5680
6420
|
}).catch(() => {});
|
|
5681
6421
|
}
|
|
5682
6422
|
const items = remote.channel.deadLetter.items;
|
|
@@ -5703,7 +6443,7 @@ var SyncManager = class {
|
|
|
5703
6443
|
const syncOps = [];
|
|
5704
6444
|
for (const record of records) {
|
|
5705
6445
|
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)));
|
|
6446
|
+
syncOp.failed(new ChannelError(record.errorSource, new Error(record.errorMessage), record.errorType));
|
|
5707
6447
|
syncOps.push(syncOp);
|
|
5708
6448
|
}
|
|
5709
6449
|
remote.channel.deadLetter.add(...syncOps);
|
|
@@ -5784,8 +6524,7 @@ var SyncManager = class {
|
|
|
5784
6524
|
if (completedJobInfo.status === JobStatus.FAILED) {
|
|
5785
6525
|
const errorMessage = completedJobInfo.error?.message || "Unknown error";
|
|
5786
6526
|
this.logger.error("Failed to apply operations from inbox (@remote, @documentId, @jobId, @error)", remote.meta.name, syncOp.documentId, completedJobInfo.id, errorMessage);
|
|
5787
|
-
|
|
5788
|
-
syncOp.failed(error);
|
|
6527
|
+
syncOp.failed(this.inboxFailure(completedJobInfo.error));
|
|
5789
6528
|
remote.channel.deadLetter.add(syncOp);
|
|
5790
6529
|
} else syncOp.executed();
|
|
5791
6530
|
remote.channel.inbox.remove(syncOp);
|
|
@@ -5862,14 +6601,26 @@ var SyncManager = class {
|
|
|
5862
6601
|
}
|
|
5863
6602
|
if (this.isShutdown) return;
|
|
5864
6603
|
if (completedJobInfo.status === JobStatus.FAILED) {
|
|
5865
|
-
|
|
5866
|
-
const channelError = new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${errorMessage}`));
|
|
5867
|
-
syncOp.failed(channelError);
|
|
6604
|
+
syncOp.failed(this.inboxFailure(completedJobInfo.error));
|
|
5868
6605
|
remote.channel.deadLetter.add(syncOp);
|
|
5869
6606
|
} else syncOp.executed();
|
|
5870
6607
|
remote.channel.inbox.remove(syncOp);
|
|
5871
6608
|
}
|
|
5872
6609
|
}
|
|
6610
|
+
/**
|
|
6611
|
+
* The dead letter for a load job the executor failed.
|
|
6612
|
+
*
|
|
6613
|
+
* The classification is passed explicitly because it cannot be recovered
|
|
6614
|
+
* downstream: the wrapper carries the failure's message, not the failure, so
|
|
6615
|
+
* deriving it from the wrapper's own name would classify every one of these
|
|
6616
|
+
* as unclassified and quarantine the document. A held auth operation must
|
|
6617
|
+
* keep syncing, because reconciling the two policies needs the traffic a
|
|
6618
|
+
* quarantine would stop.
|
|
6619
|
+
*/
|
|
6620
|
+
inboxFailure(error) {
|
|
6621
|
+
const message = error?.message || "Unknown error";
|
|
6622
|
+
return new ChannelError(ChannelErrorSource.Inbox, /* @__PURE__ */ new Error(`Failed to apply operations: ${message}`), classifyJobFailure(error?.name ?? "Error"));
|
|
6623
|
+
}
|
|
5873
6624
|
async updateOutbox(remote, ackOrdinal, mode = OutboxMode.Backfill, signal) {
|
|
5874
6625
|
const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
|
|
5875
6626
|
let maxOrdinal = ackOrdinal;
|
|
@@ -6190,7 +6941,7 @@ var Reactor = class {
|
|
|
6190
6941
|
documentId: document.header.id,
|
|
6191
6942
|
model: document.header.documentType,
|
|
6192
6943
|
fromVersion: 0,
|
|
6193
|
-
toVersion: document.state.document
|
|
6944
|
+
toVersion: normalizeDocumentModelVersion(document.state.document?.version),
|
|
6194
6945
|
initialState: document.state
|
|
6195
6946
|
})];
|
|
6196
6947
|
if (signer) actions = await signActions(actions, signer, signal);
|
|
@@ -6769,6 +7520,8 @@ var ReactorBuilder = class {
|
|
|
6769
7520
|
}
|
|
6770
7521
|
async buildModule() {
|
|
6771
7522
|
if (!this.logger) this.logger = new ConsoleLogger(["reactor"]);
|
|
7523
|
+
const featureFlags = resolveFeatureFlags(this.executorConfig.featureFlags);
|
|
7524
|
+
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
7525
|
const resolvedSources = await resolveModelSources(this.documentModelSources);
|
|
6773
7526
|
if (this.workerPool) {
|
|
6774
7527
|
if (resolvedSources.manifest.length === 0) throw new Error("withWorkerPool requires at least one worker-importable document-model source ({ filePath } or { packageName }).");
|
|
@@ -6826,7 +7579,7 @@ var ReactorBuilder = class {
|
|
|
6826
7579
|
await executorManager.start(executorStartCount);
|
|
6827
7580
|
const readModelInstances = Array.from(new Set([...this.readModels]));
|
|
6828
7581
|
const documentViewConsistencyTracker = new ConsistencyTracker();
|
|
6829
|
-
const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker);
|
|
7582
|
+
const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
|
|
6830
7583
|
try {
|
|
6831
7584
|
await documentView.init();
|
|
6832
7585
|
} catch (error) {
|
|
@@ -6852,6 +7605,7 @@ var ReactorBuilder = class {
|
|
|
6852
7605
|
}
|
|
6853
7606
|
for (const factory of this.readModelFactories) {
|
|
6854
7607
|
const readModel = await factory({
|
|
7608
|
+
documentModelRegistry,
|
|
6855
7609
|
operationIndex,
|
|
6856
7610
|
writeCache,
|
|
6857
7611
|
processorManagerConsistencyTracker
|
|
@@ -6869,9 +7623,15 @@ var ReactorBuilder = class {
|
|
|
6869
7623
|
syncModule = this.syncBuilder.buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
|
|
6870
7624
|
await syncModule.syncManager.startup();
|
|
6871
7625
|
}
|
|
7626
|
+
let groupReevaluationTrigger;
|
|
7627
|
+
if (featureFlags.authGroups) {
|
|
7628
|
+
groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex);
|
|
7629
|
+
groupReevaluationTrigger.startup();
|
|
7630
|
+
}
|
|
6872
7631
|
const module = {
|
|
6873
7632
|
eventBus,
|
|
6874
7633
|
documentModelRegistry,
|
|
7634
|
+
featureFlags,
|
|
6875
7635
|
queue,
|
|
6876
7636
|
jobTracker,
|
|
6877
7637
|
executorManager,
|
|
@@ -6890,6 +7650,7 @@ var ReactorBuilder = class {
|
|
|
6890
7650
|
processorManagerConsistencyTracker,
|
|
6891
7651
|
syncModule,
|
|
6892
7652
|
reactor,
|
|
7653
|
+
groupReevaluationTrigger,
|
|
6893
7654
|
pools: this.instrumentedPools
|
|
6894
7655
|
};
|
|
6895
7656
|
if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
|
|
@@ -6962,9 +7723,9 @@ var ReactorBuilder = class {
|
|
|
6962
7723
|
*/
|
|
6963
7724
|
async createDefaultWorkerFactory(numWorkers, db, signatureVerifier) {
|
|
6964
7725
|
const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
|
|
6965
|
-
import("./worker-handle-
|
|
7726
|
+
import("./worker-handle-CrERzl8s.js"),
|
|
6966
7727
|
import("./transport-ByGviWdZ.js"),
|
|
6967
|
-
import("./worker-
|
|
7728
|
+
import("./worker-sw2vjrd3.js").then((n) => n.n)
|
|
6968
7729
|
]);
|
|
6969
7730
|
const poolConfig = {
|
|
6970
7731
|
enabled: true,
|
|
@@ -6985,7 +7746,8 @@ var ReactorBuilder = class {
|
|
|
6985
7746
|
poolConfig,
|
|
6986
7747
|
db,
|
|
6987
7748
|
signatureVerifier,
|
|
6988
|
-
models
|
|
7749
|
+
models,
|
|
7750
|
+
executorConfig: this.executorConfig
|
|
6989
7751
|
},
|
|
6990
7752
|
logger,
|
|
6991
7753
|
poolInstrumentation
|
|
@@ -7106,6 +7868,7 @@ var ReactorClientBuilder = class {
|
|
|
7106
7868
|
subscriptionManager;
|
|
7107
7869
|
jobAwaiter;
|
|
7108
7870
|
documentModelLoader;
|
|
7871
|
+
readGate;
|
|
7109
7872
|
/**
|
|
7110
7873
|
* Sets the logger for the ReactorClient.
|
|
7111
7874
|
* @param logger - The logger to use.
|
|
@@ -7158,6 +7921,47 @@ var ReactorClientBuilder = class {
|
|
|
7158
7921
|
this.documentModelLoader = loader;
|
|
7159
7922
|
return this;
|
|
7160
7923
|
}
|
|
7924
|
+
/**
|
|
7925
|
+
* Overrides how reads are gated. A client built from a ReactorBuilder derives
|
|
7926
|
+
* this from that reactor's flags; one built from `withReactor` cannot, because
|
|
7927
|
+
* it is handed no flags and no registry, so it gates on the policy alone
|
|
7928
|
+
* unless a gate is supplied here.
|
|
7929
|
+
*/
|
|
7930
|
+
withReadGate(readGate) {
|
|
7931
|
+
this.readGate = readGate;
|
|
7932
|
+
return this;
|
|
7933
|
+
}
|
|
7934
|
+
/**
|
|
7935
|
+
* The gate the resolved model calls for. Below authEnforcement there is no
|
|
7936
|
+
* model to enforce -- the registered one ignores the auth scope -- so the
|
|
7937
|
+
* policy is evaluated on its own, which is what reads did before the model
|
|
7938
|
+
* existed. Group serving turns on with authGroups, because below it a
|
|
7939
|
+
* `{ group }` grant does not match, so a served roster is one no grant can
|
|
7940
|
+
* use.
|
|
7941
|
+
*/
|
|
7942
|
+
resolveReadGate(reactorModule, documentView, model) {
|
|
7943
|
+
if (!reactorModule || model === void 0) return new BareReadGate();
|
|
7944
|
+
return new ModelReadGate(model, documentView, reactorModule.featureFlags.authGroups, reactorModule.operationIndex, this.logger);
|
|
7945
|
+
}
|
|
7946
|
+
/**
|
|
7947
|
+
* What the client answers an authorization preflight from, or undefined when
|
|
7948
|
+
* it answers none.
|
|
7949
|
+
*
|
|
7950
|
+
* Resolved from the same model reads enforce, so a preflight and a read can
|
|
7951
|
+
* never decide against different models. Undefined below authEnforcement, and
|
|
7952
|
+
* undefined on the `withReactor` path, where there are no flags and no
|
|
7953
|
+
* registry to select a model with -- a client with no model refuses the
|
|
7954
|
+
* preflight rather than answering it from the legacy host-side permission
|
|
7955
|
+
* tables. Deliberately not derived from the read gate: `withReadGate`
|
|
7956
|
+
* overrides that, so sniffing the gate's type would report enforcement from a
|
|
7957
|
+
* caller's substitution.
|
|
7958
|
+
*/
|
|
7959
|
+
resolveActionEvaluation(reactorModule, model) {
|
|
7960
|
+
return reactorModule && model ? {
|
|
7961
|
+
model,
|
|
7962
|
+
flags: reactorModule.featureFlags
|
|
7963
|
+
} : void 0;
|
|
7964
|
+
}
|
|
7161
7965
|
async build() {
|
|
7162
7966
|
return (await this.buildModule()).client;
|
|
7163
7967
|
}
|
|
@@ -7186,8 +7990,9 @@ var ReactorClientBuilder = class {
|
|
|
7186
7990
|
const signer = this.signer ?? new PassthroughSigner();
|
|
7187
7991
|
const subscriptionManager = this.subscriptionManager ?? reactorModule?.subscriptionManager ?? new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
|
|
7188
7992
|
const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
|
|
7993
|
+
const decisionModel = reactorModule ? readDecisionModel(reactorModule.featureFlags, reactorModule.documentModelRegistry) : void 0;
|
|
7189
7994
|
return {
|
|
7190
|
-
client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView),
|
|
7995
|
+
client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel)),
|
|
7191
7996
|
reactor,
|
|
7192
7997
|
eventBus,
|
|
7193
7998
|
documentIndexer,
|
|
@@ -7225,6 +8030,43 @@ function driveIdFromUrl(url) {
|
|
|
7225
8030
|
return url.split("/").pop() ?? "";
|
|
7226
8031
|
}
|
|
7227
8032
|
//#endregion
|
|
8033
|
+
//#region src/decision/stream-order.ts
|
|
8034
|
+
/**
|
|
8035
|
+
* The first pair of effective operations whose stored order contradicts their
|
|
8036
|
+
* timestamps, or undefined when the stream is in position order.
|
|
8037
|
+
*
|
|
8038
|
+
* Such a stream cannot be walked, and the auth stream is never reshuffled once
|
|
8039
|
+
* the monotonic rule is on, so run this before enabling enforcement on a fleet.
|
|
8040
|
+
*
|
|
8041
|
+
* `requireStrict` additionally rejects a tie, which is what the auth stream's
|
|
8042
|
+
* monotonic rule requires and what the walk alone does not care about.
|
|
8043
|
+
*/
|
|
8044
|
+
function firstOutOfOrderPair(operations, options) {
|
|
8045
|
+
const requireStrict = options?.requireStrict ?? false;
|
|
8046
|
+
const effective = garbageCollect(sortOperations([...operations]));
|
|
8047
|
+
for (let i = 1; i < effective.length; i++) {
|
|
8048
|
+
const previous = effective[i - 1];
|
|
8049
|
+
const current = effective[i];
|
|
8050
|
+
const previousAt = Date.parse(previous.timestampUtcMs);
|
|
8051
|
+
const currentAt = Date.parse(current.timestampUtcMs);
|
|
8052
|
+
if (currentAt < previousAt) return {
|
|
8053
|
+
previous,
|
|
8054
|
+
current,
|
|
8055
|
+
kind: "descending"
|
|
8056
|
+
};
|
|
8057
|
+
if (requireStrict && currentAt === previousAt) return {
|
|
8058
|
+
previous,
|
|
8059
|
+
current,
|
|
8060
|
+
kind: "tied"
|
|
8061
|
+
};
|
|
8062
|
+
}
|
|
8063
|
+
}
|
|
8064
|
+
//#endregion
|
|
8065
|
+
//#region src/read-models/interfaces.ts
|
|
8066
|
+
function supportsLiveReadModelRegistration(coordinator) {
|
|
8067
|
+
return "addReadModel" in coordinator && typeof coordinator.addReadModel === "function";
|
|
8068
|
+
}
|
|
8069
|
+
//#endregion
|
|
7228
8070
|
//#region src/admin/passthrough-keyframe-store.ts
|
|
7229
8071
|
const passthroughKeyframeStore = {
|
|
7230
8072
|
putKeyframe: () => Promise.resolve(),
|
|
@@ -7250,6 +8092,7 @@ var DocumentIntegrityService = class {
|
|
|
7250
8092
|
async validateDocument(documentId, branch = "main", signal) {
|
|
7251
8093
|
const keyframeIssues = [];
|
|
7252
8094
|
const snapshotIssues = [];
|
|
8095
|
+
const streamOrderIssues = await this.findStreamOrderIssues(documentId, branch, signal);
|
|
7253
8096
|
const replayCache = new KyselyWriteCache(passthroughKeyframeStore, this.operationStore, this.documentModelRegistry, {
|
|
7254
8097
|
maxDocuments: 1,
|
|
7255
8098
|
ringBufferSize: 1,
|
|
@@ -7276,9 +8119,10 @@ var DocumentIntegrityService = class {
|
|
|
7276
8119
|
} catch {
|
|
7277
8120
|
return {
|
|
7278
8121
|
documentId,
|
|
7279
|
-
isConsistent: keyframeIssues.length === 0,
|
|
8122
|
+
isConsistent: keyframeIssues.length === 0 && streamOrderIssues.length === 0,
|
|
7280
8123
|
keyframeIssues,
|
|
7281
|
-
snapshotIssues
|
|
8124
|
+
snapshotIssues,
|
|
8125
|
+
streamOrderIssues
|
|
7282
8126
|
};
|
|
7283
8127
|
}
|
|
7284
8128
|
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
@@ -7304,9 +8148,10 @@ var DocumentIntegrityService = class {
|
|
|
7304
8148
|
}
|
|
7305
8149
|
return {
|
|
7306
8150
|
documentId,
|
|
7307
|
-
isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0,
|
|
8151
|
+
isConsistent: keyframeIssues.length === 0 && snapshotIssues.length === 0 && streamOrderIssues.length === 0,
|
|
7308
8152
|
keyframeIssues,
|
|
7309
|
-
snapshotIssues
|
|
8153
|
+
snapshotIssues,
|
|
8154
|
+
streamOrderIssues
|
|
7310
8155
|
};
|
|
7311
8156
|
}
|
|
7312
8157
|
async rebuildKeyframes(documentId, branch = "main", signal) {
|
|
@@ -7328,12 +8173,26 @@ var DocumentIntegrityService = class {
|
|
|
7328
8173
|
scopesInvalidated: scopes.length
|
|
7329
8174
|
};
|
|
7330
8175
|
}
|
|
8176
|
+
async findStreamOrderIssues(documentId, branch, signal) {
|
|
8177
|
+
const scopes = await this.discoverScopes(documentId, branch, signal);
|
|
8178
|
+
const issues = [];
|
|
8179
|
+
for (const scope of scopes) {
|
|
8180
|
+
throwIfAborted(signal);
|
|
8181
|
+
const pair = firstOutOfOrderPair((await this.operationStore.getSince(documentId, scope, branch, -1, void 0, void 0, signal)).results, { requireStrict: scope === "auth" });
|
|
8182
|
+
if (pair !== void 0) issues.push({
|
|
8183
|
+
scope,
|
|
8184
|
+
branch,
|
|
8185
|
+
...pair
|
|
8186
|
+
});
|
|
8187
|
+
}
|
|
8188
|
+
return issues;
|
|
8189
|
+
}
|
|
7331
8190
|
async discoverScopes(documentId, branch, signal) {
|
|
7332
8191
|
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
7333
8192
|
return Object.keys(revisions.revision);
|
|
7334
8193
|
}
|
|
7335
8194
|
};
|
|
7336
8195
|
//#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 };
|
|
8196
|
+
export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, 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, SeededStateReader, 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
8197
|
|
|
7339
8198
|
//# sourceMappingURL=index.js.map
|