@powerhousedao/reactor 6.2.2-dev.6 → 6.2.2-dev.60

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.
@@ -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,12 +182,40 @@ 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 {
92
210
  documentId;
93
- constructor(documentId) {
94
- super(`Document ${documentId} not found`);
211
+ /**
212
+ * @param message Overrides the default text. A handler that knows which of
213
+ * several documents an action reads - a relationship's source, say - says so
214
+ * here rather than rewrapping in a bare Error, which would strip the name the
215
+ * executor classifies by.
216
+ */
217
+ constructor(documentId, message) {
218
+ super(message ?? `Document ${documentId} not found`);
95
219
  this.name = "DocumentNotFoundError";
96
220
  this.documentId = documentId;
97
221
  Error.captureStackTrace(this, DocumentNotFoundError);
@@ -100,122 +224,413 @@ var DocumentNotFoundError = class DocumentNotFoundError extends Error {
100
224
  return Error.isError(error) && error.name === "DocumentNotFoundError";
101
225
  }
102
226
  };
103
- //#endregion
104
- //#region src/registry/errors.ts
105
227
  /**
106
- * Error thrown when a document model module is not found in the registry.
228
+ * An authorization preflight was asked for while the reactor's decision model
229
+ * is off, so there is no model to answer from.
230
+ *
231
+ * Thrown rather than answered from the legacy host-side permission tables. The
232
+ * two systems do not compose: the tables record which addresses a host lets
233
+ * near a drive, the policy records what a document's own grants permit, and an
234
+ * answer stitched from both would report an admission verdict neither system
235
+ * would reach. A caller that cannot get a prediction disables nothing, which
236
+ * leaves the submit path -- and its real gate -- as the only authority.
237
+ *
238
+ * Detection is by `name`, not `instanceof`: the SharedWorker RPC boundary
239
+ * rebuilds a thrown error from `{ name, message, stack, cause }` alone
240
+ * (`reactor-browser/src/rpc/error-info.ts`), so the class identity and any
241
+ * custom field are lost in transit. This error therefore carries no fields.
107
242
  */
108
- var ModuleNotFoundError = class extends Error {
109
- documentType;
110
- requestedVersion;
111
- constructor(documentType, version) {
112
- const versionSuffix = version !== void 0 ? ` version ${version}` : "";
113
- super(`Document model module not found for type: ${documentType}${versionSuffix}`);
114
- this.name = "ModuleNotFoundError";
115
- this.documentType = documentType;
116
- this.requestedVersion = version;
243
+ var AuthEnforcementDisabledError = class AuthEnforcementDisabledError extends Error {
244
+ constructor() {
245
+ 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");
246
+ this.name = "AuthEnforcementDisabledError";
247
+ Error.captureStackTrace(this, AuthEnforcementDisabledError);
117
248
  }
118
249
  static isError(error) {
119
- return Error.isError(error) && error.name === "ModuleNotFoundError";
250
+ return Error.isError(error) && error.name === "AuthEnforcementDisabledError";
120
251
  }
121
252
  };
253
+ //#endregion
254
+ //#region src/decision/build-decision-model.ts
122
255
  /**
123
- * Error thrown when attempting to register a module that already exists.
256
+ * Reads each projection's stream through the supplied reader, recording the
257
+ * revision observed. Static projections resolve first; derived projections
258
+ * see only those and contribute a map from document id to state. Each
259
+ * distinct stream is read once and yields one append condition entry.
124
260
  */
125
- var DuplicateModuleError = class extends Error {
126
- constructor(documentType, version) {
127
- const versionSuffix = version !== void 0 ? ` (version ${version})` : "";
128
- super(`Document model module already registered for type: ${documentType}${versionSuffix}`);
129
- this.name = "DuplicateModuleError";
130
- }
131
- static isError(error) {
132
- return Error.isError(error) && error.name === "DuplicateModuleError";
261
+ async function buildDecisionModel(reader, definition, target, signal) {
262
+ const decisionModel = definition(target);
263
+ const projections = Object.entries(decisionModel.projections);
264
+ const reads = /* @__PURE__ */ new Map();
265
+ const model = {};
266
+ for (const [key, projection] of projections) {
267
+ if (typeof projection.query === "function") continue;
268
+ model[key] = (await readStream(reader, projection.query, reads, signal)).state;
269
+ }
270
+ const staticModel = { ...model };
271
+ for (const [key, projection] of projections) {
272
+ if (typeof projection.query !== "function") continue;
273
+ const queries = projection.query(staticModel);
274
+ const value = {};
275
+ for (const query of queries) {
276
+ let read;
277
+ try {
278
+ read = await readStream(reader, query, reads, signal);
279
+ } catch (error) {
280
+ if (error instanceof DocumentNotFoundError) {
281
+ recordEmptyStream(query, reads);
282
+ continue;
283
+ }
284
+ throw error;
285
+ }
286
+ value[query.documentId] = read.state;
287
+ }
288
+ model[key] = value;
133
289
  }
134
- };
290
+ return {
291
+ model,
292
+ appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
293
+ };
294
+ }
295
+ /** Guards a stream that holds nothing yet: any operation appearing is growth. */
296
+ function recordEmptyStream(query, reads) {
297
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
298
+ if (reads.has(key)) return;
299
+ reads.set(key, {
300
+ state: void 0,
301
+ stream: {
302
+ documentId: query.documentId,
303
+ scope: query.scope,
304
+ branch: query.branch,
305
+ revision: -1
306
+ }
307
+ });
308
+ }
309
+ async function readStream(reader, query, reads, signal) {
310
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
311
+ const existing = reads.get(key);
312
+ if (existing) return existing;
313
+ const document = await reader.getState(query.documentId, query.scope, query.branch, void 0, signal);
314
+ const read = {
315
+ state: document.state[query.scope],
316
+ stream: {
317
+ documentId: query.documentId,
318
+ scope: query.scope,
319
+ branch: query.branch,
320
+ revision: observedRevision(document, query.scope)
321
+ }
322
+ };
323
+ reads.set(key, read);
324
+ return read;
325
+ }
135
326
  /**
136
- * Error thrown when a module is invalid or malformed.
327
+ * The highest operation index the document reflects for the scope, or -1 if
328
+ * empty. `header.revision` is authoritative, not the rebuilt operation list.
137
329
  */
138
- var InvalidModuleError = class extends Error {
139
- constructor(message) {
140
- super(`Invalid document model module: ${message}`);
141
- this.name = "InvalidModuleError";
330
+ function observedRevision(document, scope) {
331
+ if (scope in document.header.revision) return document.header.revision[scope] - 1;
332
+ if (scope in document.operations) {
333
+ const operations = document.operations[scope];
334
+ if (operations.length > 0) return operations[operations.length - 1].index;
335
+ }
336
+ if (!(scope in document.header.revision)) return -1;
337
+ return document.header.revision[scope] - 1;
338
+ }
339
+ /**
340
+ * The projections whose queries depend on folded state. A positional walk
341
+ * resolves their streams through `queryOverHistory`; a projection without one
342
+ * contributes no streams to a walk.
343
+ */
344
+ function derivedReadSet(definition) {
345
+ const projections = [];
346
+ for (const [name, projection] of Object.entries(definition.projections)) {
347
+ if (typeof projection.query !== "function") continue;
348
+ projections.push({
349
+ name,
350
+ decidingActions: projection.decidingActions,
351
+ apply: projection.apply,
352
+ queryOverHistory: projection.queryOverHistory
353
+ });
142
354
  }
143
- };
355
+ return projections;
356
+ }
144
357
  /**
145
- * Error thrown when attempting to register an upgrade manifest that already exists.
358
+ * The streams a model reads whose queries are known before it is built. A
359
+ * derived query needs the statically-queried projections first, so it is not
360
+ * included here.
146
361
  */
147
- var DuplicateManifestError = class extends Error {
148
- constructor(documentType) {
149
- super(`Upgrade manifest already registered for type: ${documentType}`);
150
- this.name = "DuplicateManifestError";
362
+ function staticReadSet(definition) {
363
+ const streams = [];
364
+ for (const [name, projection] of Object.entries(definition.projections)) {
365
+ if (typeof projection.query === "function") continue;
366
+ streams.push({
367
+ name,
368
+ query: projection.query,
369
+ decidingActions: projection.decidingActions,
370
+ apply: projection.apply
371
+ });
151
372
  }
152
- static isError(error) {
153
- return Error.isError(error) && error.name === "DuplicateManifestError";
373
+ return streams;
374
+ }
375
+ //#endregion
376
+ //#region src/decision/auth-decision-model.ts
377
+ function refusalReason(refusal) {
378
+ switch (refusal) {
379
+ case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
380
+ case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
381
+ case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
154
382
  }
155
- };
383
+ }
384
+ function decideAuthModel(model, subject, request, groups, conditions) {
385
+ if (request.verb === "execute" && model.document.isDeleted) return {
386
+ decision: "deny",
387
+ reason: DOCUMENT_DELETED_REASON
388
+ };
389
+ const evaluation = evaluate(model.auth, subject, request, groups, conditions);
390
+ if (evaluation.decision === "allow") return { decision: "allow" };
391
+ return {
392
+ decision: "deny",
393
+ reason: refusalReason(evaluation.refusal)
394
+ };
395
+ }
396
+ function documentProjection(target) {
397
+ return {
398
+ decidingActions: ["DELETE_DOCUMENT"],
399
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
400
+ ...document,
401
+ state: { ...document.state }
402
+ }, operation.action) : document,
403
+ query: {
404
+ documentId: target.documentId,
405
+ branch: target.branch,
406
+ scope: "document"
407
+ }
408
+ };
409
+ }
410
+ function authProjection(target) {
411
+ return {
412
+ decidingActions: [...AUTH_ACTION_TYPES],
413
+ apply: (document, operation) => applyAuthAction(document, operation.action),
414
+ query: {
415
+ documentId: target.documentId,
416
+ branch: target.branch,
417
+ scope: "auth"
418
+ }
419
+ };
420
+ }
421
+ /** This decision model uses both the document and the auth streams. */
422
+ function authDecisionModel(target) {
423
+ return {
424
+ projections: {
425
+ document: documentProjection(target),
426
+ auth: authProjection(target)
427
+ },
428
+ evaluatesScope() {
429
+ return true;
430
+ },
431
+ decide(model, subject, request) {
432
+ return decideAuthModel(model, subject, request);
433
+ }
434
+ };
435
+ }
156
436
  /**
157
- * Error thrown when an upgrade manifest is not found.
437
+ * Folds one group-stream operation with the registered group model's reducer.
438
+ * A reactor without the module registered folds nothing, so the member list
439
+ * stays as read and a missing reducer never widens access.
158
440
  */
159
- var ManifestNotFoundError = class extends Error {
160
- constructor(documentType) {
161
- super(`Upgrade manifest not found for type: ${documentType}`);
162
- this.name = "ManifestNotFoundError";
441
+ function applyGroupOperation(registry, document, operation) {
442
+ let reducer;
443
+ try {
444
+ reducer = registry.getModule(groupDocumentType).reducer;
445
+ } catch {
446
+ return document;
163
447
  }
164
- };
448
+ return reducer(document, operation.action);
449
+ }
165
450
  /**
166
- * Error thrown when a required upgrade transition is missing from the manifest.
451
+ * Folds one evaluated-scope operation with the reducer registered for the
452
+ * document's own type, at the document's stamped version. A reactor without
453
+ * that module folds nothing, so conditions read the base state and an
454
+ * unresolvable reducer never widens access.
167
455
  */
168
- var MissingUpgradeTransitionError = class extends Error {
169
- constructor(documentType, fromVersion, toVersion) {
170
- super(`Missing upgrade transition for ${documentType}: v${fromVersion} to v${toVersion}`);
171
- this.name = "MissingUpgradeTransitionError";
456
+ function applyModelOperation(registry, document, operation) {
457
+ let reducer;
458
+ try {
459
+ const version = normalizeDocumentModelVersion(document.state.document?.version);
460
+ reducer = registry.getModule(document.header.documentType, version).reducer;
461
+ } catch {
462
+ return document;
172
463
  }
173
- };
464
+ return reducer(document, operation.action);
465
+ }
174
466
  /**
175
- * Error thrown when getUpgradeReducer is called with a non-single-step version increment.
467
+ * The auth model extended with a derived groups projection: the streams it
468
+ * reads are the group documents the folded grant list names, so adding a
469
+ * grant that names a new group pulls that group's stream into the read-set.
470
+ * Group queries pin the main branch, because a group's member list lives on
471
+ * its main branch no matter which branch the referencing document is on.
176
472
  */
177
- var InvalidUpgradeStepError = class extends Error {
178
- constructor(documentType, fromVersion, toVersion) {
179
- super(`Invalid upgrade step for ${documentType}: must be single version increment, got v${fromVersion} to v${toVersion}`);
180
- this.name = "InvalidUpgradeStepError";
181
- }
182
- };
183
- //#endregion
184
- //#region src/cache/collection-membership-cache.ts
185
- var CollectionMembershipCache = class CollectionMembershipCache {
186
- cache = /* @__PURE__ */ new Map();
187
- constructor(operationIndex) {
188
- this.operationIndex = operationIndex;
189
- }
190
- withScopedIndex(operationIndex) {
191
- const scoped = new CollectionMembershipCache(operationIndex);
192
- scoped.cache = this.cache;
193
- return scoped;
194
- }
195
- async getCollectionsForDocuments(documentIds) {
196
- const result = {};
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);
473
+ function groupsProjection(registry) {
474
+ return {
475
+ decidingActions: [...groupMembershipActionTypes],
476
+ apply: (document, operation) => applyGroupOperation(registry, document, operation),
477
+ query: (model) => referencedGroupIds(model.auth?.grants ?? []).map((id) => ({
478
+ documentId: id,
479
+ branch: "main",
480
+ scope: "global"
481
+ })),
482
+ queryOverHistory: (reads) => {
483
+ const ids = [];
484
+ for (const read of reads) {
485
+ if (read.name !== "auth") continue;
486
+ for (const operation of read.operations) for (const id of mentionedGroupIds(operation.action)) if (!ids.includes(id)) ids.push(id);
487
+ }
488
+ return ids.map((id) => ({
489
+ documentId: id,
490
+ branch: "main",
491
+ scope: "global"
492
+ }));
202
493
  }
203
- if (missing.length > 0) {
204
- const fromDb = await this.operationIndex.getCollectionsForDocuments(missing);
205
- for (const docId of missing) {
206
- const collections = fromDb[docId] ?? [];
207
- result[docId] = collections;
208
- this.cache.set(docId, collections);
494
+ };
495
+ }
496
+ function authGroupsDecisionModel(registry) {
497
+ return (target) => ({
498
+ projections: {
499
+ document: documentProjection(target),
500
+ auth: authProjection(target),
501
+ groups: groupsProjection(registry)
502
+ },
503
+ evaluatesScope() {
504
+ return true;
505
+ },
506
+ decide(model, subject, request) {
507
+ return decideAuthModel(model, subject, request, model.groups);
508
+ }
509
+ });
510
+ }
511
+ /**
512
+ * The groups model with conditions live: decide hands the executing scope's
513
+ * state and the action input through to the evaluator, so `where` clauses
514
+ * and { match } principals apply. The model folds the evaluated scope during
515
+ * a positional walk, so a condition reads the state as it stood at each
516
+ * operation's position.
517
+ */
518
+ function authConditionsDecisionModel(registry) {
519
+ return (target) => ({
520
+ projections: {
521
+ document: documentProjection(target),
522
+ auth: authProjection(target),
523
+ groups: groupsProjection(registry)
524
+ },
525
+ foldEvaluatedScope: (document, operation) => applyModelOperation(registry, document, operation),
526
+ evaluatesScope() {
527
+ return true;
528
+ },
529
+ decide(model, subject, request, ctx) {
530
+ return decideAuthModel(model, subject, request, model.groups, {
531
+ scopeState: ctx.scopeState,
532
+ actionInput: ctx.actionInput
533
+ });
534
+ }
535
+ });
536
+ }
537
+ //#endregion
538
+ //#region src/decision/document-decision-model.ts
539
+ /**
540
+ * The simplest decision model: one projection over the document scope, which
541
+ * rejects on a deleted document.
542
+ */
543
+ function documentDecisionModel(target) {
544
+ return {
545
+ projections: { document: {
546
+ decidingActions: ["DELETE_DOCUMENT"],
547
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
548
+ ...document,
549
+ state: { ...document.state }
550
+ }, operation.action) : document,
551
+ query: {
552
+ documentId: target.documentId,
553
+ branch: target.branch,
554
+ scope: "document"
209
555
  }
556
+ } },
557
+ evaluatesScope() {
558
+ return true;
559
+ },
560
+ decide(model, subject, request) {
561
+ return request.verb === "execute" && model.document.isDeleted ? {
562
+ decision: "deny",
563
+ reason: DOCUMENT_DELETED_REASON
564
+ } : { decision: "allow" };
210
565
  }
211
- return result;
212
- }
213
- invalidate(documentId) {
214
- this.cache.delete(documentId);
215
- }
216
- };
566
+ };
567
+ }
568
+ //#endregion
569
+ //#region src/decision/registered-model.ts
570
+ /**
571
+ * Builds the model at the stream heads and decides one request against it. The
572
+ * append condition it returns is the read-set the store enforces at write time.
573
+ *
574
+ * With `conditions` supplied, the executing scope's state is read at the head
575
+ * for `doc.<scope>.*` paths. That read carries no append-condition entry of
576
+ * its own: the written stream's expected-revision check already refuses a
577
+ * write whose scope grew between the read and the append.
578
+ */
579
+ async function decideAtHead(model, cache, target, subject, request, signal, conditions) {
580
+ const built = await buildDecisionModel(cache, model, target, signal);
581
+ let scopeState;
582
+ if (conditions !== void 0) scopeState = (await cache.getState(target.documentId, request.scope, target.branch, void 0, signal)).state[request.scope];
583
+ return {
584
+ evaluation: model(target).decide(built.model, subject, request, {
585
+ scopeState,
586
+ actionInput: conditions?.actionInput
587
+ }),
588
+ appendCondition: built.appendCondition,
589
+ documentVersion: built.model.document.version,
590
+ deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
591
+ };
592
+ }
593
+ /**
594
+ * The model this reactor enforces. With `authEnforcement` off the auth scope is
595
+ * absent from every append condition and no load walks it; with `authGroups`
596
+ * on, the group documents the grant list names join the read-set and the
597
+ * registry supplies the reducer that folds them.
598
+ */
599
+ function selectDecisionModel(flags, registry) {
600
+ if (flags.authConditions) return authConditionsDecisionModel(registry);
601
+ if (flags.authGroups) return authGroupsDecisionModel(registry);
602
+ return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
603
+ }
217
604
  //#endregion
218
605
  //#region src/executor/util.ts
606
+ /** Actions the reactor reduces itself, onto the document scope. */
607
+ const DOCUMENT_SCOPE_ACTIONS = new Set([
608
+ "CREATE_DOCUMENT",
609
+ "DELETE_DOCUMENT",
610
+ "UPGRADE_DOCUMENT",
611
+ "ADD_RELATIONSHIP",
612
+ "REMOVE_RELATIONSHIP",
613
+ "UPDATE_RELATIONSHIP"
614
+ ]);
615
+ /**
616
+ * `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
617
+ * so building a decision model would throw and defer the job forever.
618
+ */
619
+ const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
620
+ /**
621
+ * The document a document-scope action writes to, which is not always the job's
622
+ * own document: delete and upgrade name it in `input.documentId`, and the
623
+ * relationship actions in `input.sourceId`. `execute` only checks that a batch
624
+ * shares one scope, so a caller can submit an action whose target is a document
625
+ * other than the one the job is keyed by. The policy gate has to follow the
626
+ * action rather than the job, or it decides against a policy the caller may
627
+ * control instead of the one guarding the write.
628
+ */
629
+ function targetDocumentId(action, fallback) {
630
+ const input = action.input;
631
+ 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;
632
+ return typeof input?.documentId === "string" && input.documentId.length > 0 ? input.documentId : fallback;
633
+ }
219
634
  /**
220
635
  * Creates a PHDocument from a CREATE_DOCUMENT action input.
221
636
  * Reconstructs the document header and initializes the base state.
@@ -353,6 +768,203 @@ function buildErrorResult(job, error, startTime) {
353
768
  duration: Date.now() - startTime
354
769
  };
355
770
  }
771
+ /**
772
+ * The error a refusal surfaces as. Both classes are already terminal in the job
773
+ * result handler, so a refusal never burns a retry.
774
+ */
775
+ function refusalError(reason, documentId, deletedAtUtcIso, action) {
776
+ if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
777
+ return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
778
+ }
779
+ /**
780
+ * Whether this operation is part of the document's creation. The create and the
781
+ * upgrade from version zero hold the first two indexes for the life of the
782
+ * document, so a reshuffle has to leave them where they are.
783
+ */
784
+ function isGenesisOperation(operation) {
785
+ if (operation.action.type === "CREATE_DOCUMENT") return true;
786
+ if (operation.action.type !== "UPGRADE_DOCUMENT") return false;
787
+ return operation.action.input.fromVersion === 0;
788
+ }
789
+ //#endregion
790
+ //#region src/registry/errors.ts
791
+ /**
792
+ * Error thrown when a document model module is not found in the registry.
793
+ */
794
+ var ModuleNotFoundError = class extends Error {
795
+ documentType;
796
+ requestedVersion;
797
+ constructor(documentType, version) {
798
+ const versionSuffix = version !== void 0 ? ` version ${version}` : "";
799
+ super(`Document model module not found for type: ${documentType}${versionSuffix}`);
800
+ this.name = "ModuleNotFoundError";
801
+ this.documentType = documentType;
802
+ this.requestedVersion = version;
803
+ }
804
+ static isError(error) {
805
+ return Error.isError(error) && error.name === "ModuleNotFoundError";
806
+ }
807
+ };
808
+ /**
809
+ * Error thrown when attempting to register a module that already exists.
810
+ */
811
+ var DuplicateModuleError = class extends Error {
812
+ constructor(documentType, version) {
813
+ const versionSuffix = version !== void 0 ? ` (version ${version})` : "";
814
+ super(`Document model module already registered for type: ${documentType}${versionSuffix}`);
815
+ this.name = "DuplicateModuleError";
816
+ }
817
+ static isError(error) {
818
+ return Error.isError(error) && error.name === "DuplicateModuleError";
819
+ }
820
+ };
821
+ /**
822
+ * Error thrown when a module is invalid or malformed.
823
+ */
824
+ var InvalidModuleError = class extends Error {
825
+ constructor(message) {
826
+ super(`Invalid document model module: ${message}`);
827
+ this.name = "InvalidModuleError";
828
+ }
829
+ };
830
+ /**
831
+ * Error thrown when attempting to register an upgrade manifest that already exists.
832
+ */
833
+ var DuplicateManifestError = class extends Error {
834
+ constructor(documentType) {
835
+ super(`Upgrade manifest already registered for type: ${documentType}`);
836
+ this.name = "DuplicateManifestError";
837
+ }
838
+ static isError(error) {
839
+ return Error.isError(error) && error.name === "DuplicateManifestError";
840
+ }
841
+ };
842
+ /**
843
+ * Error thrown when an upgrade manifest is not found.
844
+ */
845
+ var ManifestNotFoundError = class extends Error {
846
+ constructor(documentType) {
847
+ super(`Upgrade manifest not found for type: ${documentType}`);
848
+ this.name = "ManifestNotFoundError";
849
+ }
850
+ };
851
+ /**
852
+ * Error thrown when a required upgrade transition is missing from the manifest.
853
+ */
854
+ var MissingUpgradeTransitionError = class extends Error {
855
+ constructor(documentType, fromVersion, toVersion) {
856
+ super(`Missing upgrade transition for ${documentType}: v${fromVersion} to v${toVersion}`);
857
+ this.name = "MissingUpgradeTransitionError";
858
+ }
859
+ };
860
+ /**
861
+ * Error thrown when getUpgradeReducer is called with a non-single-step version increment.
862
+ */
863
+ var InvalidUpgradeStepError = class extends Error {
864
+ constructor(documentType, fromVersion, toVersion) {
865
+ super(`Invalid upgrade step for ${documentType}: must be single version increment, got v${fromVersion} to v${toVersion}`);
866
+ this.name = "InvalidUpgradeStepError";
867
+ }
868
+ };
869
+ //#endregion
870
+ //#region src/storage/interfaces.ts
871
+ /**
872
+ * Thrown when an operation with the same identity already exists in the store.
873
+ */
874
+ var DuplicateOperationError = class extends Error {
875
+ constructor(description) {
876
+ super(`Duplicate operation: ${description}`);
877
+ this.name = "DuplicateOperationError";
878
+ }
879
+ };
880
+ /**
881
+ * Thrown when a concurrent write conflict is detected during an atomic apply.
882
+ */
883
+ var OptimisticLockError = class extends Error {
884
+ constructor(message) {
885
+ super(message);
886
+ this.name = "OptimisticLockError";
887
+ }
888
+ };
889
+ /**
890
+ * Thrown when the caller-provided revision does not match the current
891
+ * stored revision, indicating a stale read.
892
+ */
893
+ var RevisionMismatchError = class extends Error {
894
+ constructor(expected, actual) {
895
+ super(`Revision mismatch: expected ${expected}, got ${actual}`);
896
+ this.name = "RevisionMismatchError";
897
+ }
898
+ };
899
+ /** Error history keeps messages, not classes, so failures match by prefix. */
900
+ const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
901
+ /**
902
+ * A read-set stream grew before the append committed. A concurrency
903
+ * conflict, not a fault: the caller retries against the new stream heads.
904
+ */
905
+ var AppendConditionFailedError = class extends Error {
906
+ constructor(condition) {
907
+ const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
908
+ super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
909
+ this.condition = condition;
910
+ this.name = "AppendConditionFailedError";
911
+ }
912
+ static isError(error) {
913
+ return Error.isError(error) && error.name === "AppendConditionFailedError";
914
+ }
915
+ /** True when a recorded error message is an append-condition failure. */
916
+ static isFailureMessage(message) {
917
+ return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
918
+ }
919
+ };
920
+ //#endregion
921
+ //#region src/executor/types.ts
922
+ /** How long a deferred job waits for its document before it fails. */
923
+ const DEFAULT_DEFERRED_JOB_TTL_MS = 3e4;
924
+ /**
925
+ * Event types for the job executor
926
+ */
927
+ const JobExecutorEventTypes = {
928
+ JOB_STARTED: 2e4,
929
+ JOB_COMPLETED: 20001,
930
+ JOB_FAILED: 20002,
931
+ EXECUTOR_STARTED: 20003,
932
+ EXECUTOR_STOPPED: 20004
933
+ };
934
+ //#endregion
935
+ //#region src/cache/collection-membership-cache.ts
936
+ var CollectionMembershipCache = class CollectionMembershipCache {
937
+ cache = /* @__PURE__ */ new Map();
938
+ constructor(operationIndex) {
939
+ this.operationIndex = operationIndex;
940
+ }
941
+ withScopedIndex(operationIndex) {
942
+ const scoped = new CollectionMembershipCache(operationIndex);
943
+ scoped.cache = this.cache;
944
+ return scoped;
945
+ }
946
+ async getCollectionsForDocuments(documentIds) {
947
+ const result = {};
948
+ const missing = [];
949
+ for (const docId of documentIds) {
950
+ const cached = this.cache.get(docId);
951
+ if (cached !== void 0) result[docId] = cached;
952
+ else missing.push(docId);
953
+ }
954
+ if (missing.length > 0) {
955
+ const fromDb = await this.operationIndex.getCollectionsForDocuments(missing);
956
+ for (const docId of missing) {
957
+ const collections = fromDb[docId] ?? [];
958
+ result[docId] = collections;
959
+ this.cache.set(docId, collections);
960
+ }
961
+ }
962
+ return result;
963
+ }
964
+ invalidate(documentId) {
965
+ this.cache.delete(documentId);
966
+ }
967
+ };
356
968
  //#endregion
357
969
  //#region src/cache/lru/lru-tracker.ts
358
970
  var LRUNode = class {
@@ -543,6 +1155,7 @@ var KyselyOperationIndexTxn = class {
543
1155
  collections = [];
544
1156
  collectionMemberships = [];
545
1157
  collectionRemovals = [];
1158
+ groupReferences = [];
546
1159
  operations = [];
547
1160
  createCollection(collectionId) {
548
1161
  this.collections.push(collectionId);
@@ -565,12 +1178,25 @@ var KyselyOperationIndexTxn = class {
565
1178
  operationIndex: lastOpIndex
566
1179
  });
567
1180
  }
1181
+ recordGroupReferences(documentId, groupIds) {
1182
+ const lastOpIndex = this.operations.length - 1;
1183
+ if (lastOpIndex < 0) throw new Error("recordGroupReferences must be called after write() - no operations in transaction");
1184
+ if (groupIds.length === 0) return;
1185
+ this.groupReferences.push({
1186
+ documentId,
1187
+ groupIds,
1188
+ operationIndex: lastOpIndex
1189
+ });
1190
+ }
568
1191
  write(operations) {
569
1192
  this.operations.push(...operations);
570
1193
  }
571
1194
  getCollections() {
572
1195
  return this.collections;
573
1196
  }
1197
+ getGroupReferenceRecords() {
1198
+ return this.groupReferences;
1199
+ }
574
1200
  getCollectionMembershipRecords() {
575
1201
  return this.collectionMemberships;
576
1202
  }
@@ -607,10 +1233,27 @@ var KyselyOperationIndex = class KyselyOperationIndex {
607
1233
  });
608
1234
  return resultOrdinals;
609
1235
  }
1236
+ /**
1237
+ * A policy-driven join: keeps the earliest join so a rediscovered reference
1238
+ * never shrinks a backfill window remotes already rely on, and reopens a
1239
+ * closed membership because a policy reference is not a removable one.
1240
+ */
1241
+ async joinKeepingEarliest(trx, documentId, collectionId, ordinal) {
1242
+ await trx.insertInto("document_collections").values({
1243
+ documentId,
1244
+ collectionId,
1245
+ joinedOrdinal: ordinal,
1246
+ leftOrdinal: null
1247
+ }).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
1248
+ joinedOrdinal: sql`LEAST("document_collections"."joinedOrdinal", EXCLUDED."joinedOrdinal")`,
1249
+ leftOrdinal: null
1250
+ })).execute();
1251
+ }
610
1252
  async executeCommit(trx, kyselyTxn) {
611
1253
  const collections = kyselyTxn.getCollections();
612
1254
  const memberships = kyselyTxn.getCollectionMembershipRecords();
613
1255
  const removals = kyselyTxn.getCollectionRemovals();
1256
+ const groupReferences = kyselyTxn.getGroupReferenceRecords();
614
1257
  const operations = kyselyTxn.getOperations();
615
1258
  if (collections.length > 0) {
616
1259
  const collectionRows = collections.map((collectionId) => ({
@@ -634,6 +1277,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
634
1277
  skip: op.skip,
635
1278
  hash: op.hash,
636
1279
  action: op.action,
1280
+ deniedReason: op.deniedReason ?? null,
637
1281
  sourceRemote: op.sourceRemote
638
1282
  }));
639
1283
  operationOrdinals = (await trx.insertInto("operation_index_operations").values(operationRows).returning("ordinal").execute()).map((row) => row.ordinal);
@@ -649,13 +1293,28 @@ var KyselyOperationIndex = class KyselyOperationIndex {
649
1293
  joinedOrdinal: BigInt(ordinal),
650
1294
  leftOrdinal: null
651
1295
  })).execute();
1296
+ const references = await trx.selectFrom("group_references").select("groupId").where("documentId", "=", m.documentId).execute();
1297
+ for (const { groupId } of references) await this.joinKeepingEarliest(trx, groupId, m.collectionId, BigInt(ordinal));
652
1298
  }
653
1299
  if (removals.length > 0) for (const r of removals) {
654
1300
  const ordinal = operationOrdinals[r.operationIndex];
655
1301
  await trx.updateTable("document_collections").set({ leftOrdinal: BigInt(ordinal) }).where("collectionId", "=", r.collectionId).where("documentId", "=", r.documentId).where("leftOrdinal", "is", null).execute();
656
1302
  }
1303
+ if (groupReferences.length > 0) for (const record of groupReferences) {
1304
+ const ordinal = operationOrdinals[record.operationIndex];
1305
+ await trx.insertInto("group_references").values(record.groupIds.map((groupId) => ({
1306
+ documentId: record.documentId,
1307
+ groupId
1308
+ }))).onConflict((oc) => oc.doNothing()).execute();
1309
+ const rows = await trx.selectFrom("document_collections").select("collectionId").where("documentId", "=", record.documentId).execute();
1310
+ for (const groupId of record.groupIds) for (const { collectionId } of rows) await this.joinKeepingEarliest(trx, groupId, collectionId, BigInt(ordinal));
1311
+ }
657
1312
  return operationOrdinals;
658
1313
  }
1314
+ async getGroupReferencers(groupId, signal) {
1315
+ if (signal?.aborted) throw new Error("Operation aborted");
1316
+ return (await this.queryExecutor.selectFrom("group_references").select("documentId").where("groupId", "=", groupId).orderBy("documentId").execute()).map((row) => row.documentId);
1317
+ }
659
1318
  async find(collectionId, cursor, view, paging, signal) {
660
1319
  if (signal?.aborted) throw new Error("Operation aborted");
661
1320
  const outerCursor = cursor ?? -1;
@@ -765,6 +1424,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
765
1424
  hash: row.hash,
766
1425
  skip: row.skip,
767
1426
  action: row.action,
1427
+ deniedReason: row.deniedReason ?? void 0,
768
1428
  id: row.opId
769
1429
  },
770
1430
  context: {
@@ -788,6 +1448,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
788
1448
  hash: row.hash,
789
1449
  skip: row.skip,
790
1450
  action: row.action,
1451
+ deniedReason: row.deniedReason ?? void 0,
791
1452
  id: row.opId,
792
1453
  sourceRemote: row.sourceRemote
793
1454
  };
@@ -873,10 +1534,54 @@ var RingBuffer = class {
873
1534
  }
874
1535
  };
875
1536
  //#endregion
1537
+ //#region src/cache/write-cache-types.ts
1538
+ /**
1539
+ * Where a snapshot sits in its stream.
1540
+ *
1541
+ * - `Head`: the newest revision of the stream when it was stored. Only these
1542
+ * can answer a read that asks for the head.
1543
+ * - `Historical`: state at an earlier revision. Usable as a starting point to
1544
+ * replay forward from, and as an answer to a read for that same revision.
1545
+ */
1546
+ let SnapshotPosition = /* @__PURE__ */ function(SnapshotPosition) {
1547
+ SnapshotPosition["Head"] = "head";
1548
+ SnapshotPosition["Historical"] = "historical";
1549
+ return SnapshotPosition;
1550
+ }({});
1551
+ //#endregion
876
1552
  //#region src/cache/kysely-write-cache.ts
1553
+ /**
1554
+ * The last operation index a keyframe's document reflects for the scope. A
1555
+ * keyframe only exists for a scope that has operations, so a missing entry
1556
+ * means the stored row is corrupt.
1557
+ */
1558
+ function keyframeRevision(keyframe, documentId, scope) {
1559
+ const nextIndex = keyframe.document.header.revision[scope];
1560
+ if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
1561
+ return nextIndex - 1;
1562
+ }
877
1563
  function extractModuleVersion(doc) {
878
1564
  const v = doc.state.document.version;
879
- return v === 0 ? void 0 : v;
1565
+ return normalizeDocumentModelVersion(v);
1566
+ }
1567
+ /** The highest revision held, latest push winning a tie. */
1568
+ function highestRevision(snapshots) {
1569
+ let newest = void 0;
1570
+ for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
1571
+ return newest;
1572
+ }
1573
+ /**
1574
+ * Copies a document far enough that the caller cannot write through it. Inside
1575
+ * this class, callers only ever replace whole fields on these four, so one
1576
+ * level each is enough.
1577
+ */
1578
+ function copyDocument(document) {
1579
+ return {
1580
+ ...document,
1581
+ header: { ...document.header },
1582
+ state: { ...document.state },
1583
+ operations: { ...document.operations }
1584
+ };
880
1585
  }
881
1586
  /**
882
1587
  * In-memory write cache with keyframe persistence for PHDocuments.
@@ -956,6 +1661,8 @@ var KyselyWriteCache = class KyselyWriteCache {
956
1661
  /**
957
1662
  * Retrieves document state at a specific revision from cache or rebuilds it.
958
1663
  *
1664
+ * Note: this returns a _shallow_ copy of the document.
1665
+ *
959
1666
  * Cache hit path: Returns cached snapshot if available (O(1))
960
1667
  * Warm miss path: Rebuilds from cached base revision + incremental ops
961
1668
  * Cold miss path: Rebuilds from keyframe or from scratch using all operations
@@ -979,30 +1686,35 @@ var KyselyWriteCache = class KyselyWriteCache {
979
1686
  if (stream) {
980
1687
  const snapshots = stream.ringBuffer.getAll();
981
1688
  if (targetRevision === void 0) {
982
- if (snapshots.length > 0) {
983
- const newest = snapshots[snapshots.length - 1];
1689
+ const newest = highestRevision(snapshots);
1690
+ if (newest?.position === SnapshotPosition.Head) {
1691
+ this.lruTracker.touch(streamKey);
1692
+ return copyDocument(newest.document);
1693
+ }
1694
+ if (newest) {
1695
+ const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
1696
+ this.store(documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, SnapshotPosition.Head);
984
1697
  this.lruTracker.touch(streamKey);
985
- return newest.document;
1698
+ return document;
986
1699
  }
987
1700
  } else {
988
- const exactMatch = snapshots.find((s) => s.revision === targetRevision);
1701
+ const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
989
1702
  if (exactMatch) {
990
1703
  this.lruTracker.touch(streamKey);
991
- return exactMatch.document;
1704
+ return copyDocument(exactMatch.document);
992
1705
  }
993
1706
  const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
994
1707
  if (newestOlder) {
995
1708
  const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
996
- this.putState(documentId, scope, branch, targetRevision, document);
1709
+ this.store(documentId, scope, branch, targetRevision, document, SnapshotPosition.Historical);
997
1710
  this.lruTracker.touch(streamKey);
998
1711
  return document;
999
1712
  }
1000
1713
  }
1001
1714
  }
1002
1715
  const document = await this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
1003
- let revision = targetRevision;
1004
- if (revision === void 0) revision = document.header.revision[scope] || 0;
1005
- this.putState(documentId, scope, branch, revision, document);
1716
+ const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
1717
+ this.store(documentId, scope, branch, revision, document, targetRevision === void 0 ? SnapshotPosition.Head : SnapshotPosition.Historical);
1006
1718
  return document;
1007
1719
  }
1008
1720
  /**
@@ -1025,16 +1737,20 @@ var KyselyWriteCache = class KyselyWriteCache {
1025
1737
  * @param document - The document to cache
1026
1738
  * @throws {Error} If document serialization fails
1027
1739
  */
1028
- putState(documentId, scope, branch, revision, document) {
1740
+ putState(documentId, scope, branch, revision, document, position) {
1741
+ this.store(documentId, scope, branch, revision, document, position);
1742
+ }
1743
+ store(documentId, scope, branch, revision, document, position) {
1029
1744
  const streamKey = this.makeStreamKey(documentId, scope, branch);
1030
1745
  const stream = this.getOrCreateStream(streamKey);
1031
1746
  const snapshot = {
1032
1747
  revision,
1033
1748
  document: {
1034
- ...document,
1749
+ ...copyDocument(document),
1035
1750
  operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
1036
1751
  clipboard: []
1037
- }
1752
+ },
1753
+ position
1038
1754
  };
1039
1755
  stream.ringBuffer.push(snapshot);
1040
1756
  if (this.isKeyframeRevision(revision)) this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
@@ -1102,64 +1818,102 @@ var KyselyWriteCache = class KyselyWriteCache {
1102
1818
  }
1103
1819
  async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
1104
1820
  if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
1105
- return this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1821
+ const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1822
+ if (!keyframe) return;
1823
+ return {
1824
+ revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
1825
+ document: keyframe.document
1826
+ };
1106
1827
  }
1828
+ /**
1829
+ * Rebuilds a scope from a keyframe or from the whole operation history.
1830
+ *
1831
+ * The document scope is always rebuilt first, because it carries the type,
1832
+ * the upgrades and the deletion marker. Its version-changing upgrades are not
1833
+ * applied there though: an upgrade reducer must see the state the requested
1834
+ * scope has reached at that upgrade's boundary, so each one is held back and
1835
+ * applied when the replay below crosses the boundary that
1836
+ * resolveModuleVersionForOp derives from it. Upgrades whose boundary lies past
1837
+ * the last replayed operation are applied at the end. Creation-time 0->N seed
1838
+ * upgrades carry the initial state, so they still apply immediately.
1839
+ */
1107
1840
  async coldMissRebuild(documentId, scope, branch, targetRevision, signal) {
1108
1841
  const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
1109
1842
  const keyframe = await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal);
1843
+ const documentScopeBound = scope === "document" ? targetRevision : void 0;
1110
1844
  let document;
1111
1845
  let startRevision;
1112
1846
  let documentType;
1113
1847
  const validatedUpgrades = [];
1848
+ const pendingUpgrades = [];
1849
+ let lastDocumentScopeOperation;
1114
1850
  if (keyframe) {
1115
1851
  document = keyframe.document;
1116
1852
  startRevision = keyframe.revision;
1117
1853
  documentType = keyframe.document.header.documentType;
1118
- const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, keyframe.revision, void 0, void 0, signal);
1119
- for (const operation of docScopeOpsAfterKeyframe.results) if (operation.action.type === "UPGRADE_DOCUMENT") {
1120
- const upgradeAction = operation.action;
1121
- const fromVersion = upgradeAction.input.fromVersion;
1122
- const toVersion = upgradeAction.input.toVersion;
1123
- if (fromVersion > 0 && fromVersion < toVersion) {
1124
- let upgradePath;
1125
- try {
1126
- upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1127
- } catch (err) {
1128
- if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
1129
- 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 });
1854
+ const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
1855
+ const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
1856
+ for (const operation of docScopeOpsAfterKeyframe.results) {
1857
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1858
+ lastDocumentScopeOperation = operation;
1859
+ if (operation.error || isDenied(operation)) continue;
1860
+ if (operation.action.type === "UPGRADE_DOCUMENT") {
1861
+ const upgradeAction = operation.action;
1862
+ const fromVersion = upgradeAction.input.fromVersion;
1863
+ const toVersion = upgradeAction.input.toVersion;
1864
+ if (fromVersion > 0 && fromVersion < toVersion) {
1865
+ let upgradePath;
1866
+ try {
1867
+ upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1868
+ } catch (err) {
1869
+ if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
1870
+ 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 });
1871
+ }
1872
+ validatedUpgrades.push({
1873
+ fromVersion,
1874
+ toVersion,
1875
+ revision: upgradeAction.input.revision,
1876
+ timestampUtcMs: operation.timestampUtcMs
1877
+ });
1878
+ pendingUpgrades.push({
1879
+ action: upgradeAction,
1880
+ upgradePath,
1881
+ index: operation.index,
1882
+ subsequentDeletes: []
1883
+ });
1130
1884
  }
1131
- validatedUpgrades.push({
1132
- fromVersion,
1133
- toVersion,
1134
- revision: upgradeAction.input.revision,
1135
- timestampUtcMs: operation.timestampUtcMs
1136
- });
1137
- document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1885
+ } else if (operation.action.type === "DELETE_DOCUMENT") {
1886
+ applyDeleteDocumentAction(document, operation.action);
1887
+ for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
1138
1888
  }
1139
- } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1889
+ }
1140
1890
  } else {
1141
1891
  startRevision = -1;
1142
1892
  const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
1143
1893
  cursor: "0",
1144
1894
  limit: 1
1145
1895
  }, signal);
1146
- if (createOpResult.results.length === 0) throw new Error(`Failed to rebuild document ${documentId}: no CREATE_DOCUMENT operation found in document scope`);
1896
+ if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
1147
1897
  const createOp = createOpResult.results[0];
1148
1898
  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
1899
  const documentCreateAction = createOp.action;
1150
1900
  documentType = documentCreateAction.input.model;
1151
1901
  if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
1152
1902
  document = createDocumentFromAction(documentCreateAction);
1903
+ lastDocumentScopeOperation = createOp;
1153
1904
  let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1154
1905
  const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
1155
1906
  for (const operation of docScopeOps.results) {
1907
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1908
+ lastDocumentScopeOperation = operation;
1156
1909
  if (operation.index === 0) continue;
1910
+ if (operation.error || isDenied(operation)) continue;
1157
1911
  if (operation.action.type === "UPGRADE_DOCUMENT") {
1158
1912
  const upgradeAction = operation.action;
1159
1913
  const fromVersion = upgradeAction.input.fromVersion;
1160
1914
  const toVersion = upgradeAction.input.toVersion;
1161
- let upgradePath;
1162
1915
  if (fromVersion > 0 && fromVersion < toVersion) {
1916
+ let upgradePath;
1163
1917
  try {
1164
1918
  upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1165
1919
  } catch (err) {
@@ -1172,12 +1926,19 @@ var KyselyWriteCache = class KyselyWriteCache {
1172
1926
  revision: upgradeAction.input.revision,
1173
1927
  timestampUtcMs: operation.timestampUtcMs
1174
1928
  });
1175
- }
1176
- document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1177
- docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1178
- } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1179
- else {
1180
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1929
+ pendingUpgrades.push({
1930
+ action: upgradeAction,
1931
+ upgradePath,
1932
+ index: operation.index,
1933
+ subsequentDeletes: []
1934
+ });
1935
+ } else document = applyUpgradeDocumentAction(document, upgradeAction, void 0);
1936
+ docModule = this.registry.getModule(documentType, normalizeDocumentModelVersion(toVersion));
1937
+ } else if (operation.action.type === "DELETE_DOCUMENT") {
1938
+ applyDeleteDocumentAction(document, operation.action);
1939
+ for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
1940
+ } else {
1941
+ const protocolVersion = baseReducerVersion(document.header);
1181
1942
  document = docModule.reducer(document, operation.action, void 0, {
1182
1943
  skip: operation.skip,
1183
1944
  protocolVersion
@@ -1185,6 +1946,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1185
1946
  }
1186
1947
  }
1187
1948
  }
1949
+ if (scope === "document") {
1950
+ document = this.applyPendingUpgrades(document, pendingUpgrades, Number.MAX_SAFE_INTEGER);
1951
+ const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
1952
+ document.operations = {
1953
+ ...document.operations,
1954
+ document: last ? [last] : []
1955
+ };
1956
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1957
+ }
1958
+ if (keyframe) {
1959
+ const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
1960
+ if (resumeOperation) document.operations = {
1961
+ ...document.operations,
1962
+ [scope]: [resumeOperation]
1963
+ };
1964
+ }
1188
1965
  const moduleCache = /* @__PURE__ */ new Map();
1189
1966
  const getModuleCached = (version) => {
1190
1967
  const key = version ?? 0;
@@ -1195,6 +1972,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1195
1972
  }
1196
1973
  return mod;
1197
1974
  };
1975
+ const finalVersion = validatedUpgrades.at(-1)?.toVersion ?? extractModuleVersion(document);
1198
1976
  let cursor = void 0;
1199
1977
  const pageSize = 100;
1200
1978
  let hasMorePages;
@@ -1208,12 +1986,16 @@ var KyselyWriteCache = class KyselyWriteCache {
1208
1986
  const result = await this.operationStore.getSince(documentId, scope, branch, startRevision, void 0, paging, signal);
1209
1987
  for (const operation of result.results) {
1210
1988
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1211
- const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, extractModuleVersion(document));
1212
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1213
- document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1214
- skip: operation.skip,
1215
- protocolVersion
1216
- });
1989
+ const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, finalVersion);
1990
+ document = this.applyPendingUpgrades(document, pendingUpgrades, moduleVersion ?? Number.MAX_SAFE_INTEGER);
1991
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1992
+ else {
1993
+ const protocolVersion = baseReducerVersion(document.header);
1994
+ document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1995
+ skip: operation.skip,
1996
+ protocolVersion
1997
+ });
1998
+ }
1217
1999
  }
1218
2000
  const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
1219
2001
  hasMorePages = Boolean(result.nextCursor) && !reachedTarget;
@@ -1222,11 +2004,86 @@ var KyselyWriteCache = class KyselyWriteCache {
1222
2004
  throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1223
2005
  }
1224
2006
  } while (hasMorePages);
2007
+ document = this.applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision);
2008
+ document = await this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
2009
+ if (pendingUpgrades.length > 0) {
2010
+ const firstHeldBack = pendingUpgrades[0];
2011
+ const stamped = document.header.revision["document"] ?? 0;
2012
+ document.header.revision = {
2013
+ ...document.header.revision,
2014
+ document: Math.min(stamped, firstHeldBack.index)
2015
+ };
2016
+ }
2017
+ return document;
2018
+ }
2019
+ /**
2020
+ * Applies and removes every held-back upgrade whose target version is at or
2021
+ * below `throughVersion`, in the order the document scope recorded them.
2022
+ */
2023
+ applyPendingUpgrades(document, pendingUpgrades, throughVersion) {
2024
+ while (pendingUpgrades.length > 0) {
2025
+ const pending = pendingUpgrades[0];
2026
+ if (throughVersion < pending.action.input.toVersion) break;
2027
+ pendingUpgrades.shift();
2028
+ document = this.applyPendingUpgrade(document, pending);
2029
+ }
2030
+ return document;
2031
+ }
2032
+ /**
2033
+ * Applies the remaining held-back upgrades after the requested scope's
2034
+ * replay has finished. A head read applies them all. A positional read
2035
+ * applies only those whose boundary for this scope lies at or before the
2036
+ * target position: applying a later one would label migrated state with a
2037
+ * pre-upgrade revision, and a keyframe stored from that poisons every
2038
+ * rebuild that resumes from it. Boundaries come from the upgrade's revision
2039
+ * snapshot; an upgrade without one records no position for this scope, and
2040
+ * the replay loop not having crossed it already places it past the target.
2041
+ */
2042
+ applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision) {
2043
+ while (pendingUpgrades.length > 0) {
2044
+ const pending = pendingUpgrades[0];
2045
+ if (targetRevision !== void 0) {
2046
+ const snapshot = pending.action.input.revision;
2047
+ if (snapshot === void 0) break;
2048
+ if ((snapshot[scope] ?? 0) > targetRevision) break;
2049
+ }
2050
+ pendingUpgrades.shift();
2051
+ document = this.applyPendingUpgrade(document, pending);
2052
+ }
2053
+ return document;
2054
+ }
2055
+ /**
2056
+ * Applies one held-back upgrade, then re-applies the deletes the document
2057
+ * scope recorded after it so the hold-back cannot invert their order.
2058
+ */
2059
+ applyPendingUpgrade(document, pending) {
2060
+ document = applyUpgradeDocumentAction(document, pending.action, pending.upgradePath);
2061
+ for (const deleteAction of pending.subsequentDeletes) document = applyDeleteDocumentAction(document, deleteAction);
2062
+ return document;
2063
+ }
2064
+ /**
2065
+ * Copies the current document revisions onto the document. Overwrites the
2066
+ * requested scope revision with the target revision, if provided.
2067
+ */
2068
+ async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
1225
2069
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1226
2070
  document.header.revision = revisions.revision;
2071
+ if (targetRevision !== void 0) document.header.revision = {
2072
+ ...document.header.revision,
2073
+ [scope]: targetRevision + 1
2074
+ };
1227
2075
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1228
2076
  return document;
1229
2077
  }
2078
+ /** The stored operation at `index`, or undefined if it is no longer there. */
2079
+ async operationAt(documentId, scope, branch, index, signal) {
2080
+ if (index < 0) return;
2081
+ const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
2082
+ cursor: "0",
2083
+ limit: 1
2084
+ }, signal)).results[0];
2085
+ return operation && operation.index === index ? operation : void 0;
2086
+ }
1230
2087
  /**
1231
2088
  * Resolves which module version to use for a given operation in phase 2.
1232
2089
  *
@@ -1250,19 +2107,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1250
2107
  async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
1251
2108
  const documentType = baseDocument.header.documentType;
1252
2109
  const docScopeNextIndex = baseDocument.header.revision["document"] ?? 0;
1253
- if ((await this.operationStore.getSince(documentId, "document", branch, docScopeNextIndex - 1, void 0, void 0, signal)).results.some((op) => op.action.type === "UPGRADE_DOCUMENT")) return this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
2110
+ 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
2111
  const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
1255
- let document = baseDocument;
2112
+ let document = copyDocument(baseDocument);
1256
2113
  try {
1257
2114
  const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
1258
2115
  for (const operation of pagedResults.results) {
1259
2116
  if (signal?.aborted) throw new Error("Operation aborted");
1260
2117
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1261
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1262
- document = module.reducer(document, operation.action, void 0, {
1263
- skip: operation.skip,
1264
- protocolVersion
1265
- });
2118
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
2119
+ else {
2120
+ const protocolVersion = baseReducerVersion(document.header);
2121
+ document = module.reducer(document, operation.action, void 0, {
2122
+ skip: operation.skip,
2123
+ protocolVersion
2124
+ });
2125
+ }
1266
2126
  if (targetRevision !== void 0 && operation.index === targetRevision) break;
1267
2127
  }
1268
2128
  } catch (err) {
@@ -1270,6 +2130,10 @@ var KyselyWriteCache = class KyselyWriteCache {
1270
2130
  }
1271
2131
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1272
2132
  document.header.revision = revisions.revision;
2133
+ if (targetRevision !== void 0) document.header.revision = {
2134
+ ...document.header.revision,
2135
+ [scope]: targetRevision + 1
2136
+ };
1273
2137
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1274
2138
  return document;
1275
2139
  }
@@ -1339,6 +2203,49 @@ var EventBus = class {
1339
2203
  }
1340
2204
  };
1341
2205
  //#endregion
2206
+ //#region src/core/feature-flags.ts
2207
+ /**
2208
+ * Every flag this reactor knows, with the flags it requires. A stage adds its
2209
+ * flag here when it ships, so asking an older reactor for a later stage's flag
2210
+ * is an unrecognized name rather than a flag that quietly does nothing.
2211
+ */
2212
+ const FLAG_PREREQUISITES = {
2213
+ documentDecisions: [],
2214
+ authEnforcement: ["documentDecisions"],
2215
+ authGroups: ["authEnforcement"],
2216
+ authConditions: ["authGroups"]
2217
+ };
2218
+ /**
2219
+ * The flags as plain booleans, with anything unset off, validated. Callers hold
2220
+ * a partial set, because that is what crosses to a pooled worker, and every
2221
+ * consumer needs the same resolution of it.
2222
+ */
2223
+ function resolveFeatureFlags(flags = {}) {
2224
+ const resolved = {
2225
+ documentDecisions: flags.documentDecisions ?? false,
2226
+ authEnforcement: flags.authEnforcement ?? false,
2227
+ authGroups: flags.authGroups ?? false,
2228
+ authConditions: flags.authConditions ?? false
2229
+ };
2230
+ validateFeatureFlags(flags, FLAG_PREREQUISITES);
2231
+ return resolved;
2232
+ }
2233
+ /**
2234
+ * Throws when the flags ask for enforcement the reactor cannot deliver. Either
2235
+ * failure would otherwise read as enforcement being on while the reactor
2236
+ * applies less than the caller asked for.
2237
+ */
2238
+ function validateFeatureFlags(flags, prerequisites) {
2239
+ const known = Object.keys(prerequisites);
2240
+ const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
2241
+ if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
2242
+ for (const name of known) {
2243
+ if (flags[name] !== true) continue;
2244
+ const missing = prerequisites[name].filter((required) => flags[required] !== true);
2245
+ if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
2246
+ }
2247
+ }
2248
+ //#endregion
1342
2249
  //#region src/executor/execution-scope.ts
1343
2250
  var DefaultExecutionScope = class {
1344
2251
  constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
@@ -1436,6 +2343,273 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
1436
2343
  }));
1437
2344
  }
1438
2345
  //#endregion
2346
+ //#region src/decision/merged-order.ts
2347
+ /** Identifies a stream within a walk. */
2348
+ function streamKey(query) {
2349
+ return `${query.documentId}:${query.scope}:${query.branch}`;
2350
+ }
2351
+ /**
2352
+ * Orders two operations from different streams by position. Timestamp decides;
2353
+ * an equal timestamp puts an auth operation first, and otherwise falls to the
2354
+ * action id and then the operation id, so that two replicas holding the same
2355
+ * operations agree on the order whatever order they happen to store them in.
2356
+ */
2357
+ function comparePositions(a, b) {
2358
+ const aTime = Date.parse(a.operation.timestampUtcMs);
2359
+ const bTime = Date.parse(b.operation.timestampUtcMs);
2360
+ if (aTime !== bTime) return aTime - bTime;
2361
+ if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
2362
+ const aAuth = a.scope === "auth";
2363
+ if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
2364
+ const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
2365
+ if (actionIds !== 0) return actionIds;
2366
+ return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
2367
+ }
2368
+ /**
2369
+ * Merges the read-set streams into one sequence by position. An operation's
2370
+ * place in the result is the bound a decision at that operation reads to: every
2371
+ * operation before it has been applied, and it has not.
2372
+ */
2373
+ function mergeByPosition(streams) {
2374
+ const merged = [];
2375
+ for (const stream of streams) for (const operation of stream.operations) merged.push({
2376
+ streamKey: stream.streamKey,
2377
+ scope: stream.scope,
2378
+ operation
2379
+ });
2380
+ return merged.sort(comparePositions);
2381
+ }
2382
+ /**
2383
+ * The skip that retracts everything from `firstRetractedIndex` up to where the
2384
+ * re-appended operation lands. It spans the indexes rather than counting the
2385
+ * operations, because a stream with a gap in it makes those differ.
2386
+ */
2387
+ function retractionSkip(nextIndex, firstRetractedIndex) {
2388
+ return nextIndex - firstRetractedIndex;
2389
+ }
2390
+ //#endregion
2391
+ //#region src/decision/walk.ts
2392
+ /**
2393
+ * A single forward pass is only correct while a stream's effective operations
2394
+ * are ordered.
2395
+ */
2396
+ function assertPositionOrder(streamKey, scope, operations) {
2397
+ for (let i = 1; i < operations.length; i++) {
2398
+ const previous = operations[i - 1];
2399
+ const current = operations[i];
2400
+ if (comparePositions({
2401
+ streamKey,
2402
+ scope,
2403
+ operation: previous
2404
+ }, {
2405
+ streamKey,
2406
+ scope,
2407
+ operation: current
2408
+ }) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
2409
+ }
2410
+ }
2411
+ /**
2412
+ * Visits every operation in the read-set once, in the order their positions
2413
+ * fall, and hands back the state each stream held just before it. That state is
2414
+ * what a decision at that operation reads.
2415
+ *
2416
+ * Skips are resolved first (i.e. this is performed on a garbage collected
2417
+ * stream), which means we can do a single forward pass.
2418
+ *
2419
+ * An operation that contributes no state, whether denied or holding a reducer
2420
+ * error, is visited but not applied (this matches the write cache's rebuild).
2421
+ *
2422
+ * The consumer sends back whether it refused the operation it was handed: a
2423
+ * refusal this pass produced must suppress it the same way a stored one does.
2424
+ */
2425
+ function* walkByPosition(streams) {
2426
+ const merged = mergeByPosition(streams.map((stream) => {
2427
+ const operations = garbageCollect(sortOperations([...stream.operations]));
2428
+ assertPositionOrder(stream.streamKey, stream.scope, operations);
2429
+ return {
2430
+ streamKey: stream.streamKey,
2431
+ scope: stream.scope,
2432
+ operations
2433
+ };
2434
+ }));
2435
+ const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
2436
+ const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
2437
+ for (const { streamKey, operation } of merged) {
2438
+ if ((yield {
2439
+ streamKey,
2440
+ operation,
2441
+ states: new Map(states)
2442
+ }) || operation.error !== void 0 || isDenied(operation)) continue;
2443
+ const stream = byKey.get(streamKey);
2444
+ const before = states.get(streamKey);
2445
+ if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
2446
+ states.set(streamKey, stream.apply(before, operation));
2447
+ }
2448
+ }
2449
+ //#endregion
2450
+ //#region src/decision/evaluation.ts
2451
+ /** The stream key for evaluated operations whose scope no projection reads. */
2452
+ const EVALUATED_ONLY = "evaluated";
2453
+ /**
2454
+ * Whether any stream the model reads declares this operation's action type as
2455
+ * one that can change an evaluation.
2456
+ */
2457
+ function isDecidingAction(operation, readSet) {
2458
+ return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
2459
+ }
2460
+ /**
2461
+ * Who an operation acts as. A replayed operation is evaluated as its own signer,
2462
+ * so an address-scoped policy does not deny its own author's history.
2463
+ */
2464
+ function subjectOf(operation) {
2465
+ const signer = operation.action.context?.signer;
2466
+ return {
2467
+ address: signer?.user.address,
2468
+ key: signer?.app.key
2469
+ };
2470
+ }
2471
+ /**
2472
+ * The model as the walk reached this operation: each static projection's value
2473
+ * is its own scope's state, and each derived projection's value maps document
2474
+ * id to that document's state, holding only the streams this replica walked. A
2475
+ * derived stream it does not hold stays out of the map, which fails closed.
2476
+ */
2477
+ function modelAt(readSet, derivedNames, derived, states) {
2478
+ const model = {};
2479
+ for (const stream of readSet) {
2480
+ const document = states.get(streamKey(stream.query));
2481
+ if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
2482
+ model[stream.name] = document.state[stream.query.scope];
2483
+ }
2484
+ for (const name of derivedNames) model[name] = {};
2485
+ for (const entry of derived) {
2486
+ const map = model[entry.name];
2487
+ const document = states.get(streamKey(entry.query));
2488
+ if (document !== void 0) map[entry.query.documentId] = document.state[entry.query.scope];
2489
+ }
2490
+ return model;
2491
+ }
2492
+ /**
2493
+ * Evaluates each operation at its own position and returns the refusals in an
2494
+ * array parallel to the operations, where undefined means allowed.
2495
+ *
2496
+ * A position is a timestamp, so an operation refused by a delete is one that
2497
+ * sorts after it, and the operations before it are left alone. That holds
2498
+ * whether the delete is already stored or is among the operations passed in.
2499
+ */
2500
+ async function evaluateByPosition(model, target, subject, stores, signal) {
2501
+ const { scope, operations } = subject;
2502
+ const { writeCache, operationStore } = stores;
2503
+ const definition = model(target);
2504
+ const readSet = staticReadSet(definition);
2505
+ const derivedSet = derivedReadSet(definition);
2506
+ if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
2507
+ const evaluating = new Set(operations.map((operation) => operation.id));
2508
+ const readStreams = await Promise.all(readSet.map(async (stream) => ({
2509
+ stream,
2510
+ 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))
2511
+ })));
2512
+ const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
2513
+ if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
2514
+ if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
2515
+ const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
2516
+ const walked = [];
2517
+ const histories = [];
2518
+ for (const read of readStreams) {
2519
+ const streamOperations = read.stream === writtenProjection ? [...read.operations, ...operations] : read.operations;
2520
+ const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, -1, signal);
2521
+ walked.push({
2522
+ streamKey: streamKey(read.stream.query),
2523
+ scope: read.stream.query.scope,
2524
+ document: before,
2525
+ operations: streamOperations,
2526
+ apply: read.stream.apply
2527
+ });
2528
+ histories.push({
2529
+ name: read.stream.name,
2530
+ operations: streamOperations
2531
+ });
2532
+ }
2533
+ let evaluatedStateKey;
2534
+ if (writtenProjection !== void 0) evaluatedStateKey = streamKey(writtenProjection.query);
2535
+ else if (definition.foldEvaluatedScope !== void 0) {
2536
+ const query = {
2537
+ documentId: target.documentId,
2538
+ scope,
2539
+ branch: target.branch
2540
+ };
2541
+ const storedOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, void 0, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
2542
+ const before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
2543
+ evaluatedStateKey = streamKey(query);
2544
+ walked.push({
2545
+ streamKey: evaluatedStateKey,
2546
+ scope,
2547
+ document: before,
2548
+ operations: [...storedOperations, ...operations],
2549
+ apply: definition.foldEvaluatedScope
2550
+ });
2551
+ } else walked.push({
2552
+ streamKey: EVALUATED_ONLY,
2553
+ scope,
2554
+ document: walked[0].document,
2555
+ operations,
2556
+ apply: (document) => document
2557
+ });
2558
+ const derivedEntries = [];
2559
+ const walkedKeys = new Set(walked.map((stream) => stream.streamKey));
2560
+ for (const projection of derivedSet) {
2561
+ const queries = projection.queryOverHistory?.(histories) ?? [];
2562
+ for (const query of queries) {
2563
+ const key = streamKey(query);
2564
+ if (walkedKeys.has(key)) continue;
2565
+ let before;
2566
+ try {
2567
+ before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
2568
+ } catch (error) {
2569
+ if (error instanceof DocumentNotFoundError) continue;
2570
+ throw error;
2571
+ }
2572
+ 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));
2573
+ walkedKeys.add(key);
2574
+ walked.push({
2575
+ streamKey: key,
2576
+ scope: query.scope,
2577
+ document: before,
2578
+ operations: streamOperations,
2579
+ apply: projection.apply
2580
+ });
2581
+ derivedEntries.push({
2582
+ name: projection.name,
2583
+ query
2584
+ });
2585
+ }
2586
+ }
2587
+ const reasons = /* @__PURE__ */ new Map();
2588
+ const walk = walkByPosition(walked);
2589
+ let step = walk.next(false);
2590
+ while (!step.done) {
2591
+ const position = step.value;
2592
+ if (!evaluating.has(position.operation.id)) {
2593
+ step = walk.next(false);
2594
+ continue;
2595
+ }
2596
+ const evaluatedDocument = evaluatedStateKey === void 0 ? void 0 : position.states.get(evaluatedStateKey);
2597
+ const scopeState = evaluatedDocument === void 0 ? void 0 : evaluatedDocument.state[scope];
2598
+ const evaluation = definition.decide(modelAt(readSet, derivedSet.map((projection) => projection.name), derivedEntries, position.states), subjectOf(position.operation), {
2599
+ verb: "execute",
2600
+ scope: position.operation.action.scope,
2601
+ operation: position.operation.action.type
2602
+ }, {
2603
+ scopeState,
2604
+ actionInput: position.operation.action.input
2605
+ });
2606
+ const denied = evaluation.decision === "deny";
2607
+ reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
2608
+ step = walk.next(denied);
2609
+ }
2610
+ return operations.map((operation) => reasons.get(operation.id));
2611
+ }
2612
+ //#endregion
1439
2613
  //#region src/cache/operation-index-types.ts
1440
2614
  const DRIVE_COLLECTION_PREFIX = "drive.";
1441
2615
  /**
@@ -1482,23 +2656,119 @@ var DriveCollectionId = class DriveCollectionId {
1482
2656
  //#endregion
1483
2657
  //#region src/executor/document-action-handler.ts
1484
2658
  var DocumentActionHandler = class {
1485
- constructor(registry, logger, driveContainerTypes) {
2659
+ constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel) {
1486
2660
  this.registry = registry;
1487
2661
  this.logger = logger;
1488
2662
  this.driveContainerTypes = driveContainerTypes;
1489
- }
1490
- async execute(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2663
+ this.featureFlags = featureFlags;
2664
+ this.decisionModel = decisionModel;
2665
+ }
2666
+ /** Whether the write arrives with its evaluation already decided. */
2667
+ alreadyEvaluated(executing) {
2668
+ return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
2669
+ }
2670
+ async execute(write, executing) {
2671
+ const { action } = write;
2672
+ if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
2673
+ const refusal = await this.refuseIfPolicyDenies(write, executing);
2674
+ if (refusal) return refusal;
1491
2675
  switch (action.type) {
1492
- case "CREATE_DOCUMENT": return this.executeCreate(job, action, startTime, indexTxn, stores, skip, sourceRemote, signal);
1493
- case "DELETE_DOCUMENT": return this.executeDelete(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1494
- case "UPGRADE_DOCUMENT": return this.executeUpgrade(job, action, startTime, indexTxn, stores, skip, sourceRemote, signal);
1495
- case "ADD_RELATIONSHIP": return this.executeAddRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1496
- case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1497
- case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1498
- default: return buildErrorResult(job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), startTime);
2676
+ case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
2677
+ case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
2678
+ case "UPGRADE_DOCUMENT": return this.executeUpgrade(write, executing);
2679
+ case "ADD_RELATIONSHIP": return this.executeAddRelationship(write, executing);
2680
+ case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(write, executing);
2681
+ case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(write, executing);
2682
+ default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
2683
+ }
2684
+ }
2685
+ /**
2686
+ * Refuses a document-scope write the policy denies, or undefined to proceed.
2687
+ * Without this an `execute`-on-`document` grant is unenforceable.
2688
+ */
2689
+ async refuseIfPolicyDenies(write, executing) {
2690
+ const { action } = write;
2691
+ const { job, startTime, stores, signal } = executing;
2692
+ if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
2693
+ const documentId = targetDocumentId(action, job.documentId);
2694
+ let admission;
2695
+ try {
2696
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, {
2697
+ documentId,
2698
+ branch: job.branch
2699
+ }, {
2700
+ address: action.context?.signer?.user.address,
2701
+ key: action.context?.signer?.app.key
2702
+ }, {
2703
+ verb: "execute",
2704
+ scope: action.scope,
2705
+ operation: action.type
2706
+ }, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
2707
+ } catch (error) {
2708
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1499
2709
  }
2710
+ if (admission.evaluation.decision === "allow") return;
2711
+ return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
1500
2712
  }
1501
- async executeCreate(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2713
+ /** A refused operation holds a position in the stream but changes nothing. */
2714
+ async writeDenied(write, executing) {
2715
+ const { action, skip, sourceRemote, deniedReason } = write;
2716
+ const { job, startTime, indexTxn, stores, signal } = executing;
2717
+ let document;
2718
+ try {
2719
+ document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
2720
+ } catch (error) {
2721
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2722
+ }
2723
+ const index = getNextIndexForScope(document, job.scope);
2724
+ let standing = document;
2725
+ if (skip > 0) try {
2726
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
2727
+ } catch (error) {
2728
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2729
+ }
2730
+ let operation = createOperation(action, index, skip, {
2731
+ documentId: job.documentId,
2732
+ scope: job.scope,
2733
+ branch: job.branch
2734
+ });
2735
+ operation.deniedReason = deniedReason;
2736
+ operation.hash = hashDocumentStateForScope(standing, job.scope);
2737
+ const writeResult = await this.writeOperationToStore({
2738
+ documentId: job.documentId,
2739
+ documentType: document.header.documentType,
2740
+ scope: job.scope,
2741
+ branch: job.branch
2742
+ }, operation, executing);
2743
+ if (!Array.isArray(writeResult)) return writeResult;
2744
+ operation = writeResult[0];
2745
+ updateDocumentRevision(standing, job.scope, operation.index);
2746
+ standing.operations = {
2747
+ ...standing.operations,
2748
+ [job.scope]: [...standing.operations[job.scope] ?? [], operation]
2749
+ };
2750
+ stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, SnapshotPosition.Head);
2751
+ indexTxn.write([{
2752
+ ...operation,
2753
+ documentId: job.documentId,
2754
+ documentType: document.header.documentType,
2755
+ branch: job.branch,
2756
+ scope: job.scope,
2757
+ sourceRemote
2758
+ }]);
2759
+ stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
2760
+ state: standing.state.document,
2761
+ documentType: standing.header.documentType,
2762
+ documentScopeRevision: operation.index + 1
2763
+ });
2764
+ return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
2765
+ header: standing.header,
2766
+ document: standing.state.document
2767
+ }), startTime);
2768
+ }
2769
+ async executeCreate(write, executing) {
2770
+ const { action, skip, sourceRemote } = write;
2771
+ const { job, startTime, indexTxn, stores, signal } = executing;
1502
2772
  if (job.scope !== "document") return {
1503
2773
  job,
1504
2774
  success: false,
@@ -1516,7 +2786,12 @@ var DocumentActionHandler = class {
1516
2786
  ...document.state
1517
2787
  };
1518
2788
  const resultingState = JSON.stringify(resultingStateObj);
1519
- const writeResult = await this.writeOperationToStore(document.header.id, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2789
+ const writeResult = await this.writeOperationToStore({
2790
+ documentId: document.header.id,
2791
+ documentType: document.header.documentType,
2792
+ scope: job.scope,
2793
+ branch: job.branch
2794
+ }, operation, executing);
1520
2795
  if (!Array.isArray(writeResult)) return writeResult;
1521
2796
  operation = writeResult[0];
1522
2797
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1524,7 +2799,7 @@ var DocumentActionHandler = class {
1524
2799
  ...document.operations,
1525
2800
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1526
2801
  };
1527
- stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document);
2802
+ stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1528
2803
  indexTxn.write([{
1529
2804
  ...operation,
1530
2805
  documentId: document.header.id,
@@ -1545,7 +2820,9 @@ var DocumentActionHandler = class {
1545
2820
  });
1546
2821
  return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
1547
2822
  }
1548
- async executeDelete(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
2823
+ async executeDelete(write, executing) {
2824
+ const { action, skip, sourceRemote } = write;
2825
+ const { job, startTime, indexTxn, stores, signal } = executing;
1549
2826
  const input = action.input;
1550
2827
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
1551
2828
  const documentId = input.documentId;
@@ -1556,8 +2833,8 @@ var DocumentActionHandler = class {
1556
2833
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
1557
2834
  }
1558
2835
  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), 0, {
2836
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2837
+ let operation = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
1561
2838
  documentId,
1562
2839
  scope: job.scope,
1563
2840
  branch: job.branch
@@ -1568,7 +2845,12 @@ var DocumentActionHandler = class {
1568
2845
  document: document.state.document
1569
2846
  };
1570
2847
  const resultingState = JSON.stringify(resultingStateObj);
1571
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2848
+ const writeResult = await this.writeOperationToStore({
2849
+ documentId,
2850
+ documentType: document.header.documentType,
2851
+ scope: job.scope,
2852
+ branch: job.branch
2853
+ }, operation, executing);
1572
2854
  if (!Array.isArray(writeResult)) return writeResult;
1573
2855
  operation = writeResult[0];
1574
2856
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1576,7 +2858,7 @@ var DocumentActionHandler = class {
1576
2858
  ...document.operations,
1577
2859
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1578
2860
  };
1579
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2861
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1580
2862
  indexTxn.write([{
1581
2863
  ...operation,
1582
2864
  documentId,
@@ -1592,7 +2874,9 @@ var DocumentActionHandler = class {
1592
2874
  });
1593
2875
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1594
2876
  }
1595
- async executeUpgrade(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2877
+ async executeUpgrade(write, executing) {
2878
+ const { action, skip, sourceRemote } = write;
2879
+ const { job, startTime, indexTxn, stores, signal } = executing;
1596
2880
  const input = action.input;
1597
2881
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
1598
2882
  const documentId = input.documentId;
@@ -1605,14 +2889,7 @@ var DocumentActionHandler = class {
1605
2889
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
1606
2890
  }
1607
2891
  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);
1615
- }
2892
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1616
2893
  if (fromVersion === toVersion && fromVersion > 0) return {
1617
2894
  job,
1618
2895
  success: true,
@@ -1620,6 +2897,48 @@ var DocumentActionHandler = class {
1620
2897
  operationsWithContext: [],
1621
2898
  duration: Date.now() - startTime
1622
2899
  };
2900
+ const arrivesDecided = executing.replayingAcceptedHistory || executing.evaluatedByPosition;
2901
+ if (fromVersion > 0 && !arrivesDecided) {
2902
+ const stampedVersion = normalizeDocumentModelVersion(documentState.version);
2903
+ if (fromVersion !== stampedVersion) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `fromVersion ${fromVersion} does not match the document's version ${stampedVersion}`), startTime);
2904
+ if (input.revision !== void 0) {
2905
+ let actualRevisions;
2906
+ try {
2907
+ actualRevisions = (await stores.operationStore.getRevisions(documentId, job.branch, signal)).revision;
2908
+ } catch (error) {
2909
+ return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch revisions for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
2910
+ }
2911
+ const revisionScopes = new Set([...Object.keys(input.revision), ...Object.keys(actualRevisions)]);
2912
+ for (const revisionScope of revisionScopes) {
2913
+ const snapshot = input.revision[revisionScope] ?? 0;
2914
+ const actual = actualRevisions[revisionScope] ?? 0;
2915
+ if (snapshot !== actual) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `revision snapshot for scope "${revisionScope}" is ${snapshot} but the document is at ${actual}`), startTime);
2916
+ }
2917
+ }
2918
+ }
2919
+ let upgradePath;
2920
+ if (fromVersion > 0 && fromVersion < toVersion) try {
2921
+ upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
2922
+ } catch (error) {
2923
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2924
+ }
2925
+ const otherScopes = Object.keys(document.state).filter((scope) => scope !== job.scope);
2926
+ if (fromVersion > 0) for (const scope of otherScopes) {
2927
+ let scopedDocument;
2928
+ try {
2929
+ scopedDocument = await stores.writeCache.getState(documentId, scope, job.branch, void 0, signal);
2930
+ } catch (error) {
2931
+ return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch ${scope} scope for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
2932
+ }
2933
+ document = {
2934
+ ...document,
2935
+ state: {
2936
+ ...document.state,
2937
+ [scope]: scopedDocument.state[scope]
2938
+ }
2939
+ };
2940
+ }
2941
+ const nextIndex = getNextIndexForScope(document, job.scope);
1623
2942
  try {
1624
2943
  document = applyUpgradeDocumentAction$1(document, action, upgradePath);
1625
2944
  } catch (error) {
@@ -1634,8 +2953,14 @@ var DocumentActionHandler = class {
1634
2953
  header: document.header,
1635
2954
  ...document.state
1636
2955
  };
2956
+ if (fromVersion > 0) resultingStateObj.__migrated = true;
1637
2957
  const resultingState = JSON.stringify(resultingStateObj);
1638
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2958
+ const writeResult = await this.writeOperationToStore({
2959
+ documentId,
2960
+ documentType: document.header.documentType,
2961
+ scope: job.scope,
2962
+ branch: job.branch
2963
+ }, operation, executing);
1639
2964
  if (!Array.isArray(writeResult)) return writeResult;
1640
2965
  operation = writeResult[0];
1641
2966
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1643,7 +2968,12 @@ var DocumentActionHandler = class {
1643
2968
  ...document.operations,
1644
2969
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1645
2970
  };
1646
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2971
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
2972
+ for (const scope of otherScopes) executing.postCommitInvalidations.push({
2973
+ documentId,
2974
+ scope,
2975
+ branch: job.branch
2976
+ });
1647
2977
  indexTxn.write([{
1648
2978
  ...operation,
1649
2979
  documentId,
@@ -1659,8 +2989,8 @@ var DocumentActionHandler = class {
1659
2989
  });
1660
2990
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1661
2991
  }
1662
- executeAddRelationship(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
1663
- return this.withRelationshipAction("ADD_RELATIONSHIP", job, action, startTime, indexTxn, stores, sourceRemote, signal, (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 }) => {
2992
+ executeAddRelationship(write, executing) {
2993
+ 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
2994
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1665
2995
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1666
2996
  txn.addToCollection(collectionId, input.targetId);
@@ -1668,8 +2998,8 @@ var DocumentActionHandler = class {
1668
2998
  }
1669
2999
  });
1670
3000
  }
1671
- executeRemoveRelationship(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
1672
- return this.withRelationshipAction("REMOVE_RELATIONSHIP", job, action, startTime, indexTxn, stores, sourceRemote, signal, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
3001
+ executeRemoveRelationship(write, executing) {
3002
+ return this.withRelationshipAction("REMOVE_RELATIONSHIP", write, executing, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
1673
3003
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1674
3004
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1675
3005
  txn.removeFromCollection(collectionId, input.targetId);
@@ -1677,10 +3007,12 @@ var DocumentActionHandler = class {
1677
3007
  }
1678
3008
  });
1679
3009
  }
1680
- executeUpdateRelationship(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
1681
- return this.withRelationshipAction("UPDATE_RELATIONSHIP", job, action, startTime, indexTxn, stores, sourceRemote, signal, null, null);
3010
+ executeUpdateRelationship(write, executing) {
3011
+ return this.withRelationshipAction("UPDATE_RELATIONSHIP", write, executing, null, null);
1682
3012
  }
1683
- async withRelationshipAction(actionTypeName, job, action, startTime, indexTxn, stores, sourceRemote, signal, preValidate, postWrite) {
3013
+ async withRelationshipAction(actionTypeName, write, executing, preValidate, postWrite) {
3014
+ const { action, skip, sourceRemote } = write;
3015
+ const { job, startTime, indexTxn, stores, signal } = executing;
1684
3016
  if (job.scope !== "document") return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} must be in "document" scope, got "${job.scope}"`), startTime);
1685
3017
  const input = action.input;
1686
3018
  if (!input.sourceId || !input.targetId || !input.relationshipType) return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} action requires sourceId, targetId, and relationshipType in input`), startTime);
@@ -1692,14 +3024,20 @@ var DocumentActionHandler = class {
1692
3024
  try {
1693
3025
  sourceDoc = await stores.writeCache.getState(input.sourceId, "document", job.branch, void 0, signal);
1694
3026
  } catch (error) {
3027
+ if (DocumentNotFoundError.isError(error)) return buildErrorResult(job, new DocumentNotFoundError(input.sourceId, `${actionTypeName}: source document ${input.sourceId} not found`), startTime);
1695
3028
  return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName}: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
1696
3029
  }
1697
- let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), 0, {
3030
+ let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), skip, {
1698
3031
  documentId: input.sourceId,
1699
3032
  scope: job.scope,
1700
3033
  branch: job.branch
1701
3034
  });
1702
- const writeResult = await this.writeOperationToStore(input.sourceId, sourceDoc.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
3035
+ const writeResult = await this.writeOperationToStore({
3036
+ documentId: input.sourceId,
3037
+ documentType: sourceDoc.header.documentType,
3038
+ scope: job.scope,
3039
+ branch: job.branch
3040
+ }, operation, executing);
1703
3041
  if (!Array.isArray(writeResult)) return writeResult;
1704
3042
  operation = writeResult[0];
1705
3043
  sourceDoc.header.lastModifiedAtUtcIso = operation.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString();
@@ -1714,7 +3052,7 @@ var DocumentActionHandler = class {
1714
3052
  [job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
1715
3053
  };
1716
3054
  const resultingState = JSON.stringify(resultingStateObj);
1717
- stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
3055
+ stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, SnapshotPosition.Head);
1718
3056
  indexTxn.write([{
1719
3057
  ...operation,
1720
3058
  documentId: input.sourceId,
@@ -1737,7 +3075,9 @@ var DocumentActionHandler = class {
1737
3075
  });
1738
3076
  return buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
1739
3077
  }
1740
- async writeOperationToStore(documentId, documentType, scope, branch, operation, job, startTime, stores, signal) {
3078
+ async writeOperationToStore(target, operation, executing) {
3079
+ const { documentId, documentType, scope, branch } = target;
3080
+ const { job, startTime, stores, signal } = executing;
1741
3081
  let storedOperations;
1742
3082
  try {
1743
3083
  storedOperations = await stores.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
@@ -1746,10 +3086,11 @@ var DocumentActionHandler = class {
1746
3086
  } catch (error) {
1747
3087
  this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
1748
3088
  stores.writeCache.invalidate(documentId, scope, branch);
3089
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
1749
3090
  return {
1750
3091
  job,
1751
3092
  success: false,
1752
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
3093
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
1753
3094
  duration: Date.now() - startTime
1754
3095
  };
1755
3096
  }
@@ -1814,19 +3155,13 @@ function isValidISOTimestamp(value) {
1814
3155
  if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
1815
3156
  return !isNaN(new Date(value).getTime());
1816
3157
  }
1817
- const documentScopeActions = [
1818
- "CREATE_DOCUMENT",
1819
- "DELETE_DOCUMENT",
1820
- "UPGRADE_DOCUMENT",
1821
- "ADD_RELATIONSHIP",
1822
- "REMOVE_RELATIONSHIP",
1823
- "UPDATE_RELATIONSHIP"
1824
- ];
1825
3158
  /**
1826
3159
  * Simple job executor that processes a job by applying actions through document model reducers.
1827
3160
  */
1828
3161
  var SimpleJobExecutor = class {
1829
3162
  config;
3163
+ featureFlags;
3164
+ decisionModel;
1830
3165
  signatureVerifierModule;
1831
3166
  documentActionHandler;
1832
3167
  executionScope;
@@ -1841,15 +3176,19 @@ var SimpleJobExecutor = class {
1841
3176
  this.collectionMembershipCache = collectionMembershipCache;
1842
3177
  this.driveContainerTypes = driveContainerTypes;
1843
3178
  this.config = {
3179
+ featureFlags: config.featureFlags ?? {},
1844
3180
  maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
1845
3181
  maxConcurrency: config.maxConcurrency ?? 1,
1846
3182
  jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
3183
+ deferredJobTtlMs: config.deferredJobTtlMs ?? 3e4,
1847
3184
  retryBaseDelayMs: config.retryBaseDelayMs ?? 100,
1848
3185
  retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
1849
3186
  yieldDeadlineMs: config.yieldDeadlineMs ?? 50
1850
3187
  };
3188
+ this.featureFlags = resolveFeatureFlags(config.featureFlags);
3189
+ this.decisionModel = selectDecisionModel(this.featureFlags, registry);
1851
3190
  this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
1852
- this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes);
3191
+ this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel);
1853
3192
  this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
1854
3193
  }
1855
3194
  /**
@@ -1859,13 +3198,23 @@ var SimpleJobExecutor = class {
1859
3198
  async executeJob(job, signal) {
1860
3199
  const startTime = Date.now();
1861
3200
  const touchedCacheEntries = [];
3201
+ const postCommitInvalidations = [];
1862
3202
  let pendingEvent;
1863
3203
  let result;
1864
3204
  try {
1865
3205
  result = await this.executionScope.run(async (stores) => {
1866
3206
  const indexTxn = stores.operationIndex.start();
1867
3207
  if (job.kind === "load") {
1868
- const loadResult = await this.executeLoadJob(job, startTime, indexTxn, stores, signal);
3208
+ const loadResult = await this.executeLoadJob({
3209
+ job,
3210
+ startTime,
3211
+ indexTxn,
3212
+ stores,
3213
+ signal,
3214
+ replayingAcceptedHistory: true,
3215
+ evaluatedByPosition: false,
3216
+ postCommitInvalidations
3217
+ });
1869
3218
  if (loadResult.success && loadResult.operationsWithContext) {
1870
3219
  for (const owc of loadResult.operationsWithContext) touchedCacheEntries.push({
1871
3220
  documentId: owc.context.documentId,
@@ -1884,7 +3233,50 @@ var SimpleJobExecutor = class {
1884
3233
  }
1885
3234
  return loadResult;
1886
3235
  }
1887
- const actionResult = await this.processActions(job, job.actions, startTime, indexTxn, stores, void 0, void 0, "", signal);
3236
+ if (job.kind === "reevaluation") {
3237
+ const reevalResult = await this.executeReevaluationJob({
3238
+ job,
3239
+ startTime,
3240
+ indexTxn,
3241
+ stores,
3242
+ signal,
3243
+ replayingAcceptedHistory: false,
3244
+ evaluatedByPosition: false,
3245
+ postCommitInvalidations
3246
+ });
3247
+ if (reevalResult.success && reevalResult.operationsWithContext) {
3248
+ for (const owc of reevalResult.operationsWithContext) touchedCacheEntries.push({
3249
+ documentId: owc.context.documentId,
3250
+ scope: owc.context.scope,
3251
+ branch: owc.context.branch
3252
+ });
3253
+ const ordinals = await stores.operationIndex.commit(indexTxn, signal);
3254
+ for (let i = 0; i < reevalResult.operationsWithContext.length; i++) reevalResult.operationsWithContext[i].context.ordinal = ordinals[i];
3255
+ if (reevalResult.operationsWithContext.length > 0) {
3256
+ const collectionMemberships = await this.getCollectionMembershipsForOperations(reevalResult.operationsWithContext, stores);
3257
+ pendingEvent = {
3258
+ jobId: job.id,
3259
+ operations: reevalResult.operationsWithContext,
3260
+ jobMeta: job.meta,
3261
+ collectionMemberships
3262
+ };
3263
+ }
3264
+ }
3265
+ return reevalResult;
3266
+ }
3267
+ const positioned = await this.positionByTimestamp(job, stores, signal);
3268
+ if (positioned.error) return buildErrorResult(job, positioned.error, startTime);
3269
+ const executing = {
3270
+ job,
3271
+ startTime,
3272
+ indexTxn,
3273
+ stores,
3274
+ signal,
3275
+ replayingAcceptedHistory: false,
3276
+ evaluatedByPosition: positioned.evaluatedByPosition,
3277
+ postCommitInvalidations
3278
+ };
3279
+ const actionResult = await this.processActions(positioned.writes, executing);
1888
3280
  if (!actionResult.success) return {
1889
3281
  job,
1890
3282
  success: false,
@@ -1896,6 +3288,16 @@ var SimpleJobExecutor = class {
1896
3288
  scope: owc.context.scope,
1897
3289
  branch: owc.context.branch
1898
3290
  });
3291
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
3292
+ scope: job.scope,
3293
+ operations: actionResult.generatedOperations
3294
+ }, executing);
3295
+ if (reevaluationError) return {
3296
+ job,
3297
+ success: false,
3298
+ error: reevaluationError,
3299
+ duration: Date.now() - startTime
3300
+ };
1899
3301
  const ordinals = await stores.operationIndex.commit(indexTxn, signal);
1900
3302
  if (actionResult.operationsWithContext.length > 0) {
1901
3303
  for (let i = 0; i < actionResult.operationsWithContext.length; i++) actionResult.operationsWithContext[i].context.ordinal = ordinals[i];
@@ -1922,6 +3324,7 @@ var SimpleJobExecutor = class {
1922
3324
  }
1923
3325
  throw error;
1924
3326
  }
3327
+ if (result.success) for (const entry of postCommitInvalidations) this.writeCache.invalidate(entry.documentId, entry.scope, entry.branch);
1925
3328
  if (pendingEvent) this.eventBus.emit(ReactorEventTypes.JOB_WRITE_READY, pendingEvent).catch((error) => {
1926
3329
  this.logger.error("Failed to emit JOB_WRITE_READY event: @Event : @Error", pendingEvent, error);
1927
3330
  });
@@ -1931,7 +3334,9 @@ var SimpleJobExecutor = class {
1931
3334
  const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
1932
3335
  return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
1933
3336
  }
1934
- async processActions(job, actions, startTime, indexTxn, stores, skipValues, sourceOperations, sourceRemote = "", signal) {
3337
+ async processActions(writes, executing) {
3338
+ const { job, signal } = executing;
3339
+ const actions = writes.map((write) => write.action);
1935
3340
  const generatedOperations = [];
1936
3341
  const operationsWithContext = [];
1937
3342
  try {
@@ -1948,14 +3353,11 @@ var SimpleJobExecutor = class {
1948
3353
  success: false,
1949
3354
  generatedOperations,
1950
3355
  operationsWithContext,
1951
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${action.timestampUtcMs}" on action ${action.type} (id: ${action.id})`)
3356
+ error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
1952
3357
  };
1953
3358
  let lastYield = performance.now();
1954
- for (let actionIndex = 0; actionIndex < actions.length; actionIndex++) {
1955
- const action = actions[actionIndex];
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);
3359
+ for (const write of writes) {
3360
+ const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
1959
3361
  const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
1960
3362
  if (error !== null) return {
1961
3363
  success: false,
@@ -1980,14 +3382,44 @@ var SimpleJobExecutor = class {
1980
3382
  operationsWithContext
1981
3383
  };
1982
3384
  }
1983
- async executeRegularAction(job, action, startTime, indexTxn, stores, skip = 0, sourceOperation, sourceRemote = "", signal) {
1984
- let docMeta;
1985
- try {
1986
- docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
1987
- } catch (error) {
1988
- return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3385
+ async executeRegularAction(write, executing) {
3386
+ const { action, skip, sourceOperation, sourceRemote, deniedReason } = write;
3387
+ const { job, startTime, indexTxn, stores, signal } = executing;
3388
+ let appendCondition;
3389
+ let documentVersion;
3390
+ const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
3391
+ if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
3392
+ const target = {
3393
+ documentId: job.documentId,
3394
+ branch: job.branch
3395
+ };
3396
+ let admission;
3397
+ try {
3398
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
3399
+ address: action.context?.signer?.user.address,
3400
+ key: action.context?.signer?.app.key
3401
+ }, {
3402
+ verb: "execute",
3403
+ scope: action.scope,
3404
+ operation: action.type
3405
+ }, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
3406
+ } catch (error) {
3407
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3408
+ }
3409
+ if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
3410
+ appendCondition = admission.appendCondition;
3411
+ documentVersion = admission.documentVersion;
3412
+ } else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
3413
+ else {
3414
+ let docMeta;
3415
+ try {
3416
+ docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
3417
+ } catch (error) {
3418
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3419
+ }
3420
+ if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
3421
+ documentVersion = docMeta.state.version;
1989
3422
  }
1990
- if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
1991
3423
  if (isUndoRedo(action) || action.type === "PRUNE" || action.type === "NOOP" && skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
1992
3424
  let document;
1993
3425
  try {
@@ -1995,16 +3427,48 @@ var SimpleJobExecutor = class {
1995
3427
  } catch (error) {
1996
3428
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1997
3429
  }
3430
+ if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
3431
+ const subject = {
3432
+ address: write.action.context?.signer?.user.address,
3433
+ key: write.action.context?.signer?.app.key
3434
+ };
3435
+ if (decide(document.state.auth, subject, {
3436
+ verb: "execute",
3437
+ scope: action.scope,
3438
+ operation: action.type
3439
+ }) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
3440
+ }
1998
3441
  let module;
1999
3442
  try {
2000
- const moduleVersion = docMeta.state.version === 0 ? void 0 : docMeta.state.version;
2001
- module = this.registry.getModule(document.header.documentType, moduleVersion);
3443
+ module = this.registry.getModule(document.header.documentType, normalizeDocumentModelVersion(documentVersion));
2002
3444
  } catch (error) {
2003
3445
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2004
3446
  }
2005
3447
  let updatedDocument;
2006
- try {
2007
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
3448
+ if (deniedReason !== void 0) {
3449
+ const index = getNextIndexForScope(document, job.scope);
3450
+ const denied = createOperation(action, index, skip, {
3451
+ documentId: job.documentId,
3452
+ scope: job.scope,
3453
+ branch: job.branch
3454
+ });
3455
+ denied.deniedReason = deniedReason;
3456
+ let standing = document;
3457
+ if (skip > 0) try {
3458
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
3459
+ } catch (error) {
3460
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3461
+ }
3462
+ denied.hash = hashDocumentStateForScope(standing, job.scope);
3463
+ updatedDocument = {
3464
+ ...standing,
3465
+ operations: {
3466
+ ...standing.operations,
3467
+ [job.scope]: [...standing.operations[job.scope] ?? [], denied]
3468
+ }
3469
+ };
3470
+ } else try {
3471
+ const protocolVersion = baseReducerVersion(document.header);
2008
3472
  const reducerOptions = sourceOperation ? {
2009
3473
  skip,
2010
3474
  branch: job.branch,
@@ -2035,14 +3499,15 @@ var SimpleJobExecutor = class {
2035
3499
  try {
2036
3500
  storedOperations = await stores.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
2037
3501
  txn.addOperations(newOperation);
2038
- }, signal);
3502
+ }, signal, appendCondition);
2039
3503
  } catch (error) {
2040
3504
  this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
2041
3505
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
3506
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
2042
3507
  return {
2043
3508
  job,
2044
3509
  success: false,
2045
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
3510
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2046
3511
  duration: Date.now() - startTime
2047
3512
  };
2048
3513
  }
@@ -2051,7 +3516,7 @@ var SimpleJobExecutor = class {
2051
3516
  ...updatedDocument.header.revision,
2052
3517
  [scope]: storedOperation.index + 1
2053
3518
  };
2054
- stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument);
3519
+ stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument, SnapshotPosition.Head);
2055
3520
  indexTxn.write([{
2056
3521
  ...storedOperation,
2057
3522
  documentId: job.documentId,
@@ -2060,6 +3525,7 @@ var SimpleJobExecutor = class {
2060
3525
  scope,
2061
3526
  sourceRemote
2062
3527
  }]);
3528
+ if (scope === "auth") indexTxn.recordGroupReferences(job.documentId, mentionedGroupIds(action));
2063
3529
  return {
2064
3530
  job,
2065
3531
  success: true,
@@ -2078,14 +3544,291 @@ var SimpleJobExecutor = class {
2078
3544
  duration: Date.now() - startTime
2079
3545
  };
2080
3546
  }
2081
- async executeLoadJob(job, startTime, indexTxn, stores, signal) {
3547
+ /**
3548
+ * Orders a write by timestamp and decides it where it lands. The caller
3549
+ * supplies the timestamp, so a write can belong before operations already
3550
+ * stored; those are re-appended alongside it, the way a load reshuffles.
3551
+ *
3552
+ * Deciding a backdated write at the stream heads instead of at its position
3553
+ * would overwrite the verdict every other replica computes for it.
3554
+ */
3555
+ async positionByTimestamp(job, stores, signal) {
3556
+ const plain = () => ({
3557
+ writes: job.actions.map((action) => ({
3558
+ action,
3559
+ skip: 0,
3560
+ sourceRemote: ""
3561
+ })),
3562
+ evaluatedByPosition: false
3563
+ });
3564
+ if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
3565
+ let earliest = job.actions[0].timestampUtcMs;
3566
+ let earliestAt = Date.parse(earliest);
3567
+ for (const action of job.actions) {
3568
+ const at = Date.parse(action.timestampUtcMs);
3569
+ if (at < earliestAt) {
3570
+ earliest = action.timestampUtcMs;
3571
+ earliestAt = at;
3572
+ }
3573
+ }
3574
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3575
+ const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
3576
+ if (this.featureFlags.authEnforcement && job.scope === "auth") {
3577
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3578
+ const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
3579
+ if (violation) return {
3580
+ writes: [],
3581
+ evaluatedByPosition: false,
3582
+ error: violation
3583
+ };
3584
+ if (!backdated) return plain();
3585
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3586
+ }
3587
+ if (!backdated) return plain();
3588
+ const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
3589
+ if (conflicting.length === 0) {
3590
+ if (!this.featureFlags.authEnforcement) return plain();
3591
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3592
+ }
3593
+ const nextIndex = revisions.revision[job.scope] ?? 0;
3594
+ let firstConflicting = conflicting[0].index;
3595
+ for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
3596
+ const incoming = job.actions.map((action, i) => ({
3597
+ id: action.id,
3598
+ index: nextIndex + i,
3599
+ skip: 0,
3600
+ hash: "",
3601
+ timestampUtcMs: action.timestampUtcMs,
3602
+ action
3603
+ }));
3604
+ const merged = reshuffleByTimestamp({
3605
+ index: nextIndex,
3606
+ skip: retractionSkip(nextIndex, firstConflicting)
3607
+ }, conflicting, incoming);
3608
+ stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
3609
+ if (!this.featureFlags.authEnforcement) return {
3610
+ writes: merged.map((operation) => ({
3611
+ action: operation.action,
3612
+ skip: operation.skip,
3613
+ sourceRemote: ""
3614
+ })),
3615
+ evaluatedByPosition: false
3616
+ };
3617
+ return this.evaluatePositioned(job, stores, merged, signal);
3618
+ }
3619
+ /**
3620
+ * Decides each operation where it lands and carries the verdict on it. A
3621
+ * refused submitted action is reported to the caller and nothing is stored; a
3622
+ * refused operation the reshuffle merely moved keeps its verdict, because it
3623
+ * already holds a position.
3624
+ *
3625
+ * The operations carry the indexes and skips they will be stored at, because
3626
+ * the walk resolves skips before it orders them.
3627
+ */
3628
+ async evaluatePositioned(job, stores, operations, signal) {
3629
+ const reasons = await evaluateByPosition(this.decisionModel, {
3630
+ documentId: job.documentId,
3631
+ branch: job.branch
3632
+ }, {
3633
+ scope: job.scope,
3634
+ operations
3635
+ }, stores, signal);
3636
+ const submitted = new Set(job.actions.map((action) => action.id));
3637
+ for (let i = 0; i < operations.length; i++) {
3638
+ const reason = reasons[i];
3639
+ if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
3640
+ writes: [],
3641
+ evaluatedByPosition: false,
3642
+ error: refusalError(reason, job.documentId, null, operations[i].action)
3643
+ };
3644
+ }
3645
+ return {
3646
+ writes: operations.map((operation, i) => ({
3647
+ action: operation.action,
3648
+ skip: operation.skip,
3649
+ sourceRemote: "",
3650
+ deniedReason: reasons[i]
3651
+ })),
3652
+ evaluatedByPosition: true
3653
+ };
3654
+ }
3655
+ /**
3656
+ * The scopes a re-evaluation pass visits, in a fixed order.
3657
+ *
3658
+ * The revisions map comes from a query with no ORDER BY, and the order is
3659
+ * load-bearing: each scope's pass re-reads the auth stream, and the walk skips
3660
+ * an operation by its stored denial, so a denial this pass just wrote is
3661
+ * visible to a later-visited scope and invisible to an earlier one. The model's
3662
+ * own projection order leads, then the rest sorted, so the pass is reproducible
3663
+ * across replicas and across runs.
3664
+ */
3665
+ evaluationOrder(target, revision) {
3666
+ const definition = this.decisionModel(target);
3667
+ const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
3668
+ const leading = [];
3669
+ for (const stream of staticReadSet(definition)) {
3670
+ const scope = stream.query.scope;
3671
+ if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
3672
+ }
3673
+ const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
3674
+ return [...leading, ...rest];
3675
+ }
3676
+ /**
3677
+ * The first timestamp in the batch that does not strictly exceed everything
3678
+ * ahead of it, or undefined when the whole batch is monotonic.
3679
+ *
3680
+ * The bound is carried forward rather than compared against one stored maximum,
3681
+ * because a single execute can carry several auth actions stamped in the same
3682
+ * millisecond. Letting a tie through would store a stream the position walk
3683
+ * then refuses to read, with no repair path.
3684
+ */
3685
+ firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
3686
+ let boundIso = newest;
3687
+ let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
3688
+ for (const entry of entries) {
3689
+ if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
3690
+ const at = Date.parse(entry.timestampUtcMs);
3691
+ if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
3692
+ bound = at;
3693
+ boundIso = entry.timestampUtcMs;
3694
+ }
3695
+ }
3696
+ /** The operations a batch of submitted actions appends at the scope's tail. */
3697
+ appendedOperations(job, nextIndex) {
3698
+ return job.actions.map((action, i) => ({
3699
+ id: action.id,
3700
+ index: nextIndex + i,
3701
+ skip: 0,
3702
+ hash: "",
3703
+ timestampUtcMs: action.timestampUtcMs,
3704
+ action
3705
+ }));
3706
+ }
3707
+ /**
3708
+ * Re-evaluates the document when a write meets both criteria: it was written
3709
+ * to a stream the model reads, and it is timestamped before an operation
3710
+ * already stored. The caller supplies the timestamp and the reactor does not replace
3711
+ * it, so a mutation job can write such an operation just as a load job can,
3712
+ * which is why both executeJob and executeLoadJob call this.
3713
+ */
3714
+ async reevaluateIfCriteriaMet(criteria, executing) {
3715
+ if (!this.featureFlags.documentDecisions) return;
3716
+ const { job, stores, signal } = executing;
3717
+ const target = {
3718
+ documentId: job.documentId,
3719
+ branch: job.branch
3720
+ };
3721
+ if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
3722
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3723
+ const latest = Date.parse(revisions.latestTimestamp);
3724
+ if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
3725
+ return (await this.reevaluateDocument(executing)).error;
3726
+ }
3727
+ /**
3728
+ * Re-evaluates every scope the model evaluates. Where an operation's
3729
+ * evaluation differs from what is stored, the tail from that operation is
3730
+ * re-appended, carrying a skip that spans the indices it supersedes.
3731
+ */
3732
+ async reevaluateDocument(executing) {
3733
+ const { job, stores, signal } = executing;
3734
+ const target = {
3735
+ documentId: job.documentId,
3736
+ branch: job.branch
3737
+ };
3738
+ const reappended = [];
3739
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3740
+ for (const scope of this.evaluationOrder(target, revisions.revision)) {
3741
+ const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
3742
+ const effective = garbageCollect(sortOperations([...stored]));
3743
+ if (effective.length === 0) continue;
3744
+ const reevaluated = await evaluateByPosition(this.decisionModel, target, {
3745
+ scope,
3746
+ operations: effective
3747
+ }, stores, signal);
3748
+ const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== reevaluated[i]);
3749
+ if (firstChange === -1) continue;
3750
+ const tail = effective.slice(firstChange);
3751
+ const nextIndex = revisions.revision[scope];
3752
+ stores.writeCache.invalidate(job.documentId, scope, job.branch);
3753
+ const result = await this.processActions(tail.map((operation, i) => ({
3754
+ action: operation.action,
3755
+ skip: i === 0 ? retractionSkip(nextIndex, tail[0].index) : 0,
3756
+ sourceRemote: "",
3757
+ deniedReason: reevaluated[firstChange + i]
3758
+ })), {
3759
+ ...executing,
3760
+ job: {
3761
+ ...job,
3762
+ scope
3763
+ },
3764
+ replayingAcceptedHistory: true,
3765
+ evaluatedByPosition: true
3766
+ });
3767
+ if (!result.success) return {
3768
+ error: result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`),
3769
+ operationsWithContext: reappended
3770
+ };
3771
+ reappended.push(...result.operationsWithContext);
3772
+ }
3773
+ return { operationsWithContext: reappended };
3774
+ }
3775
+ /**
3776
+ * Re-judges a document's stored operations because a read-set stream in
3777
+ * another document (a group) gained an operation. The trigger timestamp
3778
+ * bounds the work: an operation later than everything this document holds
3779
+ * cannot change any evaluation, so the pass is skipped.
3780
+ */
3781
+ async executeReevaluationJob(executing) {
3782
+ const { job, startTime, stores, signal } = executing;
3783
+ if (!this.featureFlags.documentDecisions) return {
3784
+ job,
3785
+ success: true,
3786
+ operations: [],
3787
+ operationsWithContext: [],
3788
+ duration: Date.now() - startTime
3789
+ };
3790
+ const trigger = job.meta.triggerTimestampUtcMs;
3791
+ if (typeof trigger === "string") {
3792
+ let latestTimestamp;
3793
+ try {
3794
+ latestTimestamp = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).latestTimestamp;
3795
+ } catch {
3796
+ return {
3797
+ job,
3798
+ success: true,
3799
+ operations: [],
3800
+ operationsWithContext: [],
3801
+ duration: Date.now() - startTime
3802
+ };
3803
+ }
3804
+ if (Date.parse(trigger) > Date.parse(latestTimestamp)) return {
3805
+ job,
3806
+ success: true,
3807
+ operations: [],
3808
+ operationsWithContext: [],
3809
+ duration: Date.now() - startTime
3810
+ };
3811
+ }
3812
+ const outcome = await this.reevaluateDocument(executing);
3813
+ if (outcome.error) return buildErrorResult(job, outcome.error, startTime);
3814
+ return {
3815
+ job,
3816
+ success: true,
3817
+ operations: outcome.operationsWithContext.map((owc) => owc.operation),
3818
+ operationsWithContext: outcome.operationsWithContext,
3819
+ duration: Date.now() - startTime
3820
+ };
3821
+ }
3822
+ async executeLoadJob(executing) {
3823
+ const { job, startTime, indexTxn, stores, signal } = executing;
2082
3824
  if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
2083
3825
  let docMeta;
2084
3826
  try {
2085
3827
  docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2086
3828
  } catch {}
2087
- if (docMeta?.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
3829
+ if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2088
3830
  const scope = job.scope;
3831
+ const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
2089
3832
  let latestRevision;
2090
3833
  try {
2091
3834
  latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
@@ -2095,7 +3838,7 @@ var SimpleJobExecutor = class {
2095
3838
  for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
2096
3839
  job,
2097
3840
  success: false,
2098
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${operation.timestampUtcMs}" on operation (index: ${operation.index})`),
3841
+ error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
2099
3842
  duration: Date.now() - startTime
2100
3843
  };
2101
3844
  let minIncomingIndex = Number.POSITIVE_INFINITY;
@@ -2103,7 +3846,7 @@ var SimpleJobExecutor = class {
2103
3846
  for (const operation of job.operations) {
2104
3847
  minIncomingIndex = Math.min(minIncomingIndex, operation.index);
2105
3848
  const ts = operation.timestampUtcMs || "";
2106
- if (ts < minIncomingTimestamp) minIncomingTimestamp = ts;
3849
+ if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
2107
3850
  }
2108
3851
  let conflictingOps;
2109
3852
  try {
@@ -2128,11 +3871,14 @@ var SimpleJobExecutor = class {
2128
3871
  }
2129
3872
  return true;
2130
3873
  });
2131
- const existingOpsToReshuffle = nonSupersededOps;
2132
- if (existingOpsToReshuffle.length > this.config.maxSkipThreshold) return {
3874
+ const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
3875
+ const actionIdCounts = /* @__PURE__ */ new Map();
3876
+ for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
3877
+ const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
3878
+ if (reshuffleCost > this.config.maxSkipThreshold) return {
2133
3879
  job,
2134
3880
  success: false,
2135
- error: /* @__PURE__ */ new Error(`Excessive reshuffle detected: existing op count of ${existingOpsToReshuffle.length} exceeds threshold of ${this.config.maxSkipThreshold}. This indicates a significant divergence between local and incoming operations.`),
3881
+ error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
2136
3882
  duration: Date.now() - startTime
2137
3883
  };
2138
3884
  let skipCount = existingOpsToReshuffle.length;
@@ -2160,6 +3906,16 @@ var SimpleJobExecutor = class {
2160
3906
  operationsWithContext: [],
2161
3907
  duration: Date.now() - startTime
2162
3908
  };
3909
+ if (monotonicAuthStream) {
3910
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3911
+ const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
3912
+ if (violation) return {
3913
+ job,
3914
+ success: false,
3915
+ error: violation,
3916
+ duration: Date.now() - startTime
3917
+ };
3918
+ }
2163
3919
  const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
2164
3920
  ...operation,
2165
3921
  index: latestRevision + i
@@ -2171,10 +3927,31 @@ var SimpleJobExecutor = class {
2171
3927
  id: operation.id
2172
3928
  })));
2173
3929
  for (const operation of reshuffledOperations) if (operation.action.type === "NOOP") operation.skip = 1;
2174
- const actions = reshuffledOperations.map((operation) => operation.action);
2175
- const skipValues = reshuffledOperations.map((operation) => operation.skip);
3930
+ let deniedReasons;
3931
+ if (this.featureFlags.documentDecisions) try {
3932
+ deniedReasons = await evaluateByPosition(this.decisionModel, {
3933
+ documentId: job.documentId,
3934
+ branch: job.branch
3935
+ }, {
3936
+ scope,
3937
+ operations: reshuffledOperations
3938
+ }, stores, signal);
3939
+ } catch (error) {
3940
+ return {
3941
+ job,
3942
+ success: false,
3943
+ error: error instanceof Error ? error : new Error(String(error)),
3944
+ duration: Date.now() - startTime
3945
+ };
3946
+ }
2176
3947
  const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
2177
- const result = await this.processActions(job, actions, startTime, indexTxn, stores, skipValues, reshuffledOperations, effectiveSourceRemote, signal);
3948
+ const result = await this.processActions(reshuffledOperations.map((operation, i) => ({
3949
+ action: operation.action,
3950
+ skip: operation.skip,
3951
+ sourceOperation: operation,
3952
+ sourceRemote: effectiveSourceRemote,
3953
+ deniedReason: deniedReasons?.[i]
3954
+ })), executing);
2178
3955
  if (!result.success) return {
2179
3956
  job,
2180
3957
  success: false,
@@ -2183,6 +3960,16 @@ var SimpleJobExecutor = class {
2183
3960
  };
2184
3961
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2185
3962
  if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
3963
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
3964
+ scope,
3965
+ operations: result.generatedOperations
3966
+ }, executing);
3967
+ if (reevaluationError) return {
3968
+ job,
3969
+ success: false,
3970
+ error: reevaluationError,
3971
+ duration: Date.now() - startTime
3972
+ };
2186
3973
  return {
2187
3974
  job,
2188
3975
  success: true,
@@ -2315,7 +4102,7 @@ var DocumentModelRegistry = class {
2315
4102
  }
2316
4103
  computeUpgradePath(documentType, fromVersion, toVersion) {
2317
4104
  if (fromVersion === toVersion) return [];
2318
- if (toVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, toVersion);
4105
+ if (toVersion < fromVersion) throw new DowngradeNotSupportedError$1(documentType, fromVersion, toVersion);
2319
4106
  const manifest = this.getUpgradeManifest(documentType);
2320
4107
  const path = [];
2321
4108
  for (let v = fromVersion + 1; v <= toVersion; v++) {
@@ -2419,36 +4206,6 @@ function paginateRows(rows, paging, cursorOf, toItem, refetch) {
2419
4206
  };
2420
4207
  }
2421
4208
  //#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
4209
  //#region src/storage/txn.ts
2453
4210
  var AtomicTransaction = class {
2454
4211
  operations = [];
@@ -2473,6 +4230,7 @@ var AtomicTransaction = class {
2473
4230
  action: JSON.stringify(op.action),
2474
4231
  skip: op.skip,
2475
4232
  error: op.error || null,
4233
+ deniedReason: op.deniedReason || null,
2476
4234
  hash: op.hash
2477
4235
  });
2478
4236
  }
@@ -2506,12 +4264,12 @@ var KyselyOperationStore = class KyselyOperationStore {
2506
4264
  instance.trx = trx;
2507
4265
  return instance;
2508
4266
  }
2509
- async apply(documentId, documentType, scope, branch, revision, fn, signal) {
4267
+ async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
2510
4268
  if (this.trx) {
2511
4269
  let executeResult = null;
2512
4270
  let uniqueCtx = null;
2513
4271
  try {
2514
- executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal);
4272
+ executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2515
4273
  } catch (error) {
2516
4274
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
2517
4275
  else throw error;
@@ -2523,7 +4281,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2523
4281
  let uniqueCtx = null;
2524
4282
  try {
2525
4283
  transactionResult = await this.db.transaction().execute(async (trx) => {
2526
- return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal);
4284
+ return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2527
4285
  });
2528
4286
  } catch (error) {
2529
4287
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
@@ -2542,12 +4300,13 @@ var KyselyOperationStore = class KyselyOperationStore {
2542
4300
  const op = ctx.stagedOps[0];
2543
4301
  throw new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
2544
4302
  }
2545
- async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal) {
4303
+ async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
2546
4304
  throwIfAborted(signal);
2547
4305
  const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
2548
4306
  await fn(atomicTxn);
2549
4307
  const operations = atomicTxn.getOperations();
2550
4308
  if (operations.length === 0) return [];
4309
+ if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
2551
4310
  const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
2552
4311
  const currentRevision = latestOp ? latestOp.index : -1;
2553
4312
  if (currentRevision !== revision - 1) {
@@ -2563,22 +4322,91 @@ var KyselyOperationStore = class KyselyOperationStore {
2563
4322
  op.prevOpId = prevOpId;
2564
4323
  prevOpId = op.opId;
2565
4324
  }
4325
+ let insertedCount = operations.length;
2566
4326
  try {
2567
- await trx.insertInto("Operation").values(operations).execute();
4327
+ if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
4328
+ else await trx.insertInto("Operation").values(operations).execute();
2568
4329
  } catch (error) {
2569
4330
  if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
2570
4331
  throw error;
2571
4332
  }
4333
+ if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
2572
4334
  return operations.map((op) => ({
2573
4335
  index: op.index,
2574
4336
  timestampUtcMs: op.timestampUtcMs.toISOString(),
2575
4337
  hash: op.hash,
2576
4338
  skip: op.skip,
2577
4339
  error: op.error || void 0,
4340
+ deniedReason: op.deniedReason || void 0,
2578
4341
  id: op.opId,
2579
4342
  action: JSON.parse(op.action)
2580
4343
  }));
2581
4344
  }
4345
+ /**
4346
+ * Locks the written stream and every read-set stream, in sorted key order
4347
+ * so that overlapping concurrent appends serialize rather than deadlock.
4348
+ * The locks are still taken one row at a time, so the query preserves that
4349
+ * order. It must stay separate from the guarded insert, which would
4350
+ * otherwise read a snapshot taken before the locks were held.
4351
+ */
4352
+ async acquireStreamLocks(trx, documentId, scope, branch, condition) {
4353
+ const keys = new Set([`${documentId}:${scope}:${branch}`]);
4354
+ for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
4355
+ await sql`
4356
+ with ordered as materialized (
4357
+ select key
4358
+ from unnest(array[${sql.join([...keys].sort())}]::text[]) with ordinality as t(key, ord)
4359
+ order by ord
4360
+ )
4361
+ select pg_advisory_xact_lock(hashtext(key)) from ordered
4362
+ `.execute(trx);
4363
+ }
4364
+ /**
4365
+ * Inserts the staged operations with the condition compiled in as a WHERE
4366
+ * NOT EXISTS guard, making the check and the append one statement. Returns
4367
+ * the rows inserted; zero means the guard failed and nothing was written.
4368
+ */
4369
+ async insertGuarded(trx, operations, condition) {
4370
+ const branches = operations.map((op) => trx.selectNoFrom([
4371
+ sql`${op.jobId}::text`.as("jobId"),
4372
+ sql`${op.opId}::text`.as("opId"),
4373
+ sql`${op.prevOpId}::text`.as("prevOpId"),
4374
+ sql`${op.documentId}::text`.as("documentId"),
4375
+ sql`${op.documentType}::text`.as("documentType"),
4376
+ sql`${op.scope}::text`.as("scope"),
4377
+ sql`${op.branch}::text`.as("branch"),
4378
+ sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
4379
+ sql`${op.index}::integer`.as("index"),
4380
+ sql`${op.action}::jsonb`.as("action"),
4381
+ sql`${op.skip}::integer`.as("skip"),
4382
+ sql`${op.error ?? null}::text`.as("error"),
4383
+ sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
4384
+ sql`${op.hash}::text`.as("hash")
4385
+ ]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
4386
+ web("Operation.documentId", "=", s.documentId),
4387
+ web("Operation.scope", "=", s.scope),
4388
+ web("Operation.branch", "=", s.branch),
4389
+ web("Operation.index", ">", s.revision)
4390
+ ]))))))));
4391
+ let expression = branches[0];
4392
+ for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
4393
+ return (await trx.insertInto("Operation").columns([
4394
+ "jobId",
4395
+ "opId",
4396
+ "prevOpId",
4397
+ "documentId",
4398
+ "documentType",
4399
+ "scope",
4400
+ "branch",
4401
+ "timestampUtcMs",
4402
+ "index",
4403
+ "action",
4404
+ "skip",
4405
+ "error",
4406
+ "deniedReason",
4407
+ "hash"
4408
+ ]).expression(expression).returning("id").execute()).length;
4409
+ }
2582
4410
  async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
2583
4411
  const minIndex = revision;
2584
4412
  const maxIndex = revision + stagedOps.length - 1;
@@ -2646,18 +4474,18 @@ var KyselyOperationStore = class KyselyOperationStore {
2646
4474
  "o1.index",
2647
4475
  "o1.timestampUtcMs"
2648
4476
  ]).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();
4477
+ const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("branch", "=", branch).executeTakeFirst();
2649
4478
  const revision = {};
2650
- let latestTimestamp = (/* @__PURE__ */ new Date(0)).toISOString();
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
- }
4479
+ for (const row of scopeRevisions) revision[row.scope] = row.index + 1;
2656
4480
  return {
2657
4481
  revision,
2658
- latestTimestamp
4482
+ latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
2659
4483
  };
2660
4484
  }
4485
+ async getStreamLatestTimestamp(documentId, scope, branch, signal) {
4486
+ 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();
4487
+ return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
4488
+ }
2661
4489
  rowToOperation(row) {
2662
4490
  return {
2663
4491
  index: row.index,
@@ -2665,6 +4493,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2665
4493
  hash: row.hash,
2666
4494
  skip: row.skip,
2667
4495
  error: row.error || void 0,
4496
+ deniedReason: row.deniedReason || void 0,
2668
4497
  id: row.opId,
2669
4498
  action: row.action
2670
4499
  };
@@ -2750,8 +4579,8 @@ function createForwardingPoolInstrumentation(name) {
2750
4579
  }
2751
4580
  //#endregion
2752
4581
  //#region src/storage/migrations/001_create_operation_table.ts
2753
- var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
2754
- async function up$13(db) {
4582
+ var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$18 });
4583
+ async function up$18(db) {
2755
4584
  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
4585
  "documentId",
2757
4586
  "scope",
@@ -2776,8 +4605,8 @@ async function up$13(db) {
2776
4605
  }
2777
4606
  //#endregion
2778
4607
  //#region src/storage/migrations/002_create_keyframe_table.ts
2779
- var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
2780
- async function up$12(db) {
4608
+ var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$17 });
4609
+ async function up$17(db) {
2781
4610
  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
4611
  "documentId",
2783
4612
  "scope",
@@ -2793,14 +4622,14 @@ async function up$12(db) {
2793
4622
  }
2794
4623
  //#endregion
2795
4624
  //#region src/storage/migrations/003_create_document_table.ts
2796
- var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
2797
- async function up$11(db) {
4625
+ var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$16 });
4626
+ async function up$16(db) {
2798
4627
  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
4628
  }
2800
4629
  //#endregion
2801
4630
  //#region src/storage/migrations/004_create_document_relationship_table.ts
2802
- var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
2803
- async function up$10(db) {
4631
+ var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
4632
+ async function up$15(db) {
2804
4633
  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
4634
  "sourceId",
2806
4635
  "targetId",
@@ -2812,14 +4641,14 @@ async function up$10(db) {
2812
4641
  }
2813
4642
  //#endregion
2814
4643
  //#region src/storage/migrations/005_create_indexer_state_table.ts
2815
- var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
2816
- async function up$9(db) {
4644
+ var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
4645
+ async function up$14(db) {
2817
4646
  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
4647
  }
2819
4648
  //#endregion
2820
4649
  //#region src/storage/migrations/006_create_document_snapshot_table.ts
2821
- var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
2822
- async function up$8(db) {
4650
+ var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
4651
+ async function up$13(db) {
2823
4652
  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
4653
  "documentId",
2825
4654
  "scope",
@@ -2840,8 +4669,8 @@ async function up$8(db) {
2840
4669
  }
2841
4670
  //#endregion
2842
4671
  //#region src/storage/migrations/007_create_slug_mapping_table.ts
2843
- var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
2844
- async function up$7(db) {
4672
+ var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
4673
+ async function up$12(db) {
2845
4674
  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
4675
  "documentId",
2847
4676
  "scope",
@@ -2851,14 +4680,14 @@ async function up$7(db) {
2851
4680
  }
2852
4681
  //#endregion
2853
4682
  //#region src/storage/migrations/008_create_view_state_table.ts
2854
- var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
2855
- async function up$6(db) {
4683
+ var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
4684
+ async function up$11(db) {
2856
4685
  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
4686
  }
2858
4687
  //#endregion
2859
4688
  //#region src/storage/migrations/009_create_operation_index_tables.ts
2860
- var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
2861
- async function up$5(db) {
4689
+ var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
4690
+ async function up$10(db) {
2862
4691
  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
4692
  await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
2864
4693
  await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
@@ -2872,8 +4701,8 @@ async function up$5(db) {
2872
4701
  }
2873
4702
  //#endregion
2874
4703
  //#region src/storage/migrations/010_create_sync_tables.ts
2875
- var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
2876
- async function up$4(db) {
4704
+ var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
4705
+ async function up$9(db) {
2877
4706
  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
4707
  await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
2879
4708
  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 +4710,8 @@ async function up$4(db) {
2881
4710
  }
2882
4711
  //#endregion
2883
4712
  //#region src/storage/migrations/011_add_cursor_type_column.ts
2884
- var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
2885
- async function up$3(db) {
4713
+ var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
4714
+ async function up$8(db) {
2886
4715
  await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
2887
4716
  await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
2888
4717
  await db.schema.dropTable("sync_cursors").execute();
@@ -2891,24 +4720,150 @@ async function up$3(db) {
2891
4720
  }
2892
4721
  //#endregion
2893
4722
  //#region src/storage/migrations/012_add_source_remote_column.ts
2894
- var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$2 });
2895
- async function up$2(db) {
4723
+ var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
4724
+ async function up$7(db) {
2896
4725
  await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
2897
4726
  }
2898
4727
  //#endregion
2899
4728
  //#region src/storage/migrations/013_create_sync_dead_letters_table.ts
2900
- var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$1 });
2901
- async function up$1(db) {
4729
+ var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
4730
+ async function up$6(db) {
2902
4731
  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
4732
  await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
2904
4733
  }
2905
4734
  //#endregion
2906
4735
  //#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) {
4736
+ var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
4737
+ async function up$5(db) {
2909
4738
  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
4739
  }
2911
4740
  //#endregion
4741
+ //#region src/storage/migrations/015_add_operation_denied_reason.ts
4742
+ var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
4743
+ down: () => down$4,
4744
+ up: () => up$4
4745
+ });
4746
+ /**
4747
+ * Records why authorization refused an operation. Separate from `error` so a
4748
+ * denial is distinguishable from a reducer failure without matching on a
4749
+ * message. Null for every operation written before decisions were enforced.
4750
+ */
4751
+ async function up$4(db) {
4752
+ await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
4753
+ await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
4754
+ }
4755
+ async function down$4(db) {
4756
+ await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
4757
+ await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
4758
+ }
4759
+ //#endregion
4760
+ //#region src/storage/migrations/016_add_dead_letter_error_type.ts
4761
+ var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
4762
+ down: () => down$3,
4763
+ up: () => up$3
4764
+ });
4765
+ /**
4766
+ * The classification a dead letter falls into, stored because it decides whether
4767
+ * the document stays quarantined and the in-memory error is gone after a restart.
4768
+ * Defaulted rather than nullable, so a pre-existing row rehydrates.
4769
+ */
4770
+ async function up$3(db) {
4771
+ await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
4772
+ }
4773
+ async function down$3(db) {
4774
+ await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
4775
+ }
4776
+ //#endregion
4777
+ //#region src/storage/migrations/017_create_group_references.ts
4778
+ var _017_create_group_references_exports = /* @__PURE__ */ __exportAll({
4779
+ down: () => down$2,
4780
+ up: () => up$2
4781
+ });
4782
+ /**
4783
+ * One row per (document, group) reference ever discovered from an auth
4784
+ * operation's input. Rows are never updated or deleted: auth evaluation is
4785
+ * positional, so a grant that named a group at any position keeps that
4786
+ * group's stream in the document's read-set even after a later operation
4787
+ * removes the reference. Read by documentId for the groups a document
4788
+ * requires (sync), and by groupId for the documents a group change affects
4789
+ * (re-evaluation).
4790
+ */
4791
+ async function up$2(db) {
4792
+ 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();
4793
+ await db.schema.createIndex("idx_group_references_groupId").on("group_references").column("groupId").execute();
4794
+ }
4795
+ async function down$2(db) {
4796
+ await db.schema.dropTable("group_references").execute();
4797
+ }
4798
+ //#endregion
4799
+ //#region src/storage/migrations/018_add_sync_remote_bound_address.ts
4800
+ var _018_add_sync_remote_bound_address_exports = /* @__PURE__ */ __exportAll({
4801
+ down: () => down$1,
4802
+ up: () => up$1
4803
+ });
4804
+ /**
4805
+ * The address a sync channel is bound to, so a channel created by one subject
4806
+ * cannot be polled by another.
4807
+ *
4808
+ * Nullable rather than defaulted: null is a channel nobody has claimed, which is
4809
+ * what every pre-existing row is and what an anonymously created channel stays
4810
+ * until its first authenticated poll adopts it. A default would claim them all
4811
+ * for one address.
4812
+ */
4813
+ async function up$1(db) {
4814
+ await db.schema.alterTable("sync_remotes").addColumn("bound_address", "text").execute();
4815
+ }
4816
+ async function down$1(db) {
4817
+ await db.schema.alterTable("sync_remotes").dropColumn("bound_address").execute();
4818
+ }
4819
+ //#endregion
4820
+ //#region src/storage/migrations/019_require_action_id.ts
4821
+ var _019_require_action_id_exports = /* @__PURE__ */ __exportAll({
4822
+ down: () => down,
4823
+ up: () => up
4824
+ });
4825
+ /**
4826
+ * Makes an operation whose action carries no id physically unstorable.
4827
+ *
4828
+ * The id is not decoration: `deriveOperationId` hashes it into the operation id
4829
+ * and replay dedupes incoming operations by it, so an action without one
4830
+ * collapses every id-less operation on a document/scope/branch onto a single
4831
+ * derived operation id. The API rejects such an action now, and this is the
4832
+ * last line of defense behind it.
4833
+ *
4834
+ * Both tables are constrained because sync reads operations from the index
4835
+ * rather than the operation table, so poison reaching only the index would
4836
+ * still be served to a replica.
4837
+ *
4838
+ * Pre-existing rows are backfilled rather than left behind a NOT VALID
4839
+ * constraint: a row the index and the operation table disagree about is worse
4840
+ * than a missing id, because dedup keys off the value each side serves. The
4841
+ * backfill therefore mints one id per operation and writes that same id to both
4842
+ * tables, joined on the identity they share. Rewriting the action is safe: the
4843
+ * operation hash is taken over the resulting state, not over the action, and a
4844
+ * signature is verified from the params carried in the signature tuple, which
4845
+ * do not include the action id.
4846
+ *
4847
+ * The empty string is rejected alongside null. It derives the same colliding
4848
+ * operation id as an absent id, so admitting it would leave the hole open.
4849
+ */
4850
+ async function up(db) {
4851
+ await db.updateTable("Operation").set({ action: sql`jsonb_set(action, '{id}', to_jsonb(gen_random_uuid()::text))` }).where(sql`jsonb_typeof(action) = 'object' and coalesce(action->>'id', '') = ''`).execute();
4852
+ await db.updateTable("operation_index_operations as oio").from("Operation as op").set({ action: sql`jsonb_set(oio.action, '{id}', to_jsonb(op.action->>'id'))` }).whereRef("oio.opId", "=", "op.opId").whereRef("oio.index", "=", "op.index").whereRef("oio.skip", "=", "op.skip").where(sql`jsonb_typeof(oio.action) = 'object' and coalesce(oio.action->>'id', '') = ''`).where(sql`coalesce(op.action->>'id', '') <> ''`).execute();
4853
+ await db.updateTable("operation_index_operations").set({ action: sql`jsonb_set(action, '{id}', to_jsonb(gen_random_uuid()::text))` }).where(sql`jsonb_typeof(action) = 'object' and coalesce(action->>'id', '') = ''`).execute();
4854
+ await db.schema.alterTable("Operation").addCheckConstraint("action_must_have_id", sql`action->>'id' is not null and action->>'id' <> ''`).execute();
4855
+ await db.schema.alterTable("operation_index_operations").addCheckConstraint("action_must_have_id", sql`action->>'id' is not null and action->>'id' <> ''`).execute();
4856
+ }
4857
+ /**
4858
+ * Only the constraints are dropped. The backfilled ids stay: they are the ids
4859
+ * their operations are now known by, and reverting them would reintroduce the
4860
+ * collision the migration removed.
4861
+ */
4862
+ async function down(db) {
4863
+ await db.schema.alterTable("operation_index_operations").dropConstraint("action_must_have_id").execute();
4864
+ await db.schema.alterTable("Operation").dropConstraint("action_must_have_id").execute();
4865
+ }
4866
+ //#endregion
2912
4867
  //#region src/storage/migrations/migrator.ts
2913
4868
  const REACTOR_SCHEMA = "reactor";
2914
4869
  const migrations = {
@@ -2925,14 +4880,25 @@ const migrations = {
2925
4880
  "011_add_cursor_type_column": _011_add_cursor_type_column_exports,
2926
4881
  "012_add_source_remote_column": _012_add_source_remote_column_exports,
2927
4882
  "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
4883
+ "014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
4884
+ "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
4885
+ "016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports,
4886
+ "017_create_group_references": _017_create_group_references_exports,
4887
+ "018_add_sync_remote_bound_address": _018_add_sync_remote_bound_address_exports,
4888
+ "019_require_action_id": _019_require_action_id_exports
2929
4889
  };
2930
4890
  var ProgrammaticMigrationProvider = class {
2931
4891
  getMigrations() {
2932
4892
  return Promise.resolve(migrations);
2933
4893
  }
2934
4894
  };
2935
- async function runMigrations(db, schema = REACTOR_SCHEMA) {
4895
+ /**
4896
+ * Applies every pending migration, or every one up to and including `upTo`.
4897
+ *
4898
+ * The bound exists so a test can reach the schema a data migration is written
4899
+ * against, populate it, and then migrate across the migration under test.
4900
+ */
4901
+ async function runMigrations(db, schema = REACTOR_SCHEMA, upTo) {
2936
4902
  try {
2937
4903
  await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);
2938
4904
  } catch (error) {
@@ -2950,7 +4916,7 @@ async function runMigrations(db, schema = REACTOR_SCHEMA) {
2950
4916
  let error;
2951
4917
  let results;
2952
4918
  try {
2953
- const result = await migrator.migrateToLatest();
4919
+ const result = upTo ? await migrator.migrateTo(upTo) : await migrator.migrateToLatest();
2954
4920
  error = result.error;
2955
4921
  results = result.results;
2956
4922
  } catch (e) {
@@ -2979,6 +4945,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
2979
4945
  //#region src/core/drive-container-types.ts
2980
4946
  const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
2981
4947
  //#endregion
2982
- export { parsePagingOptions as A, DuplicateManifestError as C, DocumentDeletedError as D, ModuleNotFoundError as E, __exportAll as M, DocumentNotFoundError as O, CollectionMembershipCache as S, InvalidModuleError as T, KyselyWriteCache as _, createForwardingPoolInstrumentation as a, createConsistencyToken as b, DuplicateOperationError as c, KyselyKeyframeStore as d, DocumentModelRegistry as f, EventBus as g, KyselyExecutionScope as h, runMigrations as i, throwIfAborted as j, matchesScope as k, OptimisticLockError as l, DriveCollectionId as m, REACTOR_SCHEMA as n, instrumentPgPool as o, SimpleJobExecutor as p, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, RevisionMismatchError as u, KyselyOperationIndex as v, DuplicateModuleError as w, createEmptyConsistencyToken as x, DocumentMetaCache as y };
4948
+ export { GATED_DOCUMENT_ACTIONS as A, AuthTimestampNotMonotonicError as B, DuplicateOperationError as C, DuplicateModuleError as D, DuplicateManifestError as E, selectDecisionModel as F, InvalidOperationTimestampError as G, DocumentDeletedError as H, documentDecisionModel as I, parsePagingOptions as J, UpgradePreconditionFailedError as K, authDecisionModel as L, createEmptyConsistencyToken as M, targetDocumentId as N, InvalidModuleError as O, decideAtHead as P, buildDecisionModel as R, AppendConditionFailedError as S, RevisionMismatchError as T, DocumentNotFoundError as U, AuthorizationDeniedError as V, ExcessiveReshuffleError as W, __exportAll as X, throwIfAborted as Y, DocumentMetaCache as _, createForwardingPoolInstrumentation as a, JobExecutorEventTypes as b, KyselyKeyframeStore as c, DriveCollectionId as d, KyselyExecutionScope as f, KyselyOperationIndex as g, KyselyWriteCache as h, runMigrations as i, createConsistencyToken as j, ModuleNotFoundError as k, DocumentModelRegistry as l, EventBus as m, REACTOR_SCHEMA as n, instrumentPgPool as o, resolveFeatureFlags as p, matchesScope as q, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, CollectionMembershipCache as v, OptimisticLockError as w, APPEND_CONDITION_FAILED_PREFIX as x, DEFAULT_DEFERRED_JOB_TTL_MS as y, AuthEnforcementDisabledError as z };
2983
4949
 
2984
- //# sourceMappingURL=drive-container-types-DpJp2AmE.js.map
4950
+ //# sourceMappingURL=drive-container-types-bVQ_8YwX.js.map