@powerhousedao/reactor 6.2.2-dev.5 → 6.2.2-dev.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-worker-executor--nhFRF47.js → build-worker-executor-NT9b3rNm.js} +2 -2
- package/dist/{build-worker-executor--nhFRF47.js.map → build-worker-executor-NT9b3rNm.js.map} +1 -1
- package/dist/{document-indexer-FGJmRAdX.js → document-indexer-DlpJB8AK.js} +37 -22
- package/dist/document-indexer-DlpJB8AK.js.map +1 -0
- package/dist/{drive-container-types-DpJp2AmE.js → drive-container-types-RZa1wukO.js} +2182 -314
- package/dist/drive-container-types-RZa1wukO.js.map +1 -0
- package/dist/entry.js +3 -2
- package/dist/entry.js.map +1 -1
- package/dist/index.d.ts +2302 -1313
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +959 -100
- package/dist/index.js.map +1 -1
- package/dist/projection-entry.js +4 -4
- package/dist/projection-entry.js.map +1 -1
- package/dist/{worker-handle-B1w03nRA.js → worker-handle-CrERzl8s.js} +3 -2
- package/dist/worker-handle-CrERzl8s.js.map +1 -0
- package/dist/{worker-DBJOv8Gp.js → worker-sw2vjrd3.js} +2 -2
- package/dist/{worker-DBJOv8Gp.js.map → worker-sw2vjrd3.js.map} +1 -1
- package/package.json +5 -4
- package/dist/document-indexer-FGJmRAdX.js.map +0 -1
- package/dist/drive-container-types-DpJp2AmE.js.map +0 -1
- package/dist/worker-handle-B1w03nRA.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
|
|
2
|
-
import { DowngradeNotSupportedError, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, createPresignedHeader, defaultBaseState, deriveOperationId, isUndoRedo } from "@powerhousedao/shared/document-model";
|
|
2
|
+
import { AUTH_ACTION_TYPES, AUTH_DENIED_BY_GRANT_REASON, AUTH_NO_GRANT_REASON, AUTH_VERSION_UNSUPPORTED_REASON, DOCUMENT_DELETED_REASON, DowngradeNotSupportedError as DowngradeNotSupportedError$1, appendWithoutApplying, applyAuthAction, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, evaluate, garbageCollect, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, isDenied, isUndoRedo, mentionedGroupIds, normalizeDocumentModelVersion, referencedGroupIds, sortOperations } from "@powerhousedao/shared/document-model";
|
|
3
3
|
import { v4 } from "uuid";
|
|
4
4
|
import { Migrator, sql } from "kysely";
|
|
5
5
|
//#region \0rolldown/runtime.js
|
|
@@ -72,6 +72,102 @@ var DocumentDeletedError = class DocumentDeletedError extends Error {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
/**
|
|
75
|
+
* Error thrown when the auth policy denies an action at the executor gate.
|
|
76
|
+
*/
|
|
77
|
+
var AuthorizationDeniedError = class AuthorizationDeniedError extends Error {
|
|
78
|
+
documentId;
|
|
79
|
+
scope;
|
|
80
|
+
operation;
|
|
81
|
+
subject;
|
|
82
|
+
constructor(documentId, scope, operation, subject) {
|
|
83
|
+
super(`Authorization denied: ${subject ?? "anonymous"} may not execute ${operation} in scope "${scope}" of document ${documentId}`);
|
|
84
|
+
this.name = "AuthorizationDeniedError";
|
|
85
|
+
this.documentId = documentId;
|
|
86
|
+
this.scope = scope;
|
|
87
|
+
this.operation = operation;
|
|
88
|
+
this.subject = subject;
|
|
89
|
+
Error.captureStackTrace(this, AuthorizationDeniedError);
|
|
90
|
+
}
|
|
91
|
+
static isError(error) {
|
|
92
|
+
return Error.isError(error) && error.name === "AuthorizationDeniedError";
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* An auth operation did not strictly exceed the newest timestamp in its stream.
|
|
97
|
+
*
|
|
98
|
+
* Terminal and asymmetric by design: no ordering rule can reconcile two replicas
|
|
99
|
+
* that each accepted an auth operation offline, because either order hands one
|
|
100
|
+
* authority the other never granted, so the replica ahead holds the arrival.
|
|
101
|
+
*/
|
|
102
|
+
var AuthTimestampNotMonotonicError = class AuthTimestampNotMonotonicError extends Error {
|
|
103
|
+
documentId;
|
|
104
|
+
branch;
|
|
105
|
+
timestampUtcMs;
|
|
106
|
+
newestTimestampUtcMs;
|
|
107
|
+
constructor(documentId, branch, timestampUtcMs, newestTimestampUtcMs) {
|
|
108
|
+
super(`Auth timestamp not monotonic: ${timestampUtcMs} does not exceed ${newestTimestampUtcMs} in the auth stream of document ${documentId} on branch ${branch}`);
|
|
109
|
+
this.name = "AuthTimestampNotMonotonicError";
|
|
110
|
+
this.documentId = documentId;
|
|
111
|
+
this.branch = branch;
|
|
112
|
+
this.timestampUtcMs = timestampUtcMs;
|
|
113
|
+
this.newestTimestampUtcMs = newestTimestampUtcMs;
|
|
114
|
+
Error.captureStackTrace(this, AuthTimestampNotMonotonicError);
|
|
115
|
+
}
|
|
116
|
+
static isError(error) {
|
|
117
|
+
return Error.isError(error) && error.name === "AuthTimestampNotMonotonicError";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* An operation or action carried a timestamp that is not an ISO-8601 UTC
|
|
122
|
+
* instant.
|
|
123
|
+
*
|
|
124
|
+
* Terminal rather than retryable: the value does not change between attempts,
|
|
125
|
+
* so a retry re-runs the whole job to fail identically. Quarantining, unlike a
|
|
126
|
+
* held auth operation — this is malformed data rather than two replicas
|
|
127
|
+
* disagreeing, and nothing further from that source should be trusted until it
|
|
128
|
+
* is looked at.
|
|
129
|
+
*/
|
|
130
|
+
var InvalidOperationTimestampError = class InvalidOperationTimestampError extends Error {
|
|
131
|
+
documentId;
|
|
132
|
+
scope;
|
|
133
|
+
timestampUtcMs;
|
|
134
|
+
constructor(documentId, scope, timestampUtcMs, context) {
|
|
135
|
+
super(`Invalid timestamp "${timestampUtcMs}" on ${context} in scope "${scope}" of document ${documentId}`);
|
|
136
|
+
this.name = "InvalidOperationTimestampError";
|
|
137
|
+
this.documentId = documentId;
|
|
138
|
+
this.scope = scope;
|
|
139
|
+
this.timestampUtcMs = timestampUtcMs;
|
|
140
|
+
Error.captureStackTrace(this, InvalidOperationTimestampError);
|
|
141
|
+
}
|
|
142
|
+
static isError(error) {
|
|
143
|
+
return Error.isError(error) && error.name === "InvalidOperationTimestampError";
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* A load would move more operations than the bound allows, indicating a real
|
|
148
|
+
* divergence between local and incoming history. Counts only first-time moves,
|
|
149
|
+
* so a re-evaluation pass's re-appends do not make busy documents
|
|
150
|
+
* revocation-proof. Terminal: the condition is deterministic.
|
|
151
|
+
*/
|
|
152
|
+
var ExcessiveReshuffleError = class ExcessiveReshuffleError extends Error {
|
|
153
|
+
documentId;
|
|
154
|
+
scope;
|
|
155
|
+
count;
|
|
156
|
+
threshold;
|
|
157
|
+
constructor(documentId, scope, count, threshold) {
|
|
158
|
+
super(`Excessive reshuffle detected: ${count} operations in scope "${scope}" of document ${documentId} exceeds the threshold of ${threshold}. This indicates a significant divergence between local and incoming operations.`);
|
|
159
|
+
this.name = "ExcessiveReshuffleError";
|
|
160
|
+
this.documentId = documentId;
|
|
161
|
+
this.scope = scope;
|
|
162
|
+
this.count = count;
|
|
163
|
+
this.threshold = threshold;
|
|
164
|
+
Error.captureStackTrace(this, ExcessiveReshuffleError);
|
|
165
|
+
}
|
|
166
|
+
static isError(error) {
|
|
167
|
+
return Error.isError(error) && error.name === "ExcessiveReshuffleError";
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
75
171
|
* Error thrown when an operation has an invalid signature.
|
|
76
172
|
*/
|
|
77
173
|
var InvalidSignatureError = class InvalidSignatureError extends Error {
|
|
@@ -86,6 +182,28 @@ var InvalidSignatureError = class InvalidSignatureError extends Error {
|
|
|
86
182
|
}
|
|
87
183
|
};
|
|
88
184
|
/**
|
|
185
|
+
* An UPGRADE_DOCUMENT action's preconditions (fromVersion and the per-scope
|
|
186
|
+
* revision snapshot) did not match the document state the executor loaded.
|
|
187
|
+
*
|
|
188
|
+
* Terminal rather than retryable: the action carries the client's snapshot,
|
|
189
|
+
* which stays stale no matter how often the job re-runs. The client is
|
|
190
|
+
* expected to re-read the document and submit a fresh action instead.
|
|
191
|
+
*/
|
|
192
|
+
var UpgradePreconditionFailedError = class UpgradePreconditionFailedError extends Error {
|
|
193
|
+
documentId;
|
|
194
|
+
detail;
|
|
195
|
+
constructor(documentId, detail) {
|
|
196
|
+
super(`Upgrade precondition failed for document ${documentId}: ${detail}`);
|
|
197
|
+
this.name = "UpgradePreconditionFailedError";
|
|
198
|
+
this.documentId = documentId;
|
|
199
|
+
this.detail = detail;
|
|
200
|
+
Error.captureStackTrace(this, UpgradePreconditionFailedError);
|
|
201
|
+
}
|
|
202
|
+
static isError(error) {
|
|
203
|
+
return Error.isError(error) && error.name === "UpgradePreconditionFailedError";
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
89
207
|
* Error thrown when a document is not found (no operations exist for the document ID).
|
|
90
208
|
*/
|
|
91
209
|
var DocumentNotFoundError = class DocumentNotFoundError extends Error {
|
|
@@ -100,122 +218,413 @@ var DocumentNotFoundError = class DocumentNotFoundError extends Error {
|
|
|
100
218
|
return Error.isError(error) && error.name === "DocumentNotFoundError";
|
|
101
219
|
}
|
|
102
220
|
};
|
|
103
|
-
//#endregion
|
|
104
|
-
//#region src/registry/errors.ts
|
|
105
221
|
/**
|
|
106
|
-
*
|
|
222
|
+
* An authorization preflight was asked for while the reactor's decision model
|
|
223
|
+
* is off, so there is no model to answer from.
|
|
224
|
+
*
|
|
225
|
+
* Thrown rather than answered from the legacy host-side permission tables. The
|
|
226
|
+
* two systems do not compose: the tables record which addresses a host lets
|
|
227
|
+
* near a drive, the policy records what a document's own grants permit, and an
|
|
228
|
+
* answer stitched from both would report an admission verdict neither system
|
|
229
|
+
* would reach. A caller that cannot get a prediction disables nothing, which
|
|
230
|
+
* leaves the submit path -- and its real gate -- as the only authority.
|
|
231
|
+
*
|
|
232
|
+
* Detection is by `name`, not `instanceof`: the SharedWorker RPC boundary
|
|
233
|
+
* rebuilds a thrown error from `{ name, message, stack, cause }` alone
|
|
234
|
+
* (`reactor-browser/src/rpc/error-info.ts`), so the class identity and any
|
|
235
|
+
* custom field are lost in transit. This error therefore carries no fields.
|
|
107
236
|
*/
|
|
108
|
-
var
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
super(`Document model module not found for type: ${documentType}${versionSuffix}`);
|
|
114
|
-
this.name = "ModuleNotFoundError";
|
|
115
|
-
this.documentType = documentType;
|
|
116
|
-
this.requestedVersion = version;
|
|
237
|
+
var AuthEnforcementDisabledError = class AuthEnforcementDisabledError extends Error {
|
|
238
|
+
constructor() {
|
|
239
|
+
super("Authorization evaluation requires the authEnforcement feature flag; this reactor holds no decision model, and the legacy host-table permission system cannot answer for one");
|
|
240
|
+
this.name = "AuthEnforcementDisabledError";
|
|
241
|
+
Error.captureStackTrace(this, AuthEnforcementDisabledError);
|
|
117
242
|
}
|
|
118
243
|
static isError(error) {
|
|
119
|
-
return Error.isError(error) && error.name === "
|
|
244
|
+
return Error.isError(error) && error.name === "AuthEnforcementDisabledError";
|
|
120
245
|
}
|
|
121
246
|
};
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/decision/build-decision-model.ts
|
|
122
249
|
/**
|
|
123
|
-
*
|
|
250
|
+
* Reads each projection's stream through the supplied reader, recording the
|
|
251
|
+
* revision observed. Static projections resolve first; derived projections
|
|
252
|
+
* see only those and contribute a map from document id to state. Each
|
|
253
|
+
* distinct stream is read once and yields one append condition entry.
|
|
124
254
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
255
|
+
async function buildDecisionModel(reader, definition, target, signal) {
|
|
256
|
+
const decisionModel = definition(target);
|
|
257
|
+
const projections = Object.entries(decisionModel.projections);
|
|
258
|
+
const reads = /* @__PURE__ */ new Map();
|
|
259
|
+
const model = {};
|
|
260
|
+
for (const [key, projection] of projections) {
|
|
261
|
+
if (typeof projection.query === "function") continue;
|
|
262
|
+
model[key] = (await readStream(reader, projection.query, reads, signal)).state;
|
|
263
|
+
}
|
|
264
|
+
const staticModel = { ...model };
|
|
265
|
+
for (const [key, projection] of projections) {
|
|
266
|
+
if (typeof projection.query !== "function") continue;
|
|
267
|
+
const queries = projection.query(staticModel);
|
|
268
|
+
const value = {};
|
|
269
|
+
for (const query of queries) {
|
|
270
|
+
let read;
|
|
271
|
+
try {
|
|
272
|
+
read = await readStream(reader, query, reads, signal);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (error instanceof DocumentNotFoundError) {
|
|
275
|
+
recordEmptyStream(query, reads);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
value[query.documentId] = read.state;
|
|
281
|
+
}
|
|
282
|
+
model[key] = value;
|
|
133
283
|
}
|
|
134
|
-
|
|
284
|
+
return {
|
|
285
|
+
model,
|
|
286
|
+
appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** Guards a stream that holds nothing yet: any operation appearing is growth. */
|
|
290
|
+
function recordEmptyStream(query, reads) {
|
|
291
|
+
const key = `${query.documentId}:${query.scope}:${query.branch}`;
|
|
292
|
+
if (reads.has(key)) return;
|
|
293
|
+
reads.set(key, {
|
|
294
|
+
state: void 0,
|
|
295
|
+
stream: {
|
|
296
|
+
documentId: query.documentId,
|
|
297
|
+
scope: query.scope,
|
|
298
|
+
branch: query.branch,
|
|
299
|
+
revision: -1
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async function readStream(reader, query, reads, signal) {
|
|
304
|
+
const key = `${query.documentId}:${query.scope}:${query.branch}`;
|
|
305
|
+
const existing = reads.get(key);
|
|
306
|
+
if (existing) return existing;
|
|
307
|
+
const document = await reader.getState(query.documentId, query.scope, query.branch, void 0, signal);
|
|
308
|
+
const read = {
|
|
309
|
+
state: document.state[query.scope],
|
|
310
|
+
stream: {
|
|
311
|
+
documentId: query.documentId,
|
|
312
|
+
scope: query.scope,
|
|
313
|
+
branch: query.branch,
|
|
314
|
+
revision: observedRevision(document, query.scope)
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
reads.set(key, read);
|
|
318
|
+
return read;
|
|
319
|
+
}
|
|
135
320
|
/**
|
|
136
|
-
*
|
|
321
|
+
* The highest operation index the document reflects for the scope, or -1 if
|
|
322
|
+
* empty. `header.revision` is authoritative, not the rebuilt operation list.
|
|
137
323
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
324
|
+
function observedRevision(document, scope) {
|
|
325
|
+
if (scope in document.header.revision) return document.header.revision[scope] - 1;
|
|
326
|
+
if (scope in document.operations) {
|
|
327
|
+
const operations = document.operations[scope];
|
|
328
|
+
if (operations.length > 0) return operations[operations.length - 1].index;
|
|
329
|
+
}
|
|
330
|
+
if (!(scope in document.header.revision)) return -1;
|
|
331
|
+
return document.header.revision[scope] - 1;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The projections whose queries depend on folded state. A positional walk
|
|
335
|
+
* resolves their streams through `queryOverHistory`; a projection without one
|
|
336
|
+
* contributes no streams to a walk.
|
|
337
|
+
*/
|
|
338
|
+
function derivedReadSet(definition) {
|
|
339
|
+
const projections = [];
|
|
340
|
+
for (const [name, projection] of Object.entries(definition.projections)) {
|
|
341
|
+
if (typeof projection.query !== "function") continue;
|
|
342
|
+
projections.push({
|
|
343
|
+
name,
|
|
344
|
+
decidingActions: projection.decidingActions,
|
|
345
|
+
apply: projection.apply,
|
|
346
|
+
queryOverHistory: projection.queryOverHistory
|
|
347
|
+
});
|
|
142
348
|
}
|
|
143
|
-
|
|
349
|
+
return projections;
|
|
350
|
+
}
|
|
144
351
|
/**
|
|
145
|
-
*
|
|
352
|
+
* The streams a model reads whose queries are known before it is built. A
|
|
353
|
+
* derived query needs the statically-queried projections first, so it is not
|
|
354
|
+
* included here.
|
|
146
355
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
356
|
+
function staticReadSet(definition) {
|
|
357
|
+
const streams = [];
|
|
358
|
+
for (const [name, projection] of Object.entries(definition.projections)) {
|
|
359
|
+
if (typeof projection.query === "function") continue;
|
|
360
|
+
streams.push({
|
|
361
|
+
name,
|
|
362
|
+
query: projection.query,
|
|
363
|
+
decidingActions: projection.decidingActions,
|
|
364
|
+
apply: projection.apply
|
|
365
|
+
});
|
|
151
366
|
}
|
|
152
|
-
|
|
153
|
-
|
|
367
|
+
return streams;
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/decision/auth-decision-model.ts
|
|
371
|
+
function refusalReason(refusal) {
|
|
372
|
+
switch (refusal) {
|
|
373
|
+
case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
|
|
374
|
+
case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
|
|
375
|
+
case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
|
|
154
376
|
}
|
|
155
|
-
}
|
|
377
|
+
}
|
|
378
|
+
function decideAuthModel(model, subject, request, groups, conditions) {
|
|
379
|
+
if (request.verb === "execute" && model.document.isDeleted) return {
|
|
380
|
+
decision: "deny",
|
|
381
|
+
reason: DOCUMENT_DELETED_REASON
|
|
382
|
+
};
|
|
383
|
+
const evaluation = evaluate(model.auth, subject, request, groups, conditions);
|
|
384
|
+
if (evaluation.decision === "allow") return { decision: "allow" };
|
|
385
|
+
return {
|
|
386
|
+
decision: "deny",
|
|
387
|
+
reason: refusalReason(evaluation.refusal)
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function documentProjection(target) {
|
|
391
|
+
return {
|
|
392
|
+
decidingActions: ["DELETE_DOCUMENT"],
|
|
393
|
+
apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
|
|
394
|
+
...document,
|
|
395
|
+
state: { ...document.state }
|
|
396
|
+
}, operation.action) : document,
|
|
397
|
+
query: {
|
|
398
|
+
documentId: target.documentId,
|
|
399
|
+
branch: target.branch,
|
|
400
|
+
scope: "document"
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function authProjection(target) {
|
|
405
|
+
return {
|
|
406
|
+
decidingActions: [...AUTH_ACTION_TYPES],
|
|
407
|
+
apply: (document, operation) => applyAuthAction(document, operation.action),
|
|
408
|
+
query: {
|
|
409
|
+
documentId: target.documentId,
|
|
410
|
+
branch: target.branch,
|
|
411
|
+
scope: "auth"
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** This decision model uses both the document and the auth streams. */
|
|
416
|
+
function authDecisionModel(target) {
|
|
417
|
+
return {
|
|
418
|
+
projections: {
|
|
419
|
+
document: documentProjection(target),
|
|
420
|
+
auth: authProjection(target)
|
|
421
|
+
},
|
|
422
|
+
evaluatesScope() {
|
|
423
|
+
return true;
|
|
424
|
+
},
|
|
425
|
+
decide(model, subject, request) {
|
|
426
|
+
return decideAuthModel(model, subject, request);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
156
430
|
/**
|
|
157
|
-
*
|
|
431
|
+
* Folds one group-stream operation with the registered group model's reducer.
|
|
432
|
+
* A reactor without the module registered folds nothing, so the member list
|
|
433
|
+
* stays as read and a missing reducer never widens access.
|
|
158
434
|
*/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
435
|
+
function applyGroupOperation(registry, document, operation) {
|
|
436
|
+
let reducer;
|
|
437
|
+
try {
|
|
438
|
+
reducer = registry.getModule(groupDocumentType).reducer;
|
|
439
|
+
} catch {
|
|
440
|
+
return document;
|
|
163
441
|
}
|
|
164
|
-
|
|
442
|
+
return reducer(document, operation.action);
|
|
443
|
+
}
|
|
165
444
|
/**
|
|
166
|
-
*
|
|
445
|
+
* Folds one evaluated-scope operation with the reducer registered for the
|
|
446
|
+
* document's own type, at the document's stamped version. A reactor without
|
|
447
|
+
* that module folds nothing, so conditions read the base state and an
|
|
448
|
+
* unresolvable reducer never widens access.
|
|
167
449
|
*/
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
450
|
+
function applyModelOperation(registry, document, operation) {
|
|
451
|
+
let reducer;
|
|
452
|
+
try {
|
|
453
|
+
const version = normalizeDocumentModelVersion(document.state.document?.version);
|
|
454
|
+
reducer = registry.getModule(document.header.documentType, version).reducer;
|
|
455
|
+
} catch {
|
|
456
|
+
return document;
|
|
172
457
|
}
|
|
173
|
-
|
|
458
|
+
return reducer(document, operation.action);
|
|
459
|
+
}
|
|
174
460
|
/**
|
|
175
|
-
*
|
|
461
|
+
* The auth model extended with a derived groups projection: the streams it
|
|
462
|
+
* reads are the group documents the folded grant list names, so adding a
|
|
463
|
+
* grant that names a new group pulls that group's stream into the read-set.
|
|
464
|
+
* Group queries pin the main branch, because a group's member list lives on
|
|
465
|
+
* its main branch no matter which branch the referencing document is on.
|
|
176
466
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const missing = [];
|
|
198
|
-
for (const docId of documentIds) {
|
|
199
|
-
const cached = this.cache.get(docId);
|
|
200
|
-
if (cached !== void 0) result[docId] = cached;
|
|
201
|
-
else missing.push(docId);
|
|
467
|
+
function groupsProjection(registry) {
|
|
468
|
+
return {
|
|
469
|
+
decidingActions: [...groupMembershipActionTypes],
|
|
470
|
+
apply: (document, operation) => applyGroupOperation(registry, document, operation),
|
|
471
|
+
query: (model) => referencedGroupIds(model.auth?.grants ?? []).map((id) => ({
|
|
472
|
+
documentId: id,
|
|
473
|
+
branch: "main",
|
|
474
|
+
scope: "global"
|
|
475
|
+
})),
|
|
476
|
+
queryOverHistory: (reads) => {
|
|
477
|
+
const ids = [];
|
|
478
|
+
for (const read of reads) {
|
|
479
|
+
if (read.name !== "auth") continue;
|
|
480
|
+
for (const operation of read.operations) for (const id of mentionedGroupIds(operation.action)) if (!ids.includes(id)) ids.push(id);
|
|
481
|
+
}
|
|
482
|
+
return ids.map((id) => ({
|
|
483
|
+
documentId: id,
|
|
484
|
+
branch: "main",
|
|
485
|
+
scope: "global"
|
|
486
|
+
}));
|
|
202
487
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
function authGroupsDecisionModel(registry) {
|
|
491
|
+
return (target) => ({
|
|
492
|
+
projections: {
|
|
493
|
+
document: documentProjection(target),
|
|
494
|
+
auth: authProjection(target),
|
|
495
|
+
groups: groupsProjection(registry)
|
|
496
|
+
},
|
|
497
|
+
evaluatesScope() {
|
|
498
|
+
return true;
|
|
499
|
+
},
|
|
500
|
+
decide(model, subject, request) {
|
|
501
|
+
return decideAuthModel(model, subject, request, model.groups);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The groups model with conditions live: decide hands the executing scope's
|
|
507
|
+
* state and the action input through to the evaluator, so `where` clauses
|
|
508
|
+
* and { match } principals apply. The model folds the evaluated scope during
|
|
509
|
+
* a positional walk, so a condition reads the state as it stood at each
|
|
510
|
+
* operation's position.
|
|
511
|
+
*/
|
|
512
|
+
function authConditionsDecisionModel(registry) {
|
|
513
|
+
return (target) => ({
|
|
514
|
+
projections: {
|
|
515
|
+
document: documentProjection(target),
|
|
516
|
+
auth: authProjection(target),
|
|
517
|
+
groups: groupsProjection(registry)
|
|
518
|
+
},
|
|
519
|
+
foldEvaluatedScope: (document, operation) => applyModelOperation(registry, document, operation),
|
|
520
|
+
evaluatesScope() {
|
|
521
|
+
return true;
|
|
522
|
+
},
|
|
523
|
+
decide(model, subject, request, ctx) {
|
|
524
|
+
return decideAuthModel(model, subject, request, model.groups, {
|
|
525
|
+
scopeState: ctx.scopeState,
|
|
526
|
+
actionInput: ctx.actionInput
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/decision/document-decision-model.ts
|
|
533
|
+
/**
|
|
534
|
+
* The simplest decision model: one projection over the document scope, which
|
|
535
|
+
* rejects on a deleted document.
|
|
536
|
+
*/
|
|
537
|
+
function documentDecisionModel(target) {
|
|
538
|
+
return {
|
|
539
|
+
projections: { document: {
|
|
540
|
+
decidingActions: ["DELETE_DOCUMENT"],
|
|
541
|
+
apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
|
|
542
|
+
...document,
|
|
543
|
+
state: { ...document.state }
|
|
544
|
+
}, operation.action) : document,
|
|
545
|
+
query: {
|
|
546
|
+
documentId: target.documentId,
|
|
547
|
+
branch: target.branch,
|
|
548
|
+
scope: "document"
|
|
209
549
|
}
|
|
550
|
+
} },
|
|
551
|
+
evaluatesScope() {
|
|
552
|
+
return true;
|
|
553
|
+
},
|
|
554
|
+
decide(model, subject, request) {
|
|
555
|
+
return request.verb === "execute" && model.document.isDeleted ? {
|
|
556
|
+
decision: "deny",
|
|
557
|
+
reason: DOCUMENT_DELETED_REASON
|
|
558
|
+
} : { decision: "allow" };
|
|
210
559
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/decision/registered-model.ts
|
|
564
|
+
/**
|
|
565
|
+
* Builds the model at the stream heads and decides one request against it. The
|
|
566
|
+
* append condition it returns is the read-set the store enforces at write time.
|
|
567
|
+
*
|
|
568
|
+
* With `conditions` supplied, the executing scope's state is read at the head
|
|
569
|
+
* for `doc.<scope>.*` paths. That read carries no append-condition entry of
|
|
570
|
+
* its own: the written stream's expected-revision check already refuses a
|
|
571
|
+
* write whose scope grew between the read and the append.
|
|
572
|
+
*/
|
|
573
|
+
async function decideAtHead(model, cache, target, subject, request, signal, conditions) {
|
|
574
|
+
const built = await buildDecisionModel(cache, model, target, signal);
|
|
575
|
+
let scopeState;
|
|
576
|
+
if (conditions !== void 0) scopeState = (await cache.getState(target.documentId, request.scope, target.branch, void 0, signal)).state[request.scope];
|
|
577
|
+
return {
|
|
578
|
+
evaluation: model(target).decide(built.model, subject, request, {
|
|
579
|
+
scopeState,
|
|
580
|
+
actionInput: conditions?.actionInput
|
|
581
|
+
}),
|
|
582
|
+
appendCondition: built.appendCondition,
|
|
583
|
+
documentVersion: built.model.document.version,
|
|
584
|
+
deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The model this reactor enforces. With `authEnforcement` off the auth scope is
|
|
589
|
+
* absent from every append condition and no load walks it; with `authGroups`
|
|
590
|
+
* on, the group documents the grant list names join the read-set and the
|
|
591
|
+
* registry supplies the reducer that folds them.
|
|
592
|
+
*/
|
|
593
|
+
function selectDecisionModel(flags, registry) {
|
|
594
|
+
if (flags.authConditions) return authConditionsDecisionModel(registry);
|
|
595
|
+
if (flags.authGroups) return authGroupsDecisionModel(registry);
|
|
596
|
+
return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
|
|
597
|
+
}
|
|
217
598
|
//#endregion
|
|
218
599
|
//#region src/executor/util.ts
|
|
600
|
+
/** Actions the reactor reduces itself, onto the document scope. */
|
|
601
|
+
const DOCUMENT_SCOPE_ACTIONS = new Set([
|
|
602
|
+
"CREATE_DOCUMENT",
|
|
603
|
+
"DELETE_DOCUMENT",
|
|
604
|
+
"UPGRADE_DOCUMENT",
|
|
605
|
+
"ADD_RELATIONSHIP",
|
|
606
|
+
"REMOVE_RELATIONSHIP",
|
|
607
|
+
"UPDATE_RELATIONSHIP"
|
|
608
|
+
]);
|
|
609
|
+
/**
|
|
610
|
+
* `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
|
|
611
|
+
* so building a decision model would throw and defer the job forever.
|
|
612
|
+
*/
|
|
613
|
+
const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
|
|
614
|
+
/**
|
|
615
|
+
* The document a document-scope action writes to, which is not always the job's
|
|
616
|
+
* own document: delete and upgrade name it in `input.documentId`, and the
|
|
617
|
+
* relationship actions in `input.sourceId`. `execute` only checks that a batch
|
|
618
|
+
* shares one scope, so a caller can submit an action whose target is a document
|
|
619
|
+
* other than the one the job is keyed by. The policy gate has to follow the
|
|
620
|
+
* action rather than the job, or it decides against a policy the caller may
|
|
621
|
+
* control instead of the one guarding the write.
|
|
622
|
+
*/
|
|
623
|
+
function targetDocumentId(action, fallback) {
|
|
624
|
+
const input = action.input;
|
|
625
|
+
if (action.type === "ADD_RELATIONSHIP" || action.type === "REMOVE_RELATIONSHIP" || action.type === "UPDATE_RELATIONSHIP") return typeof input?.sourceId === "string" && input.sourceId.length > 0 ? input.sourceId : fallback;
|
|
626
|
+
return typeof input?.documentId === "string" && input.documentId.length > 0 ? input.documentId : fallback;
|
|
627
|
+
}
|
|
219
628
|
/**
|
|
220
629
|
* Creates a PHDocument from a CREATE_DOCUMENT action input.
|
|
221
630
|
* Reconstructs the document header and initializes the base state.
|
|
@@ -341,18 +750,201 @@ function buildSuccessResult(job, operation, documentId, documentType, resultingS
|
|
|
341
750
|
resultingState,
|
|
342
751
|
ordinal: 0
|
|
343
752
|
}
|
|
344
|
-
}],
|
|
345
|
-
duration: Date.now() - startTime
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
function buildErrorResult(job, error, startTime) {
|
|
349
|
-
return {
|
|
350
|
-
job,
|
|
351
|
-
success: false,
|
|
352
|
-
error,
|
|
353
|
-
duration: Date.now() - startTime
|
|
354
|
-
};
|
|
355
|
-
}
|
|
753
|
+
}],
|
|
754
|
+
duration: Date.now() - startTime
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function buildErrorResult(job, error, startTime) {
|
|
758
|
+
return {
|
|
759
|
+
job,
|
|
760
|
+
success: false,
|
|
761
|
+
error,
|
|
762
|
+
duration: Date.now() - startTime
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* The error a refusal surfaces as. Both classes are already terminal in the job
|
|
767
|
+
* result handler, so a refusal never burns a retry.
|
|
768
|
+
*/
|
|
769
|
+
function refusalError(reason, documentId, deletedAtUtcIso, action) {
|
|
770
|
+
if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
|
|
771
|
+
return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Whether this operation is part of the document's creation. The create and the
|
|
775
|
+
* upgrade from version zero hold the first two indexes for the life of the
|
|
776
|
+
* document, so a reshuffle has to leave them where they are.
|
|
777
|
+
*/
|
|
778
|
+
function isGenesisOperation(operation) {
|
|
779
|
+
if (operation.action.type === "CREATE_DOCUMENT") return true;
|
|
780
|
+
if (operation.action.type !== "UPGRADE_DOCUMENT") return false;
|
|
781
|
+
return operation.action.input.fromVersion === 0;
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/registry/errors.ts
|
|
785
|
+
/**
|
|
786
|
+
* Error thrown when a document model module is not found in the registry.
|
|
787
|
+
*/
|
|
788
|
+
var ModuleNotFoundError = class extends Error {
|
|
789
|
+
documentType;
|
|
790
|
+
requestedVersion;
|
|
791
|
+
constructor(documentType, version) {
|
|
792
|
+
const versionSuffix = version !== void 0 ? ` version ${version}` : "";
|
|
793
|
+
super(`Document model module not found for type: ${documentType}${versionSuffix}`);
|
|
794
|
+
this.name = "ModuleNotFoundError";
|
|
795
|
+
this.documentType = documentType;
|
|
796
|
+
this.requestedVersion = version;
|
|
797
|
+
}
|
|
798
|
+
static isError(error) {
|
|
799
|
+
return Error.isError(error) && error.name === "ModuleNotFoundError";
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
/**
|
|
803
|
+
* Error thrown when attempting to register a module that already exists.
|
|
804
|
+
*/
|
|
805
|
+
var DuplicateModuleError = class extends Error {
|
|
806
|
+
constructor(documentType, version) {
|
|
807
|
+
const versionSuffix = version !== void 0 ? ` (version ${version})` : "";
|
|
808
|
+
super(`Document model module already registered for type: ${documentType}${versionSuffix}`);
|
|
809
|
+
this.name = "DuplicateModuleError";
|
|
810
|
+
}
|
|
811
|
+
static isError(error) {
|
|
812
|
+
return Error.isError(error) && error.name === "DuplicateModuleError";
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
/**
|
|
816
|
+
* Error thrown when a module is invalid or malformed.
|
|
817
|
+
*/
|
|
818
|
+
var InvalidModuleError = class extends Error {
|
|
819
|
+
constructor(message) {
|
|
820
|
+
super(`Invalid document model module: ${message}`);
|
|
821
|
+
this.name = "InvalidModuleError";
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
/**
|
|
825
|
+
* Error thrown when attempting to register an upgrade manifest that already exists.
|
|
826
|
+
*/
|
|
827
|
+
var DuplicateManifestError = class extends Error {
|
|
828
|
+
constructor(documentType) {
|
|
829
|
+
super(`Upgrade manifest already registered for type: ${documentType}`);
|
|
830
|
+
this.name = "DuplicateManifestError";
|
|
831
|
+
}
|
|
832
|
+
static isError(error) {
|
|
833
|
+
return Error.isError(error) && error.name === "DuplicateManifestError";
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
/**
|
|
837
|
+
* Error thrown when an upgrade manifest is not found.
|
|
838
|
+
*/
|
|
839
|
+
var ManifestNotFoundError = class extends Error {
|
|
840
|
+
constructor(documentType) {
|
|
841
|
+
super(`Upgrade manifest not found for type: ${documentType}`);
|
|
842
|
+
this.name = "ManifestNotFoundError";
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
/**
|
|
846
|
+
* Error thrown when a required upgrade transition is missing from the manifest.
|
|
847
|
+
*/
|
|
848
|
+
var MissingUpgradeTransitionError = class extends Error {
|
|
849
|
+
constructor(documentType, fromVersion, toVersion) {
|
|
850
|
+
super(`Missing upgrade transition for ${documentType}: v${fromVersion} to v${toVersion}`);
|
|
851
|
+
this.name = "MissingUpgradeTransitionError";
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Error thrown when getUpgradeReducer is called with a non-single-step version increment.
|
|
856
|
+
*/
|
|
857
|
+
var InvalidUpgradeStepError = class extends Error {
|
|
858
|
+
constructor(documentType, fromVersion, toVersion) {
|
|
859
|
+
super(`Invalid upgrade step for ${documentType}: must be single version increment, got v${fromVersion} to v${toVersion}`);
|
|
860
|
+
this.name = "InvalidUpgradeStepError";
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/storage/interfaces.ts
|
|
865
|
+
/**
|
|
866
|
+
* Thrown when an operation with the same identity already exists in the store.
|
|
867
|
+
*/
|
|
868
|
+
var DuplicateOperationError = class extends Error {
|
|
869
|
+
constructor(description) {
|
|
870
|
+
super(`Duplicate operation: ${description}`);
|
|
871
|
+
this.name = "DuplicateOperationError";
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
/**
|
|
875
|
+
* Thrown when a concurrent write conflict is detected during an atomic apply.
|
|
876
|
+
*/
|
|
877
|
+
var OptimisticLockError = class extends Error {
|
|
878
|
+
constructor(message) {
|
|
879
|
+
super(message);
|
|
880
|
+
this.name = "OptimisticLockError";
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Thrown when the caller-provided revision does not match the current
|
|
885
|
+
* stored revision, indicating a stale read.
|
|
886
|
+
*/
|
|
887
|
+
var RevisionMismatchError = class extends Error {
|
|
888
|
+
constructor(expected, actual) {
|
|
889
|
+
super(`Revision mismatch: expected ${expected}, got ${actual}`);
|
|
890
|
+
this.name = "RevisionMismatchError";
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
/** Error history keeps messages, not classes, so failures match by prefix. */
|
|
894
|
+
const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
|
|
895
|
+
/**
|
|
896
|
+
* A read-set stream grew before the append committed. A concurrency
|
|
897
|
+
* conflict, not a fault: the caller retries against the new stream heads.
|
|
898
|
+
*/
|
|
899
|
+
var AppendConditionFailedError = class extends Error {
|
|
900
|
+
constructor(condition) {
|
|
901
|
+
const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
|
|
902
|
+
super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
|
|
903
|
+
this.condition = condition;
|
|
904
|
+
this.name = "AppendConditionFailedError";
|
|
905
|
+
}
|
|
906
|
+
static isError(error) {
|
|
907
|
+
return Error.isError(error) && error.name === "AppendConditionFailedError";
|
|
908
|
+
}
|
|
909
|
+
/** True when a recorded error message is an append-condition failure. */
|
|
910
|
+
static isFailureMessage(message) {
|
|
911
|
+
return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/cache/collection-membership-cache.ts
|
|
916
|
+
var CollectionMembershipCache = class CollectionMembershipCache {
|
|
917
|
+
cache = /* @__PURE__ */ new Map();
|
|
918
|
+
constructor(operationIndex) {
|
|
919
|
+
this.operationIndex = operationIndex;
|
|
920
|
+
}
|
|
921
|
+
withScopedIndex(operationIndex) {
|
|
922
|
+
const scoped = new CollectionMembershipCache(operationIndex);
|
|
923
|
+
scoped.cache = this.cache;
|
|
924
|
+
return scoped;
|
|
925
|
+
}
|
|
926
|
+
async getCollectionsForDocuments(documentIds) {
|
|
927
|
+
const result = {};
|
|
928
|
+
const missing = [];
|
|
929
|
+
for (const docId of documentIds) {
|
|
930
|
+
const cached = this.cache.get(docId);
|
|
931
|
+
if (cached !== void 0) result[docId] = cached;
|
|
932
|
+
else missing.push(docId);
|
|
933
|
+
}
|
|
934
|
+
if (missing.length > 0) {
|
|
935
|
+
const fromDb = await this.operationIndex.getCollectionsForDocuments(missing);
|
|
936
|
+
for (const docId of missing) {
|
|
937
|
+
const collections = fromDb[docId] ?? [];
|
|
938
|
+
result[docId] = collections;
|
|
939
|
+
this.cache.set(docId, collections);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
invalidate(documentId) {
|
|
945
|
+
this.cache.delete(documentId);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
356
948
|
//#endregion
|
|
357
949
|
//#region src/cache/lru/lru-tracker.ts
|
|
358
950
|
var LRUNode = class {
|
|
@@ -543,6 +1135,7 @@ var KyselyOperationIndexTxn = class {
|
|
|
543
1135
|
collections = [];
|
|
544
1136
|
collectionMemberships = [];
|
|
545
1137
|
collectionRemovals = [];
|
|
1138
|
+
groupReferences = [];
|
|
546
1139
|
operations = [];
|
|
547
1140
|
createCollection(collectionId) {
|
|
548
1141
|
this.collections.push(collectionId);
|
|
@@ -565,12 +1158,25 @@ var KyselyOperationIndexTxn = class {
|
|
|
565
1158
|
operationIndex: lastOpIndex
|
|
566
1159
|
});
|
|
567
1160
|
}
|
|
1161
|
+
recordGroupReferences(documentId, groupIds) {
|
|
1162
|
+
const lastOpIndex = this.operations.length - 1;
|
|
1163
|
+
if (lastOpIndex < 0) throw new Error("recordGroupReferences must be called after write() - no operations in transaction");
|
|
1164
|
+
if (groupIds.length === 0) return;
|
|
1165
|
+
this.groupReferences.push({
|
|
1166
|
+
documentId,
|
|
1167
|
+
groupIds,
|
|
1168
|
+
operationIndex: lastOpIndex
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
568
1171
|
write(operations) {
|
|
569
1172
|
this.operations.push(...operations);
|
|
570
1173
|
}
|
|
571
1174
|
getCollections() {
|
|
572
1175
|
return this.collections;
|
|
573
1176
|
}
|
|
1177
|
+
getGroupReferenceRecords() {
|
|
1178
|
+
return this.groupReferences;
|
|
1179
|
+
}
|
|
574
1180
|
getCollectionMembershipRecords() {
|
|
575
1181
|
return this.collectionMemberships;
|
|
576
1182
|
}
|
|
@@ -607,10 +1213,27 @@ var KyselyOperationIndex = class KyselyOperationIndex {
|
|
|
607
1213
|
});
|
|
608
1214
|
return resultOrdinals;
|
|
609
1215
|
}
|
|
1216
|
+
/**
|
|
1217
|
+
* A policy-driven join: keeps the earliest join so a rediscovered reference
|
|
1218
|
+
* never shrinks a backfill window remotes already rely on, and reopens a
|
|
1219
|
+
* closed membership because a policy reference is not a removable one.
|
|
1220
|
+
*/
|
|
1221
|
+
async joinKeepingEarliest(trx, documentId, collectionId, ordinal) {
|
|
1222
|
+
await trx.insertInto("document_collections").values({
|
|
1223
|
+
documentId,
|
|
1224
|
+
collectionId,
|
|
1225
|
+
joinedOrdinal: ordinal,
|
|
1226
|
+
leftOrdinal: null
|
|
1227
|
+
}).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
|
|
1228
|
+
joinedOrdinal: sql`LEAST("document_collections"."joinedOrdinal", EXCLUDED."joinedOrdinal")`,
|
|
1229
|
+
leftOrdinal: null
|
|
1230
|
+
})).execute();
|
|
1231
|
+
}
|
|
610
1232
|
async executeCommit(trx, kyselyTxn) {
|
|
611
1233
|
const collections = kyselyTxn.getCollections();
|
|
612
1234
|
const memberships = kyselyTxn.getCollectionMembershipRecords();
|
|
613
1235
|
const removals = kyselyTxn.getCollectionRemovals();
|
|
1236
|
+
const groupReferences = kyselyTxn.getGroupReferenceRecords();
|
|
614
1237
|
const operations = kyselyTxn.getOperations();
|
|
615
1238
|
if (collections.length > 0) {
|
|
616
1239
|
const collectionRows = collections.map((collectionId) => ({
|
|
@@ -634,6 +1257,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
|
|
|
634
1257
|
skip: op.skip,
|
|
635
1258
|
hash: op.hash,
|
|
636
1259
|
action: op.action,
|
|
1260
|
+
deniedReason: op.deniedReason ?? null,
|
|
637
1261
|
sourceRemote: op.sourceRemote
|
|
638
1262
|
}));
|
|
639
1263
|
operationOrdinals = (await trx.insertInto("operation_index_operations").values(operationRows).returning("ordinal").execute()).map((row) => row.ordinal);
|
|
@@ -649,13 +1273,28 @@ var KyselyOperationIndex = class KyselyOperationIndex {
|
|
|
649
1273
|
joinedOrdinal: BigInt(ordinal),
|
|
650
1274
|
leftOrdinal: null
|
|
651
1275
|
})).execute();
|
|
1276
|
+
const references = await trx.selectFrom("group_references").select("groupId").where("documentId", "=", m.documentId).execute();
|
|
1277
|
+
for (const { groupId } of references) await this.joinKeepingEarliest(trx, groupId, m.collectionId, BigInt(ordinal));
|
|
652
1278
|
}
|
|
653
1279
|
if (removals.length > 0) for (const r of removals) {
|
|
654
1280
|
const ordinal = operationOrdinals[r.operationIndex];
|
|
655
1281
|
await trx.updateTable("document_collections").set({ leftOrdinal: BigInt(ordinal) }).where("collectionId", "=", r.collectionId).where("documentId", "=", r.documentId).where("leftOrdinal", "is", null).execute();
|
|
656
1282
|
}
|
|
1283
|
+
if (groupReferences.length > 0) for (const record of groupReferences) {
|
|
1284
|
+
const ordinal = operationOrdinals[record.operationIndex];
|
|
1285
|
+
await trx.insertInto("group_references").values(record.groupIds.map((groupId) => ({
|
|
1286
|
+
documentId: record.documentId,
|
|
1287
|
+
groupId
|
|
1288
|
+
}))).onConflict((oc) => oc.doNothing()).execute();
|
|
1289
|
+
const rows = await trx.selectFrom("document_collections").select("collectionId").where("documentId", "=", record.documentId).execute();
|
|
1290
|
+
for (const groupId of record.groupIds) for (const { collectionId } of rows) await this.joinKeepingEarliest(trx, groupId, collectionId, BigInt(ordinal));
|
|
1291
|
+
}
|
|
657
1292
|
return operationOrdinals;
|
|
658
1293
|
}
|
|
1294
|
+
async getGroupReferencers(groupId, signal) {
|
|
1295
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1296
|
+
return (await this.queryExecutor.selectFrom("group_references").select("documentId").where("groupId", "=", groupId).orderBy("documentId").execute()).map((row) => row.documentId);
|
|
1297
|
+
}
|
|
659
1298
|
async find(collectionId, cursor, view, paging, signal) {
|
|
660
1299
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
661
1300
|
const outerCursor = cursor ?? -1;
|
|
@@ -765,6 +1404,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
|
|
|
765
1404
|
hash: row.hash,
|
|
766
1405
|
skip: row.skip,
|
|
767
1406
|
action: row.action,
|
|
1407
|
+
deniedReason: row.deniedReason ?? void 0,
|
|
768
1408
|
id: row.opId
|
|
769
1409
|
},
|
|
770
1410
|
context: {
|
|
@@ -788,6 +1428,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
|
|
|
788
1428
|
hash: row.hash,
|
|
789
1429
|
skip: row.skip,
|
|
790
1430
|
action: row.action,
|
|
1431
|
+
deniedReason: row.deniedReason ?? void 0,
|
|
791
1432
|
id: row.opId,
|
|
792
1433
|
sourceRemote: row.sourceRemote
|
|
793
1434
|
};
|
|
@@ -873,10 +1514,54 @@ var RingBuffer = class {
|
|
|
873
1514
|
}
|
|
874
1515
|
};
|
|
875
1516
|
//#endregion
|
|
1517
|
+
//#region src/cache/write-cache-types.ts
|
|
1518
|
+
/**
|
|
1519
|
+
* Where a snapshot sits in its stream.
|
|
1520
|
+
*
|
|
1521
|
+
* - `Head`: the newest revision of the stream when it was stored. Only these
|
|
1522
|
+
* can answer a read that asks for the head.
|
|
1523
|
+
* - `Historical`: state at an earlier revision. Usable as a starting point to
|
|
1524
|
+
* replay forward from, and as an answer to a read for that same revision.
|
|
1525
|
+
*/
|
|
1526
|
+
let SnapshotPosition = /* @__PURE__ */ function(SnapshotPosition) {
|
|
1527
|
+
SnapshotPosition["Head"] = "head";
|
|
1528
|
+
SnapshotPosition["Historical"] = "historical";
|
|
1529
|
+
return SnapshotPosition;
|
|
1530
|
+
}({});
|
|
1531
|
+
//#endregion
|
|
876
1532
|
//#region src/cache/kysely-write-cache.ts
|
|
1533
|
+
/**
|
|
1534
|
+
* The last operation index a keyframe's document reflects for the scope. A
|
|
1535
|
+
* keyframe only exists for a scope that has operations, so a missing entry
|
|
1536
|
+
* means the stored row is corrupt.
|
|
1537
|
+
*/
|
|
1538
|
+
function keyframeRevision(keyframe, documentId, scope) {
|
|
1539
|
+
const nextIndex = keyframe.document.header.revision[scope];
|
|
1540
|
+
if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
|
|
1541
|
+
return nextIndex - 1;
|
|
1542
|
+
}
|
|
877
1543
|
function extractModuleVersion(doc) {
|
|
878
1544
|
const v = doc.state.document.version;
|
|
879
|
-
return v
|
|
1545
|
+
return normalizeDocumentModelVersion(v);
|
|
1546
|
+
}
|
|
1547
|
+
/** The highest revision held, latest push winning a tie. */
|
|
1548
|
+
function highestRevision(snapshots) {
|
|
1549
|
+
let newest = void 0;
|
|
1550
|
+
for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
|
|
1551
|
+
return newest;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Copies a document far enough that the caller cannot write through it. Inside
|
|
1555
|
+
* this class, callers only ever replace whole fields on these four, so one
|
|
1556
|
+
* level each is enough.
|
|
1557
|
+
*/
|
|
1558
|
+
function copyDocument(document) {
|
|
1559
|
+
return {
|
|
1560
|
+
...document,
|
|
1561
|
+
header: { ...document.header },
|
|
1562
|
+
state: { ...document.state },
|
|
1563
|
+
operations: { ...document.operations }
|
|
1564
|
+
};
|
|
880
1565
|
}
|
|
881
1566
|
/**
|
|
882
1567
|
* In-memory write cache with keyframe persistence for PHDocuments.
|
|
@@ -956,6 +1641,8 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
956
1641
|
/**
|
|
957
1642
|
* Retrieves document state at a specific revision from cache or rebuilds it.
|
|
958
1643
|
*
|
|
1644
|
+
* Note: this returns a _shallow_ copy of the document.
|
|
1645
|
+
*
|
|
959
1646
|
* Cache hit path: Returns cached snapshot if available (O(1))
|
|
960
1647
|
* Warm miss path: Rebuilds from cached base revision + incremental ops
|
|
961
1648
|
* Cold miss path: Rebuilds from keyframe or from scratch using all operations
|
|
@@ -979,30 +1666,35 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
979
1666
|
if (stream) {
|
|
980
1667
|
const snapshots = stream.ringBuffer.getAll();
|
|
981
1668
|
if (targetRevision === void 0) {
|
|
982
|
-
|
|
983
|
-
|
|
1669
|
+
const newest = highestRevision(snapshots);
|
|
1670
|
+
if (newest?.position === SnapshotPosition.Head) {
|
|
1671
|
+
this.lruTracker.touch(streamKey);
|
|
1672
|
+
return copyDocument(newest.document);
|
|
1673
|
+
}
|
|
1674
|
+
if (newest) {
|
|
1675
|
+
const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
|
|
1676
|
+
this.store(documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, SnapshotPosition.Head);
|
|
984
1677
|
this.lruTracker.touch(streamKey);
|
|
985
|
-
return
|
|
1678
|
+
return document;
|
|
986
1679
|
}
|
|
987
1680
|
} else {
|
|
988
|
-
const exactMatch = snapshots.
|
|
1681
|
+
const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
|
|
989
1682
|
if (exactMatch) {
|
|
990
1683
|
this.lruTracker.touch(streamKey);
|
|
991
|
-
return exactMatch.document;
|
|
1684
|
+
return copyDocument(exactMatch.document);
|
|
992
1685
|
}
|
|
993
1686
|
const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
|
|
994
1687
|
if (newestOlder) {
|
|
995
1688
|
const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
|
|
996
|
-
this.
|
|
1689
|
+
this.store(documentId, scope, branch, targetRevision, document, SnapshotPosition.Historical);
|
|
997
1690
|
this.lruTracker.touch(streamKey);
|
|
998
1691
|
return document;
|
|
999
1692
|
}
|
|
1000
1693
|
}
|
|
1001
1694
|
}
|
|
1002
1695
|
const document = await this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
this.putState(documentId, scope, branch, revision, document);
|
|
1696
|
+
const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
|
|
1697
|
+
this.store(documentId, scope, branch, revision, document, targetRevision === void 0 ? SnapshotPosition.Head : SnapshotPosition.Historical);
|
|
1006
1698
|
return document;
|
|
1007
1699
|
}
|
|
1008
1700
|
/**
|
|
@@ -1025,16 +1717,20 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1025
1717
|
* @param document - The document to cache
|
|
1026
1718
|
* @throws {Error} If document serialization fails
|
|
1027
1719
|
*/
|
|
1028
|
-
putState(documentId, scope, branch, revision, document) {
|
|
1720
|
+
putState(documentId, scope, branch, revision, document, position) {
|
|
1721
|
+
this.store(documentId, scope, branch, revision, document, position);
|
|
1722
|
+
}
|
|
1723
|
+
store(documentId, scope, branch, revision, document, position) {
|
|
1029
1724
|
const streamKey = this.makeStreamKey(documentId, scope, branch);
|
|
1030
1725
|
const stream = this.getOrCreateStream(streamKey);
|
|
1031
1726
|
const snapshot = {
|
|
1032
1727
|
revision,
|
|
1033
1728
|
document: {
|
|
1034
|
-
...document,
|
|
1729
|
+
...copyDocument(document),
|
|
1035
1730
|
operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
|
|
1036
1731
|
clipboard: []
|
|
1037
|
-
}
|
|
1732
|
+
},
|
|
1733
|
+
position
|
|
1038
1734
|
};
|
|
1039
1735
|
stream.ringBuffer.push(snapshot);
|
|
1040
1736
|
if (this.isKeyframeRevision(revision)) this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
|
|
@@ -1102,64 +1798,102 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1102
1798
|
}
|
|
1103
1799
|
async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
|
|
1104
1800
|
if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
|
|
1105
|
-
|
|
1801
|
+
const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
|
|
1802
|
+
if (!keyframe) return;
|
|
1803
|
+
return {
|
|
1804
|
+
revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
|
|
1805
|
+
document: keyframe.document
|
|
1806
|
+
};
|
|
1106
1807
|
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Rebuilds a scope from a keyframe or from the whole operation history.
|
|
1810
|
+
*
|
|
1811
|
+
* The document scope is always rebuilt first, because it carries the type,
|
|
1812
|
+
* the upgrades and the deletion marker. Its version-changing upgrades are not
|
|
1813
|
+
* applied there though: an upgrade reducer must see the state the requested
|
|
1814
|
+
* scope has reached at that upgrade's boundary, so each one is held back and
|
|
1815
|
+
* applied when the replay below crosses the boundary that
|
|
1816
|
+
* resolveModuleVersionForOp derives from it. Upgrades whose boundary lies past
|
|
1817
|
+
* the last replayed operation are applied at the end. Creation-time 0->N seed
|
|
1818
|
+
* upgrades carry the initial state, so they still apply immediately.
|
|
1819
|
+
*/
|
|
1107
1820
|
async coldMissRebuild(documentId, scope, branch, targetRevision, signal) {
|
|
1108
1821
|
const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
|
|
1109
1822
|
const keyframe = await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal);
|
|
1823
|
+
const documentScopeBound = scope === "document" ? targetRevision : void 0;
|
|
1110
1824
|
let document;
|
|
1111
1825
|
let startRevision;
|
|
1112
1826
|
let documentType;
|
|
1113
1827
|
const validatedUpgrades = [];
|
|
1828
|
+
const pendingUpgrades = [];
|
|
1829
|
+
let lastDocumentScopeOperation;
|
|
1114
1830
|
if (keyframe) {
|
|
1115
1831
|
document = keyframe.document;
|
|
1116
1832
|
startRevision = keyframe.revision;
|
|
1117
1833
|
documentType = keyframe.document.header.documentType;
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1834
|
+
const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
|
|
1835
|
+
const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
|
|
1836
|
+
for (const operation of docScopeOpsAfterKeyframe.results) {
|
|
1837
|
+
if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
|
|
1838
|
+
lastDocumentScopeOperation = operation;
|
|
1839
|
+
if (operation.error || isDenied(operation)) continue;
|
|
1840
|
+
if (operation.action.type === "UPGRADE_DOCUMENT") {
|
|
1841
|
+
const upgradeAction = operation.action;
|
|
1842
|
+
const fromVersion = upgradeAction.input.fromVersion;
|
|
1843
|
+
const toVersion = upgradeAction.input.toVersion;
|
|
1844
|
+
if (fromVersion > 0 && fromVersion < toVersion) {
|
|
1845
|
+
let upgradePath;
|
|
1846
|
+
try {
|
|
1847
|
+
upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
|
|
1850
|
+
else throw new Error(`Failed to rebuild document ${documentId}: no upgrade manifest for ${documentType} v${fromVersion}→v${toVersion} and no initialState snapshot. ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1851
|
+
}
|
|
1852
|
+
validatedUpgrades.push({
|
|
1853
|
+
fromVersion,
|
|
1854
|
+
toVersion,
|
|
1855
|
+
revision: upgradeAction.input.revision,
|
|
1856
|
+
timestampUtcMs: operation.timestampUtcMs
|
|
1857
|
+
});
|
|
1858
|
+
pendingUpgrades.push({
|
|
1859
|
+
action: upgradeAction,
|
|
1860
|
+
upgradePath,
|
|
1861
|
+
index: operation.index,
|
|
1862
|
+
subsequentDeletes: []
|
|
1863
|
+
});
|
|
1130
1864
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
revision: upgradeAction.input.revision,
|
|
1135
|
-
timestampUtcMs: operation.timestampUtcMs
|
|
1136
|
-
});
|
|
1137
|
-
document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
|
|
1865
|
+
} else if (operation.action.type === "DELETE_DOCUMENT") {
|
|
1866
|
+
applyDeleteDocumentAction(document, operation.action);
|
|
1867
|
+
for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
|
|
1138
1868
|
}
|
|
1139
|
-
}
|
|
1869
|
+
}
|
|
1140
1870
|
} else {
|
|
1141
1871
|
startRevision = -1;
|
|
1142
1872
|
const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
|
|
1143
1873
|
cursor: "0",
|
|
1144
1874
|
limit: 1
|
|
1145
1875
|
}, signal);
|
|
1146
|
-
if (createOpResult.results.length === 0) throw new
|
|
1876
|
+
if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
|
|
1147
1877
|
const createOp = createOpResult.results[0];
|
|
1148
1878
|
if (createOp.action.type !== "CREATE_DOCUMENT") throw new Error(`Failed to rebuild document ${documentId}: first operation in document scope must be CREATE_DOCUMENT, found ${createOp.action.type}`);
|
|
1149
1879
|
const documentCreateAction = createOp.action;
|
|
1150
1880
|
documentType = documentCreateAction.input.model;
|
|
1151
1881
|
if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
|
|
1152
1882
|
document = createDocumentFromAction(documentCreateAction);
|
|
1883
|
+
lastDocumentScopeOperation = createOp;
|
|
1153
1884
|
let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
|
|
1154
1885
|
const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
|
|
1155
1886
|
for (const operation of docScopeOps.results) {
|
|
1887
|
+
if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
|
|
1888
|
+
lastDocumentScopeOperation = operation;
|
|
1156
1889
|
if (operation.index === 0) continue;
|
|
1890
|
+
if (operation.error || isDenied(operation)) continue;
|
|
1157
1891
|
if (operation.action.type === "UPGRADE_DOCUMENT") {
|
|
1158
1892
|
const upgradeAction = operation.action;
|
|
1159
1893
|
const fromVersion = upgradeAction.input.fromVersion;
|
|
1160
1894
|
const toVersion = upgradeAction.input.toVersion;
|
|
1161
|
-
let upgradePath;
|
|
1162
1895
|
if (fromVersion > 0 && fromVersion < toVersion) {
|
|
1896
|
+
let upgradePath;
|
|
1163
1897
|
try {
|
|
1164
1898
|
upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
|
|
1165
1899
|
} catch (err) {
|
|
@@ -1172,12 +1906,19 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1172
1906
|
revision: upgradeAction.input.revision,
|
|
1173
1907
|
timestampUtcMs: operation.timestampUtcMs
|
|
1174
1908
|
});
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1909
|
+
pendingUpgrades.push({
|
|
1910
|
+
action: upgradeAction,
|
|
1911
|
+
upgradePath,
|
|
1912
|
+
index: operation.index,
|
|
1913
|
+
subsequentDeletes: []
|
|
1914
|
+
});
|
|
1915
|
+
} else document = applyUpgradeDocumentAction(document, upgradeAction, void 0);
|
|
1916
|
+
docModule = this.registry.getModule(documentType, normalizeDocumentModelVersion(toVersion));
|
|
1917
|
+
} else if (operation.action.type === "DELETE_DOCUMENT") {
|
|
1918
|
+
applyDeleteDocumentAction(document, operation.action);
|
|
1919
|
+
for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
|
|
1920
|
+
} else {
|
|
1921
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
1181
1922
|
document = docModule.reducer(document, operation.action, void 0, {
|
|
1182
1923
|
skip: operation.skip,
|
|
1183
1924
|
protocolVersion
|
|
@@ -1185,6 +1926,22 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1185
1926
|
}
|
|
1186
1927
|
}
|
|
1187
1928
|
}
|
|
1929
|
+
if (scope === "document") {
|
|
1930
|
+
document = this.applyPendingUpgrades(document, pendingUpgrades, Number.MAX_SAFE_INTEGER);
|
|
1931
|
+
const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
|
|
1932
|
+
document.operations = {
|
|
1933
|
+
...document.operations,
|
|
1934
|
+
document: last ? [last] : []
|
|
1935
|
+
};
|
|
1936
|
+
return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
|
|
1937
|
+
}
|
|
1938
|
+
if (keyframe) {
|
|
1939
|
+
const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
|
|
1940
|
+
if (resumeOperation) document.operations = {
|
|
1941
|
+
...document.operations,
|
|
1942
|
+
[scope]: [resumeOperation]
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1188
1945
|
const moduleCache = /* @__PURE__ */ new Map();
|
|
1189
1946
|
const getModuleCached = (version) => {
|
|
1190
1947
|
const key = version ?? 0;
|
|
@@ -1195,6 +1952,7 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1195
1952
|
}
|
|
1196
1953
|
return mod;
|
|
1197
1954
|
};
|
|
1955
|
+
const finalVersion = validatedUpgrades.at(-1)?.toVersion ?? extractModuleVersion(document);
|
|
1198
1956
|
let cursor = void 0;
|
|
1199
1957
|
const pageSize = 100;
|
|
1200
1958
|
let hasMorePages;
|
|
@@ -1208,12 +1966,16 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1208
1966
|
const result = await this.operationStore.getSince(documentId, scope, branch, startRevision, void 0, paging, signal);
|
|
1209
1967
|
for (const operation of result.results) {
|
|
1210
1968
|
if (targetRevision !== void 0 && operation.index > targetRevision) break;
|
|
1211
|
-
const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades,
|
|
1212
|
-
|
|
1213
|
-
document =
|
|
1214
|
-
|
|
1215
|
-
protocolVersion
|
|
1216
|
-
|
|
1969
|
+
const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, finalVersion);
|
|
1970
|
+
document = this.applyPendingUpgrades(document, pendingUpgrades, moduleVersion ?? Number.MAX_SAFE_INTEGER);
|
|
1971
|
+
if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
|
|
1972
|
+
else {
|
|
1973
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
1974
|
+
document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
|
|
1975
|
+
skip: operation.skip,
|
|
1976
|
+
protocolVersion
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1217
1979
|
}
|
|
1218
1980
|
const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
|
|
1219
1981
|
hasMorePages = Boolean(result.nextCursor) && !reachedTarget;
|
|
@@ -1222,11 +1984,86 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1222
1984
|
throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1223
1985
|
}
|
|
1224
1986
|
} while (hasMorePages);
|
|
1987
|
+
document = this.applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision);
|
|
1988
|
+
document = await this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
|
|
1989
|
+
if (pendingUpgrades.length > 0) {
|
|
1990
|
+
const firstHeldBack = pendingUpgrades[0];
|
|
1991
|
+
const stamped = document.header.revision["document"] ?? 0;
|
|
1992
|
+
document.header.revision = {
|
|
1993
|
+
...document.header.revision,
|
|
1994
|
+
document: Math.min(stamped, firstHeldBack.index)
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
return document;
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Applies and removes every held-back upgrade whose target version is at or
|
|
2001
|
+
* below `throughVersion`, in the order the document scope recorded them.
|
|
2002
|
+
*/
|
|
2003
|
+
applyPendingUpgrades(document, pendingUpgrades, throughVersion) {
|
|
2004
|
+
while (pendingUpgrades.length > 0) {
|
|
2005
|
+
const pending = pendingUpgrades[0];
|
|
2006
|
+
if (throughVersion < pending.action.input.toVersion) break;
|
|
2007
|
+
pendingUpgrades.shift();
|
|
2008
|
+
document = this.applyPendingUpgrade(document, pending);
|
|
2009
|
+
}
|
|
2010
|
+
return document;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Applies the remaining held-back upgrades after the requested scope's
|
|
2014
|
+
* replay has finished. A head read applies them all. A positional read
|
|
2015
|
+
* applies only those whose boundary for this scope lies at or before the
|
|
2016
|
+
* target position: applying a later one would label migrated state with a
|
|
2017
|
+
* pre-upgrade revision, and a keyframe stored from that poisons every
|
|
2018
|
+
* rebuild that resumes from it. Boundaries come from the upgrade's revision
|
|
2019
|
+
* snapshot; an upgrade without one records no position for this scope, and
|
|
2020
|
+
* the replay loop not having crossed it already places it past the target.
|
|
2021
|
+
*/
|
|
2022
|
+
applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision) {
|
|
2023
|
+
while (pendingUpgrades.length > 0) {
|
|
2024
|
+
const pending = pendingUpgrades[0];
|
|
2025
|
+
if (targetRevision !== void 0) {
|
|
2026
|
+
const snapshot = pending.action.input.revision;
|
|
2027
|
+
if (snapshot === void 0) break;
|
|
2028
|
+
if ((snapshot[scope] ?? 0) > targetRevision) break;
|
|
2029
|
+
}
|
|
2030
|
+
pendingUpgrades.shift();
|
|
2031
|
+
document = this.applyPendingUpgrade(document, pending);
|
|
2032
|
+
}
|
|
2033
|
+
return document;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Applies one held-back upgrade, then re-applies the deletes the document
|
|
2037
|
+
* scope recorded after it so the hold-back cannot invert their order.
|
|
2038
|
+
*/
|
|
2039
|
+
applyPendingUpgrade(document, pending) {
|
|
2040
|
+
document = applyUpgradeDocumentAction(document, pending.action, pending.upgradePath);
|
|
2041
|
+
for (const deleteAction of pending.subsequentDeletes) document = applyDeleteDocumentAction(document, deleteAction);
|
|
2042
|
+
return document;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Copies the current document revisions onto the document. Overwrites the
|
|
2046
|
+
* requested scope revision with the target revision, if provided.
|
|
2047
|
+
*/
|
|
2048
|
+
async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
|
|
1225
2049
|
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
1226
2050
|
document.header.revision = revisions.revision;
|
|
2051
|
+
if (targetRevision !== void 0) document.header.revision = {
|
|
2052
|
+
...document.header.revision,
|
|
2053
|
+
[scope]: targetRevision + 1
|
|
2054
|
+
};
|
|
1227
2055
|
document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
|
|
1228
2056
|
return document;
|
|
1229
2057
|
}
|
|
2058
|
+
/** The stored operation at `index`, or undefined if it is no longer there. */
|
|
2059
|
+
async operationAt(documentId, scope, branch, index, signal) {
|
|
2060
|
+
if (index < 0) return;
|
|
2061
|
+
const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
|
|
2062
|
+
cursor: "0",
|
|
2063
|
+
limit: 1
|
|
2064
|
+
}, signal)).results[0];
|
|
2065
|
+
return operation && operation.index === index ? operation : void 0;
|
|
2066
|
+
}
|
|
1230
2067
|
/**
|
|
1231
2068
|
* Resolves which module version to use for a given operation in phase 2.
|
|
1232
2069
|
*
|
|
@@ -1250,19 +2087,22 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1250
2087
|
async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
|
|
1251
2088
|
const documentType = baseDocument.header.documentType;
|
|
1252
2089
|
const docScopeNextIndex = baseDocument.header.revision["document"] ?? 0;
|
|
1253
|
-
if ((await this.operationStore.getSince(documentId, "document", branch, docScopeNextIndex - 1, void 0, void 0, signal)).results.
|
|
2090
|
+
if ((await this.operationStore.getSince(documentId, "document", branch, docScopeNextIndex - 1, void 0, void 0, signal)).results.length > 0) return this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
|
|
1254
2091
|
const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
|
|
1255
|
-
let document = baseDocument;
|
|
2092
|
+
let document = copyDocument(baseDocument);
|
|
1256
2093
|
try {
|
|
1257
2094
|
const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
|
|
1258
2095
|
for (const operation of pagedResults.results) {
|
|
1259
2096
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1260
2097
|
if (targetRevision !== void 0 && operation.index > targetRevision) break;
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
2098
|
+
if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
|
|
2099
|
+
else {
|
|
2100
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
2101
|
+
document = module.reducer(document, operation.action, void 0, {
|
|
2102
|
+
skip: operation.skip,
|
|
2103
|
+
protocolVersion
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
1266
2106
|
if (targetRevision !== void 0 && operation.index === targetRevision) break;
|
|
1267
2107
|
}
|
|
1268
2108
|
} catch (err) {
|
|
@@ -1270,6 +2110,10 @@ var KyselyWriteCache = class KyselyWriteCache {
|
|
|
1270
2110
|
}
|
|
1271
2111
|
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
1272
2112
|
document.header.revision = revisions.revision;
|
|
2113
|
+
if (targetRevision !== void 0) document.header.revision = {
|
|
2114
|
+
...document.header.revision,
|
|
2115
|
+
[scope]: targetRevision + 1
|
|
2116
|
+
};
|
|
1273
2117
|
document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
|
|
1274
2118
|
return document;
|
|
1275
2119
|
}
|
|
@@ -1339,6 +2183,49 @@ var EventBus = class {
|
|
|
1339
2183
|
}
|
|
1340
2184
|
};
|
|
1341
2185
|
//#endregion
|
|
2186
|
+
//#region src/core/feature-flags.ts
|
|
2187
|
+
/**
|
|
2188
|
+
* Every flag this reactor knows, with the flags it requires. A stage adds its
|
|
2189
|
+
* flag here when it ships, so asking an older reactor for a later stage's flag
|
|
2190
|
+
* is an unrecognized name rather than a flag that quietly does nothing.
|
|
2191
|
+
*/
|
|
2192
|
+
const FLAG_PREREQUISITES = {
|
|
2193
|
+
documentDecisions: [],
|
|
2194
|
+
authEnforcement: ["documentDecisions"],
|
|
2195
|
+
authGroups: ["authEnforcement"],
|
|
2196
|
+
authConditions: ["authGroups"]
|
|
2197
|
+
};
|
|
2198
|
+
/**
|
|
2199
|
+
* The flags as plain booleans, with anything unset off, validated. Callers hold
|
|
2200
|
+
* a partial set, because that is what crosses to a pooled worker, and every
|
|
2201
|
+
* consumer needs the same resolution of it.
|
|
2202
|
+
*/
|
|
2203
|
+
function resolveFeatureFlags(flags = {}) {
|
|
2204
|
+
const resolved = {
|
|
2205
|
+
documentDecisions: flags.documentDecisions ?? false,
|
|
2206
|
+
authEnforcement: flags.authEnforcement ?? false,
|
|
2207
|
+
authGroups: flags.authGroups ?? false,
|
|
2208
|
+
authConditions: flags.authConditions ?? false
|
|
2209
|
+
};
|
|
2210
|
+
validateFeatureFlags(flags, FLAG_PREREQUISITES);
|
|
2211
|
+
return resolved;
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Throws when the flags ask for enforcement the reactor cannot deliver. Either
|
|
2215
|
+
* failure would otherwise read as enforcement being on while the reactor
|
|
2216
|
+
* applies less than the caller asked for.
|
|
2217
|
+
*/
|
|
2218
|
+
function validateFeatureFlags(flags, prerequisites) {
|
|
2219
|
+
const known = Object.keys(prerequisites);
|
|
2220
|
+
const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
|
|
2221
|
+
if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
|
|
2222
|
+
for (const name of known) {
|
|
2223
|
+
if (flags[name] !== true) continue;
|
|
2224
|
+
const missing = prerequisites[name].filter((required) => flags[required] !== true);
|
|
2225
|
+
if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
//#endregion
|
|
1342
2229
|
//#region src/executor/execution-scope.ts
|
|
1343
2230
|
var DefaultExecutionScope = class {
|
|
1344
2231
|
constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
|
|
@@ -1436,6 +2323,273 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
|
|
|
1436
2323
|
}));
|
|
1437
2324
|
}
|
|
1438
2325
|
//#endregion
|
|
2326
|
+
//#region src/decision/merged-order.ts
|
|
2327
|
+
/** Identifies a stream within a walk. */
|
|
2328
|
+
function streamKey(query) {
|
|
2329
|
+
return `${query.documentId}:${query.scope}:${query.branch}`;
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Orders two operations from different streams by position. Timestamp decides;
|
|
2333
|
+
* an equal timestamp puts an auth operation first, and otherwise falls to the
|
|
2334
|
+
* action id and then the operation id, so that two replicas holding the same
|
|
2335
|
+
* operations agree on the order whatever order they happen to store them in.
|
|
2336
|
+
*/
|
|
2337
|
+
function comparePositions(a, b) {
|
|
2338
|
+
const aTime = Date.parse(a.operation.timestampUtcMs);
|
|
2339
|
+
const bTime = Date.parse(b.operation.timestampUtcMs);
|
|
2340
|
+
if (aTime !== bTime) return aTime - bTime;
|
|
2341
|
+
if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
|
|
2342
|
+
const aAuth = a.scope === "auth";
|
|
2343
|
+
if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
|
|
2344
|
+
const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
|
|
2345
|
+
if (actionIds !== 0) return actionIds;
|
|
2346
|
+
return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Merges the read-set streams into one sequence by position. An operation's
|
|
2350
|
+
* place in the result is the bound a decision at that operation reads to: every
|
|
2351
|
+
* operation before it has been applied, and it has not.
|
|
2352
|
+
*/
|
|
2353
|
+
function mergeByPosition(streams) {
|
|
2354
|
+
const merged = [];
|
|
2355
|
+
for (const stream of streams) for (const operation of stream.operations) merged.push({
|
|
2356
|
+
streamKey: stream.streamKey,
|
|
2357
|
+
scope: stream.scope,
|
|
2358
|
+
operation
|
|
2359
|
+
});
|
|
2360
|
+
return merged.sort(comparePositions);
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* The skip that retracts everything from `firstRetractedIndex` up to where the
|
|
2364
|
+
* re-appended operation lands. It spans the indexes rather than counting the
|
|
2365
|
+
* operations, because a stream with a gap in it makes those differ.
|
|
2366
|
+
*/
|
|
2367
|
+
function retractionSkip(nextIndex, firstRetractedIndex) {
|
|
2368
|
+
return nextIndex - firstRetractedIndex;
|
|
2369
|
+
}
|
|
2370
|
+
//#endregion
|
|
2371
|
+
//#region src/decision/walk.ts
|
|
2372
|
+
/**
|
|
2373
|
+
* A single forward pass is only correct while a stream's effective operations
|
|
2374
|
+
* are ordered.
|
|
2375
|
+
*/
|
|
2376
|
+
function assertPositionOrder(streamKey, scope, operations) {
|
|
2377
|
+
for (let i = 1; i < operations.length; i++) {
|
|
2378
|
+
const previous = operations[i - 1];
|
|
2379
|
+
const current = operations[i];
|
|
2380
|
+
if (comparePositions({
|
|
2381
|
+
streamKey,
|
|
2382
|
+
scope,
|
|
2383
|
+
operation: previous
|
|
2384
|
+
}, {
|
|
2385
|
+
streamKey,
|
|
2386
|
+
scope,
|
|
2387
|
+
operation: current
|
|
2388
|
+
}) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Visits every operation in the read-set once, in the order their positions
|
|
2393
|
+
* fall, and hands back the state each stream held just before it. That state is
|
|
2394
|
+
* what a decision at that operation reads.
|
|
2395
|
+
*
|
|
2396
|
+
* Skips are resolved first (i.e. this is performed on a garbage collected
|
|
2397
|
+
* stream), which means we can do a single forward pass.
|
|
2398
|
+
*
|
|
2399
|
+
* An operation that contributes no state, whether denied or holding a reducer
|
|
2400
|
+
* error, is visited but not applied (this matches the write cache's rebuild).
|
|
2401
|
+
*
|
|
2402
|
+
* The consumer sends back whether it refused the operation it was handed: a
|
|
2403
|
+
* refusal this pass produced must suppress it the same way a stored one does.
|
|
2404
|
+
*/
|
|
2405
|
+
function* walkByPosition(streams) {
|
|
2406
|
+
const merged = mergeByPosition(streams.map((stream) => {
|
|
2407
|
+
const operations = garbageCollect(sortOperations([...stream.operations]));
|
|
2408
|
+
assertPositionOrder(stream.streamKey, stream.scope, operations);
|
|
2409
|
+
return {
|
|
2410
|
+
streamKey: stream.streamKey,
|
|
2411
|
+
scope: stream.scope,
|
|
2412
|
+
operations
|
|
2413
|
+
};
|
|
2414
|
+
}));
|
|
2415
|
+
const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
|
|
2416
|
+
const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
|
|
2417
|
+
for (const { streamKey, operation } of merged) {
|
|
2418
|
+
if ((yield {
|
|
2419
|
+
streamKey,
|
|
2420
|
+
operation,
|
|
2421
|
+
states: new Map(states)
|
|
2422
|
+
}) || operation.error !== void 0 || isDenied(operation)) continue;
|
|
2423
|
+
const stream = byKey.get(streamKey);
|
|
2424
|
+
const before = states.get(streamKey);
|
|
2425
|
+
if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
|
|
2426
|
+
states.set(streamKey, stream.apply(before, operation));
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/decision/evaluation.ts
|
|
2431
|
+
/** The stream key for evaluated operations whose scope no projection reads. */
|
|
2432
|
+
const EVALUATED_ONLY = "evaluated";
|
|
2433
|
+
/**
|
|
2434
|
+
* Whether any stream the model reads declares this operation's action type as
|
|
2435
|
+
* one that can change an evaluation.
|
|
2436
|
+
*/
|
|
2437
|
+
function isDecidingAction(operation, readSet) {
|
|
2438
|
+
return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Who an operation acts as. A replayed operation is evaluated as its own signer,
|
|
2442
|
+
* so an address-scoped policy does not deny its own author's history.
|
|
2443
|
+
*/
|
|
2444
|
+
function subjectOf(operation) {
|
|
2445
|
+
const signer = operation.action.context?.signer;
|
|
2446
|
+
return {
|
|
2447
|
+
address: signer?.user.address,
|
|
2448
|
+
key: signer?.app.key
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* The model as the walk reached this operation: each static projection's value
|
|
2453
|
+
* is its own scope's state, and each derived projection's value maps document
|
|
2454
|
+
* id to that document's state, holding only the streams this replica walked. A
|
|
2455
|
+
* derived stream it does not hold stays out of the map, which fails closed.
|
|
2456
|
+
*/
|
|
2457
|
+
function modelAt(readSet, derivedNames, derived, states) {
|
|
2458
|
+
const model = {};
|
|
2459
|
+
for (const stream of readSet) {
|
|
2460
|
+
const document = states.get(streamKey(stream.query));
|
|
2461
|
+
if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
|
|
2462
|
+
model[stream.name] = document.state[stream.query.scope];
|
|
2463
|
+
}
|
|
2464
|
+
for (const name of derivedNames) model[name] = {};
|
|
2465
|
+
for (const entry of derived) {
|
|
2466
|
+
const map = model[entry.name];
|
|
2467
|
+
const document = states.get(streamKey(entry.query));
|
|
2468
|
+
if (document !== void 0) map[entry.query.documentId] = document.state[entry.query.scope];
|
|
2469
|
+
}
|
|
2470
|
+
return model;
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Evaluates each operation at its own position and returns the refusals in an
|
|
2474
|
+
* array parallel to the operations, where undefined means allowed.
|
|
2475
|
+
*
|
|
2476
|
+
* A position is a timestamp, so an operation refused by a delete is one that
|
|
2477
|
+
* sorts after it, and the operations before it are left alone. That holds
|
|
2478
|
+
* whether the delete is already stored or is among the operations passed in.
|
|
2479
|
+
*/
|
|
2480
|
+
async function evaluateByPosition(model, target, subject, stores, signal) {
|
|
2481
|
+
const { scope, operations } = subject;
|
|
2482
|
+
const { writeCache, operationStore } = stores;
|
|
2483
|
+
const definition = model(target);
|
|
2484
|
+
const readSet = staticReadSet(definition);
|
|
2485
|
+
const derivedSet = derivedReadSet(definition);
|
|
2486
|
+
if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
|
|
2487
|
+
const evaluating = new Set(operations.map((operation) => operation.id));
|
|
2488
|
+
const readStreams = await Promise.all(readSet.map(async (stream) => ({
|
|
2489
|
+
stream,
|
|
2490
|
+
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))
|
|
2491
|
+
})));
|
|
2492
|
+
const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
|
|
2493
|
+
if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
|
|
2494
|
+
if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
|
|
2495
|
+
const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
|
|
2496
|
+
const walked = [];
|
|
2497
|
+
const histories = [];
|
|
2498
|
+
for (const read of readStreams) {
|
|
2499
|
+
const streamOperations = read.stream === writtenProjection ? [...read.operations, ...operations] : read.operations;
|
|
2500
|
+
const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, -1, signal);
|
|
2501
|
+
walked.push({
|
|
2502
|
+
streamKey: streamKey(read.stream.query),
|
|
2503
|
+
scope: read.stream.query.scope,
|
|
2504
|
+
document: before,
|
|
2505
|
+
operations: streamOperations,
|
|
2506
|
+
apply: read.stream.apply
|
|
2507
|
+
});
|
|
2508
|
+
histories.push({
|
|
2509
|
+
name: read.stream.name,
|
|
2510
|
+
operations: streamOperations
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
let evaluatedStateKey;
|
|
2514
|
+
if (writtenProjection !== void 0) evaluatedStateKey = streamKey(writtenProjection.query);
|
|
2515
|
+
else if (definition.foldEvaluatedScope !== void 0) {
|
|
2516
|
+
const query = {
|
|
2517
|
+
documentId: target.documentId,
|
|
2518
|
+
scope,
|
|
2519
|
+
branch: target.branch
|
|
2520
|
+
};
|
|
2521
|
+
const storedOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, void 0, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
|
|
2522
|
+
const before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
|
|
2523
|
+
evaluatedStateKey = streamKey(query);
|
|
2524
|
+
walked.push({
|
|
2525
|
+
streamKey: evaluatedStateKey,
|
|
2526
|
+
scope,
|
|
2527
|
+
document: before,
|
|
2528
|
+
operations: [...storedOperations, ...operations],
|
|
2529
|
+
apply: definition.foldEvaluatedScope
|
|
2530
|
+
});
|
|
2531
|
+
} else walked.push({
|
|
2532
|
+
streamKey: EVALUATED_ONLY,
|
|
2533
|
+
scope,
|
|
2534
|
+
document: walked[0].document,
|
|
2535
|
+
operations,
|
|
2536
|
+
apply: (document) => document
|
|
2537
|
+
});
|
|
2538
|
+
const derivedEntries = [];
|
|
2539
|
+
const walkedKeys = new Set(walked.map((stream) => stream.streamKey));
|
|
2540
|
+
for (const projection of derivedSet) {
|
|
2541
|
+
const queries = projection.queryOverHistory?.(histories) ?? [];
|
|
2542
|
+
for (const query of queries) {
|
|
2543
|
+
const key = streamKey(query);
|
|
2544
|
+
if (walkedKeys.has(key)) continue;
|
|
2545
|
+
let before;
|
|
2546
|
+
try {
|
|
2547
|
+
before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
if (error instanceof DocumentNotFoundError) continue;
|
|
2550
|
+
throw error;
|
|
2551
|
+
}
|
|
2552
|
+
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));
|
|
2553
|
+
walkedKeys.add(key);
|
|
2554
|
+
walked.push({
|
|
2555
|
+
streamKey: key,
|
|
2556
|
+
scope: query.scope,
|
|
2557
|
+
document: before,
|
|
2558
|
+
operations: streamOperations,
|
|
2559
|
+
apply: projection.apply
|
|
2560
|
+
});
|
|
2561
|
+
derivedEntries.push({
|
|
2562
|
+
name: projection.name,
|
|
2563
|
+
query
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2568
|
+
const walk = walkByPosition(walked);
|
|
2569
|
+
let step = walk.next(false);
|
|
2570
|
+
while (!step.done) {
|
|
2571
|
+
const position = step.value;
|
|
2572
|
+
if (!evaluating.has(position.operation.id)) {
|
|
2573
|
+
step = walk.next(false);
|
|
2574
|
+
continue;
|
|
2575
|
+
}
|
|
2576
|
+
const evaluatedDocument = evaluatedStateKey === void 0 ? void 0 : position.states.get(evaluatedStateKey);
|
|
2577
|
+
const scopeState = evaluatedDocument === void 0 ? void 0 : evaluatedDocument.state[scope];
|
|
2578
|
+
const evaluation = definition.decide(modelAt(readSet, derivedSet.map((projection) => projection.name), derivedEntries, position.states), subjectOf(position.operation), {
|
|
2579
|
+
verb: "execute",
|
|
2580
|
+
scope: position.operation.action.scope,
|
|
2581
|
+
operation: position.operation.action.type
|
|
2582
|
+
}, {
|
|
2583
|
+
scopeState,
|
|
2584
|
+
actionInput: position.operation.action.input
|
|
2585
|
+
});
|
|
2586
|
+
const denied = evaluation.decision === "deny";
|
|
2587
|
+
reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
|
|
2588
|
+
step = walk.next(denied);
|
|
2589
|
+
}
|
|
2590
|
+
return operations.map((operation) => reasons.get(operation.id));
|
|
2591
|
+
}
|
|
2592
|
+
//#endregion
|
|
1439
2593
|
//#region src/cache/operation-index-types.ts
|
|
1440
2594
|
const DRIVE_COLLECTION_PREFIX = "drive.";
|
|
1441
2595
|
/**
|
|
@@ -1482,23 +2636,119 @@ var DriveCollectionId = class DriveCollectionId {
|
|
|
1482
2636
|
//#endregion
|
|
1483
2637
|
//#region src/executor/document-action-handler.ts
|
|
1484
2638
|
var DocumentActionHandler = class {
|
|
1485
|
-
constructor(registry, logger, driveContainerTypes) {
|
|
2639
|
+
constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel) {
|
|
1486
2640
|
this.registry = registry;
|
|
1487
2641
|
this.logger = logger;
|
|
1488
2642
|
this.driveContainerTypes = driveContainerTypes;
|
|
1489
|
-
|
|
1490
|
-
|
|
2643
|
+
this.featureFlags = featureFlags;
|
|
2644
|
+
this.decisionModel = decisionModel;
|
|
2645
|
+
}
|
|
2646
|
+
/** Whether the write arrives with its evaluation already decided. */
|
|
2647
|
+
alreadyEvaluated(executing) {
|
|
2648
|
+
return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
|
|
2649
|
+
}
|
|
2650
|
+
async execute(write, executing) {
|
|
2651
|
+
const { action } = write;
|
|
2652
|
+
if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
|
|
2653
|
+
const refusal = await this.refuseIfPolicyDenies(write, executing);
|
|
2654
|
+
if (refusal) return refusal;
|
|
1491
2655
|
switch (action.type) {
|
|
1492
|
-
case "CREATE_DOCUMENT": return this.executeCreate(
|
|
1493
|
-
case "DELETE_DOCUMENT": return this.executeDelete(
|
|
1494
|
-
case "UPGRADE_DOCUMENT": return this.executeUpgrade(
|
|
1495
|
-
case "ADD_RELATIONSHIP": return this.executeAddRelationship(
|
|
1496
|
-
case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(
|
|
1497
|
-
case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(
|
|
1498
|
-
default: return buildErrorResult(job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), startTime);
|
|
2656
|
+
case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
|
|
2657
|
+
case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
|
|
2658
|
+
case "UPGRADE_DOCUMENT": return this.executeUpgrade(write, executing);
|
|
2659
|
+
case "ADD_RELATIONSHIP": return this.executeAddRelationship(write, executing);
|
|
2660
|
+
case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(write, executing);
|
|
2661
|
+
case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(write, executing);
|
|
2662
|
+
default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Refuses a document-scope write the policy denies, or undefined to proceed.
|
|
2667
|
+
* Without this an `execute`-on-`document` grant is unenforceable.
|
|
2668
|
+
*/
|
|
2669
|
+
async refuseIfPolicyDenies(write, executing) {
|
|
2670
|
+
const { action } = write;
|
|
2671
|
+
const { job, startTime, stores, signal } = executing;
|
|
2672
|
+
if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
|
|
2673
|
+
const documentId = targetDocumentId(action, job.documentId);
|
|
2674
|
+
let admission;
|
|
2675
|
+
try {
|
|
2676
|
+
admission = await decideAtHead(this.decisionModel, stores.writeCache, {
|
|
2677
|
+
documentId,
|
|
2678
|
+
branch: job.branch
|
|
2679
|
+
}, {
|
|
2680
|
+
address: action.context?.signer?.user.address,
|
|
2681
|
+
key: action.context?.signer?.app.key
|
|
2682
|
+
}, {
|
|
2683
|
+
verb: "execute",
|
|
2684
|
+
scope: action.scope,
|
|
2685
|
+
operation: action.type
|
|
2686
|
+
}, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
|
|
2687
|
+
} catch (error) {
|
|
2688
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1499
2689
|
}
|
|
2690
|
+
if (admission.evaluation.decision === "allow") return;
|
|
2691
|
+
return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
|
|
1500
2692
|
}
|
|
1501
|
-
|
|
2693
|
+
/** A refused operation holds a position in the stream but changes nothing. */
|
|
2694
|
+
async writeDenied(write, executing) {
|
|
2695
|
+
const { action, skip, sourceRemote, deniedReason } = write;
|
|
2696
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
2697
|
+
let document;
|
|
2698
|
+
try {
|
|
2699
|
+
document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
|
|
2700
|
+
} catch (error) {
|
|
2701
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
2702
|
+
}
|
|
2703
|
+
const index = getNextIndexForScope(document, job.scope);
|
|
2704
|
+
let standing = document;
|
|
2705
|
+
if (skip > 0) try {
|
|
2706
|
+
standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
2709
|
+
}
|
|
2710
|
+
let operation = createOperation(action, index, skip, {
|
|
2711
|
+
documentId: job.documentId,
|
|
2712
|
+
scope: job.scope,
|
|
2713
|
+
branch: job.branch
|
|
2714
|
+
});
|
|
2715
|
+
operation.deniedReason = deniedReason;
|
|
2716
|
+
operation.hash = hashDocumentStateForScope(standing, job.scope);
|
|
2717
|
+
const writeResult = await this.writeOperationToStore({
|
|
2718
|
+
documentId: job.documentId,
|
|
2719
|
+
documentType: document.header.documentType,
|
|
2720
|
+
scope: job.scope,
|
|
2721
|
+
branch: job.branch
|
|
2722
|
+
}, operation, executing);
|
|
2723
|
+
if (!Array.isArray(writeResult)) return writeResult;
|
|
2724
|
+
operation = writeResult[0];
|
|
2725
|
+
updateDocumentRevision(standing, job.scope, operation.index);
|
|
2726
|
+
standing.operations = {
|
|
2727
|
+
...standing.operations,
|
|
2728
|
+
[job.scope]: [...standing.operations[job.scope] ?? [], operation]
|
|
2729
|
+
};
|
|
2730
|
+
stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, SnapshotPosition.Head);
|
|
2731
|
+
indexTxn.write([{
|
|
2732
|
+
...operation,
|
|
2733
|
+
documentId: job.documentId,
|
|
2734
|
+
documentType: document.header.documentType,
|
|
2735
|
+
branch: job.branch,
|
|
2736
|
+
scope: job.scope,
|
|
2737
|
+
sourceRemote
|
|
2738
|
+
}]);
|
|
2739
|
+
stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
|
|
2740
|
+
state: standing.state.document,
|
|
2741
|
+
documentType: standing.header.documentType,
|
|
2742
|
+
documentScopeRevision: operation.index + 1
|
|
2743
|
+
});
|
|
2744
|
+
return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
|
|
2745
|
+
header: standing.header,
|
|
2746
|
+
document: standing.state.document
|
|
2747
|
+
}), startTime);
|
|
2748
|
+
}
|
|
2749
|
+
async executeCreate(write, executing) {
|
|
2750
|
+
const { action, skip, sourceRemote } = write;
|
|
2751
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1502
2752
|
if (job.scope !== "document") return {
|
|
1503
2753
|
job,
|
|
1504
2754
|
success: false,
|
|
@@ -1516,7 +2766,12 @@ var DocumentActionHandler = class {
|
|
|
1516
2766
|
...document.state
|
|
1517
2767
|
};
|
|
1518
2768
|
const resultingState = JSON.stringify(resultingStateObj);
|
|
1519
|
-
const writeResult = await this.writeOperationToStore(
|
|
2769
|
+
const writeResult = await this.writeOperationToStore({
|
|
2770
|
+
documentId: document.header.id,
|
|
2771
|
+
documentType: document.header.documentType,
|
|
2772
|
+
scope: job.scope,
|
|
2773
|
+
branch: job.branch
|
|
2774
|
+
}, operation, executing);
|
|
1520
2775
|
if (!Array.isArray(writeResult)) return writeResult;
|
|
1521
2776
|
operation = writeResult[0];
|
|
1522
2777
|
updateDocumentRevision(document, job.scope, operation.index);
|
|
@@ -1524,7 +2779,7 @@ var DocumentActionHandler = class {
|
|
|
1524
2779
|
...document.operations,
|
|
1525
2780
|
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1526
2781
|
};
|
|
1527
|
-
stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document);
|
|
2782
|
+
stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
|
|
1528
2783
|
indexTxn.write([{
|
|
1529
2784
|
...operation,
|
|
1530
2785
|
documentId: document.header.id,
|
|
@@ -1545,7 +2800,9 @@ var DocumentActionHandler = class {
|
|
|
1545
2800
|
});
|
|
1546
2801
|
return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
|
|
1547
2802
|
}
|
|
1548
|
-
async executeDelete(
|
|
2803
|
+
async executeDelete(write, executing) {
|
|
2804
|
+
const { action, skip, sourceRemote } = write;
|
|
2805
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1549
2806
|
const input = action.input;
|
|
1550
2807
|
if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
|
|
1551
2808
|
const documentId = input.documentId;
|
|
@@ -1556,8 +2813,8 @@ var DocumentActionHandler = class {
|
|
|
1556
2813
|
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1557
2814
|
}
|
|
1558
2815
|
const documentState = document.state.document;
|
|
1559
|
-
if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
1560
|
-
let operation = createOperation(action, getNextIndexForScope(document, job.scope),
|
|
2816
|
+
if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
2817
|
+
let operation = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
|
|
1561
2818
|
documentId,
|
|
1562
2819
|
scope: job.scope,
|
|
1563
2820
|
branch: job.branch
|
|
@@ -1568,7 +2825,12 @@ var DocumentActionHandler = class {
|
|
|
1568
2825
|
document: document.state.document
|
|
1569
2826
|
};
|
|
1570
2827
|
const resultingState = JSON.stringify(resultingStateObj);
|
|
1571
|
-
const writeResult = await this.writeOperationToStore(
|
|
2828
|
+
const writeResult = await this.writeOperationToStore({
|
|
2829
|
+
documentId,
|
|
2830
|
+
documentType: document.header.documentType,
|
|
2831
|
+
scope: job.scope,
|
|
2832
|
+
branch: job.branch
|
|
2833
|
+
}, operation, executing);
|
|
1572
2834
|
if (!Array.isArray(writeResult)) return writeResult;
|
|
1573
2835
|
operation = writeResult[0];
|
|
1574
2836
|
updateDocumentRevision(document, job.scope, operation.index);
|
|
@@ -1576,7 +2838,7 @@ var DocumentActionHandler = class {
|
|
|
1576
2838
|
...document.operations,
|
|
1577
2839
|
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1578
2840
|
};
|
|
1579
|
-
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
|
|
2841
|
+
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
|
|
1580
2842
|
indexTxn.write([{
|
|
1581
2843
|
...operation,
|
|
1582
2844
|
documentId,
|
|
@@ -1592,7 +2854,9 @@ var DocumentActionHandler = class {
|
|
|
1592
2854
|
});
|
|
1593
2855
|
return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
1594
2856
|
}
|
|
1595
|
-
async executeUpgrade(
|
|
2857
|
+
async executeUpgrade(write, executing) {
|
|
2858
|
+
const { action, skip, sourceRemote } = write;
|
|
2859
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1596
2860
|
const input = action.input;
|
|
1597
2861
|
if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
|
|
1598
2862
|
const documentId = input.documentId;
|
|
@@ -1602,17 +2866,10 @@ var DocumentActionHandler = class {
|
|
|
1602
2866
|
try {
|
|
1603
2867
|
document = await stores.writeCache.getState(documentId, job.scope, job.branch, void 0, signal);
|
|
1604
2868
|
} catch (error) {
|
|
1605
|
-
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1606
|
-
}
|
|
1607
|
-
const documentState = document.state.document;
|
|
1608
|
-
if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
1609
|
-
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
1610
|
-
let upgradePath;
|
|
1611
|
-
if (fromVersion > 0 && fromVersion < toVersion) try {
|
|
1612
|
-
upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
|
|
1613
|
-
} catch (error) {
|
|
1614
|
-
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
2869
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1615
2870
|
}
|
|
2871
|
+
const documentState = document.state.document;
|
|
2872
|
+
if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
1616
2873
|
if (fromVersion === toVersion && fromVersion > 0) return {
|
|
1617
2874
|
job,
|
|
1618
2875
|
success: true,
|
|
@@ -1620,6 +2877,48 @@ var DocumentActionHandler = class {
|
|
|
1620
2877
|
operationsWithContext: [],
|
|
1621
2878
|
duration: Date.now() - startTime
|
|
1622
2879
|
};
|
|
2880
|
+
const arrivesDecided = executing.replayingAcceptedHistory || executing.evaluatedByPosition;
|
|
2881
|
+
if (fromVersion > 0 && !arrivesDecided) {
|
|
2882
|
+
const stampedVersion = normalizeDocumentModelVersion(documentState.version);
|
|
2883
|
+
if (fromVersion !== stampedVersion) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `fromVersion ${fromVersion} does not match the document's version ${stampedVersion}`), startTime);
|
|
2884
|
+
if (input.revision !== void 0) {
|
|
2885
|
+
let actualRevisions;
|
|
2886
|
+
try {
|
|
2887
|
+
actualRevisions = (await stores.operationStore.getRevisions(documentId, job.branch, signal)).revision;
|
|
2888
|
+
} catch (error) {
|
|
2889
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch revisions for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
2890
|
+
}
|
|
2891
|
+
const revisionScopes = new Set([...Object.keys(input.revision), ...Object.keys(actualRevisions)]);
|
|
2892
|
+
for (const revisionScope of revisionScopes) {
|
|
2893
|
+
const snapshot = input.revision[revisionScope] ?? 0;
|
|
2894
|
+
const actual = actualRevisions[revisionScope] ?? 0;
|
|
2895
|
+
if (snapshot !== actual) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `revision snapshot for scope "${revisionScope}" is ${snapshot} but the document is at ${actual}`), startTime);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
let upgradePath;
|
|
2900
|
+
if (fromVersion > 0 && fromVersion < toVersion) try {
|
|
2901
|
+
upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
|
|
2902
|
+
} catch (error) {
|
|
2903
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
2904
|
+
}
|
|
2905
|
+
const otherScopes = Object.keys(document.state).filter((scope) => scope !== job.scope);
|
|
2906
|
+
if (fromVersion > 0) for (const scope of otherScopes) {
|
|
2907
|
+
let scopedDocument;
|
|
2908
|
+
try {
|
|
2909
|
+
scopedDocument = await stores.writeCache.getState(documentId, scope, job.branch, void 0, signal);
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch ${scope} scope for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
2912
|
+
}
|
|
2913
|
+
document = {
|
|
2914
|
+
...document,
|
|
2915
|
+
state: {
|
|
2916
|
+
...document.state,
|
|
2917
|
+
[scope]: scopedDocument.state[scope]
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
1623
2922
|
try {
|
|
1624
2923
|
document = applyUpgradeDocumentAction$1(document, action, upgradePath);
|
|
1625
2924
|
} catch (error) {
|
|
@@ -1634,8 +2933,14 @@ var DocumentActionHandler = class {
|
|
|
1634
2933
|
header: document.header,
|
|
1635
2934
|
...document.state
|
|
1636
2935
|
};
|
|
2936
|
+
if (fromVersion > 0) resultingStateObj.__migrated = true;
|
|
1637
2937
|
const resultingState = JSON.stringify(resultingStateObj);
|
|
1638
|
-
const writeResult = await this.writeOperationToStore(
|
|
2938
|
+
const writeResult = await this.writeOperationToStore({
|
|
2939
|
+
documentId,
|
|
2940
|
+
documentType: document.header.documentType,
|
|
2941
|
+
scope: job.scope,
|
|
2942
|
+
branch: job.branch
|
|
2943
|
+
}, operation, executing);
|
|
1639
2944
|
if (!Array.isArray(writeResult)) return writeResult;
|
|
1640
2945
|
operation = writeResult[0];
|
|
1641
2946
|
updateDocumentRevision(document, job.scope, operation.index);
|
|
@@ -1643,7 +2948,12 @@ var DocumentActionHandler = class {
|
|
|
1643
2948
|
...document.operations,
|
|
1644
2949
|
[job.scope]: [...document.operations[job.scope] ?? [], operation]
|
|
1645
2950
|
};
|
|
1646
|
-
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
|
|
2951
|
+
stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
|
|
2952
|
+
for (const scope of otherScopes) executing.postCommitInvalidations.push({
|
|
2953
|
+
documentId,
|
|
2954
|
+
scope,
|
|
2955
|
+
branch: job.branch
|
|
2956
|
+
});
|
|
1647
2957
|
indexTxn.write([{
|
|
1648
2958
|
...operation,
|
|
1649
2959
|
documentId,
|
|
@@ -1659,8 +2969,8 @@ var DocumentActionHandler = class {
|
|
|
1659
2969
|
});
|
|
1660
2970
|
return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
1661
2971
|
}
|
|
1662
|
-
executeAddRelationship(
|
|
1663
|
-
return this.withRelationshipAction("ADD_RELATIONSHIP",
|
|
2972
|
+
executeAddRelationship(write, executing) {
|
|
2973
|
+
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 }) => {
|
|
1664
2974
|
if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
|
|
1665
2975
|
const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
|
|
1666
2976
|
txn.addToCollection(collectionId, input.targetId);
|
|
@@ -1668,8 +2978,8 @@ var DocumentActionHandler = class {
|
|
|
1668
2978
|
}
|
|
1669
2979
|
});
|
|
1670
2980
|
}
|
|
1671
|
-
executeRemoveRelationship(
|
|
1672
|
-
return this.withRelationshipAction("REMOVE_RELATIONSHIP",
|
|
2981
|
+
executeRemoveRelationship(write, executing) {
|
|
2982
|
+
return this.withRelationshipAction("REMOVE_RELATIONSHIP", write, executing, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
|
|
1673
2983
|
if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
|
|
1674
2984
|
const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
|
|
1675
2985
|
txn.removeFromCollection(collectionId, input.targetId);
|
|
@@ -1677,10 +2987,12 @@ var DocumentActionHandler = class {
|
|
|
1677
2987
|
}
|
|
1678
2988
|
});
|
|
1679
2989
|
}
|
|
1680
|
-
executeUpdateRelationship(
|
|
1681
|
-
return this.withRelationshipAction("UPDATE_RELATIONSHIP",
|
|
2990
|
+
executeUpdateRelationship(write, executing) {
|
|
2991
|
+
return this.withRelationshipAction("UPDATE_RELATIONSHIP", write, executing, null, null);
|
|
1682
2992
|
}
|
|
1683
|
-
async withRelationshipAction(actionTypeName,
|
|
2993
|
+
async withRelationshipAction(actionTypeName, write, executing, preValidate, postWrite) {
|
|
2994
|
+
const { action, skip, sourceRemote } = write;
|
|
2995
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
1684
2996
|
if (job.scope !== "document") return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} must be in "document" scope, got "${job.scope}"`), startTime);
|
|
1685
2997
|
const input = action.input;
|
|
1686
2998
|
if (!input.sourceId || !input.targetId || !input.relationshipType) return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} action requires sourceId, targetId, and relationshipType in input`), startTime);
|
|
@@ -1694,12 +3006,17 @@ var DocumentActionHandler = class {
|
|
|
1694
3006
|
} catch (error) {
|
|
1695
3007
|
return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName}: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
1696
3008
|
}
|
|
1697
|
-
let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope),
|
|
3009
|
+
let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), skip, {
|
|
1698
3010
|
documentId: input.sourceId,
|
|
1699
3011
|
scope: job.scope,
|
|
1700
3012
|
branch: job.branch
|
|
1701
3013
|
});
|
|
1702
|
-
const writeResult = await this.writeOperationToStore(
|
|
3014
|
+
const writeResult = await this.writeOperationToStore({
|
|
3015
|
+
documentId: input.sourceId,
|
|
3016
|
+
documentType: sourceDoc.header.documentType,
|
|
3017
|
+
scope: job.scope,
|
|
3018
|
+
branch: job.branch
|
|
3019
|
+
}, operation, executing);
|
|
1703
3020
|
if (!Array.isArray(writeResult)) return writeResult;
|
|
1704
3021
|
operation = writeResult[0];
|
|
1705
3022
|
sourceDoc.header.lastModifiedAtUtcIso = operation.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1714,7 +3031,7 @@ var DocumentActionHandler = class {
|
|
|
1714
3031
|
[job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
|
|
1715
3032
|
};
|
|
1716
3033
|
const resultingState = JSON.stringify(resultingStateObj);
|
|
1717
|
-
stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
|
|
3034
|
+
stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, SnapshotPosition.Head);
|
|
1718
3035
|
indexTxn.write([{
|
|
1719
3036
|
...operation,
|
|
1720
3037
|
documentId: input.sourceId,
|
|
@@ -1737,7 +3054,9 @@ var DocumentActionHandler = class {
|
|
|
1737
3054
|
});
|
|
1738
3055
|
return buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
|
|
1739
3056
|
}
|
|
1740
|
-
async writeOperationToStore(
|
|
3057
|
+
async writeOperationToStore(target, operation, executing) {
|
|
3058
|
+
const { documentId, documentType, scope, branch } = target;
|
|
3059
|
+
const { job, startTime, stores, signal } = executing;
|
|
1741
3060
|
let storedOperations;
|
|
1742
3061
|
try {
|
|
1743
3062
|
storedOperations = await stores.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
|
|
@@ -1746,10 +3065,11 @@ var DocumentActionHandler = class {
|
|
|
1746
3065
|
} catch (error) {
|
|
1747
3066
|
this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
|
|
1748
3067
|
stores.writeCache.invalidate(documentId, scope, branch);
|
|
3068
|
+
if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
|
|
1749
3069
|
return {
|
|
1750
3070
|
job,
|
|
1751
3071
|
success: false,
|
|
1752
|
-
error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
3072
|
+
error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
1753
3073
|
duration: Date.now() - startTime
|
|
1754
3074
|
};
|
|
1755
3075
|
}
|
|
@@ -1814,19 +3134,13 @@ function isValidISOTimestamp(value) {
|
|
|
1814
3134
|
if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
|
|
1815
3135
|
return !isNaN(new Date(value).getTime());
|
|
1816
3136
|
}
|
|
1817
|
-
const documentScopeActions = [
|
|
1818
|
-
"CREATE_DOCUMENT",
|
|
1819
|
-
"DELETE_DOCUMENT",
|
|
1820
|
-
"UPGRADE_DOCUMENT",
|
|
1821
|
-
"ADD_RELATIONSHIP",
|
|
1822
|
-
"REMOVE_RELATIONSHIP",
|
|
1823
|
-
"UPDATE_RELATIONSHIP"
|
|
1824
|
-
];
|
|
1825
3137
|
/**
|
|
1826
3138
|
* Simple job executor that processes a job by applying actions through document model reducers.
|
|
1827
3139
|
*/
|
|
1828
3140
|
var SimpleJobExecutor = class {
|
|
1829
3141
|
config;
|
|
3142
|
+
featureFlags;
|
|
3143
|
+
decisionModel;
|
|
1830
3144
|
signatureVerifierModule;
|
|
1831
3145
|
documentActionHandler;
|
|
1832
3146
|
executionScope;
|
|
@@ -1841,6 +3155,7 @@ var SimpleJobExecutor = class {
|
|
|
1841
3155
|
this.collectionMembershipCache = collectionMembershipCache;
|
|
1842
3156
|
this.driveContainerTypes = driveContainerTypes;
|
|
1843
3157
|
this.config = {
|
|
3158
|
+
featureFlags: config.featureFlags ?? {},
|
|
1844
3159
|
maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
|
|
1845
3160
|
maxConcurrency: config.maxConcurrency ?? 1,
|
|
1846
3161
|
jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
|
|
@@ -1848,8 +3163,10 @@ var SimpleJobExecutor = class {
|
|
|
1848
3163
|
retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
|
|
1849
3164
|
yieldDeadlineMs: config.yieldDeadlineMs ?? 50
|
|
1850
3165
|
};
|
|
3166
|
+
this.featureFlags = resolveFeatureFlags(config.featureFlags);
|
|
3167
|
+
this.decisionModel = selectDecisionModel(this.featureFlags, registry);
|
|
1851
3168
|
this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
|
|
1852
|
-
this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes);
|
|
3169
|
+
this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel);
|
|
1853
3170
|
this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
|
|
1854
3171
|
}
|
|
1855
3172
|
/**
|
|
@@ -1859,13 +3176,23 @@ var SimpleJobExecutor = class {
|
|
|
1859
3176
|
async executeJob(job, signal) {
|
|
1860
3177
|
const startTime = Date.now();
|
|
1861
3178
|
const touchedCacheEntries = [];
|
|
3179
|
+
const postCommitInvalidations = [];
|
|
1862
3180
|
let pendingEvent;
|
|
1863
3181
|
let result;
|
|
1864
3182
|
try {
|
|
1865
3183
|
result = await this.executionScope.run(async (stores) => {
|
|
1866
3184
|
const indexTxn = stores.operationIndex.start();
|
|
1867
3185
|
if (job.kind === "load") {
|
|
1868
|
-
const loadResult = await this.executeLoadJob(
|
|
3186
|
+
const loadResult = await this.executeLoadJob({
|
|
3187
|
+
job,
|
|
3188
|
+
startTime,
|
|
3189
|
+
indexTxn,
|
|
3190
|
+
stores,
|
|
3191
|
+
signal,
|
|
3192
|
+
replayingAcceptedHistory: true,
|
|
3193
|
+
evaluatedByPosition: false,
|
|
3194
|
+
postCommitInvalidations
|
|
3195
|
+
});
|
|
1869
3196
|
if (loadResult.success && loadResult.operationsWithContext) {
|
|
1870
3197
|
for (const owc of loadResult.operationsWithContext) touchedCacheEntries.push({
|
|
1871
3198
|
documentId: owc.context.documentId,
|
|
@@ -1884,7 +3211,50 @@ var SimpleJobExecutor = class {
|
|
|
1884
3211
|
}
|
|
1885
3212
|
return loadResult;
|
|
1886
3213
|
}
|
|
1887
|
-
|
|
3214
|
+
if (job.kind === "reevaluation") {
|
|
3215
|
+
const reevalResult = await this.executeReevaluationJob({
|
|
3216
|
+
job,
|
|
3217
|
+
startTime,
|
|
3218
|
+
indexTxn,
|
|
3219
|
+
stores,
|
|
3220
|
+
signal,
|
|
3221
|
+
replayingAcceptedHistory: false,
|
|
3222
|
+
evaluatedByPosition: false,
|
|
3223
|
+
postCommitInvalidations
|
|
3224
|
+
});
|
|
3225
|
+
if (reevalResult.success && reevalResult.operationsWithContext) {
|
|
3226
|
+
for (const owc of reevalResult.operationsWithContext) touchedCacheEntries.push({
|
|
3227
|
+
documentId: owc.context.documentId,
|
|
3228
|
+
scope: owc.context.scope,
|
|
3229
|
+
branch: owc.context.branch
|
|
3230
|
+
});
|
|
3231
|
+
const ordinals = await stores.operationIndex.commit(indexTxn, signal);
|
|
3232
|
+
for (let i = 0; i < reevalResult.operationsWithContext.length; i++) reevalResult.operationsWithContext[i].context.ordinal = ordinals[i];
|
|
3233
|
+
if (reevalResult.operationsWithContext.length > 0) {
|
|
3234
|
+
const collectionMemberships = await this.getCollectionMembershipsForOperations(reevalResult.operationsWithContext, stores);
|
|
3235
|
+
pendingEvent = {
|
|
3236
|
+
jobId: job.id,
|
|
3237
|
+
operations: reevalResult.operationsWithContext,
|
|
3238
|
+
jobMeta: job.meta,
|
|
3239
|
+
collectionMemberships
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
return reevalResult;
|
|
3244
|
+
}
|
|
3245
|
+
const positioned = await this.positionByTimestamp(job, stores, signal);
|
|
3246
|
+
if (positioned.error) return buildErrorResult(job, positioned.error, startTime);
|
|
3247
|
+
const executing = {
|
|
3248
|
+
job,
|
|
3249
|
+
startTime,
|
|
3250
|
+
indexTxn,
|
|
3251
|
+
stores,
|
|
3252
|
+
signal,
|
|
3253
|
+
replayingAcceptedHistory: false,
|
|
3254
|
+
evaluatedByPosition: positioned.evaluatedByPosition,
|
|
3255
|
+
postCommitInvalidations
|
|
3256
|
+
};
|
|
3257
|
+
const actionResult = await this.processActions(positioned.writes, executing);
|
|
1888
3258
|
if (!actionResult.success) return {
|
|
1889
3259
|
job,
|
|
1890
3260
|
success: false,
|
|
@@ -1896,6 +3266,16 @@ var SimpleJobExecutor = class {
|
|
|
1896
3266
|
scope: owc.context.scope,
|
|
1897
3267
|
branch: owc.context.branch
|
|
1898
3268
|
});
|
|
3269
|
+
const reevaluationError = await this.reevaluateIfCriteriaMet({
|
|
3270
|
+
scope: job.scope,
|
|
3271
|
+
operations: actionResult.generatedOperations
|
|
3272
|
+
}, executing);
|
|
3273
|
+
if (reevaluationError) return {
|
|
3274
|
+
job,
|
|
3275
|
+
success: false,
|
|
3276
|
+
error: reevaluationError,
|
|
3277
|
+
duration: Date.now() - startTime
|
|
3278
|
+
};
|
|
1899
3279
|
const ordinals = await stores.operationIndex.commit(indexTxn, signal);
|
|
1900
3280
|
if (actionResult.operationsWithContext.length > 0) {
|
|
1901
3281
|
for (let i = 0; i < actionResult.operationsWithContext.length; i++) actionResult.operationsWithContext[i].context.ordinal = ordinals[i];
|
|
@@ -1922,6 +3302,7 @@ var SimpleJobExecutor = class {
|
|
|
1922
3302
|
}
|
|
1923
3303
|
throw error;
|
|
1924
3304
|
}
|
|
3305
|
+
if (result.success) for (const entry of postCommitInvalidations) this.writeCache.invalidate(entry.documentId, entry.scope, entry.branch);
|
|
1925
3306
|
if (pendingEvent) this.eventBus.emit(ReactorEventTypes.JOB_WRITE_READY, pendingEvent).catch((error) => {
|
|
1926
3307
|
this.logger.error("Failed to emit JOB_WRITE_READY event: @Event : @Error", pendingEvent, error);
|
|
1927
3308
|
});
|
|
@@ -1931,7 +3312,9 @@ var SimpleJobExecutor = class {
|
|
|
1931
3312
|
const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
|
|
1932
3313
|
return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
|
|
1933
3314
|
}
|
|
1934
|
-
async processActions(
|
|
3315
|
+
async processActions(writes, executing) {
|
|
3316
|
+
const { job, signal } = executing;
|
|
3317
|
+
const actions = writes.map((write) => write.action);
|
|
1935
3318
|
const generatedOperations = [];
|
|
1936
3319
|
const operationsWithContext = [];
|
|
1937
3320
|
try {
|
|
@@ -1948,14 +3331,11 @@ var SimpleJobExecutor = class {
|
|
|
1948
3331
|
success: false,
|
|
1949
3332
|
generatedOperations,
|
|
1950
3333
|
operationsWithContext,
|
|
1951
|
-
error:
|
|
3334
|
+
error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
|
|
1952
3335
|
};
|
|
1953
3336
|
let lastYield = performance.now();
|
|
1954
|
-
for (
|
|
1955
|
-
const
|
|
1956
|
-
const skip = skipValues?.[actionIndex] ?? 0;
|
|
1957
|
-
const sourceOperation = sourceOperations?.[actionIndex];
|
|
1958
|
-
const result = documentScopeActions.includes(action.type) ? await this.documentActionHandler.execute(job, action, startTime, indexTxn, stores, skip, sourceRemote, signal) : await this.executeRegularAction(job, action, startTime, indexTxn, stores, skip, sourceOperation, sourceRemote, signal);
|
|
3337
|
+
for (const write of writes) {
|
|
3338
|
+
const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
|
|
1959
3339
|
const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
|
|
1960
3340
|
if (error !== null) return {
|
|
1961
3341
|
success: false,
|
|
@@ -1980,14 +3360,44 @@ var SimpleJobExecutor = class {
|
|
|
1980
3360
|
operationsWithContext
|
|
1981
3361
|
};
|
|
1982
3362
|
}
|
|
1983
|
-
async executeRegularAction(
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
3363
|
+
async executeRegularAction(write, executing) {
|
|
3364
|
+
const { action, skip, sourceOperation, sourceRemote, deniedReason } = write;
|
|
3365
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
3366
|
+
let appendCondition;
|
|
3367
|
+
let documentVersion;
|
|
3368
|
+
const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
|
|
3369
|
+
if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
|
|
3370
|
+
const target = {
|
|
3371
|
+
documentId: job.documentId,
|
|
3372
|
+
branch: job.branch
|
|
3373
|
+
};
|
|
3374
|
+
let admission;
|
|
3375
|
+
try {
|
|
3376
|
+
admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
|
|
3377
|
+
address: action.context?.signer?.user.address,
|
|
3378
|
+
key: action.context?.signer?.app.key
|
|
3379
|
+
}, {
|
|
3380
|
+
verb: "execute",
|
|
3381
|
+
scope: action.scope,
|
|
3382
|
+
operation: action.type
|
|
3383
|
+
}, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3386
|
+
}
|
|
3387
|
+
if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
|
|
3388
|
+
appendCondition = admission.appendCondition;
|
|
3389
|
+
documentVersion = admission.documentVersion;
|
|
3390
|
+
} else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
|
|
3391
|
+
else {
|
|
3392
|
+
let docMeta;
|
|
3393
|
+
try {
|
|
3394
|
+
docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3397
|
+
}
|
|
3398
|
+
if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
3399
|
+
documentVersion = docMeta.state.version;
|
|
1989
3400
|
}
|
|
1990
|
-
if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
1991
3401
|
if (isUndoRedo(action) || action.type === "PRUNE" || action.type === "NOOP" && skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
|
|
1992
3402
|
let document;
|
|
1993
3403
|
try {
|
|
@@ -1995,16 +3405,48 @@ var SimpleJobExecutor = class {
|
|
|
1995
3405
|
} catch (error) {
|
|
1996
3406
|
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
1997
3407
|
}
|
|
3408
|
+
if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
|
|
3409
|
+
const subject = {
|
|
3410
|
+
address: write.action.context?.signer?.user.address,
|
|
3411
|
+
key: write.action.context?.signer?.app.key
|
|
3412
|
+
};
|
|
3413
|
+
if (decide(document.state.auth, subject, {
|
|
3414
|
+
verb: "execute",
|
|
3415
|
+
scope: action.scope,
|
|
3416
|
+
operation: action.type
|
|
3417
|
+
}) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
|
|
3418
|
+
}
|
|
1998
3419
|
let module;
|
|
1999
3420
|
try {
|
|
2000
|
-
|
|
2001
|
-
module = this.registry.getModule(document.header.documentType, moduleVersion);
|
|
3421
|
+
module = this.registry.getModule(document.header.documentType, normalizeDocumentModelVersion(documentVersion));
|
|
2002
3422
|
} catch (error) {
|
|
2003
3423
|
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
2004
3424
|
}
|
|
2005
3425
|
let updatedDocument;
|
|
2006
|
-
|
|
2007
|
-
const
|
|
3426
|
+
if (deniedReason !== void 0) {
|
|
3427
|
+
const index = getNextIndexForScope(document, job.scope);
|
|
3428
|
+
const denied = createOperation(action, index, skip, {
|
|
3429
|
+
documentId: job.documentId,
|
|
3430
|
+
scope: job.scope,
|
|
3431
|
+
branch: job.branch
|
|
3432
|
+
});
|
|
3433
|
+
denied.deniedReason = deniedReason;
|
|
3434
|
+
let standing = document;
|
|
3435
|
+
if (skip > 0) try {
|
|
3436
|
+
standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
3439
|
+
}
|
|
3440
|
+
denied.hash = hashDocumentStateForScope(standing, job.scope);
|
|
3441
|
+
updatedDocument = {
|
|
3442
|
+
...standing,
|
|
3443
|
+
operations: {
|
|
3444
|
+
...standing.operations,
|
|
3445
|
+
[job.scope]: [...standing.operations[job.scope] ?? [], denied]
|
|
3446
|
+
}
|
|
3447
|
+
};
|
|
3448
|
+
} else try {
|
|
3449
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
2008
3450
|
const reducerOptions = sourceOperation ? {
|
|
2009
3451
|
skip,
|
|
2010
3452
|
branch: job.branch,
|
|
@@ -2035,14 +3477,15 @@ var SimpleJobExecutor = class {
|
|
|
2035
3477
|
try {
|
|
2036
3478
|
storedOperations = await stores.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
|
|
2037
3479
|
txn.addOperations(newOperation);
|
|
2038
|
-
}, signal);
|
|
3480
|
+
}, signal, appendCondition);
|
|
2039
3481
|
} catch (error) {
|
|
2040
3482
|
this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
|
|
2041
3483
|
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
3484
|
+
if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
|
|
2042
3485
|
return {
|
|
2043
3486
|
job,
|
|
2044
3487
|
success: false,
|
|
2045
|
-
error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
3488
|
+
error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
2046
3489
|
duration: Date.now() - startTime
|
|
2047
3490
|
};
|
|
2048
3491
|
}
|
|
@@ -2051,7 +3494,7 @@ var SimpleJobExecutor = class {
|
|
|
2051
3494
|
...updatedDocument.header.revision,
|
|
2052
3495
|
[scope]: storedOperation.index + 1
|
|
2053
3496
|
};
|
|
2054
|
-
stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument);
|
|
3497
|
+
stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument, SnapshotPosition.Head);
|
|
2055
3498
|
indexTxn.write([{
|
|
2056
3499
|
...storedOperation,
|
|
2057
3500
|
documentId: job.documentId,
|
|
@@ -2060,6 +3503,7 @@ var SimpleJobExecutor = class {
|
|
|
2060
3503
|
scope,
|
|
2061
3504
|
sourceRemote
|
|
2062
3505
|
}]);
|
|
3506
|
+
if (scope === "auth") indexTxn.recordGroupReferences(job.documentId, mentionedGroupIds(action));
|
|
2063
3507
|
return {
|
|
2064
3508
|
job,
|
|
2065
3509
|
success: true,
|
|
@@ -2078,14 +3522,291 @@ var SimpleJobExecutor = class {
|
|
|
2078
3522
|
duration: Date.now() - startTime
|
|
2079
3523
|
};
|
|
2080
3524
|
}
|
|
2081
|
-
|
|
3525
|
+
/**
|
|
3526
|
+
* Orders a write by timestamp and decides it where it lands. The caller
|
|
3527
|
+
* supplies the timestamp, so a write can belong before operations already
|
|
3528
|
+
* stored; those are re-appended alongside it, the way a load reshuffles.
|
|
3529
|
+
*
|
|
3530
|
+
* Deciding a backdated write at the stream heads instead of at its position
|
|
3531
|
+
* would overwrite the verdict every other replica computes for it.
|
|
3532
|
+
*/
|
|
3533
|
+
async positionByTimestamp(job, stores, signal) {
|
|
3534
|
+
const plain = () => ({
|
|
3535
|
+
writes: job.actions.map((action) => ({
|
|
3536
|
+
action,
|
|
3537
|
+
skip: 0,
|
|
3538
|
+
sourceRemote: ""
|
|
3539
|
+
})),
|
|
3540
|
+
evaluatedByPosition: false
|
|
3541
|
+
});
|
|
3542
|
+
if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
|
|
3543
|
+
let earliest = job.actions[0].timestampUtcMs;
|
|
3544
|
+
let earliestAt = Date.parse(earliest);
|
|
3545
|
+
for (const action of job.actions) {
|
|
3546
|
+
const at = Date.parse(action.timestampUtcMs);
|
|
3547
|
+
if (at < earliestAt) {
|
|
3548
|
+
earliest = action.timestampUtcMs;
|
|
3549
|
+
earliestAt = at;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3553
|
+
const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
|
|
3554
|
+
if (this.featureFlags.authEnforcement && job.scope === "auth") {
|
|
3555
|
+
const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
|
|
3556
|
+
const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
|
|
3557
|
+
if (violation) return {
|
|
3558
|
+
writes: [],
|
|
3559
|
+
evaluatedByPosition: false,
|
|
3560
|
+
error: violation
|
|
3561
|
+
};
|
|
3562
|
+
if (!backdated) return plain();
|
|
3563
|
+
return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
|
|
3564
|
+
}
|
|
3565
|
+
if (!backdated) return plain();
|
|
3566
|
+
const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
|
|
3567
|
+
if (conflicting.length === 0) {
|
|
3568
|
+
if (!this.featureFlags.authEnforcement) return plain();
|
|
3569
|
+
return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
|
|
3570
|
+
}
|
|
3571
|
+
const nextIndex = revisions.revision[job.scope] ?? 0;
|
|
3572
|
+
let firstConflicting = conflicting[0].index;
|
|
3573
|
+
for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
|
|
3574
|
+
const incoming = job.actions.map((action, i) => ({
|
|
3575
|
+
id: action.id,
|
|
3576
|
+
index: nextIndex + i,
|
|
3577
|
+
skip: 0,
|
|
3578
|
+
hash: "",
|
|
3579
|
+
timestampUtcMs: action.timestampUtcMs,
|
|
3580
|
+
action
|
|
3581
|
+
}));
|
|
3582
|
+
const merged = reshuffleByTimestamp({
|
|
3583
|
+
index: nextIndex,
|
|
3584
|
+
skip: retractionSkip(nextIndex, firstConflicting)
|
|
3585
|
+
}, conflicting, incoming);
|
|
3586
|
+
stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
|
|
3587
|
+
if (!this.featureFlags.authEnforcement) return {
|
|
3588
|
+
writes: merged.map((operation) => ({
|
|
3589
|
+
action: operation.action,
|
|
3590
|
+
skip: operation.skip,
|
|
3591
|
+
sourceRemote: ""
|
|
3592
|
+
})),
|
|
3593
|
+
evaluatedByPosition: false
|
|
3594
|
+
};
|
|
3595
|
+
return this.evaluatePositioned(job, stores, merged, signal);
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* Decides each operation where it lands and carries the verdict on it. A
|
|
3599
|
+
* refused submitted action is reported to the caller and nothing is stored; a
|
|
3600
|
+
* refused operation the reshuffle merely moved keeps its verdict, because it
|
|
3601
|
+
* already holds a position.
|
|
3602
|
+
*
|
|
3603
|
+
* The operations carry the indexes and skips they will be stored at, because
|
|
3604
|
+
* the walk resolves skips before it orders them.
|
|
3605
|
+
*/
|
|
3606
|
+
async evaluatePositioned(job, stores, operations, signal) {
|
|
3607
|
+
const reasons = await evaluateByPosition(this.decisionModel, {
|
|
3608
|
+
documentId: job.documentId,
|
|
3609
|
+
branch: job.branch
|
|
3610
|
+
}, {
|
|
3611
|
+
scope: job.scope,
|
|
3612
|
+
operations
|
|
3613
|
+
}, stores, signal);
|
|
3614
|
+
const submitted = new Set(job.actions.map((action) => action.id));
|
|
3615
|
+
for (let i = 0; i < operations.length; i++) {
|
|
3616
|
+
const reason = reasons[i];
|
|
3617
|
+
if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
|
|
3618
|
+
writes: [],
|
|
3619
|
+
evaluatedByPosition: false,
|
|
3620
|
+
error: refusalError(reason, job.documentId, null, operations[i].action)
|
|
3621
|
+
};
|
|
3622
|
+
}
|
|
3623
|
+
return {
|
|
3624
|
+
writes: operations.map((operation, i) => ({
|
|
3625
|
+
action: operation.action,
|
|
3626
|
+
skip: operation.skip,
|
|
3627
|
+
sourceRemote: "",
|
|
3628
|
+
deniedReason: reasons[i]
|
|
3629
|
+
})),
|
|
3630
|
+
evaluatedByPosition: true
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* The scopes a re-evaluation pass visits, in a fixed order.
|
|
3635
|
+
*
|
|
3636
|
+
* The revisions map comes from a query with no ORDER BY, and the order is
|
|
3637
|
+
* load-bearing: each scope's pass re-reads the auth stream, and the walk skips
|
|
3638
|
+
* an operation by its stored denial, so a denial this pass just wrote is
|
|
3639
|
+
* visible to a later-visited scope and invisible to an earlier one. The model's
|
|
3640
|
+
* own projection order leads, then the rest sorted, so the pass is reproducible
|
|
3641
|
+
* across replicas and across runs.
|
|
3642
|
+
*/
|
|
3643
|
+
evaluationOrder(target, revision) {
|
|
3644
|
+
const definition = this.decisionModel(target);
|
|
3645
|
+
const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
|
|
3646
|
+
const leading = [];
|
|
3647
|
+
for (const stream of staticReadSet(definition)) {
|
|
3648
|
+
const scope = stream.query.scope;
|
|
3649
|
+
if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
|
|
3650
|
+
}
|
|
3651
|
+
const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
|
|
3652
|
+
return [...leading, ...rest];
|
|
3653
|
+
}
|
|
3654
|
+
/**
|
|
3655
|
+
* The first timestamp in the batch that does not strictly exceed everything
|
|
3656
|
+
* ahead of it, or undefined when the whole batch is monotonic.
|
|
3657
|
+
*
|
|
3658
|
+
* The bound is carried forward rather than compared against one stored maximum,
|
|
3659
|
+
* because a single execute can carry several auth actions stamped in the same
|
|
3660
|
+
* millisecond. Letting a tie through would store a stream the position walk
|
|
3661
|
+
* then refuses to read, with no repair path.
|
|
3662
|
+
*/
|
|
3663
|
+
firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
|
|
3664
|
+
let boundIso = newest;
|
|
3665
|
+
let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
|
|
3666
|
+
for (const entry of entries) {
|
|
3667
|
+
if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
|
|
3668
|
+
const at = Date.parse(entry.timestampUtcMs);
|
|
3669
|
+
if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
|
|
3670
|
+
bound = at;
|
|
3671
|
+
boundIso = entry.timestampUtcMs;
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
/** The operations a batch of submitted actions appends at the scope's tail. */
|
|
3675
|
+
appendedOperations(job, nextIndex) {
|
|
3676
|
+
return job.actions.map((action, i) => ({
|
|
3677
|
+
id: action.id,
|
|
3678
|
+
index: nextIndex + i,
|
|
3679
|
+
skip: 0,
|
|
3680
|
+
hash: "",
|
|
3681
|
+
timestampUtcMs: action.timestampUtcMs,
|
|
3682
|
+
action
|
|
3683
|
+
}));
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* Re-evaluates the document when a write meets both criteria: it was written
|
|
3687
|
+
* to a stream the model reads, and it is timestamped before an operation
|
|
3688
|
+
* already stored. The caller supplies the timestamp and the reactor does not replace
|
|
3689
|
+
* it, so a mutation job can write such an operation just as a load job can,
|
|
3690
|
+
* which is why both executeJob and executeLoadJob call this.
|
|
3691
|
+
*/
|
|
3692
|
+
async reevaluateIfCriteriaMet(criteria, executing) {
|
|
3693
|
+
if (!this.featureFlags.documentDecisions) return;
|
|
3694
|
+
const { job, stores, signal } = executing;
|
|
3695
|
+
const target = {
|
|
3696
|
+
documentId: job.documentId,
|
|
3697
|
+
branch: job.branch
|
|
3698
|
+
};
|
|
3699
|
+
if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
|
|
3700
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3701
|
+
const latest = Date.parse(revisions.latestTimestamp);
|
|
3702
|
+
if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
|
|
3703
|
+
return (await this.reevaluateDocument(executing)).error;
|
|
3704
|
+
}
|
|
3705
|
+
/**
|
|
3706
|
+
* Re-evaluates every scope the model evaluates. Where an operation's
|
|
3707
|
+
* evaluation differs from what is stored, the tail from that operation is
|
|
3708
|
+
* re-appended, carrying a skip that spans the indices it supersedes.
|
|
3709
|
+
*/
|
|
3710
|
+
async reevaluateDocument(executing) {
|
|
3711
|
+
const { job, stores, signal } = executing;
|
|
3712
|
+
const target = {
|
|
3713
|
+
documentId: job.documentId,
|
|
3714
|
+
branch: job.branch
|
|
3715
|
+
};
|
|
3716
|
+
const reappended = [];
|
|
3717
|
+
const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
|
|
3718
|
+
for (const scope of this.evaluationOrder(target, revisions.revision)) {
|
|
3719
|
+
const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
|
|
3720
|
+
const effective = garbageCollect(sortOperations([...stored]));
|
|
3721
|
+
if (effective.length === 0) continue;
|
|
3722
|
+
const reevaluated = await evaluateByPosition(this.decisionModel, target, {
|
|
3723
|
+
scope,
|
|
3724
|
+
operations: effective
|
|
3725
|
+
}, stores, signal);
|
|
3726
|
+
const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== reevaluated[i]);
|
|
3727
|
+
if (firstChange === -1) continue;
|
|
3728
|
+
const tail = effective.slice(firstChange);
|
|
3729
|
+
const nextIndex = revisions.revision[scope];
|
|
3730
|
+
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
3731
|
+
const result = await this.processActions(tail.map((operation, i) => ({
|
|
3732
|
+
action: operation.action,
|
|
3733
|
+
skip: i === 0 ? retractionSkip(nextIndex, tail[0].index) : 0,
|
|
3734
|
+
sourceRemote: "",
|
|
3735
|
+
deniedReason: reevaluated[firstChange + i]
|
|
3736
|
+
})), {
|
|
3737
|
+
...executing,
|
|
3738
|
+
job: {
|
|
3739
|
+
...job,
|
|
3740
|
+
scope
|
|
3741
|
+
},
|
|
3742
|
+
replayingAcceptedHistory: true,
|
|
3743
|
+
evaluatedByPosition: true
|
|
3744
|
+
});
|
|
3745
|
+
if (!result.success) return {
|
|
3746
|
+
error: result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`),
|
|
3747
|
+
operationsWithContext: reappended
|
|
3748
|
+
};
|
|
3749
|
+
reappended.push(...result.operationsWithContext);
|
|
3750
|
+
}
|
|
3751
|
+
return { operationsWithContext: reappended };
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Re-judges a document's stored operations because a read-set stream in
|
|
3755
|
+
* another document (a group) gained an operation. The trigger timestamp
|
|
3756
|
+
* bounds the work: an operation later than everything this document holds
|
|
3757
|
+
* cannot change any evaluation, so the pass is skipped.
|
|
3758
|
+
*/
|
|
3759
|
+
async executeReevaluationJob(executing) {
|
|
3760
|
+
const { job, startTime, stores, signal } = executing;
|
|
3761
|
+
if (!this.featureFlags.documentDecisions) return {
|
|
3762
|
+
job,
|
|
3763
|
+
success: true,
|
|
3764
|
+
operations: [],
|
|
3765
|
+
operationsWithContext: [],
|
|
3766
|
+
duration: Date.now() - startTime
|
|
3767
|
+
};
|
|
3768
|
+
const trigger = job.meta.triggerTimestampUtcMs;
|
|
3769
|
+
if (typeof trigger === "string") {
|
|
3770
|
+
let latestTimestamp;
|
|
3771
|
+
try {
|
|
3772
|
+
latestTimestamp = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).latestTimestamp;
|
|
3773
|
+
} catch {
|
|
3774
|
+
return {
|
|
3775
|
+
job,
|
|
3776
|
+
success: true,
|
|
3777
|
+
operations: [],
|
|
3778
|
+
operationsWithContext: [],
|
|
3779
|
+
duration: Date.now() - startTime
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
if (Date.parse(trigger) > Date.parse(latestTimestamp)) return {
|
|
3783
|
+
job,
|
|
3784
|
+
success: true,
|
|
3785
|
+
operations: [],
|
|
3786
|
+
operationsWithContext: [],
|
|
3787
|
+
duration: Date.now() - startTime
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
const outcome = await this.reevaluateDocument(executing);
|
|
3791
|
+
if (outcome.error) return buildErrorResult(job, outcome.error, startTime);
|
|
3792
|
+
return {
|
|
3793
|
+
job,
|
|
3794
|
+
success: true,
|
|
3795
|
+
operations: outcome.operationsWithContext.map((owc) => owc.operation),
|
|
3796
|
+
operationsWithContext: outcome.operationsWithContext,
|
|
3797
|
+
duration: Date.now() - startTime
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
async executeLoadJob(executing) {
|
|
3801
|
+
const { job, startTime, indexTxn, stores, signal } = executing;
|
|
2082
3802
|
if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
|
|
2083
3803
|
let docMeta;
|
|
2084
3804
|
try {
|
|
2085
3805
|
docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
|
|
2086
3806
|
} catch {}
|
|
2087
|
-
if (docMeta?.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
3807
|
+
if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
2088
3808
|
const scope = job.scope;
|
|
3809
|
+
const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
|
|
2089
3810
|
let latestRevision;
|
|
2090
3811
|
try {
|
|
2091
3812
|
latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
|
|
@@ -2095,7 +3816,7 @@ var SimpleJobExecutor = class {
|
|
|
2095
3816
|
for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
|
|
2096
3817
|
job,
|
|
2097
3818
|
success: false,
|
|
2098
|
-
error:
|
|
3819
|
+
error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
|
|
2099
3820
|
duration: Date.now() - startTime
|
|
2100
3821
|
};
|
|
2101
3822
|
let minIncomingIndex = Number.POSITIVE_INFINITY;
|
|
@@ -2103,7 +3824,7 @@ var SimpleJobExecutor = class {
|
|
|
2103
3824
|
for (const operation of job.operations) {
|
|
2104
3825
|
minIncomingIndex = Math.min(minIncomingIndex, operation.index);
|
|
2105
3826
|
const ts = operation.timestampUtcMs || "";
|
|
2106
|
-
if (ts < minIncomingTimestamp) minIncomingTimestamp = ts;
|
|
3827
|
+
if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
|
|
2107
3828
|
}
|
|
2108
3829
|
let conflictingOps;
|
|
2109
3830
|
try {
|
|
@@ -2128,11 +3849,14 @@ var SimpleJobExecutor = class {
|
|
|
2128
3849
|
}
|
|
2129
3850
|
return true;
|
|
2130
3851
|
});
|
|
2131
|
-
const existingOpsToReshuffle = nonSupersededOps;
|
|
2132
|
-
|
|
3852
|
+
const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
|
|
3853
|
+
const actionIdCounts = /* @__PURE__ */ new Map();
|
|
3854
|
+
for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
|
|
3855
|
+
const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
|
|
3856
|
+
if (reshuffleCost > this.config.maxSkipThreshold) return {
|
|
2133
3857
|
job,
|
|
2134
3858
|
success: false,
|
|
2135
|
-
error:
|
|
3859
|
+
error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
|
|
2136
3860
|
duration: Date.now() - startTime
|
|
2137
3861
|
};
|
|
2138
3862
|
let skipCount = existingOpsToReshuffle.length;
|
|
@@ -2160,6 +3884,16 @@ var SimpleJobExecutor = class {
|
|
|
2160
3884
|
operationsWithContext: [],
|
|
2161
3885
|
duration: Date.now() - startTime
|
|
2162
3886
|
};
|
|
3887
|
+
if (monotonicAuthStream) {
|
|
3888
|
+
const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
|
|
3889
|
+
const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
|
|
3890
|
+
if (violation) return {
|
|
3891
|
+
job,
|
|
3892
|
+
success: false,
|
|
3893
|
+
error: violation,
|
|
3894
|
+
duration: Date.now() - startTime
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
2163
3897
|
const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
|
|
2164
3898
|
...operation,
|
|
2165
3899
|
index: latestRevision + i
|
|
@@ -2171,10 +3905,31 @@ var SimpleJobExecutor = class {
|
|
|
2171
3905
|
id: operation.id
|
|
2172
3906
|
})));
|
|
2173
3907
|
for (const operation of reshuffledOperations) if (operation.action.type === "NOOP") operation.skip = 1;
|
|
2174
|
-
|
|
2175
|
-
|
|
3908
|
+
let deniedReasons;
|
|
3909
|
+
if (this.featureFlags.documentDecisions) try {
|
|
3910
|
+
deniedReasons = await evaluateByPosition(this.decisionModel, {
|
|
3911
|
+
documentId: job.documentId,
|
|
3912
|
+
branch: job.branch
|
|
3913
|
+
}, {
|
|
3914
|
+
scope,
|
|
3915
|
+
operations: reshuffledOperations
|
|
3916
|
+
}, stores, signal);
|
|
3917
|
+
} catch (error) {
|
|
3918
|
+
return {
|
|
3919
|
+
job,
|
|
3920
|
+
success: false,
|
|
3921
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
3922
|
+
duration: Date.now() - startTime
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
2176
3925
|
const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
|
|
2177
|
-
const result = await this.processActions(
|
|
3926
|
+
const result = await this.processActions(reshuffledOperations.map((operation, i) => ({
|
|
3927
|
+
action: operation.action,
|
|
3928
|
+
skip: operation.skip,
|
|
3929
|
+
sourceOperation: operation,
|
|
3930
|
+
sourceRemote: effectiveSourceRemote,
|
|
3931
|
+
deniedReason: deniedReasons?.[i]
|
|
3932
|
+
})), executing);
|
|
2178
3933
|
if (!result.success) return {
|
|
2179
3934
|
job,
|
|
2180
3935
|
success: false,
|
|
@@ -2183,6 +3938,16 @@ var SimpleJobExecutor = class {
|
|
|
2183
3938
|
};
|
|
2184
3939
|
stores.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
2185
3940
|
if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
|
|
3941
|
+
const reevaluationError = await this.reevaluateIfCriteriaMet({
|
|
3942
|
+
scope,
|
|
3943
|
+
operations: result.generatedOperations
|
|
3944
|
+
}, executing);
|
|
3945
|
+
if (reevaluationError) return {
|
|
3946
|
+
job,
|
|
3947
|
+
success: false,
|
|
3948
|
+
error: reevaluationError,
|
|
3949
|
+
duration: Date.now() - startTime
|
|
3950
|
+
};
|
|
2186
3951
|
return {
|
|
2187
3952
|
job,
|
|
2188
3953
|
success: true,
|
|
@@ -2315,7 +4080,7 @@ var DocumentModelRegistry = class {
|
|
|
2315
4080
|
}
|
|
2316
4081
|
computeUpgradePath(documentType, fromVersion, toVersion) {
|
|
2317
4082
|
if (fromVersion === toVersion) return [];
|
|
2318
|
-
if (toVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, toVersion);
|
|
4083
|
+
if (toVersion < fromVersion) throw new DowngradeNotSupportedError$1(documentType, fromVersion, toVersion);
|
|
2319
4084
|
const manifest = this.getUpgradeManifest(documentType);
|
|
2320
4085
|
const path = [];
|
|
2321
4086
|
for (let v = fromVersion + 1; v <= toVersion; v++) {
|
|
@@ -2419,36 +4184,6 @@ function paginateRows(rows, paging, cursorOf, toItem, refetch) {
|
|
|
2419
4184
|
};
|
|
2420
4185
|
}
|
|
2421
4186
|
//#endregion
|
|
2422
|
-
//#region src/storage/interfaces.ts
|
|
2423
|
-
/**
|
|
2424
|
-
* Thrown when an operation with the same identity already exists in the store.
|
|
2425
|
-
*/
|
|
2426
|
-
var DuplicateOperationError = class extends Error {
|
|
2427
|
-
constructor(description) {
|
|
2428
|
-
super(`Duplicate operation: ${description}`);
|
|
2429
|
-
this.name = "DuplicateOperationError";
|
|
2430
|
-
}
|
|
2431
|
-
};
|
|
2432
|
-
/**
|
|
2433
|
-
* Thrown when a concurrent write conflict is detected during an atomic apply.
|
|
2434
|
-
*/
|
|
2435
|
-
var OptimisticLockError = class extends Error {
|
|
2436
|
-
constructor(message) {
|
|
2437
|
-
super(message);
|
|
2438
|
-
this.name = "OptimisticLockError";
|
|
2439
|
-
}
|
|
2440
|
-
};
|
|
2441
|
-
/**
|
|
2442
|
-
* Thrown when the caller-provided revision does not match the current
|
|
2443
|
-
* stored revision, indicating a stale read.
|
|
2444
|
-
*/
|
|
2445
|
-
var RevisionMismatchError = class extends Error {
|
|
2446
|
-
constructor(expected, actual) {
|
|
2447
|
-
super(`Revision mismatch: expected ${expected}, got ${actual}`);
|
|
2448
|
-
this.name = "RevisionMismatchError";
|
|
2449
|
-
}
|
|
2450
|
-
};
|
|
2451
|
-
//#endregion
|
|
2452
4187
|
//#region src/storage/txn.ts
|
|
2453
4188
|
var AtomicTransaction = class {
|
|
2454
4189
|
operations = [];
|
|
@@ -2473,6 +4208,7 @@ var AtomicTransaction = class {
|
|
|
2473
4208
|
action: JSON.stringify(op.action),
|
|
2474
4209
|
skip: op.skip,
|
|
2475
4210
|
error: op.error || null,
|
|
4211
|
+
deniedReason: op.deniedReason || null,
|
|
2476
4212
|
hash: op.hash
|
|
2477
4213
|
});
|
|
2478
4214
|
}
|
|
@@ -2506,12 +4242,12 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2506
4242
|
instance.trx = trx;
|
|
2507
4243
|
return instance;
|
|
2508
4244
|
}
|
|
2509
|
-
async apply(documentId, documentType, scope, branch, revision, fn, signal) {
|
|
4245
|
+
async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
|
|
2510
4246
|
if (this.trx) {
|
|
2511
4247
|
let executeResult = null;
|
|
2512
4248
|
let uniqueCtx = null;
|
|
2513
4249
|
try {
|
|
2514
|
-
executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal);
|
|
4250
|
+
executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
|
|
2515
4251
|
} catch (error) {
|
|
2516
4252
|
if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
|
|
2517
4253
|
else throw error;
|
|
@@ -2523,7 +4259,7 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2523
4259
|
let uniqueCtx = null;
|
|
2524
4260
|
try {
|
|
2525
4261
|
transactionResult = await this.db.transaction().execute(async (trx) => {
|
|
2526
|
-
return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal);
|
|
4262
|
+
return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
|
|
2527
4263
|
});
|
|
2528
4264
|
} catch (error) {
|
|
2529
4265
|
if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
|
|
@@ -2542,12 +4278,13 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2542
4278
|
const op = ctx.stagedOps[0];
|
|
2543
4279
|
throw new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
|
|
2544
4280
|
}
|
|
2545
|
-
async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal) {
|
|
4281
|
+
async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
|
|
2546
4282
|
throwIfAborted(signal);
|
|
2547
4283
|
const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
|
|
2548
4284
|
await fn(atomicTxn);
|
|
2549
4285
|
const operations = atomicTxn.getOperations();
|
|
2550
4286
|
if (operations.length === 0) return [];
|
|
4287
|
+
if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
|
|
2551
4288
|
const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
|
|
2552
4289
|
const currentRevision = latestOp ? latestOp.index : -1;
|
|
2553
4290
|
if (currentRevision !== revision - 1) {
|
|
@@ -2563,22 +4300,91 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2563
4300
|
op.prevOpId = prevOpId;
|
|
2564
4301
|
prevOpId = op.opId;
|
|
2565
4302
|
}
|
|
4303
|
+
let insertedCount = operations.length;
|
|
2566
4304
|
try {
|
|
2567
|
-
|
|
4305
|
+
if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
|
|
4306
|
+
else await trx.insertInto("Operation").values(operations).execute();
|
|
2568
4307
|
} catch (error) {
|
|
2569
4308
|
if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
|
|
2570
4309
|
throw error;
|
|
2571
4310
|
}
|
|
4311
|
+
if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
|
|
2572
4312
|
return operations.map((op) => ({
|
|
2573
4313
|
index: op.index,
|
|
2574
4314
|
timestampUtcMs: op.timestampUtcMs.toISOString(),
|
|
2575
4315
|
hash: op.hash,
|
|
2576
4316
|
skip: op.skip,
|
|
2577
4317
|
error: op.error || void 0,
|
|
4318
|
+
deniedReason: op.deniedReason || void 0,
|
|
2578
4319
|
id: op.opId,
|
|
2579
4320
|
action: JSON.parse(op.action)
|
|
2580
4321
|
}));
|
|
2581
4322
|
}
|
|
4323
|
+
/**
|
|
4324
|
+
* Locks the written stream and every read-set stream, in sorted key order
|
|
4325
|
+
* so that overlapping concurrent appends serialize rather than deadlock.
|
|
4326
|
+
* The locks are still taken one row at a time, so the query preserves that
|
|
4327
|
+
* order. It must stay separate from the guarded insert, which would
|
|
4328
|
+
* otherwise read a snapshot taken before the locks were held.
|
|
4329
|
+
*/
|
|
4330
|
+
async acquireStreamLocks(trx, documentId, scope, branch, condition) {
|
|
4331
|
+
const keys = new Set([`${documentId}:${scope}:${branch}`]);
|
|
4332
|
+
for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
|
|
4333
|
+
await sql`
|
|
4334
|
+
with ordered as materialized (
|
|
4335
|
+
select key
|
|
4336
|
+
from unnest(array[${sql.join([...keys].sort())}]::text[]) with ordinality as t(key, ord)
|
|
4337
|
+
order by ord
|
|
4338
|
+
)
|
|
4339
|
+
select pg_advisory_xact_lock(hashtext(key)) from ordered
|
|
4340
|
+
`.execute(trx);
|
|
4341
|
+
}
|
|
4342
|
+
/**
|
|
4343
|
+
* Inserts the staged operations with the condition compiled in as a WHERE
|
|
4344
|
+
* NOT EXISTS guard, making the check and the append one statement. Returns
|
|
4345
|
+
* the rows inserted; zero means the guard failed and nothing was written.
|
|
4346
|
+
*/
|
|
4347
|
+
async insertGuarded(trx, operations, condition) {
|
|
4348
|
+
const branches = operations.map((op) => trx.selectNoFrom([
|
|
4349
|
+
sql`${op.jobId}::text`.as("jobId"),
|
|
4350
|
+
sql`${op.opId}::text`.as("opId"),
|
|
4351
|
+
sql`${op.prevOpId}::text`.as("prevOpId"),
|
|
4352
|
+
sql`${op.documentId}::text`.as("documentId"),
|
|
4353
|
+
sql`${op.documentType}::text`.as("documentType"),
|
|
4354
|
+
sql`${op.scope}::text`.as("scope"),
|
|
4355
|
+
sql`${op.branch}::text`.as("branch"),
|
|
4356
|
+
sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
|
|
4357
|
+
sql`${op.index}::integer`.as("index"),
|
|
4358
|
+
sql`${op.action}::jsonb`.as("action"),
|
|
4359
|
+
sql`${op.skip}::integer`.as("skip"),
|
|
4360
|
+
sql`${op.error ?? null}::text`.as("error"),
|
|
4361
|
+
sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
|
|
4362
|
+
sql`${op.hash}::text`.as("hash")
|
|
4363
|
+
]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
|
|
4364
|
+
web("Operation.documentId", "=", s.documentId),
|
|
4365
|
+
web("Operation.scope", "=", s.scope),
|
|
4366
|
+
web("Operation.branch", "=", s.branch),
|
|
4367
|
+
web("Operation.index", ">", s.revision)
|
|
4368
|
+
]))))))));
|
|
4369
|
+
let expression = branches[0];
|
|
4370
|
+
for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
|
|
4371
|
+
return (await trx.insertInto("Operation").columns([
|
|
4372
|
+
"jobId",
|
|
4373
|
+
"opId",
|
|
4374
|
+
"prevOpId",
|
|
4375
|
+
"documentId",
|
|
4376
|
+
"documentType",
|
|
4377
|
+
"scope",
|
|
4378
|
+
"branch",
|
|
4379
|
+
"timestampUtcMs",
|
|
4380
|
+
"index",
|
|
4381
|
+
"action",
|
|
4382
|
+
"skip",
|
|
4383
|
+
"error",
|
|
4384
|
+
"deniedReason",
|
|
4385
|
+
"hash"
|
|
4386
|
+
]).expression(expression).returning("id").execute()).length;
|
|
4387
|
+
}
|
|
2582
4388
|
async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
|
|
2583
4389
|
const minIndex = revision;
|
|
2584
4390
|
const maxIndex = revision + stagedOps.length - 1;
|
|
@@ -2646,18 +4452,18 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2646
4452
|
"o1.index",
|
|
2647
4453
|
"o1.timestampUtcMs"
|
|
2648
4454
|
]).where("o1.documentId", "=", documentId).where("o1.branch", "=", branch).where((eb) => eb("o1.index", "=", eb.selectFrom("Operation as o2").select((eb2) => eb2.fn.max("o2.index").as("maxIndex")).where("o2.documentId", "=", eb.ref("o1.documentId")).where("o2.branch", "=", eb.ref("o1.branch")).where("o2.scope", "=", eb.ref("o1.scope")))).execute();
|
|
4455
|
+
const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("branch", "=", branch).executeTakeFirst();
|
|
2649
4456
|
const revision = {};
|
|
2650
|
-
|
|
2651
|
-
for (const row of scopeRevisions) {
|
|
2652
|
-
revision[row.scope] = row.index + 1;
|
|
2653
|
-
const timestamp = row.timestampUtcMs.toISOString();
|
|
2654
|
-
if (timestamp > latestTimestamp) latestTimestamp = timestamp;
|
|
2655
|
-
}
|
|
4457
|
+
for (const row of scopeRevisions) revision[row.scope] = row.index + 1;
|
|
2656
4458
|
return {
|
|
2657
4459
|
revision,
|
|
2658
|
-
latestTimestamp
|
|
4460
|
+
latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
2659
4461
|
};
|
|
2660
4462
|
}
|
|
4463
|
+
async getStreamLatestTimestamp(documentId, scope, branch, signal) {
|
|
4464
|
+
const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).executeTakeFirst();
|
|
4465
|
+
return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
|
|
4466
|
+
}
|
|
2661
4467
|
rowToOperation(row) {
|
|
2662
4468
|
return {
|
|
2663
4469
|
index: row.index,
|
|
@@ -2665,6 +4471,7 @@ var KyselyOperationStore = class KyselyOperationStore {
|
|
|
2665
4471
|
hash: row.hash,
|
|
2666
4472
|
skip: row.skip,
|
|
2667
4473
|
error: row.error || void 0,
|
|
4474
|
+
deniedReason: row.deniedReason || void 0,
|
|
2668
4475
|
id: row.opId,
|
|
2669
4476
|
action: row.action
|
|
2670
4477
|
};
|
|
@@ -2750,8 +4557,8 @@ function createForwardingPoolInstrumentation(name) {
|
|
|
2750
4557
|
}
|
|
2751
4558
|
//#endregion
|
|
2752
4559
|
//#region src/storage/migrations/001_create_operation_table.ts
|
|
2753
|
-
var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2754
|
-
async function up$
|
|
4560
|
+
var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$16 });
|
|
4561
|
+
async function up$16(db) {
|
|
2755
4562
|
await db.schema.createTable("Operation").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("jobId", "text", (col) => col.notNull()).addColumn("opId", "text", (col) => col.notNull()).addColumn("prevOpId", "text", (col) => col.notNull()).addColumn("writeTimestampUtcMs", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("timestampUtcMs", "timestamptz", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("action", "jsonb", (col) => col.notNull()).addColumn("skip", "integer", (col) => col.notNull()).addColumn("error", "text").addColumn("hash", "text", (col) => col.notNull()).addUniqueConstraint("unique_revision", [
|
|
2756
4563
|
"documentId",
|
|
2757
4564
|
"scope",
|
|
@@ -2776,8 +4583,8 @@ async function up$13(db) {
|
|
|
2776
4583
|
}
|
|
2777
4584
|
//#endregion
|
|
2778
4585
|
//#region src/storage/migrations/002_create_keyframe_table.ts
|
|
2779
|
-
var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2780
|
-
async function up$
|
|
4586
|
+
var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
|
|
4587
|
+
async function up$15(db) {
|
|
2781
4588
|
await db.schema.createTable("Keyframe").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("revision", "integer", (col) => col.notNull()).addColumn("document", "jsonb", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_keyframe", [
|
|
2782
4589
|
"documentId",
|
|
2783
4590
|
"scope",
|
|
@@ -2793,14 +4600,14 @@ async function up$12(db) {
|
|
|
2793
4600
|
}
|
|
2794
4601
|
//#endregion
|
|
2795
4602
|
//#region src/storage/migrations/003_create_document_table.ts
|
|
2796
|
-
var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2797
|
-
async function up$
|
|
4603
|
+
var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
|
|
4604
|
+
async function up$14(db) {
|
|
2798
4605
|
await db.schema.createTable("Document").addColumn("id", "text", (col) => col.primaryKey()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2799
4606
|
}
|
|
2800
4607
|
//#endregion
|
|
2801
4608
|
//#region src/storage/migrations/004_create_document_relationship_table.ts
|
|
2802
|
-
var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2803
|
-
async function up$
|
|
4609
|
+
var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
|
|
4610
|
+
async function up$13(db) {
|
|
2804
4611
|
await db.schema.createTable("DocumentRelationship").addColumn("id", "text", (col) => col.primaryKey()).addColumn("sourceId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("targetId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("relationshipType", "text", (col) => col.notNull()).addColumn("metadata", "jsonb").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_source_target_type", [
|
|
2805
4612
|
"sourceId",
|
|
2806
4613
|
"targetId",
|
|
@@ -2812,14 +4619,14 @@ async function up$10(db) {
|
|
|
2812
4619
|
}
|
|
2813
4620
|
//#endregion
|
|
2814
4621
|
//#region src/storage/migrations/005_create_indexer_state_table.ts
|
|
2815
|
-
var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2816
|
-
async function up$
|
|
4622
|
+
var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
|
|
4623
|
+
async function up$12(db) {
|
|
2817
4624
|
await db.schema.createTable("IndexerState").addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()).addColumn("lastOperationId", "integer", (col) => col.notNull()).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2818
4625
|
}
|
|
2819
4626
|
//#endregion
|
|
2820
4627
|
//#region src/storage/migrations/006_create_document_snapshot_table.ts
|
|
2821
|
-
var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2822
|
-
async function up$
|
|
4628
|
+
var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
|
|
4629
|
+
async function up$11(db) {
|
|
2823
4630
|
await db.schema.createTable("DocumentSnapshot").addColumn("id", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("slug", "text").addColumn("name", "text").addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("content", "jsonb", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("lastOperationIndex", "integer", (col) => col.notNull()).addColumn("lastOperationHash", "text", (col) => col.notNull()).addColumn("lastUpdatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("snapshotVersion", "integer", (col) => col.notNull().defaultTo(1)).addColumn("identifiers", "jsonb").addColumn("metadata", "jsonb").addColumn("isDeleted", "boolean", (col) => col.notNull().defaultTo(false)).addColumn("deletedAt", "timestamptz").addUniqueConstraint("unique_doc_scope_branch", [
|
|
2824
4631
|
"documentId",
|
|
2825
4632
|
"scope",
|
|
@@ -2840,8 +4647,8 @@ async function up$8(db) {
|
|
|
2840
4647
|
}
|
|
2841
4648
|
//#endregion
|
|
2842
4649
|
//#region src/storage/migrations/007_create_slug_mapping_table.ts
|
|
2843
|
-
var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2844
|
-
async function up$
|
|
4650
|
+
var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
|
|
4651
|
+
async function up$10(db) {
|
|
2845
4652
|
await db.schema.createTable("SlugMapping").addColumn("slug", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_docid_scope_branch", [
|
|
2846
4653
|
"documentId",
|
|
2847
4654
|
"scope",
|
|
@@ -2851,14 +4658,14 @@ async function up$7(db) {
|
|
|
2851
4658
|
}
|
|
2852
4659
|
//#endregion
|
|
2853
4660
|
//#region src/storage/migrations/008_create_view_state_table.ts
|
|
2854
|
-
var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2855
|
-
async function up$
|
|
4661
|
+
var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
|
|
4662
|
+
async function up$9(db) {
|
|
2856
4663
|
await db.schema.createTable("ViewState").addColumn("readModelId", "text", (col) => col.primaryKey()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(0)).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2857
4664
|
}
|
|
2858
4665
|
//#endregion
|
|
2859
4666
|
//#region src/storage/migrations/009_create_operation_index_tables.ts
|
|
2860
|
-
var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2861
|
-
async function up$
|
|
4667
|
+
var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
|
|
4668
|
+
async function up$8(db) {
|
|
2862
4669
|
await db.schema.createTable("document_collections").addColumn("documentId", "text", (col) => col.notNull()).addColumn("collectionId", "text", (col) => col.notNull()).addColumn("joinedOrdinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("leftOrdinal", "bigint").addPrimaryKeyConstraint("document_collections_pkey", ["documentId", "collectionId"]).execute();
|
|
2863
4670
|
await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
|
|
2864
4671
|
await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
|
|
@@ -2872,8 +4679,8 @@ async function up$5(db) {
|
|
|
2872
4679
|
}
|
|
2873
4680
|
//#endregion
|
|
2874
4681
|
//#region src/storage/migrations/010_create_sync_tables.ts
|
|
2875
|
-
var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2876
|
-
async function up$
|
|
4682
|
+
var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
|
|
4683
|
+
async function up$7(db) {
|
|
2877
4684
|
await db.schema.createTable("sync_remotes").addColumn("name", "text", (col) => col.primaryKey()).addColumn("collection_id", "text", (col) => col.notNull()).addColumn("channel_type", "text", (col) => col.notNull()).addColumn("channel_id", "text", (col) => col.notNull().defaultTo("")).addColumn("remote_name", "text", (col) => col.notNull().defaultTo("")).addColumn("channel_parameters", "jsonb", (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)).addColumn("filter_document_ids", "jsonb").addColumn("filter_scopes", "jsonb").addColumn("filter_branch", "text", (col) => col.notNull().defaultTo("main")).addColumn("push_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("push_last_success_utc_ms", "text").addColumn("push_last_failure_utc_ms", "text").addColumn("push_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("pull_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("pull_last_success_utc_ms", "text").addColumn("pull_last_failure_utc_ms", "text").addColumn("pull_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2878
4685
|
await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
|
|
2879
4686
|
await db.schema.createTable("sync_cursors").addColumn("remote_name", "text", (col) => col.primaryKey().references("sync_remotes.name").onDelete("cascade")).addColumn("cursor_ordinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_synced_at_utc_ms", "text").addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
@@ -2881,8 +4688,8 @@ async function up$4(db) {
|
|
|
2881
4688
|
}
|
|
2882
4689
|
//#endregion
|
|
2883
4690
|
//#region src/storage/migrations/011_add_cursor_type_column.ts
|
|
2884
|
-
var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2885
|
-
async function up$
|
|
4691
|
+
var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
|
|
4692
|
+
async function up$6(db) {
|
|
2886
4693
|
await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
|
|
2887
4694
|
await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
|
|
2888
4695
|
await db.schema.dropTable("sync_cursors").execute();
|
|
@@ -2891,24 +4698,82 @@ async function up$3(db) {
|
|
|
2891
4698
|
}
|
|
2892
4699
|
//#endregion
|
|
2893
4700
|
//#region src/storage/migrations/012_add_source_remote_column.ts
|
|
2894
|
-
var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2895
|
-
async function up$
|
|
4701
|
+
var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
|
|
4702
|
+
async function up$5(db) {
|
|
2896
4703
|
await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
|
|
2897
4704
|
}
|
|
2898
4705
|
//#endregion
|
|
2899
4706
|
//#region src/storage/migrations/013_create_sync_dead_letters_table.ts
|
|
2900
|
-
var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$
|
|
2901
|
-
async function up$
|
|
4707
|
+
var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
|
|
4708
|
+
async function up$4(db) {
|
|
2902
4709
|
await db.schema.createTable("sync_dead_letters").addColumn("ordinal", "serial", (col) => col.primaryKey()).addColumn("id", "text", (col) => col.unique().notNull()).addColumn("job_id", "text", (col) => col.notNull()).addColumn("job_dependencies", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("remote_name", "text", (col) => col.notNull().references("sync_remotes.name").onDelete("cascade")).addColumn("document_id", "text", (col) => col.notNull()).addColumn("scopes", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("branch", "text", (col) => col.notNull()).addColumn("operations", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("error_source", "text", (col) => col.notNull()).addColumn("error_message", "text", (col) => col.notNull()).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2903
4710
|
await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
|
|
2904
4711
|
}
|
|
2905
4712
|
//#endregion
|
|
2906
4713
|
//#region src/storage/migrations/014_create_processor_cursor_table.ts
|
|
2907
|
-
var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up });
|
|
2908
|
-
async function up(db) {
|
|
4714
|
+
var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
|
|
4715
|
+
async function up$3(db) {
|
|
2909
4716
|
await db.schema.createTable("ProcessorCursor").addColumn("processorId", "text", (col) => col.primaryKey()).addColumn("factoryId", "text", (col) => col.notNull()).addColumn("driveId", "text", (col) => col.notNull()).addColumn("processorIndex", "integer", (col) => col.notNull()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(sql`0`)).addColumn("status", "text", (col) => col.notNull().defaultTo(sql`'active'`)).addColumn("lastError", "text").addColumn("lastErrorTimestamp", "timestamptz").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
2910
4717
|
}
|
|
2911
4718
|
//#endregion
|
|
4719
|
+
//#region src/storage/migrations/015_add_operation_denied_reason.ts
|
|
4720
|
+
var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
|
|
4721
|
+
down: () => down$2,
|
|
4722
|
+
up: () => up$2
|
|
4723
|
+
});
|
|
4724
|
+
/**
|
|
4725
|
+
* Records why authorization refused an operation. Separate from `error` so a
|
|
4726
|
+
* denial is distinguishable from a reducer failure without matching on a
|
|
4727
|
+
* message. Null for every operation written before decisions were enforced.
|
|
4728
|
+
*/
|
|
4729
|
+
async function up$2(db) {
|
|
4730
|
+
await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
|
|
4731
|
+
await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
|
|
4732
|
+
}
|
|
4733
|
+
async function down$2(db) {
|
|
4734
|
+
await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
|
|
4735
|
+
await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
|
|
4736
|
+
}
|
|
4737
|
+
//#endregion
|
|
4738
|
+
//#region src/storage/migrations/016_add_dead_letter_error_type.ts
|
|
4739
|
+
var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
|
|
4740
|
+
down: () => down$1,
|
|
4741
|
+
up: () => up$1
|
|
4742
|
+
});
|
|
4743
|
+
/**
|
|
4744
|
+
* The classification a dead letter falls into, stored because it decides whether
|
|
4745
|
+
* the document stays quarantined and the in-memory error is gone after a restart.
|
|
4746
|
+
* Defaulted rather than nullable, so a pre-existing row rehydrates.
|
|
4747
|
+
*/
|
|
4748
|
+
async function up$1(db) {
|
|
4749
|
+
await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
|
|
4750
|
+
}
|
|
4751
|
+
async function down$1(db) {
|
|
4752
|
+
await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
|
|
4753
|
+
}
|
|
4754
|
+
//#endregion
|
|
4755
|
+
//#region src/storage/migrations/017_create_group_references.ts
|
|
4756
|
+
var _017_create_group_references_exports = /* @__PURE__ */ __exportAll({
|
|
4757
|
+
down: () => down,
|
|
4758
|
+
up: () => up
|
|
4759
|
+
});
|
|
4760
|
+
/**
|
|
4761
|
+
* One row per (document, group) reference ever discovered from an auth
|
|
4762
|
+
* operation's input. Rows are never updated or deleted: auth evaluation is
|
|
4763
|
+
* positional, so a grant that named a group at any position keeps that
|
|
4764
|
+
* group's stream in the document's read-set even after a later operation
|
|
4765
|
+
* removes the reference. Read by documentId for the groups a document
|
|
4766
|
+
* requires (sync), and by groupId for the documents a group change affects
|
|
4767
|
+
* (re-evaluation).
|
|
4768
|
+
*/
|
|
4769
|
+
async function up(db) {
|
|
4770
|
+
await db.schema.createTable("group_references").addColumn("documentId", "text", (col) => col.notNull()).addColumn("groupId", "text", (col) => col.notNull()).addPrimaryKeyConstraint("group_references_pkey", ["documentId", "groupId"]).execute();
|
|
4771
|
+
await db.schema.createIndex("idx_group_references_groupId").on("group_references").column("groupId").execute();
|
|
4772
|
+
}
|
|
4773
|
+
async function down(db) {
|
|
4774
|
+
await db.schema.dropTable("group_references").execute();
|
|
4775
|
+
}
|
|
4776
|
+
//#endregion
|
|
2912
4777
|
//#region src/storage/migrations/migrator.ts
|
|
2913
4778
|
const REACTOR_SCHEMA = "reactor";
|
|
2914
4779
|
const migrations = {
|
|
@@ -2925,7 +4790,10 @@ const migrations = {
|
|
|
2925
4790
|
"011_add_cursor_type_column": _011_add_cursor_type_column_exports,
|
|
2926
4791
|
"012_add_source_remote_column": _012_add_source_remote_column_exports,
|
|
2927
4792
|
"013_create_sync_dead_letters_table": _013_create_sync_dead_letters_table_exports,
|
|
2928
|
-
"014_create_processor_cursor_table": _014_create_processor_cursor_table_exports
|
|
4793
|
+
"014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
|
|
4794
|
+
"015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
|
|
4795
|
+
"016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports,
|
|
4796
|
+
"017_create_group_references": _017_create_group_references_exports
|
|
2929
4797
|
};
|
|
2930
4798
|
var ProgrammaticMigrationProvider = class {
|
|
2931
4799
|
getMigrations() {
|
|
@@ -2979,6 +4847,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
|
|
|
2979
4847
|
//#region src/core/drive-container-types.ts
|
|
2980
4848
|
const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
|
|
2981
4849
|
//#endregion
|
|
2982
|
-
export {
|
|
4850
|
+
export { createEmptyConsistencyToken as A, DocumentDeletedError as B, RevisionMismatchError as C, ModuleNotFoundError as D, InvalidModuleError as E, authDecisionModel as F, matchesScope as G, ExcessiveReshuffleError as H, buildDecisionModel as I, __exportAll as J, parsePagingOptions as K, AuthEnforcementDisabledError as L, decideAtHead as M, selectDecisionModel as N, GATED_DOCUMENT_ACTIONS as O, documentDecisionModel as P, AuthTimestampNotMonotonicError as R, OptimisticLockError as S, DuplicateModuleError as T, InvalidOperationTimestampError as U, DocumentNotFoundError as V, UpgradePreconditionFailedError as W, DocumentMetaCache as _, createForwardingPoolInstrumentation as a, AppendConditionFailedError as b, KyselyKeyframeStore as c, DriveCollectionId as d, KyselyExecutionScope as f, KyselyOperationIndex as g, KyselyWriteCache as h, runMigrations as i, targetDocumentId as j, createConsistencyToken as k, DocumentModelRegistry as l, EventBus as m, REACTOR_SCHEMA as n, instrumentPgPool as o, resolveFeatureFlags as p, throwIfAborted as q, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, CollectionMembershipCache as v, DuplicateManifestError as w, DuplicateOperationError as x, APPEND_CONDITION_FAILED_PREFIX as y, AuthorizationDeniedError as z };
|
|
2983
4851
|
|
|
2984
|
-
//# sourceMappingURL=drive-container-types-
|
|
4852
|
+
//# sourceMappingURL=drive-container-types-RZa1wukO.js.map
|