@powerhousedao/reactor 6.2.3-dev.5 → 6.2.3-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/document-indexer-3fkVWCaN.js +1856 -0
- package/dist/document-indexer-3fkVWCaN.js.map +1 -0
- package/dist/drive-container-types-BAfFzBOf.js +3892 -0
- package/dist/drive-container-types-BAfFzBOf.js.map +1 -0
- package/dist/entry.d.ts +2 -1
- package/dist/entry.js +111 -7
- package/dist/entry.js.map +1 -1
- package/dist/{errors-D3S6Eysd.js → errors-BM_ULVWd.js} +9 -1
- package/dist/{errors-D3S6Eysd.js.map → errors-BM_ULVWd.js.map} +1 -1
- package/dist/forwarding-logger-B9LLXItm.js +3667 -0
- package/dist/forwarding-logger-B9LLXItm.js.map +1 -0
- package/dist/index-dDm0fUBT.d.ts +6833 -0
- package/dist/index-dDm0fUBT.d.ts.map +1 -0
- package/dist/index.d.ts +1912 -5584
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7937 -1436
- package/dist/index.js.map +1 -1
- package/dist/load-spec-ZLjuyhxz.js +246 -0
- package/dist/load-spec-ZLjuyhxz.js.map +1 -0
- package/dist/projection-entry.d.ts +2 -1
- package/dist/projection-entry.js +99 -21
- package/dist/projection-entry.js.map +1 -1
- package/dist/{projection-shard-manager-CPQc5XHL.js → projection-shard-manager-Drl9w8Eo.js} +25 -15
- package/dist/projection-shard-manager-Drl9w8Eo.js.map +1 -0
- package/dist/{projection-worker-wI4PwcV2.js → projection-worker-D8uNFdwX.js} +1 -1
- package/dist/{projection-worker-wI4PwcV2.js.map → projection-worker-D8uNFdwX.js.map} +1 -1
- package/dist/rpc.d.ts +358 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +575 -0
- package/dist/rpc.js.map +1 -0
- package/dist/{transport-CuogVKN_.js → transport-B3G50KVd.js} +1 -1
- package/dist/{transport-CuogVKN_.js.map → transport-B3G50KVd.js.map} +1 -1
- package/dist/{transport-ByGviWdZ.js → transport-CZDHzfvy.js} +1 -1
- package/dist/{transport-ByGviWdZ.js.map → transport-CZDHzfvy.js.map} +1 -1
- package/dist/{worker-handle-CrERzl8s.js → worker-handle-Nso8i5eU.js} +30 -17
- package/dist/worker-handle-Nso8i5eU.js.map +1 -0
- package/dist/worker-pool-router-BROOEQSE.js +35 -0
- package/dist/worker-pool-router-BROOEQSE.js.map +1 -0
- package/package.json +34 -18
- package/dist/build-worker-executor-DnU3ZU4H.js +0 -83
- package/dist/build-worker-executor-DnU3ZU4H.js.map +0 -1
- package/dist/document-indexer-C5Gsa1B3.js +0 -958
- package/dist/document-indexer-C5Gsa1B3.js.map +0 -1
- package/dist/drive-container-types-yZrksiJR.js +0 -5310
- package/dist/drive-container-types-yZrksiJR.js.map +0 -1
- package/dist/error-info-Cpu4OY3o.js +0 -62
- package/dist/error-info-Cpu4OY3o.js.map +0 -1
- package/dist/forwarding-logger-BBkMSxuJ.js +0 -85
- package/dist/forwarding-logger-BBkMSxuJ.js.map +0 -1
- package/dist/projection-shard-manager-CPQc5XHL.js.map +0 -1
- package/dist/types-DMKLa0Ok.js +0 -33
- package/dist/types-DMKLa0Ok.js.map +0 -1
- package/dist/worker-HPysBfhx.js +0 -22
- package/dist/worker-HPysBfhx.js.map +0 -1
- package/dist/worker-handle-CrERzl8s.js.map +0 -1
|
@@ -0,0 +1,3892 @@
|
|
|
1
|
+
import { A as GATED_DOCUMENT_ACTIONS, At as yieldToMain, B as createOperation, Ct as ReactorEventTypes, F as buildErrorResult, G as relationshipTarget, H as isGenesisOperation, I as buildSuccessResult, J as targetDocumentId, K as submittedActionIds, M as TouchedStreams, N as applyDeleteDocumentAction$1, O as DOCUMENT_SCOPE_ACTIONS, P as applyUpgradeDocumentAction$1, R as createDocumentFromAction, Tt as delay, U as jobWriteIds, V as getNextIndexForScope, W as refusalError, Y as updateDocumentRevision, Z as AppendConditionFailedError, _ as acquirePurgeLocks, at as AuthorizationDeniedError, ct as DocumentNotDeletedError, dt as ExcessiveReshuffleError, ft as GroupInUseError, gt as PurgeTooLargeError, it as AuthTimestampNotMonotonicError, j as PurgeFence, k as FencedWriteCache, kt as withDeadline, lt as DocumentNotFoundError, mt as InvalidSignatureError, pt as InvalidOperationTimestampError, st as DocumentDeletedError, ut as DocumentPurgedError, v as findPurged, vt as ReservedActionError, wt as TIMED_OUT, xt as UpgradePreconditionFailedError, yt as UnsupportedProtocolVersionError } from "./forwarding-logger-B9LLXItm.js";
|
|
2
|
+
import { AUTH_ACTION_TYPES, AUTH_DENIED_BY_GRANT_REASON, AUTH_NO_GRANT_REASON, AUTH_VERSION_UNSUPPORTED_REASON, DOCUMENT_DELETED_REASON, PEER_CAPABILITIES, actionSignerIdentity, actionSigningTarget, applyAuthAction, applyDeleteDocumentAction, base58Decode, baseReducerVersion, buildOperationSignatureMessage, buildOperationSignatureParams, canonicalJson, decide, deriveDocumentId, deriveOperationId, evaluate, garbageCollect, groupDocumentType, groupMembershipActionTypes, hashActionV2, hashBrowser, hashDocumentStateForScope, isDenied, isDerivedDocumentId, isPurgeMarker, isUndoRedo, isV2ActionHash, localSupports, mentionedGroupIds, normalizeDocumentModelVersion, purgeDocumentAction, purgeMarkerOperation, referencedGroupIds, signaturePolicyOf, sortOperations, v2TupleProblem, withProtocolVersions } from "@powerhousedao/shared/document-model";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/shared/types.ts
|
|
5
|
+
/**
|
|
6
|
+
* Enum that determines deletion propagation.
|
|
7
|
+
*/
|
|
8
|
+
let PropagationMode = /* @__PURE__ */ function(PropagationMode) {
|
|
9
|
+
PropagationMode["None"] = "none";
|
|
10
|
+
PropagationMode["Cascade"] = "cascade";
|
|
11
|
+
return PropagationMode;
|
|
12
|
+
}({});
|
|
13
|
+
/**
|
|
14
|
+
* Enum that describes the type of relationship change.
|
|
15
|
+
*/
|
|
16
|
+
let RelationshipChangeType = /* @__PURE__ */ function(RelationshipChangeType) {
|
|
17
|
+
RelationshipChangeType["Added"] = "added";
|
|
18
|
+
RelationshipChangeType["Removed"] = "removed";
|
|
19
|
+
return RelationshipChangeType;
|
|
20
|
+
}({});
|
|
21
|
+
/**
|
|
22
|
+
* Marks a load whose operations a sync cursor keeps re-pullable, so its
|
|
23
|
+
* write-ready needs no flush. A symbol, not exported from the package: it
|
|
24
|
+
* cannot be set by an outside caller or survive any serialization boundary.
|
|
25
|
+
*/
|
|
26
|
+
const CURSOR_PROTECTED = Symbol("cursorProtected");
|
|
27
|
+
/** Load meta for the sync manager's inbox, the only owner of the cursor. */
|
|
28
|
+
function cursorProtectedLoadMeta(sourceRemote) {
|
|
29
|
+
return {
|
|
30
|
+
sourceRemote,
|
|
31
|
+
[CURSOR_PROTECTED]: true
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function isCursorProtectedLoad(job) {
|
|
35
|
+
const meta = job.meta;
|
|
36
|
+
return job.kind === "load" && meta[CURSOR_PROTECTED] === true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Job execution statuses
|
|
40
|
+
*/
|
|
41
|
+
let JobStatus = /* @__PURE__ */ function(JobStatus) {
|
|
42
|
+
/** Job is queued but not yet started */
|
|
43
|
+
JobStatus["PENDING"] = "PENDING";
|
|
44
|
+
/** Job is currently being executed */
|
|
45
|
+
JobStatus["RUNNING"] = "RUNNING";
|
|
46
|
+
/** Operations have been written to the operation store (JOB_WRITE_READY event) */
|
|
47
|
+
JobStatus["WRITE_READY"] = "WRITE_READY";
|
|
48
|
+
/** Read models have finished indexing operations (JOB_READ_READY event) */
|
|
49
|
+
JobStatus["READ_READY"] = "READ_READY";
|
|
50
|
+
/** Job failed (may be retried) */
|
|
51
|
+
JobStatus["FAILED"] = "FAILED";
|
|
52
|
+
return JobStatus;
|
|
53
|
+
}({});
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/cache/operation-index-types.ts
|
|
56
|
+
const DRIVE_COLLECTION_PREFIX = "drive.";
|
|
57
|
+
/**
|
|
58
|
+
* Identifies the collection a remote synchronizes. Collections are drive-level
|
|
59
|
+
* abstractions (document-drive and reactor-drive), so a collection id is the
|
|
60
|
+
* drive document id plus the branch it scopes to rather than an opaque string.
|
|
61
|
+
*
|
|
62
|
+
* The canonical string form (`drive.${branch}.${driveId}`) is produced only by
|
|
63
|
+
* `key` and parsed only by `fromKey`; that string is the wire and storage
|
|
64
|
+
* representation and is byte-for-byte identical to the legacy
|
|
65
|
+
* `driveCollectionId(branch, driveId)` output, so existing `document_collections`
|
|
66
|
+
* rows and persisted remotes remain valid without migration.
|
|
67
|
+
*/
|
|
68
|
+
var DriveCollectionId = class DriveCollectionId {
|
|
69
|
+
driveId;
|
|
70
|
+
branch;
|
|
71
|
+
constructor(driveId, branch) {
|
|
72
|
+
this.driveId = driveId;
|
|
73
|
+
this.branch = branch;
|
|
74
|
+
}
|
|
75
|
+
static forDrive(driveId, branch = "main") {
|
|
76
|
+
return new DriveCollectionId(driveId, branch);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The single deserializer for the wire/storage form. `branch` may contain
|
|
80
|
+
* dots, while `driveId` is a dot-free document id, so the drive id is the
|
|
81
|
+
* final dot-delimited segment.
|
|
82
|
+
*
|
|
83
|
+
* That asymmetry is a real constraint, not a convention: a drive id
|
|
84
|
+
* containing a dot does NOT survive `key` -> `fromKey`, because the split
|
|
85
|
+
* takes the LAST dot and so hands the tail of the drive id back as the drive
|
|
86
|
+
* id and everything before it as the branch. The key format is the wire and
|
|
87
|
+
* storage representation of existing `document_collections` rows, so it
|
|
88
|
+
* cannot be changed; callers that accept a drive id or branch from outside
|
|
89
|
+
* (a UI field, an RPC payload) must reject dots in the drive id at their own
|
|
90
|
+
* boundary. The round-trip is verifiable without parsing rules:
|
|
91
|
+
* `DriveCollectionId.fromKey(id.key).key === id.key`.
|
|
92
|
+
*/
|
|
93
|
+
static fromKey(key) {
|
|
94
|
+
if (!key.startsWith(DRIVE_COLLECTION_PREFIX)) throw new Error(`Unsupported collection id: ${key}`);
|
|
95
|
+
const rest = key.slice(6);
|
|
96
|
+
const lastDot = rest.lastIndexOf(".");
|
|
97
|
+
if (lastDot === -1 || lastDot === rest.length - 1) throw new Error(`Malformed drive collection id: ${key}`);
|
|
98
|
+
return new DriveCollectionId(rest.slice(lastDot + 1), rest.slice(0, lastDot));
|
|
99
|
+
}
|
|
100
|
+
get key() {
|
|
101
|
+
return `${DRIVE_COLLECTION_PREFIX}${this.branch}.${this.driveId}`;
|
|
102
|
+
}
|
|
103
|
+
toString() {
|
|
104
|
+
return this.key;
|
|
105
|
+
}
|
|
106
|
+
equals(other) {
|
|
107
|
+
return this.driveId === other.driveId && this.branch === other.branch;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/decision/build-decision-model.ts
|
|
112
|
+
/**
|
|
113
|
+
* Reads each projection's stream through the supplied reader, recording the
|
|
114
|
+
* revision observed. Static projections resolve first; derived projections
|
|
115
|
+
* see only those and contribute a map from document id to state. Each
|
|
116
|
+
* distinct stream is read once and yields one append condition entry.
|
|
117
|
+
*/
|
|
118
|
+
async function buildDecisionModel(reader, definition, target, signal) {
|
|
119
|
+
const decisionModel = definition(target);
|
|
120
|
+
const projections = Object.entries(decisionModel.projections);
|
|
121
|
+
const reads = /* @__PURE__ */ new Map();
|
|
122
|
+
const model = {};
|
|
123
|
+
for (const [key, projection] of projections) {
|
|
124
|
+
if (typeof projection.query === "function") continue;
|
|
125
|
+
model[key] = (await readStream(reader, projection.query, reads, signal)).state;
|
|
126
|
+
}
|
|
127
|
+
const staticModel = { ...model };
|
|
128
|
+
const derived = [];
|
|
129
|
+
for (const [key, projection] of projections) {
|
|
130
|
+
if (typeof projection.query !== "function") continue;
|
|
131
|
+
derived.push([key, projection.query(staticModel)]);
|
|
132
|
+
}
|
|
133
|
+
if (derived.length > 0 && reader.fence) await reader.fence(derived.flatMap(([, queries]) => queries.map((q) => q.documentId)));
|
|
134
|
+
for (const [key, queries] of derived) {
|
|
135
|
+
const value = {};
|
|
136
|
+
for (const query of queries) {
|
|
137
|
+
let read;
|
|
138
|
+
try {
|
|
139
|
+
read = await readStream(reader, query, reads, signal);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof DocumentNotFoundError) {
|
|
142
|
+
recordEmptyStream(query, reads);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
value[query.documentId] = read.state;
|
|
148
|
+
}
|
|
149
|
+
model[key] = value;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
model,
|
|
153
|
+
appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Guards a stream that holds nothing yet: any operation appearing is growth. */
|
|
157
|
+
function recordEmptyStream(query, reads) {
|
|
158
|
+
const key = `${query.documentId}:${query.scope}:${query.branch}`;
|
|
159
|
+
if (reads.has(key)) return;
|
|
160
|
+
reads.set(key, {
|
|
161
|
+
state: void 0,
|
|
162
|
+
stream: {
|
|
163
|
+
documentId: query.documentId,
|
|
164
|
+
scope: query.scope,
|
|
165
|
+
branch: query.branch,
|
|
166
|
+
revision: -1
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function readStream(reader, query, reads, signal) {
|
|
171
|
+
const key = `${query.documentId}:${query.scope}:${query.branch}`;
|
|
172
|
+
const existing = reads.get(key);
|
|
173
|
+
if (existing) return existing;
|
|
174
|
+
const document = await reader.getState(query.documentId, query.scope, query.branch, void 0, signal);
|
|
175
|
+
const read = {
|
|
176
|
+
state: document.state[query.scope],
|
|
177
|
+
stream: {
|
|
178
|
+
documentId: query.documentId,
|
|
179
|
+
scope: query.scope,
|
|
180
|
+
branch: query.branch,
|
|
181
|
+
revision: observedRevision(document, query.scope)
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
reads.set(key, read);
|
|
185
|
+
return read;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The highest operation index the document reflects for the scope, or -1 if
|
|
189
|
+
* empty. `header.revision` is authoritative, not the rebuilt operation list.
|
|
190
|
+
*/
|
|
191
|
+
function observedRevision(document, scope) {
|
|
192
|
+
if (scope in document.header.revision) return document.header.revision[scope] - 1;
|
|
193
|
+
if (scope in document.operations) {
|
|
194
|
+
const operations = document.operations[scope];
|
|
195
|
+
if (operations.length > 0) return operations[operations.length - 1].index;
|
|
196
|
+
}
|
|
197
|
+
if (!(scope in document.header.revision)) return -1;
|
|
198
|
+
return document.header.revision[scope] - 1;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The projections whose queries depend on folded state. A positional walk
|
|
202
|
+
* resolves their streams through `queryOverHistory`; a projection without one
|
|
203
|
+
* contributes no streams to a walk.
|
|
204
|
+
*/
|
|
205
|
+
function derivedReadSet(definition) {
|
|
206
|
+
const projections = [];
|
|
207
|
+
for (const [name, projection] of Object.entries(definition.projections)) {
|
|
208
|
+
if (typeof projection.query !== "function") continue;
|
|
209
|
+
projections.push({
|
|
210
|
+
name,
|
|
211
|
+
decidingActions: projection.decidingActions,
|
|
212
|
+
apply: projection.apply,
|
|
213
|
+
queryOverHistory: projection.queryOverHistory
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return projections;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The streams a model reads whose queries are known before it is built. A
|
|
220
|
+
* derived query needs the statically-queried projections first, so it is not
|
|
221
|
+
* included here.
|
|
222
|
+
*/
|
|
223
|
+
function staticReadSet(definition) {
|
|
224
|
+
const streams = [];
|
|
225
|
+
for (const [name, projection] of Object.entries(definition.projections)) {
|
|
226
|
+
if (typeof projection.query === "function") continue;
|
|
227
|
+
streams.push({
|
|
228
|
+
name,
|
|
229
|
+
query: projection.query,
|
|
230
|
+
decidingActions: projection.decidingActions,
|
|
231
|
+
apply: projection.apply
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return streams;
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/decision/auth-decision-model.ts
|
|
238
|
+
function refusalReason(refusal) {
|
|
239
|
+
switch (refusal) {
|
|
240
|
+
case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
|
|
241
|
+
case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
|
|
242
|
+
case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function decideAuthModel(model, subject, request, groups, conditions) {
|
|
246
|
+
if (request.verb === "execute" && model.document.isDeleted) return {
|
|
247
|
+
decision: "deny",
|
|
248
|
+
reason: DOCUMENT_DELETED_REASON
|
|
249
|
+
};
|
|
250
|
+
const evaluation = evaluate(model.auth, subject, request, groups, conditions);
|
|
251
|
+
if (evaluation.decision === "allow") return { decision: "allow" };
|
|
252
|
+
return {
|
|
253
|
+
decision: "deny",
|
|
254
|
+
reason: refusalReason(evaluation.refusal)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function documentProjection(target) {
|
|
258
|
+
return {
|
|
259
|
+
decidingActions: ["DELETE_DOCUMENT"],
|
|
260
|
+
apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
|
|
261
|
+
...document,
|
|
262
|
+
state: { ...document.state }
|
|
263
|
+
}, operation.action) : document,
|
|
264
|
+
query: {
|
|
265
|
+
documentId: target.documentId,
|
|
266
|
+
branch: target.branch,
|
|
267
|
+
scope: "document"
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function authProjection(target) {
|
|
272
|
+
return {
|
|
273
|
+
decidingActions: [...AUTH_ACTION_TYPES],
|
|
274
|
+
apply: (document, operation) => applyAuthAction(document, operation.action),
|
|
275
|
+
query: {
|
|
276
|
+
documentId: target.documentId,
|
|
277
|
+
branch: target.branch,
|
|
278
|
+
scope: "auth"
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** This decision model uses both the document and the auth streams. */
|
|
283
|
+
function authDecisionModel(target) {
|
|
284
|
+
return {
|
|
285
|
+
projections: {
|
|
286
|
+
document: documentProjection(target),
|
|
287
|
+
auth: authProjection(target)
|
|
288
|
+
},
|
|
289
|
+
evaluatesScope() {
|
|
290
|
+
return true;
|
|
291
|
+
},
|
|
292
|
+
decide(model, subject, request) {
|
|
293
|
+
return decideAuthModel(model, subject, request);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Folds one group-stream operation with the registered group model's reducer.
|
|
299
|
+
* A reactor without the module registered folds nothing, so the member list
|
|
300
|
+
* stays as read and a missing reducer never widens access.
|
|
301
|
+
*/
|
|
302
|
+
function applyGroupOperation(registry, document, operation) {
|
|
303
|
+
let reducer;
|
|
304
|
+
try {
|
|
305
|
+
reducer = registry.getModule(groupDocumentType).reducer;
|
|
306
|
+
} catch {
|
|
307
|
+
return document;
|
|
308
|
+
}
|
|
309
|
+
return reducer(document, operation.action);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Folds one evaluated-scope operation with the reducer registered for the
|
|
313
|
+
* document's own type, at the document's stamped version. A reactor without
|
|
314
|
+
* that module folds nothing, so conditions read the base state and an
|
|
315
|
+
* unresolvable reducer never widens access.
|
|
316
|
+
*/
|
|
317
|
+
function applyModelOperation(registry, document, operation) {
|
|
318
|
+
let reducer;
|
|
319
|
+
try {
|
|
320
|
+
const version = normalizeDocumentModelVersion(document.state.document?.version);
|
|
321
|
+
reducer = registry.getModule(document.header.documentType, version).reducer;
|
|
322
|
+
} catch {
|
|
323
|
+
return document;
|
|
324
|
+
}
|
|
325
|
+
return reducer(document, operation.action);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The auth model extended with a derived groups projection: the streams it
|
|
329
|
+
* reads are the group documents the folded grant list names, so adding a
|
|
330
|
+
* grant that names a new group pulls that group's stream into the read-set.
|
|
331
|
+
* Group queries pin the main branch, because a group's member list lives on
|
|
332
|
+
* its main branch no matter which branch the referencing document is on.
|
|
333
|
+
*/
|
|
334
|
+
function groupsProjection(registry) {
|
|
335
|
+
return {
|
|
336
|
+
decidingActions: [...groupMembershipActionTypes],
|
|
337
|
+
apply: (document, operation) => applyGroupOperation(registry, document, operation),
|
|
338
|
+
query: (model) => referencedGroupIds(model.auth?.grants ?? []).map((id) => ({
|
|
339
|
+
documentId: id,
|
|
340
|
+
branch: "main",
|
|
341
|
+
scope: "global"
|
|
342
|
+
})),
|
|
343
|
+
queryOverHistory: (reads) => {
|
|
344
|
+
const ids = [];
|
|
345
|
+
for (const read of reads) {
|
|
346
|
+
if (read.name !== "auth") continue;
|
|
347
|
+
for (const operation of read.operations) for (const id of mentionedGroupIds(operation.action)) if (!ids.includes(id)) ids.push(id);
|
|
348
|
+
}
|
|
349
|
+
return ids.map((id) => ({
|
|
350
|
+
documentId: id,
|
|
351
|
+
branch: "main",
|
|
352
|
+
scope: "global"
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function authGroupsDecisionModel(registry) {
|
|
358
|
+
return (target) => ({
|
|
359
|
+
projections: {
|
|
360
|
+
document: documentProjection(target),
|
|
361
|
+
auth: authProjection(target),
|
|
362
|
+
groups: groupsProjection(registry)
|
|
363
|
+
},
|
|
364
|
+
evaluatesScope() {
|
|
365
|
+
return true;
|
|
366
|
+
},
|
|
367
|
+
decide(model, subject, request) {
|
|
368
|
+
return decideAuthModel(model, subject, request, model.groups);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The groups model with conditions live: decide hands the executing scope's
|
|
374
|
+
* state and the action input through to the evaluator, so `where` clauses
|
|
375
|
+
* and { match } principals apply. The model folds the evaluated scope during
|
|
376
|
+
* a positional walk, so a condition reads the state as it stood at each
|
|
377
|
+
* operation's position.
|
|
378
|
+
*/
|
|
379
|
+
function authConditionsDecisionModel(registry) {
|
|
380
|
+
return (target) => ({
|
|
381
|
+
projections: {
|
|
382
|
+
document: documentProjection(target),
|
|
383
|
+
auth: authProjection(target),
|
|
384
|
+
groups: groupsProjection(registry)
|
|
385
|
+
},
|
|
386
|
+
foldEvaluatedScope: (document, operation) => applyModelOperation(registry, document, operation),
|
|
387
|
+
evaluatesScope() {
|
|
388
|
+
return true;
|
|
389
|
+
},
|
|
390
|
+
decide(model, subject, request, ctx) {
|
|
391
|
+
return decideAuthModel(model, subject, request, model.groups, {
|
|
392
|
+
scopeState: ctx.scopeState,
|
|
393
|
+
actionInput: ctx.actionInput
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/decision/document-decision-model.ts
|
|
400
|
+
/**
|
|
401
|
+
* The simplest decision model: one projection over the document scope, which
|
|
402
|
+
* rejects on a deleted document.
|
|
403
|
+
*/
|
|
404
|
+
function documentDecisionModel(target) {
|
|
405
|
+
return {
|
|
406
|
+
projections: { document: {
|
|
407
|
+
decidingActions: ["DELETE_DOCUMENT"],
|
|
408
|
+
apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
|
|
409
|
+
...document,
|
|
410
|
+
state: { ...document.state }
|
|
411
|
+
}, operation.action) : document,
|
|
412
|
+
query: {
|
|
413
|
+
documentId: target.documentId,
|
|
414
|
+
branch: target.branch,
|
|
415
|
+
scope: "document"
|
|
416
|
+
}
|
|
417
|
+
} },
|
|
418
|
+
evaluatesScope() {
|
|
419
|
+
return true;
|
|
420
|
+
},
|
|
421
|
+
decide(model, subject, request) {
|
|
422
|
+
return request.verb === "execute" && model.document.isDeleted ? {
|
|
423
|
+
decision: "deny",
|
|
424
|
+
reason: DOCUMENT_DELETED_REASON
|
|
425
|
+
} : { decision: "allow" };
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/decision/registered-model.ts
|
|
431
|
+
/**
|
|
432
|
+
* Builds the model at the stream heads and decides one request against it. The
|
|
433
|
+
* append condition it returns is the read-set the store enforces at write time.
|
|
434
|
+
*
|
|
435
|
+
* With `conditions` supplied, the executing scope's state is read at the head
|
|
436
|
+
* for `doc.<scope>.*` paths, or taken from the run's carried document when the
|
|
437
|
+
* caller has already reduced earlier writes into it. That read carries no
|
|
438
|
+
* append-condition entry of its own: the written stream's expected-revision
|
|
439
|
+
* check already refuses a write whose scope grew between the read and the
|
|
440
|
+
* append.
|
|
441
|
+
*/
|
|
442
|
+
async function decideAtHead(model, cache, target, subject, request, signal, conditions) {
|
|
443
|
+
const built = await buildDecisionModel(cache, model, target, signal);
|
|
444
|
+
let scopeState;
|
|
445
|
+
if (conditions !== void 0) scopeState = (conditions.carriedDocument ?? await cache.getState(target.documentId, request.scope, target.branch, void 0, signal)).state[request.scope];
|
|
446
|
+
return {
|
|
447
|
+
evaluation: model(target).decide(built.model, subject, request, {
|
|
448
|
+
scopeState,
|
|
449
|
+
actionInput: conditions?.actionInput
|
|
450
|
+
}),
|
|
451
|
+
appendCondition: built.appendCondition,
|
|
452
|
+
documentVersion: built.model.document.version,
|
|
453
|
+
deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The model this reactor enforces. With `authEnforcement` off the auth scope is
|
|
458
|
+
* absent from every append condition and no load walks it; with `authGroups`
|
|
459
|
+
* on, the group documents the grant list names join the read-set and the
|
|
460
|
+
* registry supplies the reducer that folds them.
|
|
461
|
+
*/
|
|
462
|
+
function selectDecisionModel(flags, registry) {
|
|
463
|
+
if (flags.authConditions) return authConditionsDecisionModel(registry);
|
|
464
|
+
if (flags.authGroups) return authGroupsDecisionModel(registry);
|
|
465
|
+
return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/executor/types.ts
|
|
469
|
+
/** 200k operations purge in 1.6 s on local Postgres; jobs time out at 30 s. */
|
|
470
|
+
const DEFAULT_MAX_PURGE_OPERATIONS = 2e5;
|
|
471
|
+
/** How long a deferred job waits for its document before it fails. */
|
|
472
|
+
const DEFAULT_DEFERRED_JOB_TTL_MS = 3e4;
|
|
473
|
+
const DEFAULT_JOB_TIMEOUT_MS = 3e4;
|
|
474
|
+
/**
|
|
475
|
+
* Event types for the job executor
|
|
476
|
+
*/
|
|
477
|
+
const JobExecutorEventTypes = {
|
|
478
|
+
JOB_STARTED: 2e4,
|
|
479
|
+
JOB_COMPLETED: 20001,
|
|
480
|
+
JOB_FAILED: 20002,
|
|
481
|
+
EXECUTOR_STARTED: 20003,
|
|
482
|
+
EXECUTOR_STOPPED: 20004
|
|
483
|
+
};
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/core/feature-flags.ts
|
|
486
|
+
/**
|
|
487
|
+
* Every flag this reactor knows, with the flags it requires. A stage adds its
|
|
488
|
+
* flag here when it ships, so asking an older reactor for a later stage's flag
|
|
489
|
+
* is an unrecognized name rather than a flag that quietly does nothing.
|
|
490
|
+
*/
|
|
491
|
+
const FLAG_PREREQUISITES = {
|
|
492
|
+
documentDecisions: [],
|
|
493
|
+
authEnforcement: ["documentDecisions"],
|
|
494
|
+
authGroups: ["authEnforcement"],
|
|
495
|
+
authConditions: ["authGroups"]
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* The flags as plain booleans, with anything unset off, validated. Callers hold
|
|
499
|
+
* a partial set, because that is what crosses to a pooled worker, and every
|
|
500
|
+
* consumer needs the same resolution of it.
|
|
501
|
+
*/
|
|
502
|
+
function resolveFeatureFlags(flags = {}) {
|
|
503
|
+
const resolved = {
|
|
504
|
+
documentDecisions: flags.documentDecisions ?? false,
|
|
505
|
+
authEnforcement: flags.authEnforcement ?? false,
|
|
506
|
+
authGroups: flags.authGroups ?? false,
|
|
507
|
+
authConditions: flags.authConditions ?? false
|
|
508
|
+
};
|
|
509
|
+
validateFeatureFlags(flags, FLAG_PREREQUISITES);
|
|
510
|
+
return resolved;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Throws when the flags ask for enforcement the reactor cannot deliver. Either
|
|
514
|
+
* failure would otherwise read as enforcement being on while the reactor
|
|
515
|
+
* applies less than the caller asked for.
|
|
516
|
+
*/
|
|
517
|
+
function validateFeatureFlags(flags, prerequisites) {
|
|
518
|
+
const known = Object.keys(prerequisites);
|
|
519
|
+
const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
|
|
520
|
+
if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
|
|
521
|
+
for (const name of known) {
|
|
522
|
+
if (flags[name] !== true) continue;
|
|
523
|
+
const missing = prerequisites[name].filter((required) => flags[required] !== true);
|
|
524
|
+
if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/storage/kysely/document-purger.ts
|
|
529
|
+
/** An `Operation` row whose DELETE_DOCUMENT applied: not errored, not denied. */
|
|
530
|
+
const appliedDelete = sql`action->>'type' = 'DELETE_DOCUMENT' and coalesce(error, '') = '' and coalesce("deniedReason", '') = ''`;
|
|
531
|
+
/** Rows removed per statement from the tables a document can fill. */
|
|
532
|
+
const DEFAULT_PURGE_DELETE_BATCH = 5e3;
|
|
533
|
+
/** A purge transaction's statements; bound to the job's transaction. */
|
|
534
|
+
var KyselyDocumentPurger = class {
|
|
535
|
+
batchSize;
|
|
536
|
+
db;
|
|
537
|
+
constructor(db, batchSize = DEFAULT_PURGE_DELETE_BATCH) {
|
|
538
|
+
this.batchSize = batchSize;
|
|
539
|
+
this.db = db;
|
|
540
|
+
}
|
|
541
|
+
/** Every stream the document holds operations in. */
|
|
542
|
+
async streams(documentId) {
|
|
543
|
+
return this.db.selectFrom("Operation").select(["scope", "branch"]).distinct().where("documentId", "=", documentId).orderBy("scope").orderBy("branch").execute();
|
|
544
|
+
}
|
|
545
|
+
async operationCount(documentId) {
|
|
546
|
+
const row = await this.db.selectFrom("operation_index_operations").select((eb) => eb.fn.countAll().as("count")).where("documentId", "=", documentId).executeTakeFirst();
|
|
547
|
+
return Number(row?.count ?? 0);
|
|
548
|
+
}
|
|
549
|
+
/** Untombstoned documents group_references records as naming the group. */
|
|
550
|
+
async groupReferencers(groupId) {
|
|
551
|
+
const ids = (await this.db.selectFrom("group_references").select("documentId").where("groupId", "=", groupId).where("documentId", "<>", groupId).orderBy("documentId").execute()).map((row) => row.documentId);
|
|
552
|
+
const purged = await findPurged(this.db, ids);
|
|
553
|
+
return ids.filter((id) => !purged.has(id));
|
|
554
|
+
}
|
|
555
|
+
/** Survivors whose accepted auth history names the group; refusals do not. */
|
|
556
|
+
async groupReferencersInHistory(groupId) {
|
|
557
|
+
const referencing = [];
|
|
558
|
+
for (const documentId of await this.groupReferencers(groupId)) if ([...(await this.authOperations(documentId)).values()].some((operations) => garbageCollect(sortOperations(operations)).some((operation) => operation.deniedReason === void 0 && operation.error === void 0 && mentionedGroupIds(operation.action).includes(groupId)))) referencing.push(documentId);
|
|
559
|
+
return referencing;
|
|
560
|
+
}
|
|
561
|
+
/** The document's auth-scope operations, per branch, in index order. */
|
|
562
|
+
async authOperations(documentId) {
|
|
563
|
+
const rows = await this.db.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", "auth").orderBy("branch").orderBy("index").execute();
|
|
564
|
+
const byBranch = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const row of rows) {
|
|
566
|
+
const operations = byBranch.get(row.branch) ?? [];
|
|
567
|
+
operations.push({
|
|
568
|
+
index: row.index,
|
|
569
|
+
timestampUtcMs: row.timestampUtcMs.toISOString(),
|
|
570
|
+
hash: row.hash,
|
|
571
|
+
skip: row.skip,
|
|
572
|
+
error: row.error || void 0,
|
|
573
|
+
deniedReason: row.deniedReason || void 0,
|
|
574
|
+
id: row.opId,
|
|
575
|
+
action: row.action
|
|
576
|
+
});
|
|
577
|
+
byBranch.set(row.branch, operations);
|
|
578
|
+
}
|
|
579
|
+
return byBranch;
|
|
580
|
+
}
|
|
581
|
+
/** Every document ever in the collection, but its owner and its own row. */
|
|
582
|
+
async collectionMembers(collectionId, ownerId, branch) {
|
|
583
|
+
return (await this.db.selectFrom("document_collections as dc").select("dc.documentId").select(sql`dc."leftOrdinal" is null`.as("openHere")).select((eb) => eb.exists(eb.selectFrom("Operation as op").select("op.opId").whereRef("op.documentId", "=", "dc.documentId").where("op.branch", "=", branch).where("op.scope", "=", "document").where(appliedDelete)).as("deleted")).select((eb) => eb.exists(eb.selectFrom("document_collections as other").select("other.documentId").whereRef("other.documentId", "=", "dc.documentId").where("other.collectionId", "<>", collectionId).where("other.leftOrdinal", "is", null)).as("openElsewhere")).where("dc.collectionId", "=", collectionId).where("dc.documentId", "not in", [ownerId, collectionId]).orderBy("dc.documentId").execute()).map((row) => {
|
|
584
|
+
const openElsewhere = Boolean(row.openElsewhere);
|
|
585
|
+
return {
|
|
586
|
+
documentId: row.documentId,
|
|
587
|
+
openElsewhere,
|
|
588
|
+
required: !openElsewhere && (Boolean(row.openHere) || Boolean(row.deleted))
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
async remoteCollection(remoteName) {
|
|
593
|
+
return (await this.db.selectFrom("sync_remotes").select("collection_id").where("name", "=", remoteName).executeTakeFirst())?.collection_id;
|
|
594
|
+
}
|
|
595
|
+
/** Deletes every row about the document; the counts are per table. */
|
|
596
|
+
async deleteRows(documentId) {
|
|
597
|
+
const removed = {};
|
|
598
|
+
removed.Operation = await this.deleteBatched(async () => {
|
|
599
|
+
const result = await this.db.deleteFrom("Operation").where("id", "in", (eb) => eb.selectFrom("Operation").select("id").where("documentId", "=", documentId).limit(this.batchSize)).executeTakeFirst();
|
|
600
|
+
return Number(result.numDeletedRows);
|
|
601
|
+
});
|
|
602
|
+
removed.operation_index_operations = await this.deleteBatched(async () => {
|
|
603
|
+
const result = await this.db.deleteFrom("operation_index_operations").where("ordinal", "in", (eb) => eb.selectFrom("operation_index_operations").select("ordinal").where("documentId", "=", documentId).limit(this.batchSize)).executeTakeFirst();
|
|
604
|
+
return Number(result.numDeletedRows);
|
|
605
|
+
});
|
|
606
|
+
removed.Keyframe = await this.deleteBatched(async () => {
|
|
607
|
+
const result = await this.db.deleteFrom("Keyframe").where("id", "in", (eb) => eb.selectFrom("Keyframe").select("id").where("documentId", "=", documentId).limit(this.batchSize)).executeTakeFirst();
|
|
608
|
+
return Number(result.numDeletedRows);
|
|
609
|
+
});
|
|
610
|
+
removed.DocumentSnapshot = await this.deleteBatched(async () => {
|
|
611
|
+
const result = await this.db.deleteFrom("DocumentSnapshot").where("id", "in", (eb) => eb.selectFrom("DocumentSnapshot").select("id").where("documentId", "=", documentId).limit(this.batchSize)).executeTakeFirst();
|
|
612
|
+
return Number(result.numDeletedRows);
|
|
613
|
+
});
|
|
614
|
+
removed.SlugMapping = await this.deleteWhere("SlugMapping", "documentId", documentId);
|
|
615
|
+
const relationships = await this.db.selectFrom("DocumentRelationship").select((eb) => eb.fn.countAll().as("count")).where((eb) => eb.or([eb("sourceId", "=", documentId), eb("targetId", "=", documentId)])).executeTakeFirst();
|
|
616
|
+
removed.DocumentRelationship = Number(relationships?.count ?? 0);
|
|
617
|
+
removed.Document = await this.deleteWhere("Document", "id", documentId);
|
|
618
|
+
removed.group_references = await this.deleteWhere("group_references", "documentId", documentId);
|
|
619
|
+
removed.sync_dead_letters = await this.deleteWhere("sync_dead_letters", "document_id", documentId);
|
|
620
|
+
removed.sync_holds = await this.deleteWhere("sync_holds", "document_id", documentId);
|
|
621
|
+
return removed;
|
|
622
|
+
}
|
|
623
|
+
async writeTombstone(tombstone) {
|
|
624
|
+
const values = {
|
|
625
|
+
documentId: tombstone.documentId,
|
|
626
|
+
ordinal: tombstone.ordinal,
|
|
627
|
+
removedRows: JSON.stringify(tombstone.removedRows),
|
|
628
|
+
purgedAtUtc: tombstone.purgedAtUtc,
|
|
629
|
+
requestId: tombstone.requestId
|
|
630
|
+
};
|
|
631
|
+
await this.db.insertInto("document_purges").values(values).onConflict((oc) => oc.column("documentId").doUpdateSet({
|
|
632
|
+
ordinal: values.ordinal,
|
|
633
|
+
removedRows: values.removedRows,
|
|
634
|
+
purgedAtUtc: values.purgedAtUtc,
|
|
635
|
+
requestId: values.requestId
|
|
636
|
+
})).execute();
|
|
637
|
+
}
|
|
638
|
+
/** Reopens every membership at the marker ordinal, plus `collectionIds`. */
|
|
639
|
+
async reopenMemberships(documentId, ordinal, collectionIds = []) {
|
|
640
|
+
await this.db.updateTable("document_collections").set({
|
|
641
|
+
joinedOrdinal: BigInt(ordinal),
|
|
642
|
+
leftOrdinal: null
|
|
643
|
+
}).where("documentId", "=", documentId).execute();
|
|
644
|
+
for (const collectionId of new Set(collectionIds)) await this.db.insertInto("document_collections").values({
|
|
645
|
+
documentId,
|
|
646
|
+
collectionId,
|
|
647
|
+
joinedOrdinal: BigInt(ordinal),
|
|
648
|
+
leftOrdinal: null
|
|
649
|
+
}).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
|
|
650
|
+
joinedOrdinal: BigInt(ordinal),
|
|
651
|
+
leftOrdinal: null
|
|
652
|
+
})).execute();
|
|
653
|
+
return (await this.db.selectFrom("document_collections").select("collectionId").where("documentId", "=", documentId).orderBy("collectionId").execute()).map((row) => row.collectionId);
|
|
654
|
+
}
|
|
655
|
+
async deleteBatched(step) {
|
|
656
|
+
let total = 0;
|
|
657
|
+
for (;;) {
|
|
658
|
+
const deleted = await step();
|
|
659
|
+
total += deleted;
|
|
660
|
+
if (deleted < this.batchSize) return total;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async deleteWhere(table, column, documentId) {
|
|
664
|
+
const result = await this.db.deleteFrom(table).where(column, "=", documentId).executeTakeFirst();
|
|
665
|
+
return Number(result.numDeletedRows);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/executor/execution-scope.ts
|
|
670
|
+
/** For scopes without a transaction to hold a lock in. */
|
|
671
|
+
const NOOP_DOCUMENT_LOCKS = {
|
|
672
|
+
shared: () => Promise.resolve(),
|
|
673
|
+
exclusive: () => Promise.resolve(),
|
|
674
|
+
purged: () => Promise.resolve(/* @__PURE__ */ new Set())
|
|
675
|
+
};
|
|
676
|
+
var DefaultExecutionScope = class {
|
|
677
|
+
operationStore;
|
|
678
|
+
operationIndex;
|
|
679
|
+
writeCache;
|
|
680
|
+
documentMetaCache;
|
|
681
|
+
collectionMembershipCache;
|
|
682
|
+
constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
|
|
683
|
+
this.operationStore = operationStore;
|
|
684
|
+
this.operationIndex = operationIndex;
|
|
685
|
+
this.writeCache = writeCache;
|
|
686
|
+
this.documentMetaCache = documentMetaCache;
|
|
687
|
+
this.collectionMembershipCache = collectionMembershipCache;
|
|
688
|
+
}
|
|
689
|
+
async run(fn, signal) {
|
|
690
|
+
signal?.throwIfAborted();
|
|
691
|
+
return fn({
|
|
692
|
+
operationStore: this.operationStore,
|
|
693
|
+
operationIndex: this.operationIndex,
|
|
694
|
+
writeCache: this.writeCache,
|
|
695
|
+
documentMetaCache: this.documentMetaCache,
|
|
696
|
+
collectionMembershipCache: this.collectionMembershipCache,
|
|
697
|
+
documentLocks: NOOP_DOCUMENT_LOCKS
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
var KyselyExecutionScope = class {
|
|
702
|
+
db;
|
|
703
|
+
operationStore;
|
|
704
|
+
operationIndex;
|
|
705
|
+
keyframeStore;
|
|
706
|
+
writeCache;
|
|
707
|
+
documentMetaCache;
|
|
708
|
+
collectionMembershipCache;
|
|
709
|
+
constructor(db, operationStore, operationIndex, keyframeStore, writeCache, documentMetaCache, collectionMembershipCache) {
|
|
710
|
+
this.db = db;
|
|
711
|
+
this.operationStore = operationStore;
|
|
712
|
+
this.operationIndex = operationIndex;
|
|
713
|
+
this.keyframeStore = keyframeStore;
|
|
714
|
+
this.writeCache = writeCache;
|
|
715
|
+
this.documentMetaCache = documentMetaCache;
|
|
716
|
+
this.collectionMembershipCache = collectionMembershipCache;
|
|
717
|
+
}
|
|
718
|
+
async run(fn, signal) {
|
|
719
|
+
signal?.throwIfAborted();
|
|
720
|
+
return this.db.transaction().execute(async (trx) => {
|
|
721
|
+
const sharedLocked = /* @__PURE__ */ new Set();
|
|
722
|
+
const liveIds = /* @__PURE__ */ new Set();
|
|
723
|
+
const scopedOperationStore = this.operationStore.withTransaction(trx, liveIds);
|
|
724
|
+
const scopedOperationIndex = this.operationIndex.withTransaction(trx, liveIds);
|
|
725
|
+
const scopedKeyframeStore = this.keyframeStore.withTransaction(trx);
|
|
726
|
+
return fn({
|
|
727
|
+
operationStore: scopedOperationStore,
|
|
728
|
+
operationIndex: scopedOperationIndex,
|
|
729
|
+
writeCache: this.writeCache.withScopedStores(scopedOperationStore, scopedKeyframeStore),
|
|
730
|
+
documentMetaCache: this.documentMetaCache.withScopedStore(scopedOperationStore),
|
|
731
|
+
collectionMembershipCache: this.collectionMembershipCache.withScopedIndex(scopedOperationIndex),
|
|
732
|
+
documentLocks: {
|
|
733
|
+
shared: async (ids) => {
|
|
734
|
+
const list = [...ids];
|
|
735
|
+
await acquirePurgeLocks(trx, list, "shared");
|
|
736
|
+
for (const id of list) sharedLocked.add(id);
|
|
737
|
+
},
|
|
738
|
+
exclusive: (id) => acquirePurgeLocks(trx, [id], "exclusive"),
|
|
739
|
+
purged: async (ids) => {
|
|
740
|
+
const list = [...ids];
|
|
741
|
+
const found = await findPurged(trx, list);
|
|
742
|
+
for (const id of list) if (sharedLocked.has(id) && !found.has(id)) liveIds.add(id);
|
|
743
|
+
return found;
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
purger: new KyselyDocumentPurger(trx)
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/storage/storage-flush.ts
|
|
753
|
+
/** The store's session is poisoned; only a host restart recovers it. */
|
|
754
|
+
var StoragePoisonedError = class extends Error {
|
|
755
|
+
constructor(message, cause) {
|
|
756
|
+
super(message, { cause });
|
|
757
|
+
this.name = "StoragePoisonedError";
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
var NoopStorageFlusher = class {
|
|
761
|
+
flush() {
|
|
762
|
+
return Promise.resolve();
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/utils/reshuffle.ts
|
|
767
|
+
const STRICT_ORDER_ACTION_TYPES = /* @__PURE__ */ new Set([
|
|
768
|
+
"CREATE_DOCUMENT",
|
|
769
|
+
"DELETE_DOCUMENT",
|
|
770
|
+
"UPGRADE_DOCUMENT",
|
|
771
|
+
"ADD_RELATIONSHIP",
|
|
772
|
+
"REMOVE_RELATIONSHIP",
|
|
773
|
+
"UPDATE_RELATIONSHIP",
|
|
774
|
+
"PURGE_DOCUMENT",
|
|
775
|
+
"ADD_FOLDER",
|
|
776
|
+
"UPDATE_FOLDER",
|
|
777
|
+
"REMOVE_FOLDER"
|
|
778
|
+
]);
|
|
779
|
+
/**
|
|
780
|
+
* Reshuffles operations by timestamp, then applies deterministic tie-breaking.
|
|
781
|
+
* Used for merging concurrent operations from different branches.
|
|
782
|
+
*
|
|
783
|
+
* For strict document-structure actions (e.g., CREATE_DOCUMENT/UPGRADE_DOCUMENT),
|
|
784
|
+
* logical index (index - skip) is prioritized to preserve causal replay order.
|
|
785
|
+
*
|
|
786
|
+
* For other actions, action ID is prioritized to ensure a canonical cross-reactor order
|
|
787
|
+
* for concurrent operations that may have diverged local indices due to prior reshuffles.
|
|
788
|
+
* Logical index and operation ID are then used as deterministic tie-breakers.
|
|
789
|
+
*
|
|
790
|
+
* Example:
|
|
791
|
+
* [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, 2:0, B3:0, B4:2, B5:0]
|
|
792
|
+
* GC => [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, B4:2, B5:0]
|
|
793
|
+
* Split => [0:0, 1:0] + [2:0, A3:0, A4:0, A5:0] + [B4:2, B5:0]
|
|
794
|
+
* Reshuffle(6:4) => [6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
795
|
+
* merge => [0:0, 1:0, 6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
796
|
+
*/
|
|
797
|
+
function reshuffleByTimestamp(startIndex, opsA, opsB) {
|
|
798
|
+
return [...opsA, ...opsB].sort((a, b) => {
|
|
799
|
+
const timestampDiff = new Date(a.timestampUtcMs).getTime() - new Date(b.timestampUtcMs).getTime();
|
|
800
|
+
if (timestampDiff !== 0) return timestampDiff;
|
|
801
|
+
const rank = (op) => STRICT_ORDER_ACTION_TYPES.has(op.action?.type ?? "") ? 0 : 1;
|
|
802
|
+
const rankDiff = rank(a) - rank(b);
|
|
803
|
+
if (rankDiff !== 0) return rankDiff;
|
|
804
|
+
if (rank(a) === 0) {
|
|
805
|
+
const logicalIndexDiff = a.index - a.skip - (b.index - b.skip);
|
|
806
|
+
if (logicalIndexDiff !== 0) return logicalIndexDiff;
|
|
807
|
+
}
|
|
808
|
+
const actionIdDiff = (a.action?.id ?? "").localeCompare(b.action?.id ?? "");
|
|
809
|
+
if (actionIdDiff !== 0) return actionIdDiff;
|
|
810
|
+
return a.id.localeCompare(b.id);
|
|
811
|
+
}).map((op, i) => ({
|
|
812
|
+
...op,
|
|
813
|
+
index: startIndex.index + i,
|
|
814
|
+
skip: i === 0 ? startIndex.skip : 0
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region src/decision/merged-order.ts
|
|
819
|
+
/** Identifies a stream within a walk. */
|
|
820
|
+
function streamKey(query) {
|
|
821
|
+
return `${query.documentId}:${query.scope}:${query.branch}`;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Orders two operations from different streams by position. Timestamp decides;
|
|
825
|
+
* an equal timestamp puts an auth operation first, and otherwise falls to the
|
|
826
|
+
* action id and then the operation id, so that two replicas holding the same
|
|
827
|
+
* operations agree on the order whatever order they happen to store them in.
|
|
828
|
+
*/
|
|
829
|
+
function comparePositions(a, b) {
|
|
830
|
+
const aTime = Date.parse(a.operation.timestampUtcMs);
|
|
831
|
+
const bTime = Date.parse(b.operation.timestampUtcMs);
|
|
832
|
+
if (aTime !== bTime) return aTime - bTime;
|
|
833
|
+
if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
|
|
834
|
+
const aAuth = a.scope === "auth";
|
|
835
|
+
if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
|
|
836
|
+
const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
|
|
837
|
+
if (actionIds !== 0) return actionIds;
|
|
838
|
+
return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Merges the read-set streams into one sequence by position. An operation's
|
|
842
|
+
* place in the result is the bound a decision at that operation reads to: every
|
|
843
|
+
* operation before it has been applied, and it has not.
|
|
844
|
+
*/
|
|
845
|
+
function mergeByPosition(streams) {
|
|
846
|
+
const merged = [];
|
|
847
|
+
for (const stream of streams) for (const operation of stream.operations) merged.push({
|
|
848
|
+
streamKey: stream.streamKey,
|
|
849
|
+
scope: stream.scope,
|
|
850
|
+
operation
|
|
851
|
+
});
|
|
852
|
+
return merged.sort(comparePositions);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* The skip that retracts everything from `firstRetractedIndex` up to where the
|
|
856
|
+
* re-appended operation lands. It spans the indexes rather than counting the
|
|
857
|
+
* operations, because a stream with a gap in it makes those differ.
|
|
858
|
+
*/
|
|
859
|
+
function retractionSkip(nextIndex, firstRetractedIndex) {
|
|
860
|
+
return nextIndex - firstRetractedIndex;
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/decision/walk.ts
|
|
864
|
+
/**
|
|
865
|
+
* A single forward pass is only correct while a stream's effective operations
|
|
866
|
+
* are ordered.
|
|
867
|
+
*/
|
|
868
|
+
function assertPositionOrder(streamKey, scope, operations) {
|
|
869
|
+
for (let i = 1; i < operations.length; i++) {
|
|
870
|
+
const previous = operations[i - 1];
|
|
871
|
+
const current = operations[i];
|
|
872
|
+
if (comparePositions({
|
|
873
|
+
streamKey,
|
|
874
|
+
scope,
|
|
875
|
+
operation: previous
|
|
876
|
+
}, {
|
|
877
|
+
streamKey,
|
|
878
|
+
scope,
|
|
879
|
+
operation: current
|
|
880
|
+
}) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Visits every operation in the read-set once, in the order their positions
|
|
885
|
+
* fall, and hands back the state each stream held just before it. That state is
|
|
886
|
+
* what a decision at that operation reads.
|
|
887
|
+
*
|
|
888
|
+
* Skips are resolved first (i.e. this is performed on a garbage collected
|
|
889
|
+
* stream), which means we can do a single forward pass.
|
|
890
|
+
*
|
|
891
|
+
* An operation that contributes no state, whether denied or holding a reducer
|
|
892
|
+
* error, is visited but not applied (this matches the write cache's rebuild).
|
|
893
|
+
*
|
|
894
|
+
* The consumer sends back whether it refused the operation it was handed: a
|
|
895
|
+
* refusal this pass produced must suppress it the same way a stored one does.
|
|
896
|
+
*/
|
|
897
|
+
function* walkByPosition(streams) {
|
|
898
|
+
const merged = mergeByPosition(streams.map((stream) => {
|
|
899
|
+
const operations = garbageCollect(sortOperations([...stream.operations]));
|
|
900
|
+
assertPositionOrder(stream.streamKey, stream.scope, operations);
|
|
901
|
+
return {
|
|
902
|
+
streamKey: stream.streamKey,
|
|
903
|
+
scope: stream.scope,
|
|
904
|
+
operations
|
|
905
|
+
};
|
|
906
|
+
}));
|
|
907
|
+
const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
|
|
908
|
+
const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
|
|
909
|
+
for (const { streamKey, operation } of merged) {
|
|
910
|
+
if ((yield {
|
|
911
|
+
streamKey,
|
|
912
|
+
operation,
|
|
913
|
+
states: new Map(states)
|
|
914
|
+
}) || operation.error !== void 0 || isDenied(operation)) continue;
|
|
915
|
+
const stream = byKey.get(streamKey);
|
|
916
|
+
const before = states.get(streamKey);
|
|
917
|
+
if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
|
|
918
|
+
states.set(streamKey, stream.apply(before, operation));
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/decision/evaluation.ts
|
|
923
|
+
/** The stream key for evaluated operations whose scope no projection reads. */
|
|
924
|
+
const EVALUATED_ONLY = "evaluated";
|
|
925
|
+
/**
|
|
926
|
+
* Whether any stream the model reads declares this operation's action type as
|
|
927
|
+
* one that can change an evaluation.
|
|
928
|
+
*/
|
|
929
|
+
function isDecidingAction(operation, readSet) {
|
|
930
|
+
return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Who an operation acts as. A replayed operation is evaluated as its own signer,
|
|
934
|
+
* so an address-scoped policy does not deny its own author's history.
|
|
935
|
+
*/
|
|
936
|
+
function subjectOf(operation) {
|
|
937
|
+
const signer = operation.action.context?.signer;
|
|
938
|
+
return {
|
|
939
|
+
address: signer?.user.address,
|
|
940
|
+
key: signer?.app.key
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The model as the walk reached this operation: each static projection's value
|
|
945
|
+
* is its own scope's state, and each derived projection's value maps document
|
|
946
|
+
* id to that document's state, holding only the streams this replica walked. A
|
|
947
|
+
* derived stream it does not hold stays out of the map, which fails closed.
|
|
948
|
+
*/
|
|
949
|
+
function modelAt(readSet, derivedNames, derived, states) {
|
|
950
|
+
const model = {};
|
|
951
|
+
for (const stream of readSet) {
|
|
952
|
+
const document = states.get(streamKey(stream.query));
|
|
953
|
+
if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
|
|
954
|
+
model[stream.name] = document.state[stream.query.scope];
|
|
955
|
+
}
|
|
956
|
+
for (const name of derivedNames) model[name] = {};
|
|
957
|
+
for (const entry of derived) {
|
|
958
|
+
const map = model[entry.name];
|
|
959
|
+
const document = states.get(streamKey(entry.query));
|
|
960
|
+
if (document !== void 0) map[entry.query.documentId] = document.state[entry.query.scope];
|
|
961
|
+
}
|
|
962
|
+
return model;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Evaluates each operation at its own position and returns the refusals in an
|
|
966
|
+
* array parallel to the operations, where undefined means allowed.
|
|
967
|
+
*
|
|
968
|
+
* A position is a timestamp, so an operation refused by a delete is one that
|
|
969
|
+
* sorts after it, and the operations before it are left alone. That holds
|
|
970
|
+
* whether the delete is already stored or is among the operations passed in.
|
|
971
|
+
*/
|
|
972
|
+
async function evaluateByPosition(model, target, subject, stores, signal) {
|
|
973
|
+
const { scope, operations } = subject;
|
|
974
|
+
const { writeCache, operationStore } = stores;
|
|
975
|
+
const definition = model(target);
|
|
976
|
+
const readSet = staticReadSet(definition);
|
|
977
|
+
const derivedSet = derivedReadSet(definition);
|
|
978
|
+
if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
|
|
979
|
+
const evaluating = new Set(operations.map((operation) => operation.id));
|
|
980
|
+
const creation = operations.find((operation) => operation.action.type === "CREATE_DOCUMENT" && operation.action.input.documentId === target.documentId);
|
|
981
|
+
async function stateBefore(query) {
|
|
982
|
+
try {
|
|
983
|
+
return await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (creation !== void 0 && query.documentId === target.documentId && error instanceof DocumentNotFoundError && !(error instanceof DocumentPurgedError)) return createDocumentFromAction(creation.action);
|
|
986
|
+
throw error;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const readStreams = await Promise.all(readSet.map(async (stream) => ({
|
|
990
|
+
stream,
|
|
991
|
+
operations: (await operationStore.getSince(stream.query.documentId, stream.query.scope, stream.query.branch, -1, { actionTypes: stream.decidingActions }, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id))
|
|
992
|
+
})));
|
|
993
|
+
const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
|
|
994
|
+
if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
|
|
995
|
+
if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
|
|
996
|
+
const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
|
|
997
|
+
const walked = [];
|
|
998
|
+
const histories = [];
|
|
999
|
+
for (const read of readStreams) {
|
|
1000
|
+
const streamOperations = read.stream === writtenProjection ? [...read.operations, ...operations] : read.operations;
|
|
1001
|
+
const before = await stateBefore(read.stream.query);
|
|
1002
|
+
walked.push({
|
|
1003
|
+
streamKey: streamKey(read.stream.query),
|
|
1004
|
+
scope: read.stream.query.scope,
|
|
1005
|
+
document: before,
|
|
1006
|
+
operations: streamOperations,
|
|
1007
|
+
apply: read.stream.apply
|
|
1008
|
+
});
|
|
1009
|
+
histories.push({
|
|
1010
|
+
name: read.stream.name,
|
|
1011
|
+
operations: streamOperations
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
let evaluatedStateKey;
|
|
1015
|
+
if (writtenProjection !== void 0) evaluatedStateKey = streamKey(writtenProjection.query);
|
|
1016
|
+
else if (definition.foldEvaluatedScope !== void 0) {
|
|
1017
|
+
const query = {
|
|
1018
|
+
documentId: target.documentId,
|
|
1019
|
+
scope,
|
|
1020
|
+
branch: target.branch
|
|
1021
|
+
};
|
|
1022
|
+
const storedOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, void 0, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
|
|
1023
|
+
const before = await stateBefore(query);
|
|
1024
|
+
evaluatedStateKey = streamKey(query);
|
|
1025
|
+
walked.push({
|
|
1026
|
+
streamKey: evaluatedStateKey,
|
|
1027
|
+
scope,
|
|
1028
|
+
document: before,
|
|
1029
|
+
operations: [...storedOperations, ...operations],
|
|
1030
|
+
apply: definition.foldEvaluatedScope
|
|
1031
|
+
});
|
|
1032
|
+
} else walked.push({
|
|
1033
|
+
streamKey: EVALUATED_ONLY,
|
|
1034
|
+
scope,
|
|
1035
|
+
document: walked[0].document,
|
|
1036
|
+
operations,
|
|
1037
|
+
apply: (document) => document
|
|
1038
|
+
});
|
|
1039
|
+
const derivedEntries = [];
|
|
1040
|
+
const walkedKeys = new Set(walked.map((stream) => stream.streamKey));
|
|
1041
|
+
for (const projection of derivedSet) {
|
|
1042
|
+
const queries = projection.queryOverHistory?.(histories) ?? [];
|
|
1043
|
+
for (const query of queries) {
|
|
1044
|
+
const key = streamKey(query);
|
|
1045
|
+
if (walkedKeys.has(key)) continue;
|
|
1046
|
+
let before;
|
|
1047
|
+
try {
|
|
1048
|
+
before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error instanceof DocumentNotFoundError) continue;
|
|
1051
|
+
throw error;
|
|
1052
|
+
}
|
|
1053
|
+
const streamOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, { actionTypes: projection.decidingActions }, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
|
|
1054
|
+
walkedKeys.add(key);
|
|
1055
|
+
walked.push({
|
|
1056
|
+
streamKey: key,
|
|
1057
|
+
scope: query.scope,
|
|
1058
|
+
document: before,
|
|
1059
|
+
operations: streamOperations,
|
|
1060
|
+
apply: projection.apply
|
|
1061
|
+
});
|
|
1062
|
+
derivedEntries.push({
|
|
1063
|
+
name: projection.name,
|
|
1064
|
+
query
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
1069
|
+
const walk = walkByPosition(walked);
|
|
1070
|
+
let step = walk.next(false);
|
|
1071
|
+
while (!step.done) {
|
|
1072
|
+
const position = step.value;
|
|
1073
|
+
if (!evaluating.has(position.operation.id)) {
|
|
1074
|
+
step = walk.next(false);
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
const evaluatedDocument = evaluatedStateKey === void 0 ? void 0 : position.states.get(evaluatedStateKey);
|
|
1078
|
+
const scopeState = evaluatedDocument === void 0 ? void 0 : evaluatedDocument.state[scope];
|
|
1079
|
+
const evaluation = definition.decide(modelAt(readSet, derivedSet.map((projection) => projection.name), derivedEntries, position.states), subjectOf(position.operation), {
|
|
1080
|
+
verb: "execute",
|
|
1081
|
+
scope: position.operation.action.scope,
|
|
1082
|
+
operation: position.operation.action.type
|
|
1083
|
+
}, {
|
|
1084
|
+
scopeState,
|
|
1085
|
+
actionInput: position.operation.action.input
|
|
1086
|
+
});
|
|
1087
|
+
const denied = evaluation.decision === "deny";
|
|
1088
|
+
reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
|
|
1089
|
+
step = walk.next(denied);
|
|
1090
|
+
}
|
|
1091
|
+
return operations.map((operation) => reasons.get(operation.id));
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/executor/document-action-handler.ts
|
|
1095
|
+
var DocumentActionHandler = class {
|
|
1096
|
+
registry;
|
|
1097
|
+
logger;
|
|
1098
|
+
driveContainerTypes;
|
|
1099
|
+
featureFlags;
|
|
1100
|
+
decisionModel;
|
|
1101
|
+
protocolSupport;
|
|
1102
|
+
loggedUnregisteredProtocols = /* @__PURE__ */ new Set();
|
|
1103
|
+
constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel, protocolSupport) {
|
|
1104
|
+
this.registry = registry;
|
|
1105
|
+
this.logger = logger;
|
|
1106
|
+
this.driveContainerTypes = driveContainerTypes;
|
|
1107
|
+
this.featureFlags = featureFlags;
|
|
1108
|
+
this.decisionModel = decisionModel;
|
|
1109
|
+
this.protocolSupport = protocolSupport;
|
|
1110
|
+
}
|
|
1111
|
+
/** Keys this reactor does not register are admitted: it cannot judge them. */
|
|
1112
|
+
unsupportedProtocol(documentId, protocolVersions) {
|
|
1113
|
+
for (const [protocol, version] of Object.entries(protocolVersions ?? {})) {
|
|
1114
|
+
const supported = this.protocolSupport[protocol];
|
|
1115
|
+
if (supported === void 0) {
|
|
1116
|
+
if (!this.loggedUnregisteredProtocols.has(protocol)) {
|
|
1117
|
+
this.loggedUnregisteredProtocols.add(protocol);
|
|
1118
|
+
this.logger.info("Admitting document @documentId with unregistered protocol @protocol @version", documentId, protocol, version);
|
|
1119
|
+
}
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (!supported.includes(version)) return new UnsupportedProtocolVersionError(documentId, protocol, version);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
/** Whether the write arrives with its evaluation already decided. */
|
|
1126
|
+
alreadyEvaluated(executing) {
|
|
1127
|
+
return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
|
|
1128
|
+
}
|
|
1129
|
+
async execute(write, executing) {
|
|
1130
|
+
const { action } = write;
|
|
1131
|
+
if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
|
|
1132
|
+
const refusal = await this.refuseIfPolicyDenies(write, executing);
|
|
1133
|
+
if (refusal) return refusal;
|
|
1134
|
+
switch (action.type) {
|
|
1135
|
+
case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
|
|
1136
|
+
case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
|
|
1137
|
+
case "UPGRADE_DOCUMENT": return this.executeUpgrade(write, executing);
|
|
1138
|
+
case "ADD_RELATIONSHIP": return this.executeAddRelationship(write, executing);
|
|
1139
|
+
case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(write, executing);
|
|
1140
|
+
case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(write, executing);
|
|
1141
|
+
case "PURGE_DOCUMENT": return buildErrorResult(executing.job, new ReservedActionError(executing.job.documentId, action.type), executing.startTime);
|
|
1142
|
+
default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Refuses a document-scope write the policy denies, or undefined to proceed.
|
|
1147
|
+
* Without this an `execute`-on-`document` grant is unenforceable.
|
|
1148
|
+
*/
|
|
1149
|
+
async refuseIfPolicyDenies(write, executing) {
|
|
1150
|
+
const { action } = write;
|
|
1151
|
+
const { job, startTime, stores, signal } = executing;
|
|
1152
|
+
if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
|
|
1153
|
+
const documentId = targetDocumentId(action, job.documentId);
|
|
1154
|
+
let admission;
|
|
1155
|
+
try {
|
|
1156
|
+
admission = await decideAtHead(this.decisionModel, stores.writeCache, {
|
|
1157
|
+
documentId,
|
|
1158
|
+
branch: job.branch
|
|
1159
|
+
}, {
|
|
1160
|
+
address: action.context?.signer?.user.address,
|
|
1161
|
+
key: action.context?.signer?.app.key
|
|
1162
|
+
}, {
|
|
1163
|
+
verb: "execute",
|
|
1164
|
+
scope: action.scope,
|
|
1165
|
+
operation: action.type
|
|
1166
|
+
}, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1169
|
+
}
|
|
1170
|
+
if (admission.evaluation.decision === "allow") return;
|
|
1171
|
+
return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
|
|
1172
|
+
}
|
|
1173
|
+
/** A refused operation holds a position in the stream but changes nothing. */
|
|
1174
|
+
async writeDenied(write, executing) {
|
|
1175
|
+
const { action, skip, sourceRemote, deniedReason } = write;
|
|
1176
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1177
|
+
let document;
|
|
1178
|
+
try {
|
|
1179
|
+
document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1182
|
+
}
|
|
1183
|
+
const index = getNextIndexForScope(document, job.scope);
|
|
1184
|
+
let standing = document;
|
|
1185
|
+
if (skip > 0) try {
|
|
1186
|
+
standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1189
|
+
}
|
|
1190
|
+
let operation = createOperation(action, index, skip, {
|
|
1191
|
+
documentId: job.documentId,
|
|
1192
|
+
scope: job.scope,
|
|
1193
|
+
branch: job.branch
|
|
1194
|
+
});
|
|
1195
|
+
operation.deniedReason = deniedReason;
|
|
1196
|
+
operation.hash = hashDocumentStateForScope(standing, job.scope);
|
|
1197
|
+
const writeResult = await this.writeOperationToStore({
|
|
1198
|
+
documentId: job.documentId,
|
|
1199
|
+
documentType: document.header.documentType,
|
|
1200
|
+
scope: job.scope,
|
|
1201
|
+
branch: job.branch
|
|
1202
|
+
}, operation, executing);
|
|
1203
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
1204
|
+
operation = writeResult[0];
|
|
1205
|
+
updateDocumentRevision(standing, job.scope, operation.index);
|
|
1206
|
+
standing.operations = {
|
|
1207
|
+
...standing.operations,
|
|
1208
|
+
[job.scope]: [...standing.operations[job.scope] ?? [], operation]
|
|
1209
|
+
};
|
|
1210
|
+
executing.touchedStreams.add(job.documentId, job.scope, job.branch);
|
|
1211
|
+
stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, "head");
|
|
1212
|
+
indexTxn.write([{
|
|
1213
|
+
...operation,
|
|
1214
|
+
documentId: job.documentId,
|
|
1215
|
+
documentType: document.header.documentType,
|
|
1216
|
+
branch: job.branch,
|
|
1217
|
+
scope: job.scope,
|
|
1218
|
+
sourceRemote
|
|
1219
|
+
}]);
|
|
1220
|
+
stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
|
|
1221
|
+
state: standing.state.document,
|
|
1222
|
+
documentType: standing.header.documentType,
|
|
1223
|
+
protocolVersions: standing.header.protocolVersions,
|
|
1224
|
+
documentScopeRevision: operation.index + 1
|
|
1225
|
+
});
|
|
1226
|
+
return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
|
|
1227
|
+
header: standing.header,
|
|
1228
|
+
document: standing.state.document
|
|
1229
|
+
}), startTime);
|
|
1230
|
+
}
|
|
1231
|
+
async executeCreate(write, executing) {
|
|
1232
|
+
const { action, skip, sourceRemote } = write;
|
|
1233
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1234
|
+
if (job.scope !== "document") return {
|
|
1235
|
+
job,
|
|
1236
|
+
success: false,
|
|
1237
|
+
error: /* @__PURE__ */ new Error(`CREATE_DOCUMENT must be in "document" scope, got "${job.scope}"`),
|
|
1238
|
+
duration: Date.now() - startTime
|
|
1239
|
+
};
|
|
1240
|
+
const input = action.input;
|
|
1241
|
+
const unsupported = this.unsupportedProtocol(input.documentId, input.protocolVersions);
|
|
1242
|
+
if (unsupported) return buildErrorResult(job, unsupported, startTime);
|
|
1243
|
+
const document = createDocumentFromAction(action);
|
|
1244
|
+
let operation = createOperation(action, 0, skip, {
|
|
1245
|
+
documentId: document.header.id,
|
|
1246
|
+
scope: job.scope,
|
|
1247
|
+
branch: job.branch
|
|
1248
|
+
});
|
|
1249
|
+
const resultingStateObj = {
|
|
1250
|
+
...document.state,
|
|
1251
|
+
header: document.header
|
|
1252
|
+
};
|
|
1253
|
+
const resultingState = JSON.stringify(resultingStateObj);
|
|
1254
|
+
const writeResult = await this.writeOperationToStore({
|
|
1255
|
+
documentId: document.header.id,
|
|
1256
|
+
documentType: document.header.documentType,
|
|
1257
|
+
scope: job.scope,
|
|
1258
|
+
branch: job.branch
|
|
1259
|
+
}, operation, executing);
|
|
1260
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
1261
|
+
operation = writeResult[0];
|
|
1262
|
+
updateDocumentRevision(document, job.scope, operation.index);
|
|
1263
|
+
document.operations = {
|
|
1264
|
+
...document.operations,
|
|
1265
|
+
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1266
|
+
};
|
|
1267
|
+
executing.touchedStreams.add(document.header.id, job.scope, job.branch);
|
|
1268
|
+
stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, "head");
|
|
1269
|
+
indexTxn.write([{
|
|
1270
|
+
...operation,
|
|
1271
|
+
documentId: document.header.id,
|
|
1272
|
+
documentType: document.header.documentType,
|
|
1273
|
+
branch: job.branch,
|
|
1274
|
+
scope: job.scope,
|
|
1275
|
+
sourceRemote
|
|
1276
|
+
}]);
|
|
1277
|
+
if (this.driveContainerTypes.has(document.header.documentType)) {
|
|
1278
|
+
const collectionId = DriveCollectionId.forDrive(document.header.id, job.branch).key;
|
|
1279
|
+
indexTxn.createCollection(collectionId);
|
|
1280
|
+
indexTxn.addToCollection(collectionId, document.header.id);
|
|
1281
|
+
}
|
|
1282
|
+
stores.documentMetaCache.putDocumentMeta(document.header.id, job.branch, {
|
|
1283
|
+
state: document.state.document,
|
|
1284
|
+
documentType: document.header.documentType,
|
|
1285
|
+
protocolVersions: document.header.protocolVersions,
|
|
1286
|
+
documentScopeRevision: 1
|
|
1287
|
+
});
|
|
1288
|
+
return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
|
|
1289
|
+
}
|
|
1290
|
+
async executeDelete(write, executing) {
|
|
1291
|
+
const { action, skip, sourceRemote } = write;
|
|
1292
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1293
|
+
const input = action.input;
|
|
1294
|
+
if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
|
|
1295
|
+
const documentId = input.documentId;
|
|
1296
|
+
let document;
|
|
1297
|
+
try {
|
|
1298
|
+
document = await stores.writeCache.getState(documentId, job.scope, job.branch, void 0, signal);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1301
|
+
}
|
|
1302
|
+
const documentState = document.state.document;
|
|
1303
|
+
if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
1304
|
+
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
1305
|
+
let operation = createOperation(action, nextIndex, skip, {
|
|
1306
|
+
documentId,
|
|
1307
|
+
scope: job.scope,
|
|
1308
|
+
branch: job.branch
|
|
1309
|
+
});
|
|
1310
|
+
applyDeleteDocumentAction$1(document, action);
|
|
1311
|
+
const resultingStateObj = {
|
|
1312
|
+
header: document.header,
|
|
1313
|
+
document: document.state.document
|
|
1314
|
+
};
|
|
1315
|
+
const resultingState = JSON.stringify(resultingStateObj);
|
|
1316
|
+
const writeResult = await this.writeOperationToStore({
|
|
1317
|
+
documentId,
|
|
1318
|
+
documentType: document.header.documentType,
|
|
1319
|
+
scope: job.scope,
|
|
1320
|
+
branch: job.branch
|
|
1321
|
+
}, operation, executing);
|
|
1322
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
1323
|
+
operation = writeResult[0];
|
|
1324
|
+
updateDocumentRevision(document, job.scope, operation.index);
|
|
1325
|
+
document.operations = {
|
|
1326
|
+
...document.operations,
|
|
1327
|
+
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1328
|
+
};
|
|
1329
|
+
executing.touchedStreams.add(documentId, job.scope, job.branch);
|
|
1330
|
+
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, "head");
|
|
1331
|
+
indexTxn.write([{
|
|
1332
|
+
...operation,
|
|
1333
|
+
documentId,
|
|
1334
|
+
documentType: document.header.documentType,
|
|
1335
|
+
branch: job.branch,
|
|
1336
|
+
scope: job.scope,
|
|
1337
|
+
sourceRemote
|
|
1338
|
+
}]);
|
|
1339
|
+
stores.documentMetaCache.putDocumentMeta(documentId, job.branch, {
|
|
1340
|
+
state: document.state.document,
|
|
1341
|
+
documentType: document.header.documentType,
|
|
1342
|
+
protocolVersions: document.header.protocolVersions,
|
|
1343
|
+
documentScopeRevision: operation.index + 1
|
|
1344
|
+
});
|
|
1345
|
+
return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
1346
|
+
}
|
|
1347
|
+
async executeUpgrade(write, executing) {
|
|
1348
|
+
const { action, skip, sourceRemote } = write;
|
|
1349
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1350
|
+
const input = action.input;
|
|
1351
|
+
if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
|
|
1352
|
+
const documentId = input.documentId;
|
|
1353
|
+
const fromVersion = input.fromVersion;
|
|
1354
|
+
const toVersion = input.toVersion;
|
|
1355
|
+
let document;
|
|
1356
|
+
try {
|
|
1357
|
+
document = await stores.writeCache.getState(documentId, job.scope, job.branch, void 0, signal);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1360
|
+
}
|
|
1361
|
+
const documentState = document.state.document;
|
|
1362
|
+
if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
1363
|
+
if (fromVersion === toVersion && fromVersion > 0) return {
|
|
1364
|
+
job,
|
|
1365
|
+
success: true,
|
|
1366
|
+
operations: [],
|
|
1367
|
+
operationsWithContext: [],
|
|
1368
|
+
duration: Date.now() - startTime
|
|
1369
|
+
};
|
|
1370
|
+
const arrivesDecided = executing.replayingAcceptedHistory || executing.evaluatedByPosition;
|
|
1371
|
+
if (fromVersion > 0 && !arrivesDecided) {
|
|
1372
|
+
const stampedVersion = normalizeDocumentModelVersion(documentState.version);
|
|
1373
|
+
if (fromVersion !== stampedVersion) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `fromVersion ${fromVersion} does not match the document's version ${stampedVersion}`), startTime);
|
|
1374
|
+
if (input.revision !== void 0) {
|
|
1375
|
+
let actualRevisions;
|
|
1376
|
+
try {
|
|
1377
|
+
actualRevisions = (await stores.operationStore.getRevisions(documentId, job.branch, signal)).revision;
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch revisions for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1380
|
+
}
|
|
1381
|
+
const revisionScopes = /* @__PURE__ */ new Set([...Object.keys(input.revision), ...Object.keys(actualRevisions)]);
|
|
1382
|
+
for (const revisionScope of revisionScopes) {
|
|
1383
|
+
const snapshot = input.revision[revisionScope] ?? 0;
|
|
1384
|
+
const actual = actualRevisions[revisionScope] ?? 0;
|
|
1385
|
+
if (snapshot !== actual) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `revision snapshot for scope "${revisionScope}" is ${snapshot} but the document is at ${actual}`), startTime);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
let upgradePath;
|
|
1390
|
+
if (fromVersion > 0 && fromVersion < toVersion) try {
|
|
1391
|
+
upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1394
|
+
}
|
|
1395
|
+
const otherScopes = Object.keys(document.state).filter((scope) => scope !== job.scope);
|
|
1396
|
+
if (fromVersion > 0) for (const scope of otherScopes) {
|
|
1397
|
+
let scopedDocument;
|
|
1398
|
+
try {
|
|
1399
|
+
scopedDocument = await stores.writeCache.getState(documentId, scope, job.branch, void 0, signal);
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch ${scope} scope for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1402
|
+
}
|
|
1403
|
+
document = {
|
|
1404
|
+
...document,
|
|
1405
|
+
state: {
|
|
1406
|
+
...document.state,
|
|
1407
|
+
[scope]: scopedDocument.state[scope]
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
1412
|
+
try {
|
|
1413
|
+
document = applyUpgradeDocumentAction$1(document, action, upgradePath);
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1416
|
+
}
|
|
1417
|
+
let operation = createOperation(action, nextIndex, skip, {
|
|
1418
|
+
documentId,
|
|
1419
|
+
scope: job.scope,
|
|
1420
|
+
branch: job.branch
|
|
1421
|
+
});
|
|
1422
|
+
const resultingStateObj = {
|
|
1423
|
+
...document.state,
|
|
1424
|
+
header: document.header
|
|
1425
|
+
};
|
|
1426
|
+
if (fromVersion > 0) resultingStateObj.__migrated = true;
|
|
1427
|
+
const resultingState = JSON.stringify(resultingStateObj);
|
|
1428
|
+
const writeResult = await this.writeOperationToStore({
|
|
1429
|
+
documentId,
|
|
1430
|
+
documentType: document.header.documentType,
|
|
1431
|
+
scope: job.scope,
|
|
1432
|
+
branch: job.branch
|
|
1433
|
+
}, operation, executing);
|
|
1434
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
1435
|
+
operation = writeResult[0];
|
|
1436
|
+
updateDocumentRevision(document, job.scope, operation.index);
|
|
1437
|
+
document.operations = {
|
|
1438
|
+
...document.operations,
|
|
1439
|
+
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1440
|
+
};
|
|
1441
|
+
executing.touchedStreams.add(documentId, job.scope, job.branch);
|
|
1442
|
+
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, "head");
|
|
1443
|
+
for (const scope of otherScopes) executing.postCommitInvalidations.push({
|
|
1444
|
+
documentId,
|
|
1445
|
+
scope,
|
|
1446
|
+
branch: job.branch
|
|
1447
|
+
});
|
|
1448
|
+
indexTxn.write([{
|
|
1449
|
+
...operation,
|
|
1450
|
+
documentId,
|
|
1451
|
+
documentType: document.header.documentType,
|
|
1452
|
+
branch: job.branch,
|
|
1453
|
+
scope: job.scope,
|
|
1454
|
+
sourceRemote
|
|
1455
|
+
}]);
|
|
1456
|
+
stores.documentMetaCache.putDocumentMeta(documentId, job.branch, {
|
|
1457
|
+
state: document.state.document,
|
|
1458
|
+
documentType: document.header.documentType,
|
|
1459
|
+
protocolVersions: document.header.protocolVersions,
|
|
1460
|
+
documentScopeRevision: operation.index + 1
|
|
1461
|
+
});
|
|
1462
|
+
return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
1463
|
+
}
|
|
1464
|
+
/** A submitted write naming a purged target was refused at job start. */
|
|
1465
|
+
async isTargetPurged(write, executing) {
|
|
1466
|
+
const target = relationshipTarget(write.action);
|
|
1467
|
+
if (target === void 0 || !executing.purgeFence) return false;
|
|
1468
|
+
return executing.purgeFence.isPurged(target);
|
|
1469
|
+
}
|
|
1470
|
+
async executeAddRelationship(write, executing) {
|
|
1471
|
+
let targetPurged;
|
|
1472
|
+
try {
|
|
1473
|
+
targetPurged = await this.isTargetPurged(write, executing);
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
return buildErrorResult(executing.job, error instanceof Error ? error : new Error(String(error)), executing.startTime);
|
|
1476
|
+
}
|
|
1477
|
+
return this.withRelationshipAction("ADD_RELATIONSHIP", write, executing, (input) => input.sourceId === input.targetId ? /* @__PURE__ */ new Error("ADD_RELATIONSHIP: sourceId and targetId cannot be the same (self-relationships not allowed)") : null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
|
|
1478
|
+
if (!targetPurged && this.driveContainerTypes.has(sourceDoc.header.documentType)) {
|
|
1479
|
+
const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
|
|
1480
|
+
txn.addToCollection(collectionId, input.targetId);
|
|
1481
|
+
s.collectionMembershipCache.invalidate(input.targetId);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
async executeRemoveRelationship(write, executing) {
|
|
1486
|
+
let targetPurged;
|
|
1487
|
+
try {
|
|
1488
|
+
targetPurged = await this.isTargetPurged(write, executing);
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
return buildErrorResult(executing.job, error instanceof Error ? error : new Error(String(error)), executing.startTime);
|
|
1491
|
+
}
|
|
1492
|
+
return this.withRelationshipAction("REMOVE_RELATIONSHIP", write, executing, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
|
|
1493
|
+
if (!targetPurged && this.driveContainerTypes.has(sourceDoc.header.documentType)) {
|
|
1494
|
+
const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
|
|
1495
|
+
txn.removeFromCollection(collectionId, input.targetId);
|
|
1496
|
+
s.collectionMembershipCache.invalidate(input.targetId);
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
executeUpdateRelationship(write, executing) {
|
|
1501
|
+
return this.withRelationshipAction("UPDATE_RELATIONSHIP", write, executing, null, null);
|
|
1502
|
+
}
|
|
1503
|
+
async withRelationshipAction(actionTypeName, write, executing, preValidate, postWrite) {
|
|
1504
|
+
const { action, skip, sourceRemote } = write;
|
|
1505
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1506
|
+
if (job.scope !== "document") return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} must be in "document" scope, got "${job.scope}"`), startTime);
|
|
1507
|
+
const input = action.input;
|
|
1508
|
+
if (!input.sourceId || !input.targetId || !input.relationshipType) return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} action requires sourceId, targetId, and relationshipType in input`), startTime);
|
|
1509
|
+
if (preValidate !== null) {
|
|
1510
|
+
const validationError = preValidate(input);
|
|
1511
|
+
if (validationError !== null) return buildErrorResult(job, validationError, startTime);
|
|
1512
|
+
}
|
|
1513
|
+
let sourceDoc;
|
|
1514
|
+
try {
|
|
1515
|
+
sourceDoc = await stores.writeCache.getState(input.sourceId, "document", job.branch, void 0, signal);
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
if (DocumentPurgedError.isError(error)) return buildErrorResult(job, error, startTime);
|
|
1518
|
+
if (DocumentNotFoundError.isError(error)) return buildErrorResult(job, new DocumentNotFoundError(input.sourceId, `${actionTypeName}: source document ${input.sourceId} not found`), startTime);
|
|
1519
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName}: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1520
|
+
}
|
|
1521
|
+
const nextIndex = getNextIndexForScope(sourceDoc, job.scope);
|
|
1522
|
+
let operation = createOperation(action, nextIndex, skip, {
|
|
1523
|
+
documentId: input.sourceId,
|
|
1524
|
+
scope: job.scope,
|
|
1525
|
+
branch: job.branch
|
|
1526
|
+
});
|
|
1527
|
+
const writeResult = await this.writeOperationToStore({
|
|
1528
|
+
documentId: input.sourceId,
|
|
1529
|
+
documentType: sourceDoc.header.documentType,
|
|
1530
|
+
scope: job.scope,
|
|
1531
|
+
branch: job.branch
|
|
1532
|
+
}, operation, executing);
|
|
1533
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
1534
|
+
operation = writeResult[0];
|
|
1535
|
+
sourceDoc.header.lastModifiedAtUtcIso = operation.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString();
|
|
1536
|
+
updateDocumentRevision(sourceDoc, job.scope, operation.index);
|
|
1537
|
+
sourceDoc.operations = {
|
|
1538
|
+
...sourceDoc.operations,
|
|
1539
|
+
[job.scope]: [...sourceDoc.operations[job.scope] ?? [], operation]
|
|
1540
|
+
};
|
|
1541
|
+
const scopeState = sourceDoc.state[job.scope];
|
|
1542
|
+
const resultingStateObj = {
|
|
1543
|
+
header: structuredClone(sourceDoc.header),
|
|
1544
|
+
[job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
|
|
1545
|
+
};
|
|
1546
|
+
const resultingState = JSON.stringify(resultingStateObj);
|
|
1547
|
+
executing.touchedStreams.add(input.sourceId, job.scope, job.branch);
|
|
1548
|
+
executing.touchedStreams.add(input.targetId, job.scope, job.branch);
|
|
1549
|
+
stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, "head");
|
|
1550
|
+
indexTxn.write([{
|
|
1551
|
+
...operation,
|
|
1552
|
+
documentId: input.sourceId,
|
|
1553
|
+
documentType: sourceDoc.header.documentType,
|
|
1554
|
+
branch: job.branch,
|
|
1555
|
+
scope: job.scope,
|
|
1556
|
+
sourceRemote
|
|
1557
|
+
}]);
|
|
1558
|
+
if (postWrite !== null) postWrite({
|
|
1559
|
+
indexTxn,
|
|
1560
|
+
stores,
|
|
1561
|
+
sourceDoc,
|
|
1562
|
+
input,
|
|
1563
|
+
job
|
|
1564
|
+
});
|
|
1565
|
+
stores.documentMetaCache.putDocumentMeta(input.sourceId, job.branch, {
|
|
1566
|
+
state: sourceDoc.state.document,
|
|
1567
|
+
documentType: sourceDoc.header.documentType,
|
|
1568
|
+
protocolVersions: sourceDoc.header.protocolVersions,
|
|
1569
|
+
documentScopeRevision: operation.index + 1
|
|
1570
|
+
});
|
|
1571
|
+
return buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
|
|
1572
|
+
}
|
|
1573
|
+
async writeOperationToStore(target, operation, executing) {
|
|
1574
|
+
const { documentId, documentType, scope, branch } = target;
|
|
1575
|
+
const { job, startTime, stores, signal } = executing;
|
|
1576
|
+
executing.touchedStreams.add(documentId, scope, branch);
|
|
1577
|
+
let storedOperations;
|
|
1578
|
+
try {
|
|
1579
|
+
storedOperations = await stores.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
|
|
1580
|
+
txn.addOperations(operation);
|
|
1581
|
+
}, signal);
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
this.logger.error("Failed to write operation to IOperationStore (@documentId @scope @branch): @Operation @Error", documentId, scope, branch, operation, error);
|
|
1584
|
+
stores.writeCache.invalidate(documentId, scope, branch);
|
|
1585
|
+
if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
|
|
1586
|
+
return {
|
|
1587
|
+
job,
|
|
1588
|
+
success: false,
|
|
1589
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${String(error)}`),
|
|
1590
|
+
duration: Date.now() - startTime
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
return storedOperations;
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
//#endregion
|
|
1597
|
+
//#region src/signer/passthrough-signer.ts
|
|
1598
|
+
/**
|
|
1599
|
+
* A no-op signer that returns empty values for all methods.
|
|
1600
|
+
* Used when signing is not required.
|
|
1601
|
+
*/
|
|
1602
|
+
var PassthroughSigner = class {
|
|
1603
|
+
publicKey = {};
|
|
1604
|
+
sign() {
|
|
1605
|
+
return Promise.resolve(/* @__PURE__ */ new Uint8Array(0));
|
|
1606
|
+
}
|
|
1607
|
+
verify() {
|
|
1608
|
+
return Promise.resolve();
|
|
1609
|
+
}
|
|
1610
|
+
signAction() {
|
|
1611
|
+
return Promise.resolve([
|
|
1612
|
+
"",
|
|
1613
|
+
"",
|
|
1614
|
+
"",
|
|
1615
|
+
"",
|
|
1616
|
+
""
|
|
1617
|
+
]);
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
//#endregion
|
|
1621
|
+
//#region src/signer/trust-policy.ts
|
|
1622
|
+
/**
|
|
1623
|
+
* The policy admission asks: the reactor's own key signing as its own user is
|
|
1624
|
+
* accepted, then the host's policy decides, else the default does. `own` is
|
|
1625
|
+
* read on every ask, since its user changes on login.
|
|
1626
|
+
*/
|
|
1627
|
+
function admissionTrustPolicy(own, authEnforcement, policy) {
|
|
1628
|
+
return { authorizeSigner(signer, key, documentId) {
|
|
1629
|
+
const identity = actionSignerIdentity(own);
|
|
1630
|
+
if (identity.app.key !== "" && key === identity.app.key) {
|
|
1631
|
+
if (sameUser(actionSignerIdentity(signer).user, identity.user)) return Promise.resolve(true);
|
|
1632
|
+
}
|
|
1633
|
+
if (policy) return policy.authorizeSigner(signer, key, documentId);
|
|
1634
|
+
return Promise.resolve(!authEnforcement);
|
|
1635
|
+
} };
|
|
1636
|
+
}
|
|
1637
|
+
function sameUser(a, b) {
|
|
1638
|
+
return a.address.toLowerCase() === b.address.toLowerCase() && a.networkId === b.networkId && a.chainId === b.chainId;
|
|
1639
|
+
}
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/signer/did-key.ts
|
|
1642
|
+
const DID_KEY_PREFIX = "did:key:z";
|
|
1643
|
+
const P256_MULTICODEC = [128, 36];
|
|
1644
|
+
const MAX_CACHED_KEYS = 1024;
|
|
1645
|
+
const P = BigInt("0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff");
|
|
1646
|
+
const A = BigInt("0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc");
|
|
1647
|
+
const B = BigInt("0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b");
|
|
1648
|
+
/** A did:key that is not a compressed P-256 public key. */
|
|
1649
|
+
var MalformedDidKeyError = class extends Error {
|
|
1650
|
+
constructor(did, detail) {
|
|
1651
|
+
super(`Malformed did:key ${did}: ${detail}`);
|
|
1652
|
+
this.name = "MalformedDidKeyError";
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
const keyCache = /* @__PURE__ */ new Map();
|
|
1656
|
+
/** Cached per process: decompressing the point costs a BigInt modPow. */
|
|
1657
|
+
async function importDidKey(did) {
|
|
1658
|
+
const cached = keyCache.get(did);
|
|
1659
|
+
if (cached) {
|
|
1660
|
+
keyCache.delete(did);
|
|
1661
|
+
keyCache.set(did, cached);
|
|
1662
|
+
return cached;
|
|
1663
|
+
}
|
|
1664
|
+
const raw = decompressP256(compressedPointFromDid(did), did);
|
|
1665
|
+
const key = await crypto.subtle.importKey("raw", raw.buffer, {
|
|
1666
|
+
name: "ECDSA",
|
|
1667
|
+
namedCurve: "P-256"
|
|
1668
|
+
}, false, ["verify"]);
|
|
1669
|
+
keyCache.set(did, key);
|
|
1670
|
+
if (keyCache.size > MAX_CACHED_KEYS) {
|
|
1671
|
+
const oldest = keyCache.keys().next().value;
|
|
1672
|
+
if (oldest !== void 0) keyCache.delete(oldest);
|
|
1673
|
+
}
|
|
1674
|
+
return key;
|
|
1675
|
+
}
|
|
1676
|
+
function compressedPointFromDid(did) {
|
|
1677
|
+
if (!did.startsWith(DID_KEY_PREFIX)) throw new MalformedDidKeyError(did, "not a base58btc did:key");
|
|
1678
|
+
const decoded = base58Decode(did.slice(9));
|
|
1679
|
+
if (!decoded || decoded.length !== 35) throw new MalformedDidKeyError(did, "not a compressed P-256 key");
|
|
1680
|
+
if (decoded[0] !== P256_MULTICODEC[0] || decoded[1] !== P256_MULTICODEC[1]) throw new MalformedDidKeyError(did, "not a P-256 multicodec");
|
|
1681
|
+
return decoded.subarray(2);
|
|
1682
|
+
}
|
|
1683
|
+
function decompressP256(compressed, did) {
|
|
1684
|
+
const prefix = compressed[0];
|
|
1685
|
+
if (prefix !== 2 && prefix !== 3) throw new MalformedDidKeyError(did, `compression prefix ${prefix}`);
|
|
1686
|
+
let x = BigInt(0);
|
|
1687
|
+
for (let i = 1; i < compressed.length; i++) x = x << BigInt(8) | BigInt(compressed[i]);
|
|
1688
|
+
if (x >= P) throw new MalformedDidKeyError(did, "x outside the field");
|
|
1689
|
+
const ySquared = (modPow(x, BigInt(3), P) + A * x + B) % P;
|
|
1690
|
+
let y = modPow(ySquared, (P + BigInt(1)) / BigInt(4), P);
|
|
1691
|
+
if (y * y % P !== ySquared) throw new MalformedDidKeyError(did, "x is not on the curve");
|
|
1692
|
+
if (y % BigInt(2) === BigInt(0) !== (prefix === 2)) y = P - y;
|
|
1693
|
+
const uncompressed = /* @__PURE__ */ new Uint8Array(65);
|
|
1694
|
+
uncompressed[0] = 4;
|
|
1695
|
+
uncompressed.set(bigIntToBytes(x), 1);
|
|
1696
|
+
uncompressed.set(bigIntToBytes(y), 33);
|
|
1697
|
+
return uncompressed;
|
|
1698
|
+
}
|
|
1699
|
+
function modPow(base, exp, mod) {
|
|
1700
|
+
let result = BigInt(1);
|
|
1701
|
+
let b = base % mod;
|
|
1702
|
+
let e = exp;
|
|
1703
|
+
while (e > BigInt(0)) {
|
|
1704
|
+
if (e & BigInt(1)) result = result * b % mod;
|
|
1705
|
+
e >>= BigInt(1);
|
|
1706
|
+
b = b * b % mod;
|
|
1707
|
+
}
|
|
1708
|
+
return result;
|
|
1709
|
+
}
|
|
1710
|
+
function bigIntToBytes(value) {
|
|
1711
|
+
const bytes = /* @__PURE__ */ new Uint8Array(32);
|
|
1712
|
+
let v = value;
|
|
1713
|
+
for (let i = 31; i >= 0; i--) {
|
|
1714
|
+
bytes[i] = Number(v & BigInt(255));
|
|
1715
|
+
v >>= BigInt(8);
|
|
1716
|
+
}
|
|
1717
|
+
return bytes;
|
|
1718
|
+
}
|
|
1719
|
+
//#endregion
|
|
1720
|
+
//#region src/signer/verify-action-signature.ts
|
|
1721
|
+
const RENOWN_HASH_LENGTH = 44;
|
|
1722
|
+
const SHARED_HASH_LENGTH = 28;
|
|
1723
|
+
const HEX_SIGNATURE = /^(0x)?([0-9a-fA-F]{2})+$/;
|
|
1724
|
+
/**
|
|
1725
|
+
* Integrity only: identity binding and the live-id check sit with the caller.
|
|
1726
|
+
* `operation` is the incoming operation at load admission.
|
|
1727
|
+
*/
|
|
1728
|
+
async function verifyActionSignature(action, target, path, operation) {
|
|
1729
|
+
const policy = target.policy ?? "legacy";
|
|
1730
|
+
const refusal = policyRefusal(action, policy);
|
|
1731
|
+
if (refusal) return refusal;
|
|
1732
|
+
const shape = signerShape(action.context?.signer);
|
|
1733
|
+
if (shape.kind === "unsigned") return policy === "v2-required" ? refuse("unsigned", "UNSIGNED_REQUIRED", `action ${action.id} is unsigned but ${target.documentId} requires v2 signatures`) : {
|
|
1734
|
+
ok: true,
|
|
1735
|
+
scheme: "unsigned"
|
|
1736
|
+
};
|
|
1737
|
+
if (shape.kind === "malformed") return refuse("legacy-unknown", "MALFORMED_TUPLE", `action ${action.id} ${shape.reason}`);
|
|
1738
|
+
const { signer, tuple } = shape;
|
|
1739
|
+
const scheme = schemeOf(tuple[2]);
|
|
1740
|
+
if (tuple[1] !== signer.app.key) return refuse(scheme, "KEY_MISMATCH", `action ${action.id} tuple key does not match signer.app.key`);
|
|
1741
|
+
if (scheme !== "v2" && policy === "v2-required") return refuse(scheme, "SCHEME_BELOW_POLICY", `action ${action.id} carries a legacy tuple but ${target.documentId} requires v2 signatures`);
|
|
1742
|
+
if (scheme === "legacy-unknown" && path === "mutation") return refuse(scheme, "MALFORMED_TUPLE", `action ${action.id} carries a hash of unknown length ${tuple[2].length}`);
|
|
1743
|
+
if (scheme === "v2") {
|
|
1744
|
+
const refusal = await checkV2(action, tuple, target, operation);
|
|
1745
|
+
if (refusal) return refusal;
|
|
1746
|
+
} else if (path === "mutation") {
|
|
1747
|
+
if (await legacyHash(scheme, action, target) !== tuple[2]) return refuse(scheme, "HASH_MISMATCH", `action ${action.id} does not match the hash its signature covers`);
|
|
1748
|
+
}
|
|
1749
|
+
return verifyEcdsa(tuple, scheme, action.id);
|
|
1750
|
+
}
|
|
1751
|
+
/** Refusals a document's policy makes before any tuple is read. */
|
|
1752
|
+
function policyRefusal(action, policy) {
|
|
1753
|
+
if (action.type !== "CREATE_DOCUMENT") return;
|
|
1754
|
+
const scheme = schemeOfAction(action);
|
|
1755
|
+
const input = action.input;
|
|
1756
|
+
const documentId = input?.documentId ?? "";
|
|
1757
|
+
if (policy === "legacy") return isDerivedDocumentId(documentId) ? refuse(scheme, "ID_MISMATCH", `action ${action.id}: a legacy document cannot take the content-addressed id ${documentId}`) : void 0;
|
|
1758
|
+
const expected = derivedCreateId(input);
|
|
1759
|
+
if (expected === documentId) return;
|
|
1760
|
+
return refuse(scheme, "ID_MISMATCH", expected === void 0 ? `action ${action.id} creates v2-required ${documentId} without the header params its id is derived from` : `action ${action.id} creates v2-required ${documentId}, but its header params derive ${expected}`);
|
|
1761
|
+
}
|
|
1762
|
+
function derivedCreateId(input) {
|
|
1763
|
+
if (!input?.signing || !input.protocolVersions) return;
|
|
1764
|
+
try {
|
|
1765
|
+
return deriveDocumentId({
|
|
1766
|
+
documentType: input.model,
|
|
1767
|
+
createdAtUtcIso: input.signing.createdAtUtcIso,
|
|
1768
|
+
nonce: input.signing.nonce,
|
|
1769
|
+
protocolVersions: input.protocolVersions
|
|
1770
|
+
});
|
|
1771
|
+
} catch {
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function schemeOfAction(action) {
|
|
1776
|
+
const shape = signerShape(action.context?.signer);
|
|
1777
|
+
if (shape.kind === "unsigned") return "unsigned";
|
|
1778
|
+
return shape.kind === "signed" ? schemeOf(shape.tuple[2]) : "legacy-unknown";
|
|
1779
|
+
}
|
|
1780
|
+
/** Peers send arbitrary JSON, so nothing past `context` is assumed. */
|
|
1781
|
+
function signerShape(value) {
|
|
1782
|
+
if (value === void 0 || value === null) return { kind: "unsigned" };
|
|
1783
|
+
if (!isRecord(value)) return {
|
|
1784
|
+
kind: "malformed",
|
|
1785
|
+
reason: "has a signer that is not an object"
|
|
1786
|
+
};
|
|
1787
|
+
const { app, user, signatures } = value;
|
|
1788
|
+
if (app !== void 0 && app !== null && !isRecord(app)) return {
|
|
1789
|
+
kind: "malformed",
|
|
1790
|
+
reason: "has a signer.app that is not an object"
|
|
1791
|
+
};
|
|
1792
|
+
const key = isRecord(app) ? app.key : void 0;
|
|
1793
|
+
if (key !== void 0 && key !== null && typeof key !== "string") return {
|
|
1794
|
+
kind: "malformed",
|
|
1795
|
+
reason: "has a signer.app.key that is not a string"
|
|
1796
|
+
};
|
|
1797
|
+
if (!key) return { kind: "unsigned" };
|
|
1798
|
+
if (!isRecord(user) || typeof user.address !== "string") return {
|
|
1799
|
+
kind: "malformed",
|
|
1800
|
+
reason: "has a signer without a user address"
|
|
1801
|
+
};
|
|
1802
|
+
if (!Array.isArray(signatures)) return {
|
|
1803
|
+
kind: "malformed",
|
|
1804
|
+
reason: "has a signer whose signatures are not a list"
|
|
1805
|
+
};
|
|
1806
|
+
const tuple = signatures.at(-1);
|
|
1807
|
+
if (!isTuple(tuple)) return {
|
|
1808
|
+
kind: "malformed",
|
|
1809
|
+
reason: signatures.length === 0 ? "has a signer but no signatures" : "carries a malformed signature tuple"
|
|
1810
|
+
};
|
|
1811
|
+
return {
|
|
1812
|
+
kind: "signed",
|
|
1813
|
+
signer: value,
|
|
1814
|
+
tuple
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
function isRecord(value) {
|
|
1818
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1819
|
+
}
|
|
1820
|
+
async function checkV2(action, tuple, target, operation) {
|
|
1821
|
+
const problem = v2TupleProblem(tuple);
|
|
1822
|
+
if (problem) return refuse("v2", "MALFORMED_TUPLE", `action ${action.id}: ${problem}`);
|
|
1823
|
+
if (operation && !sameInstant(operation.timestampUtcMs, action.timestampUtcMs)) return refuse("v2", "TIMESTAMP_MISMATCH", `action ${action.id} is stamped ${action.timestampUtcMs} but its operation ${operation.timestampUtcMs}`);
|
|
1824
|
+
let expected;
|
|
1825
|
+
try {
|
|
1826
|
+
expected = await hashActionV2(action, target, action.context.signer);
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
return refuse("v2", "HASH_MISMATCH", `action ${action.id} has no v2 hash: ${error instanceof Error ? error.message : String(error)}`);
|
|
1829
|
+
}
|
|
1830
|
+
if (expected !== tuple[2]) return refuse("v2", "HASH_MISMATCH", `action ${action.id} does not match the hash its signature covers`);
|
|
1831
|
+
}
|
|
1832
|
+
function sameInstant(a, b) {
|
|
1833
|
+
const left = Date.parse(a);
|
|
1834
|
+
return !Number.isNaN(left) && left === Date.parse(b);
|
|
1835
|
+
}
|
|
1836
|
+
function schemeOf(hash) {
|
|
1837
|
+
if (isV2ActionHash(hash)) return "v2";
|
|
1838
|
+
if (hash.length === RENOWN_HASH_LENGTH) return "legacy-renown";
|
|
1839
|
+
if (hash.length === SHARED_HASH_LENGTH) return "legacy-shared";
|
|
1840
|
+
return "legacy-unknown";
|
|
1841
|
+
}
|
|
1842
|
+
async function legacyHash(scheme, action, target) {
|
|
1843
|
+
if (scheme === "legacy-renown") {
|
|
1844
|
+
const payload = [
|
|
1845
|
+
action.scope,
|
|
1846
|
+
action.type,
|
|
1847
|
+
JSON.stringify(action.input)
|
|
1848
|
+
].join("");
|
|
1849
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
1850
|
+
return bytesToBase64(new Uint8Array(digest));
|
|
1851
|
+
}
|
|
1852
|
+
const signer = action.context.signer;
|
|
1853
|
+
return buildOperationSignatureParams({
|
|
1854
|
+
documentId: target.documentId,
|
|
1855
|
+
signer,
|
|
1856
|
+
action,
|
|
1857
|
+
previousStateHash: ""
|
|
1858
|
+
})[2];
|
|
1859
|
+
}
|
|
1860
|
+
async function verifyEcdsa(tuple, scheme, actionId) {
|
|
1861
|
+
const [timestamp, key, hash, prevStateHash, signatureHex] = tuple;
|
|
1862
|
+
if (!HEX_SIGNATURE.test(signatureHex)) return refuse(scheme, "MALFORMED_TUPLE", `action ${actionId} signature is not hex`);
|
|
1863
|
+
let cryptoKey;
|
|
1864
|
+
try {
|
|
1865
|
+
cryptoKey = await importDidKey(key);
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
return refuse(scheme, "MALFORMED_TUPLE", `action ${actionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1868
|
+
}
|
|
1869
|
+
const message = buildOperationSignatureMessage([
|
|
1870
|
+
timestamp,
|
|
1871
|
+
key,
|
|
1872
|
+
hash,
|
|
1873
|
+
prevStateHash
|
|
1874
|
+
]);
|
|
1875
|
+
let valid;
|
|
1876
|
+
try {
|
|
1877
|
+
valid = await crypto.subtle.verify({
|
|
1878
|
+
name: "ECDSA",
|
|
1879
|
+
hash: "SHA-256"
|
|
1880
|
+
}, cryptoKey, hexToBytes(signatureHex).buffer, message.buffer);
|
|
1881
|
+
} catch {
|
|
1882
|
+
valid = false;
|
|
1883
|
+
}
|
|
1884
|
+
if (!valid) return refuse(scheme, "BAD_SIGNATURE", `action ${actionId} signature does not verify under its key`);
|
|
1885
|
+
return {
|
|
1886
|
+
ok: true,
|
|
1887
|
+
scheme
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
function isTuple(value) {
|
|
1891
|
+
return Array.isArray(value) && value.length === 5 && value.every((element) => typeof element === "string");
|
|
1892
|
+
}
|
|
1893
|
+
function refuse(scheme, code, reason) {
|
|
1894
|
+
return {
|
|
1895
|
+
ok: false,
|
|
1896
|
+
scheme,
|
|
1897
|
+
code,
|
|
1898
|
+
reason
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function hexToBytes(hex) {
|
|
1902
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
1903
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
1904
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
|
|
1905
|
+
return bytes;
|
|
1906
|
+
}
|
|
1907
|
+
function bytesToBase64(bytes) {
|
|
1908
|
+
let binary = "";
|
|
1909
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
1910
|
+
return btoa(binary);
|
|
1911
|
+
}
|
|
1912
|
+
//#endregion
|
|
1913
|
+
//#region src/executor/synthesized-signing.ts
|
|
1914
|
+
/** Submitted types whose stored action the reducer writes in their place. */
|
|
1915
|
+
const SYNTHESIZING_TYPES = /* @__PURE__ */ new Set(["UNDO", "REDO"]);
|
|
1916
|
+
/**
|
|
1917
|
+
* The id of the action synthesized from `submittedId`. Derived, so the stored
|
|
1918
|
+
* operation answers for the submitted id at the live-id check.
|
|
1919
|
+
*/
|
|
1920
|
+
function synthesizedActionId(submittedId) {
|
|
1921
|
+
return hashBrowser(`synthesized:${submittedId}`, "sha1", "hex").slice(0, 32);
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Whether `operation` stores an action the reducer made from `submitted`
|
|
1925
|
+
* rather than `submitted` itself or an operation already in `history`.
|
|
1926
|
+
*/
|
|
1927
|
+
function isSynthesized(submitted, operation, history) {
|
|
1928
|
+
if (!SYNTHESIZING_TYPES.has(submitted.type)) return false;
|
|
1929
|
+
const id = operation.action.id;
|
|
1930
|
+
if (!id) return true;
|
|
1931
|
+
if (id === submitted.id) return false;
|
|
1932
|
+
return !history.some((existing) => existing.action.id === id);
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Signs the synthesized action of `operation` in place as `signer`, under the
|
|
1936
|
+
* id {@link synthesizedActionId} derives from the submitted one. A REDO's
|
|
1937
|
+
* rebuilt action has no timestamp, so it takes the submitted one; the
|
|
1938
|
+
* operation timestamp is set to the action's, which a peer requires of a v2
|
|
1939
|
+
* tuple.
|
|
1940
|
+
*/
|
|
1941
|
+
async function signSynthesized(operation, submitted, target, signer, signal) {
|
|
1942
|
+
const synthesized = operation.action;
|
|
1943
|
+
const id = synthesizedActionId(submitted.id);
|
|
1944
|
+
const timestampUtcMs = operation.timestampUtcMs || synthesized.timestampUtcMs || submitted.timestampUtcMs;
|
|
1945
|
+
const action = {
|
|
1946
|
+
id,
|
|
1947
|
+
timestampUtcMs,
|
|
1948
|
+
type: synthesized.type,
|
|
1949
|
+
input: synthesized.input,
|
|
1950
|
+
scope: synthesized.scope
|
|
1951
|
+
};
|
|
1952
|
+
const signature = await signer.signAction(action, {
|
|
1953
|
+
documentId: target.documentId,
|
|
1954
|
+
branch: target.branch
|
|
1955
|
+
}, signal);
|
|
1956
|
+
operation.action = {
|
|
1957
|
+
...action,
|
|
1958
|
+
context: { signer: {
|
|
1959
|
+
...actionSignerIdentity(signer),
|
|
1960
|
+
signatures: [signature]
|
|
1961
|
+
} }
|
|
1962
|
+
};
|
|
1963
|
+
operation.id = deriveOperationId(target.documentId, target.scope, target.branch, id);
|
|
1964
|
+
operation.timestampUtcMs = timestampUtcMs;
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/executor/signature-admission.ts
|
|
1968
|
+
const DEFAULT_TRUST_TIMEOUT_MS = 1e4;
|
|
1969
|
+
/** Runs once per write, when this reactor first stores it; never on re-appends. */
|
|
1970
|
+
var SignatureAdmission = class {
|
|
1971
|
+
mode;
|
|
1972
|
+
logger;
|
|
1973
|
+
eventBus;
|
|
1974
|
+
policySource;
|
|
1975
|
+
trustPolicy;
|
|
1976
|
+
trustTimeoutMs;
|
|
1977
|
+
constructor(mode, logger, eventBus, policySource = "meta", trust = {
|
|
1978
|
+
authEnforcement: false,
|
|
1979
|
+
timeoutMs: DEFAULT_TRUST_TIMEOUT_MS
|
|
1980
|
+
}) {
|
|
1981
|
+
this.mode = mode;
|
|
1982
|
+
this.logger = logger;
|
|
1983
|
+
this.eventBus = eventBus;
|
|
1984
|
+
this.policySource = policySource;
|
|
1985
|
+
this.trustPolicy = admissionTrustPolicy(trust.signer ?? new PassthroughSigner(), trust.authEnforcement, trust.policy);
|
|
1986
|
+
this.trustTimeoutMs = trust.timeoutMs;
|
|
1987
|
+
}
|
|
1988
|
+
/** The first refusal of a mutation's submitted actions, when enforcing. */
|
|
1989
|
+
async admitMutation(job, stores, signal) {
|
|
1990
|
+
const { operationStore } = stores;
|
|
1991
|
+
const candidates = job.actions.map((action) => candidate(action, mutationStream(action, job)));
|
|
1992
|
+
const live = await this.liveOperationIds(candidates, operationStore, signal);
|
|
1993
|
+
const committed = await this.storedAsSubmitted(candidates, live, isRetry(job), operationStore, signal);
|
|
1994
|
+
await resolvePolicies(candidates, job.actions, (documentId, branch) => storedPolicy(stores, this.policySource, documentId, branch, signal));
|
|
1995
|
+
const submitted = /* @__PURE__ */ new Set();
|
|
1996
|
+
for (const entry of candidates) {
|
|
1997
|
+
if (committed.has(entry)) continue;
|
|
1998
|
+
const verdict = await this.verdict(entry, live, submitted, "mutation", signal);
|
|
1999
|
+
submitted.add(entry.opId);
|
|
2000
|
+
if (verdict.ok) continue;
|
|
2001
|
+
const refusal = this.record(job, entry, verdict, "mutation");
|
|
2002
|
+
if (refusal) return {
|
|
2003
|
+
kind: "refused",
|
|
2004
|
+
error: refusal
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
kind: "admitted",
|
|
2009
|
+
committed: [...committed.values()]
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
/** The incoming operations a load drops, empty unless enforcing. */
|
|
2013
|
+
async admitLoad(job, operations, stores, signal) {
|
|
2014
|
+
const { operationStore } = stores;
|
|
2015
|
+
const stream = {
|
|
2016
|
+
documentId: job.documentId,
|
|
2017
|
+
scope: job.scope,
|
|
2018
|
+
branch: job.branch
|
|
2019
|
+
};
|
|
2020
|
+
const candidates = operations.map((operation) => ({
|
|
2021
|
+
...candidate(operation.action, stream),
|
|
2022
|
+
operation
|
|
2023
|
+
}));
|
|
2024
|
+
const live = await this.liveOperationIds(candidates, operationStore, signal);
|
|
2025
|
+
const redelivered = await this.redelivered(candidates, live, operationStore, signal);
|
|
2026
|
+
await resolvePolicies(candidates, job.operations.map((operation) => operation.action), (documentId, branch) => storedPolicy(stores, this.policySource, documentId, branch, signal));
|
|
2027
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
2028
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2029
|
+
const verdict = await this.verdict(candidates[i], live, void 0, "load", signal, redelivered.has(candidates[i]));
|
|
2030
|
+
if (verdict.ok) continue;
|
|
2031
|
+
if (this.record(job, candidates[i], verdict, "load")) dropped.add(operations[i]);
|
|
2032
|
+
}
|
|
2033
|
+
return dropped;
|
|
2034
|
+
}
|
|
2035
|
+
/** A marker is held to v2 and enforced whatever the policy and mode. */
|
|
2036
|
+
async admitMarker(job, marker, signal) {
|
|
2037
|
+
const entry = {
|
|
2038
|
+
...candidate(marker.action, {
|
|
2039
|
+
documentId: job.documentId,
|
|
2040
|
+
scope: "document",
|
|
2041
|
+
branch: job.branch
|
|
2042
|
+
}),
|
|
2043
|
+
operation: marker,
|
|
2044
|
+
policy: "v2-required"
|
|
2045
|
+
};
|
|
2046
|
+
const verdict = await this.verdict(entry, /* @__PURE__ */ new Set(), void 0, "load", signal);
|
|
2047
|
+
if (verdict.ok) return;
|
|
2048
|
+
return this.record(job, entry, verdict, "load", true);
|
|
2049
|
+
}
|
|
2050
|
+
async verdict(entry, live, submitted, path, signal, known = false) {
|
|
2051
|
+
const verdict = await verifyActionSignature(entry.action, {
|
|
2052
|
+
documentId: entry.stream.documentId,
|
|
2053
|
+
branch: entry.stream.branch,
|
|
2054
|
+
policy: entry.policy
|
|
2055
|
+
}, path, entry.operation);
|
|
2056
|
+
if (!verdict.ok) return verdict;
|
|
2057
|
+
if (!known && (isLive(entry, live) || submitted?.has(entry.opId))) return {
|
|
2058
|
+
ok: false,
|
|
2059
|
+
scheme: verdict.scheme,
|
|
2060
|
+
code: "DUPLICATE_ACTION",
|
|
2061
|
+
reason: `action ${entry.action.id} is already in the stream`
|
|
2062
|
+
};
|
|
2063
|
+
if (verdict.scheme === "unsigned") {
|
|
2064
|
+
const address = entry.action.context?.signer?.user?.address;
|
|
2065
|
+
return address ? {
|
|
2066
|
+
ok: false,
|
|
2067
|
+
scheme: "unsigned",
|
|
2068
|
+
code: "UNSIGNED_IDENTITY",
|
|
2069
|
+
reason: `action ${entry.action.id} is unsigned but claims to act as ${address}`
|
|
2070
|
+
} : verdict;
|
|
2071
|
+
}
|
|
2072
|
+
if (known) return verdict;
|
|
2073
|
+
const signer = entry.action.context.signer;
|
|
2074
|
+
if (!await this.authorize(signer, entry.stream.documentId, signal)) return {
|
|
2075
|
+
ok: false,
|
|
2076
|
+
scheme: verdict.scheme,
|
|
2077
|
+
code: "SIGNER_UNAUTHORIZED",
|
|
2078
|
+
reason: `action ${entry.action.id}: key ${signer.app.key} may not sign as ${signer.user.address}`
|
|
2079
|
+
};
|
|
2080
|
+
return verdict;
|
|
2081
|
+
}
|
|
2082
|
+
/** Throws when the policy does, times out or the job is aborted. */
|
|
2083
|
+
async authorize(signer, documentId, signal) {
|
|
2084
|
+
signal?.throwIfAborted();
|
|
2085
|
+
let timer;
|
|
2086
|
+
let onAbort;
|
|
2087
|
+
const stop = new Promise((_, reject) => {
|
|
2088
|
+
timer = setTimeout(() => {
|
|
2089
|
+
reject(/* @__PURE__ */ new Error(`signature trust policy did not answer for ${signer.app.key} within ${this.trustTimeoutMs}ms`));
|
|
2090
|
+
}, this.trustTimeoutMs);
|
|
2091
|
+
if (signal) {
|
|
2092
|
+
onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
|
|
2093
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
try {
|
|
2097
|
+
return await Promise.race([this.trustPolicy.authorizeSigner(signer, signer.app.key, documentId), stop]);
|
|
2098
|
+
} finally {
|
|
2099
|
+
clearTimeout(timer);
|
|
2100
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* The live candidates stored exactly as submitted and still standing: one
|
|
2105
|
+
* an undo or a reshuffle has since superseded is refused, not re-emitted with
|
|
2106
|
+
* the state it once produced. A synthesized operation
|
|
2107
|
+
* holds another action, so it cannot be compared; only a retry, whose first
|
|
2108
|
+
* attempt this job was, takes it as committed.
|
|
2109
|
+
*/
|
|
2110
|
+
async storedAsSubmitted(candidates, live, retry, operationStore, signal) {
|
|
2111
|
+
const committed = /* @__PURE__ */ new Map();
|
|
2112
|
+
const toCompare = [];
|
|
2113
|
+
for (const entry of candidates) if (entry.synthesizedOpId && live.has(entry.synthesizedOpId)) {
|
|
2114
|
+
if (retry) committed.set(entry, committedWrite(entry, entry.synthesizedOpId));
|
|
2115
|
+
} else if (live.has(entry.opId)) toCompare.push(entry);
|
|
2116
|
+
for (const [stream, entries] of byStream(toCompare)) {
|
|
2117
|
+
const stored = await operationStore.getOperationsByIds(stream.documentId, stream.scope, stream.branch, entries.map((entry) => entry.opId), signal);
|
|
2118
|
+
const byId = new Map(stored.map((operation) => [operation.id, operation]));
|
|
2119
|
+
const matching = entries.filter((entry) => {
|
|
2120
|
+
const operation = byId.get(entry.opId);
|
|
2121
|
+
return operation && sameContent(operation.action, entry.action);
|
|
2122
|
+
});
|
|
2123
|
+
if (matching.length === 0) continue;
|
|
2124
|
+
const later = await operationStore.getSince(stream.documentId, stream.scope, stream.branch, Math.min(...matching.map((entry) => byId.get(entry.opId).index)), void 0, void 0, signal);
|
|
2125
|
+
for (const entry of matching) if (!isSuperseded(byId.get(entry.opId), later.results)) committed.set(entry, committedWrite(entry, entry.opId));
|
|
2126
|
+
}
|
|
2127
|
+
return committed;
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* The load candidates stored exactly as they arrive, timestamp included: a
|
|
2131
|
+
* peer re-sending what this stream holds, not a replay of its action.
|
|
2132
|
+
*/
|
|
2133
|
+
async redelivered(candidates, live, operationStore, signal) {
|
|
2134
|
+
const found = /* @__PURE__ */ new Set();
|
|
2135
|
+
const toCompare = candidates.filter((entry) => live.has(entry.opId) && !(entry.synthesizedOpId && live.has(entry.synthesizedOpId)));
|
|
2136
|
+
for (const [stream, entries] of byStream(toCompare)) {
|
|
2137
|
+
const stored = await operationStore.getOperationsByIds(stream.documentId, stream.scope, stream.branch, entries.map((entry) => entry.opId), signal);
|
|
2138
|
+
for (const entry of entries) if (stored.some((operation) => operation.id === entry.opId && Date.parse(operation.timestampUtcMs) === Date.parse(entry.operation?.timestampUtcMs ?? "") && sameContent(operation.action, entry.action))) found.add(entry);
|
|
2139
|
+
}
|
|
2140
|
+
return found;
|
|
2141
|
+
}
|
|
2142
|
+
async liveOperationIds(candidates, operationStore, signal) {
|
|
2143
|
+
const live = /* @__PURE__ */ new Set();
|
|
2144
|
+
for (const [stream, entries] of byStream(candidates)) {
|
|
2145
|
+
const found = await operationStore.findOperationIds(stream.documentId, stream.scope, stream.branch, entries.flatMap((entry) => entry.synthesizedOpId ? [entry.opId, entry.synthesizedOpId] : [entry.opId]), signal);
|
|
2146
|
+
for (const opId of found) live.add(opId);
|
|
2147
|
+
}
|
|
2148
|
+
return live;
|
|
2149
|
+
}
|
|
2150
|
+
/** Logs and counts a refusal; returns the error only when enforcing. */
|
|
2151
|
+
record(job, entry, refusal, path, enforced = this.mode === "enforce") {
|
|
2152
|
+
const event = {
|
|
2153
|
+
jobId: job.id,
|
|
2154
|
+
documentId: entry.stream.documentId,
|
|
2155
|
+
scope: entry.stream.scope,
|
|
2156
|
+
branch: entry.stream.branch,
|
|
2157
|
+
actionId: entry.action.id,
|
|
2158
|
+
code: refusal.code,
|
|
2159
|
+
scheme: refusal.scheme,
|
|
2160
|
+
path,
|
|
2161
|
+
enforced,
|
|
2162
|
+
reason: refusal.reason
|
|
2163
|
+
};
|
|
2164
|
+
this.logger.warn(enforced ? "Signature refused: @Refusal" : "Signature refusal logged, write admitted: @Refusal", event);
|
|
2165
|
+
this.eventBus.emit(ReactorEventTypes.SIGNATURE_REFUSED, event).catch((error) => {
|
|
2166
|
+
this.logger.error("Failed to emit SIGNATURE_REFUSED: @Error", error);
|
|
2167
|
+
});
|
|
2168
|
+
return enforced ? new InvalidSignatureError(entry.stream.documentId, refusal.code, refusal.reason) : void 0;
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
/**
|
|
2172
|
+
* A CREATE_DOCUMENT is verified under its own input. Anything else takes the
|
|
2173
|
+
* stored document's policy, or, for a document this job creates, that CREATE's.
|
|
2174
|
+
*/
|
|
2175
|
+
async function resolvePolicies(candidates, jobActions, stored) {
|
|
2176
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
2177
|
+
for (const entry of candidates) {
|
|
2178
|
+
if (entry.action.type === "CREATE_DOCUMENT") {
|
|
2179
|
+
entry.policy = createPolicy(entry.action);
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
const { documentId, branch } = entry.stream;
|
|
2183
|
+
const key = `${documentId}\u0000${branch}`;
|
|
2184
|
+
let policy = resolved.get(key);
|
|
2185
|
+
if (policy === void 0) {
|
|
2186
|
+
policy = await stored(documentId, branch) ?? createdPolicy(jobActions, documentId) ?? "legacy";
|
|
2187
|
+
resolved.set(key, policy);
|
|
2188
|
+
}
|
|
2189
|
+
entry.policy = policy;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
async function storedPolicy(stores, source, documentId, branch, signal) {
|
|
2193
|
+
try {
|
|
2194
|
+
if (source === "write-cache") {
|
|
2195
|
+
const document = await stores.writeCache.getState(documentId, "document", branch, void 0, signal);
|
|
2196
|
+
return signaturePolicyOf(document.header);
|
|
2197
|
+
}
|
|
2198
|
+
const meta = await stores.documentMetaCache.getDocumentMeta(documentId, branch, signal);
|
|
2199
|
+
return signaturePolicyOf(meta.protocolVersions);
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
if (DocumentNotFoundError.isError(error)) return;
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
function createdPolicy(actions, documentId) {
|
|
2206
|
+
const create = actions.find((action) => action.type === "CREATE_DOCUMENT" && action.input?.documentId === documentId);
|
|
2207
|
+
return create ? createPolicy(create) : void 0;
|
|
2208
|
+
}
|
|
2209
|
+
function createPolicy(action) {
|
|
2210
|
+
return signaturePolicyOf(action.input);
|
|
2211
|
+
}
|
|
2212
|
+
function candidate(action, stream) {
|
|
2213
|
+
const opId = (actionId) => deriveOperationId(stream.documentId, stream.scope, stream.branch, actionId);
|
|
2214
|
+
return {
|
|
2215
|
+
action,
|
|
2216
|
+
stream,
|
|
2217
|
+
opId: opId(action.id),
|
|
2218
|
+
synthesizedOpId: SYNTHESIZING_TYPES.has(action.type) ? opId(synthesizedActionId(action.id)) : void 0
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function isLive(entry, live) {
|
|
2222
|
+
return live.has(entry.opId) || entry.synthesizedOpId !== void 0 && live.has(entry.synthesizedOpId);
|
|
2223
|
+
}
|
|
2224
|
+
function mutationStream(action, job) {
|
|
2225
|
+
return {
|
|
2226
|
+
...actionSigningTarget(action, job.documentId, job.branch),
|
|
2227
|
+
scope: job.scope
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
function byStream(candidates) {
|
|
2231
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2232
|
+
for (const entry of candidates) {
|
|
2233
|
+
const key = `${entry.stream.documentId}\u0000${entry.stream.scope}\u0000${entry.stream.branch}`;
|
|
2234
|
+
const group = groups.get(key) ?? [entry.stream, []];
|
|
2235
|
+
group[1].push(entry);
|
|
2236
|
+
groups.set(key, group);
|
|
2237
|
+
}
|
|
2238
|
+
return [...groups.values()];
|
|
2239
|
+
}
|
|
2240
|
+
function isRetry(job) {
|
|
2241
|
+
return job.errorHistory.length > 0 || (job.retryCount ?? 0) > 0;
|
|
2242
|
+
}
|
|
2243
|
+
function committedWrite(entry, opId) {
|
|
2244
|
+
return {
|
|
2245
|
+
...entry.stream,
|
|
2246
|
+
actionId: entry.action.id,
|
|
2247
|
+
opId
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
function isSuperseded(operation, later) {
|
|
2251
|
+
return later.some((next) => next.index > operation.index && next.skip > 0 && next.index - next.skip <= operation.index);
|
|
2252
|
+
}
|
|
2253
|
+
function sameContent(stored, submitted) {
|
|
2254
|
+
try {
|
|
2255
|
+
return canonicalJson(stored) === canonicalJson(submitted);
|
|
2256
|
+
} catch {
|
|
2257
|
+
return false;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
//#endregion
|
|
2261
|
+
//#region src/executor/simple-job-executor.ts
|
|
2262
|
+
const MAX_SKIP_THRESHOLD = 1e3;
|
|
2263
|
+
/** Retries of a failed flush before a committed job's write-ready is withheld. */
|
|
2264
|
+
const MAX_ANNOUNCE_FLUSH_ATTEMPTS = 5;
|
|
2265
|
+
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
2266
|
+
function isValidISOTimestamp(value) {
|
|
2267
|
+
if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
|
|
2268
|
+
return !isNaN(new Date(value).getTime());
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Carries a failed job out of the execution scope so its transaction rolls
|
|
2272
|
+
* back. A scope callback that returns commits, whatever result it returns, so
|
|
2273
|
+
* a returned failure would leave the writes the job made before it failed
|
|
2274
|
+
* standing. Never escapes executeJob: the failure goes back to being a
|
|
2275
|
+
* returned JobResult there, which is what the queue, the worker protocol and
|
|
2276
|
+
* every test expect a failed job to look like.
|
|
2277
|
+
*/
|
|
2278
|
+
/** The ids of a purge job's request, which a drive purge may precede. */
|
|
2279
|
+
function purgeRequestDocumentIds(job) {
|
|
2280
|
+
const ids = job.meta.purgeRequestDocumentIds;
|
|
2281
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
2282
|
+
}
|
|
2283
|
+
const MARKER_INPUT_KEYS = /* @__PURE__ */ new Set([
|
|
2284
|
+
"documentId",
|
|
2285
|
+
"documentType",
|
|
2286
|
+
"purgedAtUtcIso",
|
|
2287
|
+
"requestId"
|
|
2288
|
+
]);
|
|
2289
|
+
/** A peer's marker must name this job's id on main and carry nothing else. */
|
|
2290
|
+
function malformedMarker(job) {
|
|
2291
|
+
const action = job.operations.find((operation) => isPurgeMarker(operation))?.action;
|
|
2292
|
+
const input = action?.input;
|
|
2293
|
+
const refuse = (reason) => new InvalidSignatureError(job.documentId, "ID_MISMATCH", `marker ${action?.id ?? "?"} ${reason}`);
|
|
2294
|
+
if (typeof input !== "object" || input === null) return refuse("carries no input");
|
|
2295
|
+
const extra = Object.keys(input).filter((key) => !MARKER_INPUT_KEYS.has(key));
|
|
2296
|
+
if (extra.length > 0) return refuse(`carries unexpected input ${extra.join(", ")}`);
|
|
2297
|
+
for (const key of MARKER_INPUT_KEYS) if (typeof input[key] !== "string" || input[key] === "") return refuse(`input.${key} is not a non-empty string`);
|
|
2298
|
+
const purgedAt = Date.parse(input.purgedAtUtcIso);
|
|
2299
|
+
if (Number.isNaN(purgedAt) || new Date(purgedAt).toISOString() !== input.purgedAtUtcIso) return refuse("input.purgedAtUtcIso is not an ISO timestamp");
|
|
2300
|
+
if (input.documentId !== job.documentId || job.branch !== "main" || action?.scope !== "document") return refuse(`does not purge ${job.documentId} on document/main`);
|
|
2301
|
+
}
|
|
2302
|
+
var JobRollbackSignal = class extends Error {
|
|
2303
|
+
result;
|
|
2304
|
+
constructor(result) {
|
|
2305
|
+
super("job rolled back");
|
|
2306
|
+
this.result = result;
|
|
2307
|
+
this.name = "JobRollbackSignal";
|
|
2308
|
+
}
|
|
2309
|
+
};
|
|
2310
|
+
var SimpleJobExecutor = class {
|
|
2311
|
+
logger;
|
|
2312
|
+
registry;
|
|
2313
|
+
operationStore;
|
|
2314
|
+
eventBus;
|
|
2315
|
+
writeCache;
|
|
2316
|
+
operationIndex;
|
|
2317
|
+
documentMetaCache;
|
|
2318
|
+
collectionMembershipCache;
|
|
2319
|
+
driveContainerTypes;
|
|
2320
|
+
config;
|
|
2321
|
+
featureFlags;
|
|
2322
|
+
decisionModel;
|
|
2323
|
+
signatureAdmission;
|
|
2324
|
+
documentActionHandler;
|
|
2325
|
+
executionScope;
|
|
2326
|
+
signer;
|
|
2327
|
+
flusher;
|
|
2328
|
+
/**
|
|
2329
|
+
* `signer` signs the operations the reducer synthesizes; unsigned if omitted.
|
|
2330
|
+
* `trustPolicy` decides which keys may sign as which users at admission.
|
|
2331
|
+
* `flusher` makes a job durable before its write-ready is announced.
|
|
2332
|
+
*/
|
|
2333
|
+
constructor(logger, registry, operationStore, eventBus, writeCache, operationIndex, documentMetaCache, collectionMembershipCache, driveContainerTypes, config, executionScope, signer, trustPolicy, flusher = new NoopStorageFlusher()) {
|
|
2334
|
+
this.logger = logger;
|
|
2335
|
+
this.registry = registry;
|
|
2336
|
+
this.operationStore = operationStore;
|
|
2337
|
+
this.eventBus = eventBus;
|
|
2338
|
+
this.writeCache = writeCache;
|
|
2339
|
+
this.operationIndex = operationIndex;
|
|
2340
|
+
this.documentMetaCache = documentMetaCache;
|
|
2341
|
+
this.collectionMembershipCache = collectionMembershipCache;
|
|
2342
|
+
this.driveContainerTypes = driveContainerTypes;
|
|
2343
|
+
this.flusher = flusher;
|
|
2344
|
+
this.signer = signer ?? new PassthroughSigner();
|
|
2345
|
+
this.featureFlags = resolveFeatureFlags(config.featureFlags);
|
|
2346
|
+
this.config = {
|
|
2347
|
+
featureFlags: config.featureFlags ?? {},
|
|
2348
|
+
maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
|
|
2349
|
+
maxConcurrency: config.maxConcurrency ?? 1,
|
|
2350
|
+
jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
|
|
2351
|
+
deferredJobTtlMs: config.deferredJobTtlMs ?? 3e4,
|
|
2352
|
+
retryBaseDelayMs: config.retryBaseDelayMs ?? 100,
|
|
2353
|
+
retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
|
|
2354
|
+
yieldDeadlineMs: config.yieldDeadlineMs ?? 50,
|
|
2355
|
+
batchApplies: config.batchApplies ?? true,
|
|
2356
|
+
signatureVerification: config.signatureVerification ?? "enforce",
|
|
2357
|
+
protocolSupport: config.protocolSupport ?? localSupports(PEER_CAPABILITIES, this.featureFlags).protocols,
|
|
2358
|
+
maxPurgeOperations: config.maxPurgeOperations ?? 2e5,
|
|
2359
|
+
durabilityWaitMs: config.durabilityWaitMs ?? 0
|
|
2360
|
+
};
|
|
2361
|
+
this.decisionModel = selectDecisionModel(this.featureFlags, registry);
|
|
2362
|
+
this.signatureAdmission = new SignatureAdmission(this.config.signatureVerification, logger, eventBus, this.featureFlags.documentDecisions ? "write-cache" : "meta", {
|
|
2363
|
+
signer: this.signer,
|
|
2364
|
+
authEnforcement: this.featureFlags.authEnforcement,
|
|
2365
|
+
policy: trustPolicy,
|
|
2366
|
+
timeoutMs: Math.min(DEFAULT_TRUST_TIMEOUT_MS, this.config.jobTimeoutMs / 2)
|
|
2367
|
+
});
|
|
2368
|
+
this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel, this.config.protocolSupport);
|
|
2369
|
+
this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Execute a single job by applying all its actions through the appropriate reducers.
|
|
2373
|
+
* Actions are processed sequentially in order.
|
|
2374
|
+
*
|
|
2375
|
+
* The whole job runs inside one execution scope, and a scope callback that
|
|
2376
|
+
* returns commits. A failed job must therefore leave the scope by throwing,
|
|
2377
|
+
* or the writes it made before it failed would be durable: JobRollbackSignal
|
|
2378
|
+
* carries the failure out through the transaction and this method turns it
|
|
2379
|
+
* back into the returned JobResult every caller expects. A job either fully
|
|
2380
|
+
* applies or leaves nothing durable behind.
|
|
2381
|
+
*
|
|
2382
|
+
* Durable is the whole of the guarantee. The caches are shared with the
|
|
2383
|
+
* copies the scope hands the job, so a failed job's writes sit in them from
|
|
2384
|
+
* the moment it makes them until the eviction below, and a concurrent read
|
|
2385
|
+
* in that window sees a write that is never going to commit. The window is
|
|
2386
|
+
* not new -- it has always been there for a job that failed by throwing --
|
|
2387
|
+
* but nothing here closes it, and a caller that needs to know a write is
|
|
2388
|
+
* real has the job status to ask.
|
|
2389
|
+
*/
|
|
2390
|
+
async executeJob(job, signal, onCommitting) {
|
|
2391
|
+
const startTime = Date.now();
|
|
2392
|
+
const touchedStreams = new TouchedStreams();
|
|
2393
|
+
const postCommitInvalidations = [];
|
|
2394
|
+
const postCommitMembershipInvalidations = [];
|
|
2395
|
+
const postCommitMetaInvalidations = [];
|
|
2396
|
+
let outcome;
|
|
2397
|
+
try {
|
|
2398
|
+
outcome = await this.executionScope.run(async (stores) => {
|
|
2399
|
+
const scoped = await this.executeInScope({
|
|
2400
|
+
job,
|
|
2401
|
+
startTime,
|
|
2402
|
+
stores,
|
|
2403
|
+
signal,
|
|
2404
|
+
touchedStreams,
|
|
2405
|
+
postCommitInvalidations,
|
|
2406
|
+
postCommitMembershipInvalidations,
|
|
2407
|
+
postCommitMetaInvalidations
|
|
2408
|
+
});
|
|
2409
|
+
if (!scoped.result.success) throw new JobRollbackSignal(scoped.result);
|
|
2410
|
+
signal?.throwIfAborted();
|
|
2411
|
+
onCommitting?.();
|
|
2412
|
+
return scoped;
|
|
2413
|
+
}, signal);
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
this.evictTouchedStreams(touchedStreams);
|
|
2416
|
+
if (error instanceof JobRollbackSignal) return error.result;
|
|
2417
|
+
throw error;
|
|
2418
|
+
}
|
|
2419
|
+
for (const entry of postCommitInvalidations) this.writeCache.invalidate(entry.documentId, entry.scope, entry.branch);
|
|
2420
|
+
for (const documentId of postCommitMembershipInvalidations) this.collectionMembershipCache.invalidate(documentId);
|
|
2421
|
+
for (const documentId of postCommitMetaInvalidations) this.documentMetaCache.invalidate(documentId);
|
|
2422
|
+
const { pendingEvent } = outcome;
|
|
2423
|
+
if (pendingEvent && await this.awaitDurable(job, signal)) this.eventBus.emit(ReactorEventTypes.JOB_WRITE_READY, pendingEvent).catch((error) => {
|
|
2424
|
+
this.logger.error("Failed to emit JOB_WRITE_READY event: @Event : @Error", pendingEvent, error);
|
|
2425
|
+
});
|
|
2426
|
+
return outcome.result;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Waits for a flush covering the committed job before its write-ready goes
|
|
2430
|
+
* out; false withholds the announcement. A failed flush is never a failed
|
|
2431
|
+
* job, since the commit stands: it is retried, and the announcement is
|
|
2432
|
+
* withheld once the store is poisoned (the host restarts), the attempts run
|
|
2433
|
+
* out, the wait outlives `durabilityWaitMs`, or the job's signal aborts.
|
|
2434
|
+
*/
|
|
2435
|
+
async awaitDurable(job, signal) {
|
|
2436
|
+
if (isCursorProtectedLoad(job)) return true;
|
|
2437
|
+
const budgetMs = this.config.durabilityWaitMs;
|
|
2438
|
+
const deadline = budgetMs > 0 ? Date.now() + budgetMs : Infinity;
|
|
2439
|
+
for (let attempt = 1;; attempt++) {
|
|
2440
|
+
try {
|
|
2441
|
+
if (await this.withinDurabilityWait(this.flusher.flush(), deadline, signal) !== TIMED_OUT) return true;
|
|
2442
|
+
this.logger.error("Job @JobId committed but its flush outlived the durability wait or the job was aborted; its write-ready is withheld", job.id);
|
|
2443
|
+
return false;
|
|
2444
|
+
} catch (error) {
|
|
2445
|
+
this.logger.error("Flush before announcing job @JobId failed (attempt @Attempt): @Error", job.id, attempt, error);
|
|
2446
|
+
if (error instanceof StoragePoisonedError || attempt >= MAX_ANNOUNCE_FLUSH_ATTEMPTS) {
|
|
2447
|
+
this.logger.error("Job @JobId committed but is not durable; its write-ready is withheld", job.id);
|
|
2448
|
+
return false;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (await this.withinDurabilityWait(delay(Math.min(this.config.retryMaxDelayMs, this.config.retryBaseDelayMs * 2 ** (attempt - 1))), deadline, signal) === TIMED_OUT) return false;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
/** Settles with TIMED_OUT at the deadline or on abort; the wait itself is abandoned. */
|
|
2455
|
+
async withinDurabilityWait(pending, deadline, signal) {
|
|
2456
|
+
pending.catch(() => void 0);
|
|
2457
|
+
if (signal?.aborted) return TIMED_OUT;
|
|
2458
|
+
let onAbort;
|
|
2459
|
+
const aborted = new Promise((resolve) => {
|
|
2460
|
+
onAbort = () => resolve(TIMED_OUT);
|
|
2461
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2462
|
+
});
|
|
2463
|
+
try {
|
|
2464
|
+
const raced = Promise.race([pending, aborted]);
|
|
2465
|
+
return deadline === Infinity ? await raced : await withDeadline(raced, Math.max(0, deadline - Date.now()));
|
|
2466
|
+
} finally {
|
|
2467
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* The body of a job, run inside the execution scope's transaction.
|
|
2472
|
+
*
|
|
2473
|
+
* The stores and caches it works through are the copies scoped to that
|
|
2474
|
+
* transaction, so nothing it does is durable until the scope commits. The
|
|
2475
|
+
* write-ready event is handed back rather than emitted, because a job that
|
|
2476
|
+
* has not committed yet has nothing to announce.
|
|
2477
|
+
*/
|
|
2478
|
+
async executeInScope(params) {
|
|
2479
|
+
const { job, startTime, stores: scopeStores, signal, touchedStreams, postCommitInvalidations, postCommitMembershipInvalidations } = params;
|
|
2480
|
+
let pendingEvent;
|
|
2481
|
+
if (job.kind === "mutation") {
|
|
2482
|
+
const reserved = job.actions.find((action) => isPurgeMarker(action));
|
|
2483
|
+
if (reserved) return { result: buildErrorResult(job, new ReservedActionError(job.documentId, reserved.type), startTime) };
|
|
2484
|
+
}
|
|
2485
|
+
if (job.kind === "purge" || job.kind === "load" && job.operations.some((op) => isPurgeMarker(op))) return this.executePurge(params);
|
|
2486
|
+
const lockedIds = jobWriteIds(job);
|
|
2487
|
+
const lockFailure = (error) => ({ result: buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime) });
|
|
2488
|
+
try {
|
|
2489
|
+
await scopeStores.documentLocks.shared(lockedIds);
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
return lockFailure(error);
|
|
2492
|
+
}
|
|
2493
|
+
let purged;
|
|
2494
|
+
try {
|
|
2495
|
+
purged = await scopeStores.documentLocks.purged(lockedIds);
|
|
2496
|
+
} catch (error) {
|
|
2497
|
+
return lockFailure(error);
|
|
2498
|
+
}
|
|
2499
|
+
const purgedRefusal = this.purgedRefusal(job, purged);
|
|
2500
|
+
if (purgedRefusal) return { result: buildErrorResult(job, purgedRefusal, startTime) };
|
|
2501
|
+
const purgeFence = new PurgeFence(scopeStores.documentLocks, new Set(lockedIds), purged);
|
|
2502
|
+
const stores = {
|
|
2503
|
+
...scopeStores,
|
|
2504
|
+
writeCache: new FencedWriteCache(scopeStores.writeCache, purgeFence, (documentId) => {
|
|
2505
|
+
scopeStores.writeCache.invalidate(documentId);
|
|
2506
|
+
scopeStores.documentMetaCache.invalidate(documentId);
|
|
2507
|
+
})
|
|
2508
|
+
};
|
|
2509
|
+
const unsupported = await this.unsupportedStoredProtocol(job, stores, signal);
|
|
2510
|
+
if (unsupported) return { result: buildErrorResult(job, unsupported, startTime) };
|
|
2511
|
+
const indexTxn = stores.operationIndex.start();
|
|
2512
|
+
if (job.kind === "load") {
|
|
2513
|
+
const loadResult = await this.executeLoadJob({
|
|
2514
|
+
job,
|
|
2515
|
+
startTime,
|
|
2516
|
+
indexTxn,
|
|
2517
|
+
stores,
|
|
2518
|
+
signal,
|
|
2519
|
+
replayingAcceptedHistory: true,
|
|
2520
|
+
evaluatedByPosition: false,
|
|
2521
|
+
postCommitInvalidations,
|
|
2522
|
+
postCommitMembershipInvalidations,
|
|
2523
|
+
touchedStreams,
|
|
2524
|
+
purgeFence
|
|
2525
|
+
});
|
|
2526
|
+
if (loadResult.success && loadResult.operationsWithContext) {
|
|
2527
|
+
const ordinals = await stores.operationIndex.commit(indexTxn, signal);
|
|
2528
|
+
postCommitMembershipInvalidations.push(...indexTxn.getMembershipInvalidations());
|
|
2529
|
+
for (let i = 0; i < loadResult.operationsWithContext.length; i++) loadResult.operationsWithContext[i].context.ordinal = ordinals[i];
|
|
2530
|
+
const collectionMemberships = loadResult.operationsWithContext.length > 0 ? await this.getCollectionMembershipsForOperations(loadResult.operationsWithContext, stores) : {};
|
|
2531
|
+
pendingEvent = {
|
|
2532
|
+
jobId: job.id,
|
|
2533
|
+
operations: loadResult.operationsWithContext,
|
|
2534
|
+
jobMeta: job.meta,
|
|
2535
|
+
collectionMemberships
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
return {
|
|
2539
|
+
result: loadResult,
|
|
2540
|
+
pendingEvent
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
if (job.kind === "reevaluation") {
|
|
2544
|
+
const reevalResult = await this.executeReevaluationJob({
|
|
2545
|
+
job,
|
|
2546
|
+
startTime,
|
|
2547
|
+
indexTxn,
|
|
2548
|
+
stores,
|
|
2549
|
+
signal,
|
|
2550
|
+
replayingAcceptedHistory: false,
|
|
2551
|
+
evaluatedByPosition: false,
|
|
2552
|
+
postCommitInvalidations,
|
|
2553
|
+
postCommitMembershipInvalidations,
|
|
2554
|
+
touchedStreams,
|
|
2555
|
+
purgeFence
|
|
2556
|
+
});
|
|
2557
|
+
if (reevalResult.success && reevalResult.operationsWithContext) {
|
|
2558
|
+
const ordinals = await stores.operationIndex.commit(indexTxn, signal);
|
|
2559
|
+
postCommitMembershipInvalidations.push(...indexTxn.getMembershipInvalidations());
|
|
2560
|
+
for (let i = 0; i < reevalResult.operationsWithContext.length; i++) reevalResult.operationsWithContext[i].context.ordinal = ordinals[i];
|
|
2561
|
+
if (reevalResult.operationsWithContext.length > 0) {
|
|
2562
|
+
const collectionMemberships = await this.getCollectionMembershipsForOperations(reevalResult.operationsWithContext, stores);
|
|
2563
|
+
pendingEvent = {
|
|
2564
|
+
jobId: job.id,
|
|
2565
|
+
operations: reevalResult.operationsWithContext,
|
|
2566
|
+
jobMeta: job.meta,
|
|
2567
|
+
collectionMemberships
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
return {
|
|
2572
|
+
result: reevalResult,
|
|
2573
|
+
pendingEvent
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
let admission;
|
|
2577
|
+
try {
|
|
2578
|
+
admission = await this.signatureAdmission.admitMutation(job, stores, signal);
|
|
2579
|
+
} catch (error) {
|
|
2580
|
+
return { result: buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime) };
|
|
2581
|
+
}
|
|
2582
|
+
if (admission.kind === "refused") return { result: buildErrorResult(job, admission.error, startTime) };
|
|
2583
|
+
let committed;
|
|
2584
|
+
try {
|
|
2585
|
+
committed = await this.reloadCommitted(admission.committed, stores, signal);
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
return { result: buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime) };
|
|
2588
|
+
}
|
|
2589
|
+
const committedIds = new Set(admission.committed.map((write) => write.actionId));
|
|
2590
|
+
const fresh = committedIds.size === 0 ? job : {
|
|
2591
|
+
...job,
|
|
2592
|
+
actions: job.actions.filter((action) => !committedIds.has(action.id))
|
|
2593
|
+
};
|
|
2594
|
+
if (fresh.actions.length === 0) return this.committedOutcome(job, committed, [], [], stores, startTime);
|
|
2595
|
+
const positioned = await this.positionByTimestamp(fresh, stores, signal);
|
|
2596
|
+
if (positioned.error) return { result: buildErrorResult(job, positioned.error, startTime) };
|
|
2597
|
+
const executing = {
|
|
2598
|
+
job: fresh,
|
|
2599
|
+
startTime,
|
|
2600
|
+
indexTxn,
|
|
2601
|
+
stores,
|
|
2602
|
+
signal,
|
|
2603
|
+
replayingAcceptedHistory: false,
|
|
2604
|
+
evaluatedByPosition: positioned.evaluatedByPosition,
|
|
2605
|
+
postCommitInvalidations,
|
|
2606
|
+
postCommitMembershipInvalidations,
|
|
2607
|
+
touchedStreams,
|
|
2608
|
+
purgeFence
|
|
2609
|
+
};
|
|
2610
|
+
const actionResult = await this.processActions(positioned.writes, executing);
|
|
2611
|
+
if (!actionResult.success) return { result: {
|
|
2612
|
+
job,
|
|
2613
|
+
success: false,
|
|
2614
|
+
error: actionResult.error,
|
|
2615
|
+
duration: Date.now() - startTime
|
|
2616
|
+
} };
|
|
2617
|
+
const reevaluationError = await this.reevaluateIfCriteriaMet({
|
|
2618
|
+
scope: job.scope,
|
|
2619
|
+
operations: actionResult.generatedOperations
|
|
2620
|
+
}, executing);
|
|
2621
|
+
if (reevaluationError) return { result: {
|
|
2622
|
+
job,
|
|
2623
|
+
success: false,
|
|
2624
|
+
error: reevaluationError,
|
|
2625
|
+
duration: Date.now() - startTime
|
|
2626
|
+
} };
|
|
2627
|
+
const ordinals = await stores.operationIndex.commit(indexTxn, signal);
|
|
2628
|
+
postCommitMembershipInvalidations.push(...indexTxn.getMembershipInvalidations());
|
|
2629
|
+
for (let i = 0; i < actionResult.operationsWithContext.length; i++) actionResult.operationsWithContext[i].context.ordinal = ordinals[i];
|
|
2630
|
+
return this.committedOutcome(job, committed, actionResult.operationsWithContext, actionResult.generatedOperations, stores, startTime);
|
|
2631
|
+
}
|
|
2632
|
+
/** A load names only its own id purged; a foreign one is ID_MISMATCH. */
|
|
2633
|
+
purgedRefusal(job, purged) {
|
|
2634
|
+
if (purged.size === 0) return;
|
|
2635
|
+
if (purged.has(job.documentId)) return new DocumentPurgedError(job.documentId);
|
|
2636
|
+
const actions = [...job.actions, ...job.operations.map((operation) => operation.action)];
|
|
2637
|
+
for (const action of actions) {
|
|
2638
|
+
if (!DOCUMENT_SCOPE_ACTIONS.has(action.type)) continue;
|
|
2639
|
+
const target = targetDocumentId(action, job.documentId);
|
|
2640
|
+
if (!purged.has(target)) continue;
|
|
2641
|
+
if (job.kind === "mutation") return new DocumentPurgedError(target);
|
|
2642
|
+
return new InvalidSignatureError(job.documentId, "ID_MISMATCH", `${action.type} ${action.id} in ${job.documentId} writes purged ${target}`);
|
|
2643
|
+
}
|
|
2644
|
+
if (job.kind !== "mutation") return;
|
|
2645
|
+
for (const action of job.actions) {
|
|
2646
|
+
const target = relationshipTarget(action);
|
|
2647
|
+
if (action.type === "ADD_RELATIONSHIP" && target !== void 0 && purged.has(target)) return new DocumentPurgedError(target, `${action.type} target ${target} was purged`);
|
|
2648
|
+
if (action.scope !== "auth") continue;
|
|
2649
|
+
const group = mentionedGroupIds(action).find((id) => purged.has(id));
|
|
2650
|
+
if (group !== void 0) return new DocumentPurgedError(group, `${action.type} names purged group ${group}`);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
/** A purge job or a marker load: the exclusive lock is the job's only lock. */
|
|
2654
|
+
async executePurge(params) {
|
|
2655
|
+
const { job, startTime, stores } = params;
|
|
2656
|
+
const fail = (error) => ({ result: buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime) });
|
|
2657
|
+
if (job.kind === "load") {
|
|
2658
|
+
const malformed = malformedMarker(job);
|
|
2659
|
+
if (malformed) return fail(malformed);
|
|
2660
|
+
}
|
|
2661
|
+
const purger = stores.purger;
|
|
2662
|
+
if (!purger) return fail(/* @__PURE__ */ new Error(`Purge of ${job.documentId} needs a transactional scope`));
|
|
2663
|
+
try {
|
|
2664
|
+
await stores.documentLocks.exclusive(job.documentId);
|
|
2665
|
+
} catch (error) {
|
|
2666
|
+
return fail(error);
|
|
2667
|
+
}
|
|
2668
|
+
let alreadyPurged;
|
|
2669
|
+
try {
|
|
2670
|
+
alreadyPurged = (await stores.documentLocks.purged([job.documentId])).has(job.documentId);
|
|
2671
|
+
} catch (error) {
|
|
2672
|
+
return fail(error);
|
|
2673
|
+
}
|
|
2674
|
+
if (alreadyPurged) return {
|
|
2675
|
+
result: {
|
|
2676
|
+
job,
|
|
2677
|
+
success: true,
|
|
2678
|
+
operations: [],
|
|
2679
|
+
operationsWithContext: [],
|
|
2680
|
+
duration: Date.now() - startTime
|
|
2681
|
+
},
|
|
2682
|
+
pendingEvent: {
|
|
2683
|
+
jobId: job.id,
|
|
2684
|
+
operations: [],
|
|
2685
|
+
jobMeta: job.meta,
|
|
2686
|
+
collectionMemberships: {}
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
let commit;
|
|
2690
|
+
try {
|
|
2691
|
+
commit = job.kind === "purge" ? await this.preparePurgeJob(params, purger) : await this.preparePurgeLoad(params, purger);
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
return fail(error);
|
|
2694
|
+
}
|
|
2695
|
+
if (commit instanceof Error) return fail(commit);
|
|
2696
|
+
try {
|
|
2697
|
+
return await this.commitPurge(params, commit);
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
return fail(error);
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
/** Preconditions 1-5, then the marker this host signs. */
|
|
2703
|
+
async preparePurgeJob(params, purger) {
|
|
2704
|
+
const { job, stores, signal } = params;
|
|
2705
|
+
const documentId = job.documentId;
|
|
2706
|
+
if (!job.purge) return /* @__PURE__ */ new Error(`Purge job ${job.id} carries no purge options`);
|
|
2707
|
+
const held = await this.heldStreams(purger, documentId);
|
|
2708
|
+
if (held.branches.length === 0) return new DocumentNotDeletedError(documentId, `Document ${documentId} is not held here`);
|
|
2709
|
+
let documentType;
|
|
2710
|
+
for (const branch of held.branches) {
|
|
2711
|
+
let meta;
|
|
2712
|
+
try {
|
|
2713
|
+
meta = await stores.documentMetaCache.getDocumentMeta(documentId, branch, signal);
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
return new DocumentNotDeletedError(documentId, `Document ${documentId} has no readable state on ${branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2716
|
+
}
|
|
2717
|
+
if (!meta.state.isDeleted) return new DocumentNotDeletedError(documentId, `Document ${documentId} is not deleted on branch ${branch}`);
|
|
2718
|
+
documentType ??= meta.documentType;
|
|
2719
|
+
}
|
|
2720
|
+
if (documentType === void 0) return new DocumentNotDeletedError(documentId);
|
|
2721
|
+
if (documentType === groupDocumentType) {
|
|
2722
|
+
const referencers = await purger.groupReferencersInHistory(documentId);
|
|
2723
|
+
if (referencers.length > 0) return new GroupInUseError(documentId, referencers);
|
|
2724
|
+
}
|
|
2725
|
+
let survivors = [];
|
|
2726
|
+
if (this.driveContainerTypes.has(documentType)) {
|
|
2727
|
+
const requested = new Set(purgeRequestDocumentIds(job));
|
|
2728
|
+
const members = await this.driveMembers(purger, stores, documentId, held.branches);
|
|
2729
|
+
const blocking = members.required.filter((id) => !requested.has(id));
|
|
2730
|
+
if (blocking.length > 0) return new DocumentNotDeletedError(documentId, `Drive ${documentId} cannot be purged: ${blocking.join(", ")} neither purged nor in the request`);
|
|
2731
|
+
survivors = members.survivors;
|
|
2732
|
+
}
|
|
2733
|
+
const operationCount = await purger.operationCount(documentId);
|
|
2734
|
+
if (operationCount > this.config.maxPurgeOperations && !job.purge.allowLarge) return new PurgeTooLargeError(documentId, operationCount, this.config.maxPurgeOperations);
|
|
2735
|
+
return {
|
|
2736
|
+
purger,
|
|
2737
|
+
marker: await this.signMarker(documentId, documentType, job.purge.requestId, signal),
|
|
2738
|
+
documentType,
|
|
2739
|
+
held,
|
|
2740
|
+
sourceRemote: "",
|
|
2741
|
+
collectionIds: [],
|
|
2742
|
+
survivors
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
/** A peer's marker, admitted alone; a receiver cannot refuse an erasure. */
|
|
2746
|
+
async preparePurgeLoad(params, purger) {
|
|
2747
|
+
const { job, stores, signal } = params;
|
|
2748
|
+
const documentId = job.documentId;
|
|
2749
|
+
const marker = job.operations.find((operation) => isPurgeMarker(operation));
|
|
2750
|
+
const refusal = await this.signatureAdmission.admitMarker(job, marker, signal);
|
|
2751
|
+
if (refusal) return refusal;
|
|
2752
|
+
const held = await this.heldStreams(purger, documentId);
|
|
2753
|
+
let documentType = marker.action.input.documentType;
|
|
2754
|
+
let appliedDeletion = held.branches.length > 0;
|
|
2755
|
+
if (held.branches.length > 0) try {
|
|
2756
|
+
const meta = await stores.documentMetaCache.getDocumentMeta(documentId, held.branches.includes("main") ? "main" : held.branches[0], signal);
|
|
2757
|
+
documentType = meta.documentType;
|
|
2758
|
+
appliedDeletion = !meta.state.isDeleted;
|
|
2759
|
+
} catch {}
|
|
2760
|
+
let survivors = [];
|
|
2761
|
+
if (this.driveContainerTypes.has(documentType)) {
|
|
2762
|
+
const members = await this.driveMembers(purger, stores, documentId, held.branches);
|
|
2763
|
+
survivors = [...members.required, ...members.survivors];
|
|
2764
|
+
}
|
|
2765
|
+
const sourceRemote = typeof job.meta.sourceRemote === "string" ? job.meta.sourceRemote : "";
|
|
2766
|
+
const collectionId = sourceRemote ? await purger.remoteCollection(sourceRemote) : void 0;
|
|
2767
|
+
return {
|
|
2768
|
+
purger,
|
|
2769
|
+
marker: purgeMarkerOperation(marker.action),
|
|
2770
|
+
documentType,
|
|
2771
|
+
held,
|
|
2772
|
+
sourceRemote,
|
|
2773
|
+
collectionIds: collectionId === void 0 ? [] : [collectionId],
|
|
2774
|
+
survivors,
|
|
2775
|
+
...appliedDeletion ? { appliedDeletion: true } : {}
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
/** Execution: delete, write the marker and its twin, tombstone, reopen. */
|
|
2779
|
+
async commitPurge(params, commit) {
|
|
2780
|
+
const { job, startTime, stores, signal, touchedStreams } = params;
|
|
2781
|
+
const { purger, marker, documentType, held, sourceRemote } = commit;
|
|
2782
|
+
const documentId = job.documentId;
|
|
2783
|
+
const streams = [...held.streams, {
|
|
2784
|
+
scope: "document",
|
|
2785
|
+
branch: "main"
|
|
2786
|
+
}];
|
|
2787
|
+
for (const stream of streams) {
|
|
2788
|
+
touchedStreams.add(documentId, stream.scope, stream.branch);
|
|
2789
|
+
params.postCommitInvalidations.push({
|
|
2790
|
+
documentId,
|
|
2791
|
+
...stream
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
params.postCommitMetaInvalidations.push(documentId);
|
|
2795
|
+
const removedRows = await purger.deleteRows(documentId);
|
|
2796
|
+
const [stored] = await stores.operationStore.apply(documentId, documentType, "document", "main", 0, (txn) => {
|
|
2797
|
+
txn.addOperations(marker);
|
|
2798
|
+
}, signal);
|
|
2799
|
+
const indexTxn = stores.operationIndex.start();
|
|
2800
|
+
indexTxn.write([{
|
|
2801
|
+
...stored,
|
|
2802
|
+
documentId,
|
|
2803
|
+
documentType,
|
|
2804
|
+
scope: "document",
|
|
2805
|
+
branch: "main",
|
|
2806
|
+
sourceRemote
|
|
2807
|
+
}]);
|
|
2808
|
+
const [ordinal] = await stores.operationIndex.commit(indexTxn, signal);
|
|
2809
|
+
await purger.writeTombstone({
|
|
2810
|
+
documentId,
|
|
2811
|
+
ordinal,
|
|
2812
|
+
removedRows,
|
|
2813
|
+
purgedAtUtc: marker.action.input.purgedAtUtcIso,
|
|
2814
|
+
requestId: marker.action.input.requestId
|
|
2815
|
+
});
|
|
2816
|
+
const collections = await purger.reopenMemberships(documentId, ordinal, commit.collectionIds);
|
|
2817
|
+
params.postCommitMembershipInvalidations.push(documentId, ...commit.survivors, ...indexTxn.getMembershipInvalidations());
|
|
2818
|
+
const operationWithContext = {
|
|
2819
|
+
operation: stored,
|
|
2820
|
+
context: {
|
|
2821
|
+
documentId,
|
|
2822
|
+
scope: "document",
|
|
2823
|
+
branch: "main",
|
|
2824
|
+
documentType,
|
|
2825
|
+
ordinal,
|
|
2826
|
+
...commit.appliedDeletion ? { appliedDeletion: true } : {}
|
|
2827
|
+
}
|
|
2828
|
+
};
|
|
2829
|
+
return {
|
|
2830
|
+
result: {
|
|
2831
|
+
job,
|
|
2832
|
+
success: true,
|
|
2833
|
+
operations: [stored],
|
|
2834
|
+
operationsWithContext: [operationWithContext],
|
|
2835
|
+
duration: Date.now() - startTime
|
|
2836
|
+
},
|
|
2837
|
+
pendingEvent: {
|
|
2838
|
+
jobId: job.id,
|
|
2839
|
+
operations: [operationWithContext],
|
|
2840
|
+
jobMeta: job.meta,
|
|
2841
|
+
collectionMemberships: { [documentId]: collections }
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
async heldStreams(purger, documentId) {
|
|
2846
|
+
const streams = await purger.streams(documentId);
|
|
2847
|
+
return {
|
|
2848
|
+
streams,
|
|
2849
|
+
branches: [...new Set(streams.filter((stream) => stream.scope === "document").map((stream) => stream.branch))]
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
/** Members a drive purge needs gone, and those living on elsewhere. */
|
|
2853
|
+
async driveMembers(purger, stores, driveId, branches) {
|
|
2854
|
+
const required = /* @__PURE__ */ new Set();
|
|
2855
|
+
const survivors = /* @__PURE__ */ new Set();
|
|
2856
|
+
for (const branch of branches.length > 0 ? branches : ["main"]) {
|
|
2857
|
+
const collectionId = DriveCollectionId.forDrive(driveId, branch).key;
|
|
2858
|
+
const members = await purger.collectionMembers(collectionId, driveId, branch);
|
|
2859
|
+
const purged = await stores.documentLocks.purged(members.map((member) => member.documentId));
|
|
2860
|
+
for (const member of members) {
|
|
2861
|
+
if (purged.has(member.documentId)) continue;
|
|
2862
|
+
if (member.required) required.add(member.documentId);
|
|
2863
|
+
else survivors.add(member.documentId);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
return {
|
|
2867
|
+
required: [...required],
|
|
2868
|
+
survivors: [...survivors]
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
/** The marker, signed by this executor's signer as any peer admits it. */
|
|
2872
|
+
async signMarker(documentId, documentType, requestId, signal) {
|
|
2873
|
+
const action = purgeDocumentAction({
|
|
2874
|
+
documentId,
|
|
2875
|
+
documentType,
|
|
2876
|
+
requestId
|
|
2877
|
+
});
|
|
2878
|
+
const signature = await this.signer.signAction(action, {
|
|
2879
|
+
documentId,
|
|
2880
|
+
branch: "main"
|
|
2881
|
+
}, signal);
|
|
2882
|
+
return purgeMarkerOperation({
|
|
2883
|
+
...action,
|
|
2884
|
+
context: { signer: {
|
|
2885
|
+
...actionSignerIdentity(this.signer),
|
|
2886
|
+
signatures: [signature]
|
|
2887
|
+
} }
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
/** A stored document at a version this reactor does not run is read-only here. */
|
|
2891
|
+
async unsupportedStoredProtocol(job, stores, signal) {
|
|
2892
|
+
let versions;
|
|
2893
|
+
try {
|
|
2894
|
+
versions = this.featureFlags.documentDecisions && job.kind !== "load" ? (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).header.protocolVersions : (await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal)).protocolVersions;
|
|
2895
|
+
} catch {
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
return this.documentActionHandler.unsupportedProtocol(job.documentId, versions);
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* A successful mutation's result and the write-ready event it owes, carrying
|
|
2902
|
+
* what earlier attempts committed ahead of what this one wrote.
|
|
2903
|
+
*/
|
|
2904
|
+
async committedOutcome(job, reloaded, written, generated, stores, startTime) {
|
|
2905
|
+
const operationsWithContext = [...reloaded, ...written];
|
|
2906
|
+
let pendingEvent;
|
|
2907
|
+
if (operationsWithContext.length > 0) {
|
|
2908
|
+
const collectionMemberships = await this.getCollectionMembershipsForOperations(operationsWithContext, stores);
|
|
2909
|
+
pendingEvent = {
|
|
2910
|
+
jobId: job.id,
|
|
2911
|
+
operations: operationsWithContext,
|
|
2912
|
+
jobMeta: job.meta,
|
|
2913
|
+
submittedActionIds: submittedActionIds(job),
|
|
2914
|
+
collectionMemberships
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
return {
|
|
2918
|
+
result: {
|
|
2919
|
+
job,
|
|
2920
|
+
success: true,
|
|
2921
|
+
operations: [...reloaded.map((entry) => entry.operation), ...generated],
|
|
2922
|
+
operationsWithContext,
|
|
2923
|
+
duration: Date.now() - startTime
|
|
2924
|
+
},
|
|
2925
|
+
pendingEvent
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* The stored operations of writes an earlier attempt committed, as a fresh
|
|
2930
|
+
* write would report them. State is rebuilt from the write cache, and left
|
|
2931
|
+
* out when it cannot be.
|
|
2932
|
+
*/
|
|
2933
|
+
async reloadCommitted(writes, stores, signal) {
|
|
2934
|
+
const streams = /* @__PURE__ */ new Map();
|
|
2935
|
+
for (const write of writes) {
|
|
2936
|
+
const key = `${write.documentId}\u0000${write.scope}\u0000${write.branch}`;
|
|
2937
|
+
streams.set(key, [...streams.get(key) ?? [], write]);
|
|
2938
|
+
}
|
|
2939
|
+
const reloaded = [];
|
|
2940
|
+
for (const group of streams.values()) {
|
|
2941
|
+
const { documentId, scope, branch } = group[0];
|
|
2942
|
+
const opIds = [...new Set(group.map((write) => write.opId))];
|
|
2943
|
+
const operations = await stores.operationStore.getOperationsByIds(documentId, scope, branch, opIds, signal);
|
|
2944
|
+
const ordinals = await stores.operationIndex.getOrdinalsByOpIds(documentId, scope, branch, opIds, signal);
|
|
2945
|
+
const { documentType } = await stores.documentMetaCache.getDocumentMeta(documentId, branch, signal);
|
|
2946
|
+
for (const operation of operations.sort((a, b) => a.index - b.index)) reloaded.push({
|
|
2947
|
+
operation,
|
|
2948
|
+
context: {
|
|
2949
|
+
documentId,
|
|
2950
|
+
scope,
|
|
2951
|
+
branch,
|
|
2952
|
+
documentType,
|
|
2953
|
+
resultingState: await this.rebuiltState(stores, {
|
|
2954
|
+
documentId,
|
|
2955
|
+
scope,
|
|
2956
|
+
branch
|
|
2957
|
+
}, operation, signal),
|
|
2958
|
+
ordinal: ordinals.get(operation.id) ?? 0
|
|
2959
|
+
}
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
return reloaded;
|
|
2963
|
+
}
|
|
2964
|
+
async rebuiltState(stores, stream, operation, signal) {
|
|
2965
|
+
try {
|
|
2966
|
+
const document = await stores.writeCache.getState(stream.documentId, stream.scope, stream.branch, operation.index, signal);
|
|
2967
|
+
return JSON.stringify({
|
|
2968
|
+
...document.state,
|
|
2969
|
+
header: document.header
|
|
2970
|
+
});
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
this.logger.warn("No state for committed operation @OperationId in @Stream: @Error", operation.id, stream, error);
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Drops the cached state of every stream a job wrote, after its transaction
|
|
2978
|
+
* did not commit.
|
|
2979
|
+
*
|
|
2980
|
+
* The caches are shared by reference with the copies the scope hands the job,
|
|
2981
|
+
* so a rollback undoes nothing in them: what the job put there survives, as
|
|
2982
|
+
* does anything a read filled from the store while the job's own writes were
|
|
2983
|
+
* still uncommitted. An eviction that throws is swallowed rather than allowed
|
|
2984
|
+
* to replace the failure the caller is owed, and the remaining streams are
|
|
2985
|
+
* still evicted.
|
|
2986
|
+
*/
|
|
2987
|
+
evictTouchedStreams(touchedStreams) {
|
|
2988
|
+
for (const entry of touchedStreams) try {
|
|
2989
|
+
this.writeCache.invalidate(entry.documentId, entry.scope, entry.branch);
|
|
2990
|
+
this.documentMetaCache.invalidate(entry.documentId, entry.branch);
|
|
2991
|
+
this.collectionMembershipCache.invalidate(entry.documentId);
|
|
2992
|
+
} catch (error) {
|
|
2993
|
+
this.logger.error("Failed to evict cached state for rolled back @Stream : @Error", entry, error);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
async getCollectionMembershipsForOperations(operations, stores) {
|
|
2997
|
+
const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
|
|
2998
|
+
return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
|
|
2999
|
+
}
|
|
3000
|
+
async processActions(writes, executing) {
|
|
3001
|
+
const { job, signal } = executing;
|
|
3002
|
+
const actions = writes.map((write) => write.action);
|
|
3003
|
+
const generatedOperations = [];
|
|
3004
|
+
const operationsWithContext = [];
|
|
3005
|
+
for (const action of actions) if (action.timestampUtcMs && !isValidISOTimestamp(action.timestampUtcMs)) return {
|
|
3006
|
+
success: false,
|
|
3007
|
+
generatedOperations,
|
|
3008
|
+
operationsWithContext,
|
|
3009
|
+
error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
|
|
3010
|
+
};
|
|
3011
|
+
let lastYield = performance.now();
|
|
3012
|
+
if (this.config.batchApplies && this.canBatch(writes, executing)) {
|
|
3013
|
+
const batched = await this.executeRegularActionsBatched(writes, executing);
|
|
3014
|
+
const error = this.accumulateResultOrReturnError(batched, generatedOperations, operationsWithContext);
|
|
3015
|
+
if (error !== null) return {
|
|
3016
|
+
success: false,
|
|
3017
|
+
generatedOperations,
|
|
3018
|
+
operationsWithContext,
|
|
3019
|
+
error: error.error
|
|
3020
|
+
};
|
|
3021
|
+
return {
|
|
3022
|
+
success: true,
|
|
3023
|
+
generatedOperations,
|
|
3024
|
+
operationsWithContext
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
for (const write of writes) {
|
|
3028
|
+
const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
|
|
3029
|
+
const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
|
|
3030
|
+
if (error !== null) return {
|
|
3031
|
+
success: false,
|
|
3032
|
+
generatedOperations,
|
|
3033
|
+
operationsWithContext,
|
|
3034
|
+
error: error.error
|
|
3035
|
+
};
|
|
3036
|
+
if (performance.now() - lastYield > this.config.yieldDeadlineMs) {
|
|
3037
|
+
await yieldToMain();
|
|
3038
|
+
lastYield = performance.now();
|
|
3039
|
+
if (signal?.aborted) return {
|
|
3040
|
+
success: false,
|
|
3041
|
+
generatedOperations,
|
|
3042
|
+
operationsWithContext,
|
|
3043
|
+
error: /* @__PURE__ */ new Error("Aborted")
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
return {
|
|
3048
|
+
success: true,
|
|
3049
|
+
generatedOperations,
|
|
3050
|
+
operationsWithContext
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Decides a write and reduces it, without persisting anything.
|
|
3055
|
+
*
|
|
3056
|
+
* Split from the commit so one write and a batch of them share this logic
|
|
3057
|
+
* rather than keeping two copies of it. `baseDocument` lets a batch thread
|
|
3058
|
+
* the previous action's result forward instead of reading its own write back
|
|
3059
|
+
* out of the cache, which is the only reason the reduce has to be sequential.
|
|
3060
|
+
*/
|
|
3061
|
+
async prepareRegularWrite(write, executing, baseDocument) {
|
|
3062
|
+
const { action, skip, sourceOperation, sourceRemote, deniedReason } = write;
|
|
3063
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
3064
|
+
let appendCondition;
|
|
3065
|
+
let documentVersion;
|
|
3066
|
+
const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
|
|
3067
|
+
if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
|
|
3068
|
+
const target = {
|
|
3069
|
+
documentId: job.documentId,
|
|
3070
|
+
branch: job.branch
|
|
3071
|
+
};
|
|
3072
|
+
let admission;
|
|
3073
|
+
try {
|
|
3074
|
+
admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
|
|
3075
|
+
address: action.context?.signer?.user.address,
|
|
3076
|
+
key: action.context?.signer?.app.key
|
|
3077
|
+
}, {
|
|
3078
|
+
verb: "execute",
|
|
3079
|
+
scope: action.scope,
|
|
3080
|
+
operation: action.type
|
|
3081
|
+
}, signal, this.featureFlags.authConditions ? {
|
|
3082
|
+
actionInput: action.input,
|
|
3083
|
+
carriedDocument: baseDocument
|
|
3084
|
+
} : void 0);
|
|
3085
|
+
} catch (error) {
|
|
3086
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3087
|
+
}
|
|
3088
|
+
if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
|
|
3089
|
+
appendCondition = admission.appendCondition;
|
|
3090
|
+
documentVersion = admission.documentVersion;
|
|
3091
|
+
} else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
|
|
3092
|
+
else {
|
|
3093
|
+
let docMeta;
|
|
3094
|
+
try {
|
|
3095
|
+
docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
|
|
3096
|
+
} catch (error) {
|
|
3097
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3098
|
+
}
|
|
3099
|
+
if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
3100
|
+
documentVersion = docMeta.state.version;
|
|
3101
|
+
}
|
|
3102
|
+
if (isUndoRedo(action) || skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
|
|
3103
|
+
let document;
|
|
3104
|
+
if (baseDocument !== void 0) document = baseDocument;
|
|
3105
|
+
else try {
|
|
3106
|
+
document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3109
|
+
}
|
|
3110
|
+
if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
|
|
3111
|
+
const subject = {
|
|
3112
|
+
address: write.action.context?.signer?.user.address,
|
|
3113
|
+
key: write.action.context?.signer?.app.key
|
|
3114
|
+
};
|
|
3115
|
+
if (decide(document.state.auth, subject, {
|
|
3116
|
+
verb: "execute",
|
|
3117
|
+
scope: action.scope,
|
|
3118
|
+
operation: action.type
|
|
3119
|
+
}) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
|
|
3120
|
+
}
|
|
3121
|
+
let module;
|
|
3122
|
+
try {
|
|
3123
|
+
module = this.registry.getModule(document.header.documentType, normalizeDocumentModelVersion(documentVersion));
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3126
|
+
}
|
|
3127
|
+
let updatedDocument;
|
|
3128
|
+
if (deniedReason !== void 0) {
|
|
3129
|
+
const index = getNextIndexForScope(document, job.scope);
|
|
3130
|
+
const denied = createOperation(action, index, skip, {
|
|
3131
|
+
documentId: job.documentId,
|
|
3132
|
+
scope: job.scope,
|
|
3133
|
+
branch: job.branch
|
|
3134
|
+
});
|
|
3135
|
+
denied.deniedReason = deniedReason;
|
|
3136
|
+
let standing = document;
|
|
3137
|
+
if (skip > 0) try {
|
|
3138
|
+
standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
|
|
3139
|
+
} catch (error) {
|
|
3140
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3141
|
+
}
|
|
3142
|
+
denied.hash = hashDocumentStateForScope(standing, job.scope);
|
|
3143
|
+
updatedDocument = {
|
|
3144
|
+
...standing,
|
|
3145
|
+
operations: {
|
|
3146
|
+
...standing.operations,
|
|
3147
|
+
[job.scope]: [...standing.operations[job.scope] ?? [], denied]
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
} else try {
|
|
3151
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
3152
|
+
const reducerOptions = sourceOperation ? {
|
|
3153
|
+
skip,
|
|
3154
|
+
branch: job.branch,
|
|
3155
|
+
replayOptions: { operation: sourceOperation },
|
|
3156
|
+
protocolVersion
|
|
3157
|
+
} : {
|
|
3158
|
+
skip,
|
|
3159
|
+
branch: job.branch,
|
|
3160
|
+
protocolVersion
|
|
3161
|
+
};
|
|
3162
|
+
updatedDocument = withProtocolVersions(module.reducer(document, action, void 0, reducerOptions), document.header.protocolVersions);
|
|
3163
|
+
} catch (error) {
|
|
3164
|
+
const contextMessage = `Failed to apply action to document:\n Action type: ${action.type}\n Document ID: ${job.documentId}\n Document type: ${document.header.documentType}\n Scope: ${job.scope}\n Original error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3165
|
+
const enhancedError = new Error(contextMessage);
|
|
3166
|
+
if (error instanceof Error && error.stack) enhancedError.stack = `${contextMessage}\n\nOriginal stack trace:\n${error.stack}`;
|
|
3167
|
+
return buildErrorResult(job, enhancedError, startTime);
|
|
3168
|
+
}
|
|
3169
|
+
const scope = job.scope;
|
|
3170
|
+
const operations = updatedDocument.operations[scope];
|
|
3171
|
+
if (operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("No operation generated from action"), startTime);
|
|
3172
|
+
const newOperation = operations[operations.length - 1];
|
|
3173
|
+
if (!isUndoRedo(action)) newOperation.skip = skip;
|
|
3174
|
+
if (deniedReason === void 0 && sourceOperation === void 0 && !executing.replayingAcceptedHistory && isSynthesized(action, newOperation, document.operations[scope] ?? [])) try {
|
|
3175
|
+
await signSynthesized(newOperation, action, {
|
|
3176
|
+
documentId: job.documentId,
|
|
3177
|
+
scope,
|
|
3178
|
+
branch: job.branch
|
|
3179
|
+
}, this.signer, signal);
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3182
|
+
}
|
|
3183
|
+
const resultingState = JSON.stringify({
|
|
3184
|
+
...updatedDocument.state,
|
|
3185
|
+
header: updatedDocument.header
|
|
3186
|
+
});
|
|
3187
|
+
return {
|
|
3188
|
+
action,
|
|
3189
|
+
sourceRemote,
|
|
3190
|
+
scope,
|
|
3191
|
+
document,
|
|
3192
|
+
updatedDocument,
|
|
3193
|
+
operation: newOperation,
|
|
3194
|
+
resultingState,
|
|
3195
|
+
appendCondition,
|
|
3196
|
+
denied: deniedReason !== void 0
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Persists a run of prepared writes in one store transaction.
|
|
3201
|
+
*
|
|
3202
|
+
* The store has always accepted many operations per apply; the executor only
|
|
3203
|
+
* ever handed it one. Passing the whole run means one advisory lock over the
|
|
3204
|
+
* read set and one guarded insert for the batch, instead of one of each per
|
|
3205
|
+
* operation.
|
|
3206
|
+
*
|
|
3207
|
+
* The append condition is taken from the first write. Every write in a run
|
|
3208
|
+
* reads the same streams at the same revisions, because nothing outside the
|
|
3209
|
+
* run can change them mid-batch, and the caller has already refused to batch
|
|
3210
|
+
* the scopes where that does not hold.
|
|
3211
|
+
*/
|
|
3212
|
+
async commitPreparedWrites(prepared, executing) {
|
|
3213
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
3214
|
+
const first = prepared[0];
|
|
3215
|
+
const last = prepared[prepared.length - 1];
|
|
3216
|
+
const scope = first.scope;
|
|
3217
|
+
const documentType = first.document.header.documentType;
|
|
3218
|
+
const operations = prepared.map((write) => write.operation);
|
|
3219
|
+
executing.touchedStreams.add(job.documentId, scope, job.branch);
|
|
3220
|
+
let storedOperations;
|
|
3221
|
+
try {
|
|
3222
|
+
storedOperations = await stores.operationStore.apply(job.documentId, documentType, scope, job.branch, first.operation.index, (txn) => {
|
|
3223
|
+
txn.addOperations(...operations);
|
|
3224
|
+
}, signal, first.appendCondition);
|
|
3225
|
+
} catch (error) {
|
|
3226
|
+
this.logger.error("Failed to write operation to IOperationStore (@documentId @scope @branch): @Operation @Error", job.documentId, scope, job.branch, operations, error);
|
|
3227
|
+
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
3228
|
+
if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
|
|
3229
|
+
return {
|
|
3230
|
+
job,
|
|
3231
|
+
success: false,
|
|
3232
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${String(error)}`),
|
|
3233
|
+
duration: Date.now() - startTime
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
const head = storedOperations[storedOperations.length - 1];
|
|
3237
|
+
last.updatedDocument.header.revision = {
|
|
3238
|
+
...last.updatedDocument.header.revision,
|
|
3239
|
+
[scope]: head.index + 1
|
|
3240
|
+
};
|
|
3241
|
+
stores.writeCache.putRun(job.documentId, scope, job.branch, storedOperations.map((operation, position) => ({
|
|
3242
|
+
revision: operation.index,
|
|
3243
|
+
document: prepared[position].updatedDocument
|
|
3244
|
+
})));
|
|
3245
|
+
indexTxn.write(storedOperations.map((operation, position) => ({
|
|
3246
|
+
...operation,
|
|
3247
|
+
documentId: job.documentId,
|
|
3248
|
+
documentType,
|
|
3249
|
+
branch: job.branch,
|
|
3250
|
+
scope,
|
|
3251
|
+
sourceRemote: prepared[position].sourceRemote
|
|
3252
|
+
})));
|
|
3253
|
+
if (scope === "auth") for (const write of prepared) indexTxn.recordGroupReferences(job.documentId, mentionedGroupIds(write.action));
|
|
3254
|
+
return {
|
|
3255
|
+
job,
|
|
3256
|
+
success: true,
|
|
3257
|
+
operations: storedOperations,
|
|
3258
|
+
operationsWithContext: storedOperations.map((operation, position) => ({
|
|
3259
|
+
operation,
|
|
3260
|
+
context: {
|
|
3261
|
+
documentId: job.documentId,
|
|
3262
|
+
scope,
|
|
3263
|
+
branch: job.branch,
|
|
3264
|
+
documentType,
|
|
3265
|
+
resultingState: prepared[position].resultingState,
|
|
3266
|
+
ordinal: 0
|
|
3267
|
+
}
|
|
3268
|
+
})),
|
|
3269
|
+
duration: Date.now() - startTime
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Whether a job's writes may share one store transaction.
|
|
3274
|
+
*
|
|
3275
|
+
* Deliberately narrow. Batching changes only how many transactions the
|
|
3276
|
+
* operations arrive in, and every condition below is a case where that would
|
|
3277
|
+
* change something else as well:
|
|
3278
|
+
*
|
|
3279
|
+
* - A document-scope action goes through its own handler, which has its own
|
|
3280
|
+
* apply and its own reasons for it.
|
|
3281
|
+
* - A positional or replayed run carries skips and re-appended operations,
|
|
3282
|
+
* whose indices are not a simple ascending run from the head.
|
|
3283
|
+
* - UNDO, REDO and NOOP-with-skip each invalidate the write cache to
|
|
3284
|
+
* force a full-history rebuild, so they cannot be reduced against state
|
|
3285
|
+
* threaded from the write before them.
|
|
3286
|
+
* - The auth scope decides later writes against the policy earlier ones
|
|
3287
|
+
* install, so a batch would decide them all against the policy as it stood
|
|
3288
|
+
* before the batch.
|
|
3289
|
+
* - The document scope is read by every decision model, so writing it is
|
|
3290
|
+
* writing part of the read set; the per-write conditions would not agree.
|
|
3291
|
+
*
|
|
3292
|
+
* A run that fails any of these is executed one write at a time, unchanged.
|
|
3293
|
+
*/
|
|
3294
|
+
canBatch(writes, executing) {
|
|
3295
|
+
if (writes.length < 2) return false;
|
|
3296
|
+
if (executing.evaluatedByPosition || executing.replayingAcceptedHistory) return false;
|
|
3297
|
+
const scope = executing.job.scope;
|
|
3298
|
+
if (scope === "auth" || scope === "document") return false;
|
|
3299
|
+
return writes.every((write) => {
|
|
3300
|
+
const type = write.action.type;
|
|
3301
|
+
return write.skip === 0 && write.deniedReason === void 0 && write.sourceOperation === void 0 && !DOCUMENT_SCOPE_ACTIONS.has(type) && !isUndoRedo(write.action) && type !== "NOOP";
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Decides and reduces a run of writes, then persists them together.
|
|
3306
|
+
*
|
|
3307
|
+
* The reduce stays sequential - each action needs the state the one before it
|
|
3308
|
+
* produced - but the result is threaded in memory rather than read back from
|
|
3309
|
+
* the cache, and the whole run reaches the store in a single apply.
|
|
3310
|
+
*
|
|
3311
|
+
* A write that turns out to be denied abandons the batch and replays the run
|
|
3312
|
+
* one write at a time, because a denied write holds a position of its own and
|
|
3313
|
+
* that is the path where the per-write behaviour is load-bearing. A write
|
|
3314
|
+
* that cannot be prepared fails the job outright: preparing is a read, so the
|
|
3315
|
+
* replay would only reach the same failure, and the job leaves nothing behind
|
|
3316
|
+
* either way.
|
|
3317
|
+
*/
|
|
3318
|
+
async executeRegularActionsBatched(writes, executing) {
|
|
3319
|
+
const prepared = [];
|
|
3320
|
+
let carried;
|
|
3321
|
+
let lastYield = performance.now();
|
|
3322
|
+
for (const write of writes) {
|
|
3323
|
+
const outcome = await this.prepareRegularWrite(write, executing, carried);
|
|
3324
|
+
if ("success" in outcome) return outcome;
|
|
3325
|
+
if (outcome.denied) return this.executeRegularActionsSequentially(writes, executing);
|
|
3326
|
+
prepared.push(outcome);
|
|
3327
|
+
carried = outcome.updatedDocument;
|
|
3328
|
+
if (performance.now() - lastYield > this.config.yieldDeadlineMs) {
|
|
3329
|
+
await yieldToMain();
|
|
3330
|
+
lastYield = performance.now();
|
|
3331
|
+
if (executing.signal?.aborted) return buildErrorResult(executing.job, /* @__PURE__ */ new Error("Aborted"), executing.startTime);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
if (!this.conditionsAgree(prepared)) return this.executeRegularActionsSequentially(writes, executing);
|
|
3335
|
+
return this.commitPreparedWrites(prepared, executing);
|
|
3336
|
+
}
|
|
3337
|
+
/** Whether every prepared write carries the same read-set condition. */
|
|
3338
|
+
conditionsAgree(prepared) {
|
|
3339
|
+
const shape = (write) => write.appendCondition === void 0 ? "none" : JSON.stringify([...write.appendCondition.streams].map((stream) => [
|
|
3340
|
+
stream.documentId,
|
|
3341
|
+
stream.scope,
|
|
3342
|
+
stream.branch,
|
|
3343
|
+
stream.revision
|
|
3344
|
+
]).sort());
|
|
3345
|
+
const first = shape(prepared[0]);
|
|
3346
|
+
return prepared.every((write) => shape(write) === first);
|
|
3347
|
+
}
|
|
3348
|
+
/**
|
|
3349
|
+
* The unbatched path, for a run that turned out not to qualify after its
|
|
3350
|
+
* writes were prepared. Nothing has been persisted at that point, so
|
|
3351
|
+
* replaying the whole run per write is safe.
|
|
3352
|
+
*/
|
|
3353
|
+
async executeRegularActionsSequentially(writes, executing) {
|
|
3354
|
+
const operations = [];
|
|
3355
|
+
const contexts = [];
|
|
3356
|
+
let lastYield = performance.now();
|
|
3357
|
+
for (const write of writes) {
|
|
3358
|
+
const result = await this.executeRegularAction(write, executing);
|
|
3359
|
+
if (!result.success) return result;
|
|
3360
|
+
operations.push(...result.operations ?? []);
|
|
3361
|
+
contexts.push(...result.operationsWithContext ?? []);
|
|
3362
|
+
if (performance.now() - lastYield > this.config.yieldDeadlineMs) {
|
|
3363
|
+
await yieldToMain();
|
|
3364
|
+
lastYield = performance.now();
|
|
3365
|
+
if (executing.signal?.aborted) return buildErrorResult(executing.job, /* @__PURE__ */ new Error("Aborted"), executing.startTime);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return {
|
|
3369
|
+
job: executing.job,
|
|
3370
|
+
success: true,
|
|
3371
|
+
operations,
|
|
3372
|
+
operationsWithContext: contexts,
|
|
3373
|
+
duration: Date.now() - executing.startTime
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
/** Decides, reduces and persists one write. */
|
|
3377
|
+
async executeRegularAction(write, executing) {
|
|
3378
|
+
const prepared = await this.prepareRegularWrite(write, executing);
|
|
3379
|
+
if ("success" in prepared) return prepared;
|
|
3380
|
+
return this.commitPreparedWrites([prepared], executing);
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Orders a write by timestamp and decides it where it lands. The caller
|
|
3384
|
+
* supplies the timestamp, so a write can belong before operations already
|
|
3385
|
+
* stored; those are re-appended alongside it, the way a load reshuffles.
|
|
3386
|
+
*
|
|
3387
|
+
* Deciding a backdated write at the stream heads instead of at its position
|
|
3388
|
+
* would overwrite the verdict every other replica computes for it.
|
|
3389
|
+
*/
|
|
3390
|
+
async positionByTimestamp(job, stores, signal) {
|
|
3391
|
+
const plain = () => ({
|
|
3392
|
+
writes: job.actions.map((action) => ({
|
|
3393
|
+
action,
|
|
3394
|
+
skip: 0,
|
|
3395
|
+
sourceRemote: ""
|
|
3396
|
+
})),
|
|
3397
|
+
evaluatedByPosition: false
|
|
3398
|
+
});
|
|
3399
|
+
if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
|
|
3400
|
+
let earliest = job.actions[0].timestampUtcMs;
|
|
3401
|
+
let earliestAt = Date.parse(earliest);
|
|
3402
|
+
for (const action of job.actions) {
|
|
3403
|
+
const at = Date.parse(action.timestampUtcMs);
|
|
3404
|
+
if (at < earliestAt) {
|
|
3405
|
+
earliest = action.timestampUtcMs;
|
|
3406
|
+
earliestAt = at;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3410
|
+
const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
|
|
3411
|
+
if (this.featureFlags.authEnforcement && job.scope === "auth") {
|
|
3412
|
+
const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
|
|
3413
|
+
const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
|
|
3414
|
+
if (violation) return {
|
|
3415
|
+
writes: [],
|
|
3416
|
+
evaluatedByPosition: false,
|
|
3417
|
+
error: violation
|
|
3418
|
+
};
|
|
3419
|
+
if (!backdated) return plain();
|
|
3420
|
+
return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
|
|
3421
|
+
}
|
|
3422
|
+
if (!backdated) return plain();
|
|
3423
|
+
const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
|
|
3424
|
+
const nothingToMove = async () => {
|
|
3425
|
+
if (!this.featureFlags.authEnforcement) return plain();
|
|
3426
|
+
return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
|
|
3427
|
+
};
|
|
3428
|
+
if (conflicting.length === 0) return nothingToMove();
|
|
3429
|
+
const nextIndex = revisions.revision[job.scope] ?? 0;
|
|
3430
|
+
let firstConflicting = conflicting[0].index;
|
|
3431
|
+
for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
|
|
3432
|
+
const stored = (await stores.operationStore.getSince(job.documentId, job.scope, job.branch, firstConflicting - 1, void 0, void 0, signal)).results;
|
|
3433
|
+
const conflictingIds = new Set(conflicting.map((operation) => operation.id));
|
|
3434
|
+
const effective = garbageCollect(sortOperations(stored)).filter((operation) => !isGenesisOperation(operation));
|
|
3435
|
+
const firstMoving = effective.findIndex((operation) => conflictingIds.has(operation.id));
|
|
3436
|
+
const moving = firstMoving === -1 ? [] : effective.slice(firstMoving);
|
|
3437
|
+
if (moving.length === 0) return nothingToMove();
|
|
3438
|
+
let firstRetracted = moving[0].index;
|
|
3439
|
+
for (const operation of moving) firstRetracted = Math.min(firstRetracted, operation.index - operation.skip);
|
|
3440
|
+
const incoming = job.actions.map((action, i) => ({
|
|
3441
|
+
id: action.id,
|
|
3442
|
+
index: nextIndex + i,
|
|
3443
|
+
skip: 0,
|
|
3444
|
+
hash: "",
|
|
3445
|
+
timestampUtcMs: action.timestampUtcMs,
|
|
3446
|
+
action
|
|
3447
|
+
}));
|
|
3448
|
+
const merged = reshuffleByTimestamp({
|
|
3449
|
+
index: nextIndex,
|
|
3450
|
+
skip: retractionSkip(nextIndex, firstRetracted)
|
|
3451
|
+
}, moving, incoming);
|
|
3452
|
+
stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
|
|
3453
|
+
if (!this.featureFlags.authEnforcement) return {
|
|
3454
|
+
writes: merged.map((operation) => ({
|
|
3455
|
+
action: operation.action,
|
|
3456
|
+
skip: operation.skip,
|
|
3457
|
+
sourceRemote: ""
|
|
3458
|
+
})),
|
|
3459
|
+
evaluatedByPosition: false
|
|
3460
|
+
};
|
|
3461
|
+
return this.evaluatePositioned(job, stores, merged, signal);
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Decides each operation where it lands and carries the verdict on it. A
|
|
3465
|
+
* refused submitted action is reported to the caller and nothing is stored; a
|
|
3466
|
+
* refused operation the reshuffle merely moved keeps its verdict, because it
|
|
3467
|
+
* already holds a position.
|
|
3468
|
+
*
|
|
3469
|
+
* The operations carry the indexes and skips they will be stored at, because
|
|
3470
|
+
* the walk resolves skips before it orders them.
|
|
3471
|
+
*/
|
|
3472
|
+
async evaluatePositioned(job, stores, operations, signal) {
|
|
3473
|
+
const reasons = await evaluateByPosition(this.decisionModel, {
|
|
3474
|
+
documentId: job.documentId,
|
|
3475
|
+
branch: job.branch
|
|
3476
|
+
}, {
|
|
3477
|
+
scope: job.scope,
|
|
3478
|
+
operations
|
|
3479
|
+
}, stores, signal);
|
|
3480
|
+
const submitted = new Set(job.actions.map((action) => action.id));
|
|
3481
|
+
for (let i = 0; i < operations.length; i++) {
|
|
3482
|
+
const reason = reasons[i];
|
|
3483
|
+
if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
|
|
3484
|
+
writes: [],
|
|
3485
|
+
evaluatedByPosition: false,
|
|
3486
|
+
error: refusalError(reason, job.documentId, null, operations[i].action)
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
return {
|
|
3490
|
+
writes: operations.map((operation, i) => ({
|
|
3491
|
+
action: operation.action,
|
|
3492
|
+
skip: operation.skip,
|
|
3493
|
+
sourceRemote: "",
|
|
3494
|
+
deniedReason: reasons[i]
|
|
3495
|
+
})),
|
|
3496
|
+
evaluatedByPosition: true
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* The scopes a re-evaluation pass visits, in a fixed order.
|
|
3501
|
+
*
|
|
3502
|
+
* The revisions map comes from a query with no ORDER BY, and the order is
|
|
3503
|
+
* load-bearing: each scope's pass re-reads the auth stream, and the walk skips
|
|
3504
|
+
* an operation by its stored denial, so a denial this pass just wrote is
|
|
3505
|
+
* visible to a later-visited scope and invisible to an earlier one. The model's
|
|
3506
|
+
* own projection order leads, then the rest sorted, so the pass is reproducible
|
|
3507
|
+
* across replicas and across runs.
|
|
3508
|
+
*/
|
|
3509
|
+
evaluationOrder(target, revision) {
|
|
3510
|
+
const definition = this.decisionModel(target);
|
|
3511
|
+
const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
|
|
3512
|
+
const leading = [];
|
|
3513
|
+
for (const stream of staticReadSet(definition)) {
|
|
3514
|
+
const scope = stream.query.scope;
|
|
3515
|
+
if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
|
|
3516
|
+
}
|
|
3517
|
+
const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
|
|
3518
|
+
return [...leading, ...rest];
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* The first timestamp in the batch that does not strictly exceed everything
|
|
3522
|
+
* ahead of it, or undefined when the whole batch is monotonic.
|
|
3523
|
+
*
|
|
3524
|
+
* The bound is carried forward rather than compared against one stored maximum,
|
|
3525
|
+
* because a single execute can carry several auth actions stamped in the same
|
|
3526
|
+
* millisecond. Letting a tie through would store a stream the position walk
|
|
3527
|
+
* then refuses to read, with no repair path.
|
|
3528
|
+
*/
|
|
3529
|
+
firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
|
|
3530
|
+
let boundIso = newest;
|
|
3531
|
+
let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
|
|
3532
|
+
for (const entry of entries) {
|
|
3533
|
+
if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
|
|
3534
|
+
const at = Date.parse(entry.timestampUtcMs);
|
|
3535
|
+
if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
|
|
3536
|
+
bound = at;
|
|
3537
|
+
boundIso = entry.timestampUtcMs;
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
/** The operations a batch of submitted actions appends at the scope's tail. */
|
|
3541
|
+
appendedOperations(job, nextIndex) {
|
|
3542
|
+
return job.actions.map((action, i) => ({
|
|
3543
|
+
id: action.id,
|
|
3544
|
+
index: nextIndex + i,
|
|
3545
|
+
skip: 0,
|
|
3546
|
+
hash: "",
|
|
3547
|
+
timestampUtcMs: action.timestampUtcMs,
|
|
3548
|
+
action
|
|
3549
|
+
}));
|
|
3550
|
+
}
|
|
3551
|
+
/**
|
|
3552
|
+
* Re-evaluates the document when a write meets both criteria: it was written
|
|
3553
|
+
* to a stream the model reads, and it is timestamped before an operation
|
|
3554
|
+
* already stored. The caller supplies the timestamp and the reactor does not replace
|
|
3555
|
+
* it, so a mutation job can write such an operation just as a load job can,
|
|
3556
|
+
* which is why both executeJob and executeLoadJob call this.
|
|
3557
|
+
*/
|
|
3558
|
+
async reevaluateIfCriteriaMet(criteria, executing) {
|
|
3559
|
+
if (!this.featureFlags.documentDecisions) return;
|
|
3560
|
+
const { job, stores, signal } = executing;
|
|
3561
|
+
const target = {
|
|
3562
|
+
documentId: job.documentId,
|
|
3563
|
+
branch: job.branch
|
|
3564
|
+
};
|
|
3565
|
+
if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
|
|
3566
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3567
|
+
const latest = Date.parse(revisions.latestTimestamp);
|
|
3568
|
+
if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
|
|
3569
|
+
return (await this.reevaluateDocument(executing)).error;
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Re-evaluates every scope the model evaluates. Where an operation's
|
|
3573
|
+
* evaluation differs from what is stored, the tail from that operation is
|
|
3574
|
+
* re-appended, carrying a skip that spans the indices it supersedes.
|
|
3575
|
+
*/
|
|
3576
|
+
async reevaluateDocument(executing) {
|
|
3577
|
+
const { job, stores, signal } = executing;
|
|
3578
|
+
const target = {
|
|
3579
|
+
documentId: job.documentId,
|
|
3580
|
+
branch: job.branch
|
|
3581
|
+
};
|
|
3582
|
+
const reappended = [];
|
|
3583
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3584
|
+
for (const scope of this.evaluationOrder(target, revisions.revision)) {
|
|
3585
|
+
const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
|
|
3586
|
+
const effective = garbageCollect(sortOperations([...stored]));
|
|
3587
|
+
if (effective.length === 0) continue;
|
|
3588
|
+
const reevaluated = await evaluateByPosition(this.decisionModel, target, {
|
|
3589
|
+
scope,
|
|
3590
|
+
operations: effective
|
|
3591
|
+
}, stores, signal);
|
|
3592
|
+
const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== reevaluated[i]);
|
|
3593
|
+
if (firstChange === -1) continue;
|
|
3594
|
+
const tail = effective.slice(firstChange);
|
|
3595
|
+
const nextIndex = revisions.revision[scope];
|
|
3596
|
+
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
3597
|
+
const result = await this.processActions(tail.map((operation, i) => ({
|
|
3598
|
+
action: operation.action,
|
|
3599
|
+
skip: i === 0 ? retractionSkip(nextIndex, tail[0].index - tail[0].skip) : 0,
|
|
3600
|
+
sourceRemote: "",
|
|
3601
|
+
deniedReason: reevaluated[firstChange + i]
|
|
3602
|
+
})), {
|
|
3603
|
+
...executing,
|
|
3604
|
+
job: {
|
|
3605
|
+
...job,
|
|
3606
|
+
scope
|
|
3607
|
+
},
|
|
3608
|
+
replayingAcceptedHistory: true,
|
|
3609
|
+
evaluatedByPosition: true
|
|
3610
|
+
});
|
|
3611
|
+
if (!result.success) return {
|
|
3612
|
+
error: result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`),
|
|
3613
|
+
operationsWithContext: reappended
|
|
3614
|
+
};
|
|
3615
|
+
reappended.push(...result.operationsWithContext);
|
|
3616
|
+
}
|
|
3617
|
+
return { operationsWithContext: reappended };
|
|
3618
|
+
}
|
|
3619
|
+
/**
|
|
3620
|
+
* Re-judges a document's stored operations because a read-set stream in
|
|
3621
|
+
* another document (a group) gained an operation. The trigger timestamp
|
|
3622
|
+
* bounds the work: an operation later than everything this document holds
|
|
3623
|
+
* cannot change any evaluation, so the pass is skipped.
|
|
3624
|
+
*/
|
|
3625
|
+
async executeReevaluationJob(executing) {
|
|
3626
|
+
const { job, startTime, stores, signal } = executing;
|
|
3627
|
+
if (!this.featureFlags.documentDecisions) return {
|
|
3628
|
+
job,
|
|
3629
|
+
success: true,
|
|
3630
|
+
operations: [],
|
|
3631
|
+
operationsWithContext: [],
|
|
3632
|
+
duration: Date.now() - startTime
|
|
3633
|
+
};
|
|
3634
|
+
const trigger = job.meta.triggerTimestampUtcMs;
|
|
3635
|
+
if (typeof trigger === "string") {
|
|
3636
|
+
let latestTimestamp;
|
|
3637
|
+
try {
|
|
3638
|
+
latestTimestamp = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).latestTimestamp;
|
|
3639
|
+
} catch {
|
|
3640
|
+
return {
|
|
3641
|
+
job,
|
|
3642
|
+
success: true,
|
|
3643
|
+
operations: [],
|
|
3644
|
+
operationsWithContext: [],
|
|
3645
|
+
duration: Date.now() - startTime
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
if (Date.parse(trigger) > Date.parse(latestTimestamp)) return {
|
|
3649
|
+
job,
|
|
3650
|
+
success: true,
|
|
3651
|
+
operations: [],
|
|
3652
|
+
operationsWithContext: [],
|
|
3653
|
+
duration: Date.now() - startTime
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
const outcome = await this.reevaluateDocument(executing);
|
|
3657
|
+
if (outcome.error) return buildErrorResult(job, outcome.error, startTime);
|
|
3658
|
+
return {
|
|
3659
|
+
job,
|
|
3660
|
+
success: true,
|
|
3661
|
+
operations: outcome.operationsWithContext.map((owc) => owc.operation),
|
|
3662
|
+
operationsWithContext: outcome.operationsWithContext,
|
|
3663
|
+
duration: Date.now() - startTime
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
async executeLoadJob(executing) {
|
|
3667
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
3668
|
+
if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
|
|
3669
|
+
let docMeta;
|
|
3670
|
+
try {
|
|
3671
|
+
docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
|
|
3672
|
+
} catch {}
|
|
3673
|
+
if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
3674
|
+
const scope = job.scope;
|
|
3675
|
+
const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
|
|
3676
|
+
let latestRevision;
|
|
3677
|
+
try {
|
|
3678
|
+
latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
|
|
3679
|
+
} catch {
|
|
3680
|
+
latestRevision = 0;
|
|
3681
|
+
}
|
|
3682
|
+
for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
|
|
3683
|
+
job,
|
|
3684
|
+
success: false,
|
|
3685
|
+
error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
|
|
3686
|
+
duration: Date.now() - startTime
|
|
3687
|
+
};
|
|
3688
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
3689
|
+
const admitted = /* @__PURE__ */ new Set();
|
|
3690
|
+
let selection = await this.selectLoadWrites(job, job.operations, stores, signal);
|
|
3691
|
+
for (;;) {
|
|
3692
|
+
const unadmitted = selection.incomingOpsToApply.filter((operation) => !admitted.has(operation));
|
|
3693
|
+
if (unadmitted.length === 0) break;
|
|
3694
|
+
let refused;
|
|
3695
|
+
try {
|
|
3696
|
+
refused = await this.signatureAdmission.admitLoad(job, unadmitted, stores, signal);
|
|
3697
|
+
} catch (error) {
|
|
3698
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3699
|
+
}
|
|
3700
|
+
for (const operation of unadmitted) if (refused.has(operation)) dropped.add(operation);
|
|
3701
|
+
else admitted.add(operation);
|
|
3702
|
+
if (refused.size === 0) break;
|
|
3703
|
+
const remaining = job.operations.filter((operation) => !dropped.has(operation));
|
|
3704
|
+
if (remaining.length === 0) return {
|
|
3705
|
+
job,
|
|
3706
|
+
success: true,
|
|
3707
|
+
operations: [],
|
|
3708
|
+
operationsWithContext: [],
|
|
3709
|
+
duration: Date.now() - startTime
|
|
3710
|
+
};
|
|
3711
|
+
selection = await this.selectLoadWrites(job, remaining, stores, signal);
|
|
3712
|
+
}
|
|
3713
|
+
const { nonSupersededOps, allOpsFromMinConflictingIndex, incomingOpsToApply } = selection;
|
|
3714
|
+
if (incomingOpsToApply.length === 0) return {
|
|
3715
|
+
job,
|
|
3716
|
+
success: true,
|
|
3717
|
+
operations: [],
|
|
3718
|
+
operationsWithContext: [],
|
|
3719
|
+
duration: Date.now() - startTime
|
|
3720
|
+
};
|
|
3721
|
+
const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
|
|
3722
|
+
const actionIdCounts = /* @__PURE__ */ new Map();
|
|
3723
|
+
for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
|
|
3724
|
+
const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
|
|
3725
|
+
if (reshuffleCost > this.config.maxSkipThreshold) return {
|
|
3726
|
+
job,
|
|
3727
|
+
success: false,
|
|
3728
|
+
error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
|
|
3729
|
+
duration: Date.now() - startTime
|
|
3730
|
+
};
|
|
3731
|
+
let skipCount = existingOpsToReshuffle.length;
|
|
3732
|
+
if (existingOpsToReshuffle.length > 0) {
|
|
3733
|
+
let minLogicalIndex = Number.POSITIVE_INFINITY;
|
|
3734
|
+
for (const op of existingOpsToReshuffle) {
|
|
3735
|
+
const logical = op.index - op.skip;
|
|
3736
|
+
if (logical < minLogicalIndex) minLogicalIndex = logical;
|
|
3737
|
+
}
|
|
3738
|
+
const logicalSkip = latestRevision - minLogicalIndex;
|
|
3739
|
+
if (logicalSkip > skipCount) skipCount = logicalSkip;
|
|
3740
|
+
}
|
|
3741
|
+
if (monotonicAuthStream) {
|
|
3742
|
+
const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
|
|
3743
|
+
const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
|
|
3744
|
+
if (violation) return {
|
|
3745
|
+
job,
|
|
3746
|
+
success: false,
|
|
3747
|
+
error: violation,
|
|
3748
|
+
duration: Date.now() - startTime
|
|
3749
|
+
};
|
|
3750
|
+
}
|
|
3751
|
+
const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
|
|
3752
|
+
...operation,
|
|
3753
|
+
index: latestRevision + i
|
|
3754
|
+
})) : reshuffleByTimestamp({
|
|
3755
|
+
index: latestRevision,
|
|
3756
|
+
skip: skipCount
|
|
3757
|
+
}, existingOpsToReshuffle, incomingOpsToApply.map((operation) => ({
|
|
3758
|
+
...operation,
|
|
3759
|
+
id: operation.id
|
|
3760
|
+
})));
|
|
3761
|
+
const incomingIds = new Set(incomingOpsToApply.map((operation) => operation.action.id));
|
|
3762
|
+
for (const operation of reshuffledOperations) if (operation.action.type === "NOOP" && operation.skip === 0 && incomingIds.has(operation.action.id)) operation.skip = 1;
|
|
3763
|
+
let deniedReasons;
|
|
3764
|
+
if (this.featureFlags.documentDecisions) try {
|
|
3765
|
+
deniedReasons = await evaluateByPosition(this.decisionModel, {
|
|
3766
|
+
documentId: job.documentId,
|
|
3767
|
+
branch: job.branch
|
|
3768
|
+
}, {
|
|
3769
|
+
scope,
|
|
3770
|
+
operations: reshuffledOperations
|
|
3771
|
+
}, stores, signal);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
return {
|
|
3774
|
+
job,
|
|
3775
|
+
success: false,
|
|
3776
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
3777
|
+
duration: Date.now() - startTime
|
|
3778
|
+
};
|
|
3779
|
+
}
|
|
3780
|
+
const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
|
|
3781
|
+
const result = await this.processActions(reshuffledOperations.map((operation, i) => ({
|
|
3782
|
+
action: operation.action,
|
|
3783
|
+
skip: operation.skip,
|
|
3784
|
+
sourceOperation: operation,
|
|
3785
|
+
sourceRemote: effectiveSourceRemote,
|
|
3786
|
+
deniedReason: deniedReasons?.[i]
|
|
3787
|
+
})), executing);
|
|
3788
|
+
if (!result.success) return {
|
|
3789
|
+
job,
|
|
3790
|
+
success: false,
|
|
3791
|
+
error: result.error,
|
|
3792
|
+
duration: Date.now() - startTime
|
|
3793
|
+
};
|
|
3794
|
+
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
3795
|
+
if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
|
|
3796
|
+
const reevaluationError = await this.reevaluateIfCriteriaMet({
|
|
3797
|
+
scope,
|
|
3798
|
+
operations: result.generatedOperations
|
|
3799
|
+
}, executing);
|
|
3800
|
+
if (reevaluationError) return {
|
|
3801
|
+
job,
|
|
3802
|
+
success: false,
|
|
3803
|
+
error: reevaluationError,
|
|
3804
|
+
duration: Date.now() - startTime
|
|
3805
|
+
};
|
|
3806
|
+
return {
|
|
3807
|
+
job,
|
|
3808
|
+
success: true,
|
|
3809
|
+
operations: result.generatedOperations,
|
|
3810
|
+
operationsWithContext: result.operationsWithContext,
|
|
3811
|
+
duration: Date.now() - startTime
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
/** The conflicting window a load opens, and which of its operations are new. */
|
|
3815
|
+
async selectLoadWrites(job, operations, stores, signal) {
|
|
3816
|
+
const scope = job.scope;
|
|
3817
|
+
let minIncomingIndex = Number.POSITIVE_INFINITY;
|
|
3818
|
+
let minIncomingTimestamp = operations[0]?.timestampUtcMs || "";
|
|
3819
|
+
for (const operation of operations) {
|
|
3820
|
+
minIncomingIndex = Math.min(minIncomingIndex, operation.index);
|
|
3821
|
+
const ts = operation.timestampUtcMs || "";
|
|
3822
|
+
if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
|
|
3823
|
+
}
|
|
3824
|
+
let conflictingOps;
|
|
3825
|
+
try {
|
|
3826
|
+
conflictingOps = (await stores.operationStore.getConflicting(job.documentId, scope, job.branch, minIncomingTimestamp, void 0, signal)).results;
|
|
3827
|
+
} catch {
|
|
3828
|
+
conflictingOps = [];
|
|
3829
|
+
}
|
|
3830
|
+
let allOpsFromMinConflictingIndex = conflictingOps;
|
|
3831
|
+
if (conflictingOps.length > 0) {
|
|
3832
|
+
const minConflictingIndex = Math.min(...conflictingOps.map((op) => op.index));
|
|
3833
|
+
try {
|
|
3834
|
+
allOpsFromMinConflictingIndex = (await stores.operationStore.getSince(job.documentId, scope, job.branch, minConflictingIndex - 1, void 0, void 0, signal)).results;
|
|
3835
|
+
} catch {
|
|
3836
|
+
allOpsFromMinConflictingIndex = conflictingOps;
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
const incomingActionIds = new Set(operations.map((op) => op.action.id));
|
|
3840
|
+
const supersededBy = (op) => {
|
|
3841
|
+
let nearest;
|
|
3842
|
+
for (const laterOp of allOpsFromMinConflictingIndex) if (laterOp.index > op.index && laterOp.skip > 0 && laterOp.index - laterOp.skip <= op.index && (nearest === void 0 || laterOp.index < nearest.index)) nearest = laterOp;
|
|
3843
|
+
return nearest;
|
|
3844
|
+
};
|
|
3845
|
+
const undoneActionIds = /* @__PURE__ */ new Set();
|
|
3846
|
+
let predecessorBound = minIncomingIndex;
|
|
3847
|
+
const rowsByActionId = /* @__PURE__ */ new Map();
|
|
3848
|
+
for (const op of conflictingOps) if (incomingActionIds.has(op.action.id)) rowsByActionId.set(op.action.id, [...rowsByActionId.get(op.action.id) ?? [], op]);
|
|
3849
|
+
const liveOps = /* @__PURE__ */ new Set();
|
|
3850
|
+
for (const op of conflictingOps) if (supersededBy(op) === void 0) liveOps.add(op);
|
|
3851
|
+
for (const [actionId, rows] of rowsByActionId) {
|
|
3852
|
+
if (rows.some((row) => liveOps.has(row))) continue;
|
|
3853
|
+
if (rows.some((row) => supersededBy(row)?.action.type === "NOOP")) undoneActionIds.add(actionId);
|
|
3854
|
+
else predecessorBound = Math.min(predecessorBound, Math.max(...rows.map((row) => row.index)));
|
|
3855
|
+
}
|
|
3856
|
+
const nonSupersededOps = conflictingOps.filter((op) => {
|
|
3857
|
+
if (op.index < predecessorBound && !incomingActionIds.has(op.action.id)) return false;
|
|
3858
|
+
return liveOps.has(op);
|
|
3859
|
+
});
|
|
3860
|
+
let rewoundFrom = Number.POSITIVE_INFINITY;
|
|
3861
|
+
for (const op of nonSupersededOps) if (!isGenesisOperation(op)) rewoundFrom = Math.min(rewoundFrom, op.index - op.skip);
|
|
3862
|
+
const reappended = new Set(nonSupersededOps.map((op) => op.index));
|
|
3863
|
+
for (const op of allOpsFromMinConflictingIndex) if (op.index >= rewoundFrom && !reappended.has(op.index) && supersededBy(op) === void 0) nonSupersededOps.push(op);
|
|
3864
|
+
const existingActionIds = new Set(nonSupersededOps.map((op) => op.action.id));
|
|
3865
|
+
const seenIncomingActionIds = /* @__PURE__ */ new Set();
|
|
3866
|
+
const incomingOpsToApply = operations.filter((op) => {
|
|
3867
|
+
if (existingActionIds.has(op.action.id)) return false;
|
|
3868
|
+
if (undoneActionIds.has(op.action.id)) return false;
|
|
3869
|
+
if (seenIncomingActionIds.has(op.action.id)) return false;
|
|
3870
|
+
seenIncomingActionIds.add(op.action.id);
|
|
3871
|
+
return true;
|
|
3872
|
+
});
|
|
3873
|
+
return {
|
|
3874
|
+
nonSupersededOps,
|
|
3875
|
+
allOpsFromMinConflictingIndex,
|
|
3876
|
+
incomingOpsToApply
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
accumulateResultOrReturnError(result, generatedOperations, operationsWithContext) {
|
|
3880
|
+
if (!result.success) return result;
|
|
3881
|
+
if (result.operations && result.operations.length > 0) generatedOperations.push(...result.operations);
|
|
3882
|
+
if (result.operationsWithContext) operationsWithContext.push(...result.operationsWithContext);
|
|
3883
|
+
return null;
|
|
3884
|
+
}
|
|
3885
|
+
};
|
|
3886
|
+
//#endregion
|
|
3887
|
+
//#region src/core/drive-container-types.ts
|
|
3888
|
+
const DEFAULT_DRIVE_CONTAINER_TYPES = /* @__PURE__ */ new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
|
|
3889
|
+
//#endregion
|
|
3890
|
+
export { PropagationMode as C, JobStatus as S, cursorProtectedLoadMeta as T, selectDecisionModel as _, NoopStorageFlusher as a, buildDecisionModel as b, DEFAULT_PURGE_DELETE_BATCH as c, resolveFeatureFlags as d, DEFAULT_DEFERRED_JOB_TTL_MS as f, decideAtHead as g, JobExecutorEventTypes as h, PassthroughSigner as i, KyselyDocumentPurger as l, DEFAULT_MAX_PURGE_OPERATIONS as m, SimpleJobExecutor as n, StoragePoisonedError as o, DEFAULT_JOB_TIMEOUT_MS as p, verifyActionSignature as r, KyselyExecutionScope as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, appliedDelete as u, documentDecisionModel as v, RelationshipChangeType as w, DriveCollectionId as x, authDecisionModel as y };
|
|
3891
|
+
|
|
3892
|
+
//# sourceMappingURL=drive-container-types-BAfFzBOf.js.map
|