@powerhousedao/reactor 6.2.2-dev.4 → 6.2.2-dev.40

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, appendWithoutApplying, applyAuthAction, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, evaluate, garbageCollect, hashDocumentStateForScope, isDenied, isUndoRedo, 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 {
@@ -181,6 +277,57 @@ var InvalidUpgradeStepError = class extends Error {
181
277
  }
182
278
  };
183
279
  //#endregion
280
+ //#region src/storage/interfaces.ts
281
+ /**
282
+ * Thrown when an operation with the same identity already exists in the store.
283
+ */
284
+ var DuplicateOperationError = class extends Error {
285
+ constructor(description) {
286
+ super(`Duplicate operation: ${description}`);
287
+ this.name = "DuplicateOperationError";
288
+ }
289
+ };
290
+ /**
291
+ * Thrown when a concurrent write conflict is detected during an atomic apply.
292
+ */
293
+ var OptimisticLockError = class extends Error {
294
+ constructor(message) {
295
+ super(message);
296
+ this.name = "OptimisticLockError";
297
+ }
298
+ };
299
+ /**
300
+ * Thrown when the caller-provided revision does not match the current
301
+ * stored revision, indicating a stale read.
302
+ */
303
+ var RevisionMismatchError = class extends Error {
304
+ constructor(expected, actual) {
305
+ super(`Revision mismatch: expected ${expected}, got ${actual}`);
306
+ this.name = "RevisionMismatchError";
307
+ }
308
+ };
309
+ /** Error history keeps messages, not classes, so failures match by prefix. */
310
+ const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
311
+ /**
312
+ * A read-set stream grew before the append committed. A concurrency
313
+ * conflict, not a fault: the caller retries against the new stream heads.
314
+ */
315
+ var AppendConditionFailedError = class extends Error {
316
+ constructor(condition) {
317
+ const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
318
+ super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
319
+ this.condition = condition;
320
+ this.name = "AppendConditionFailedError";
321
+ }
322
+ static isError(error) {
323
+ return Error.isError(error) && error.name === "AppendConditionFailedError";
324
+ }
325
+ /** True when a recorded error message is an append-condition failure. */
326
+ static isFailureMessage(message) {
327
+ return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
328
+ }
329
+ };
330
+ //#endregion
184
331
  //#region src/cache/collection-membership-cache.ts
185
332
  var CollectionMembershipCache = class CollectionMembershipCache {
186
333
  cache = /* @__PURE__ */ new Map();
@@ -216,6 +363,34 @@ var CollectionMembershipCache = class CollectionMembershipCache {
216
363
  };
217
364
  //#endregion
218
365
  //#region src/executor/util.ts
366
+ /** Actions the reactor reduces itself, onto the document scope. */
367
+ const DOCUMENT_SCOPE_ACTIONS = new Set([
368
+ "CREATE_DOCUMENT",
369
+ "DELETE_DOCUMENT",
370
+ "UPGRADE_DOCUMENT",
371
+ "ADD_RELATIONSHIP",
372
+ "REMOVE_RELATIONSHIP",
373
+ "UPDATE_RELATIONSHIP"
374
+ ]);
375
+ /**
376
+ * `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
377
+ * so building a decision model would throw and defer the job forever.
378
+ */
379
+ const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
380
+ /**
381
+ * The document a document-scope action writes to, which is not always the job's
382
+ * own document: delete and upgrade name it in `input.documentId`, and the
383
+ * relationship actions in `input.sourceId`. `execute` only checks that a batch
384
+ * shares one scope, so a caller can submit an action whose target is a document
385
+ * other than the one the job is keyed by. The policy gate has to follow the
386
+ * action rather than the job, or it decides against a policy the caller may
387
+ * control instead of the one guarding the write.
388
+ */
389
+ function targetDocumentId(action, fallback) {
390
+ const input = action.input;
391
+ 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;
392
+ return typeof input?.documentId === "string" && input.documentId.length > 0 ? input.documentId : fallback;
393
+ }
219
394
  /**
220
395
  * Creates a PHDocument from a CREATE_DOCUMENT action input.
221
396
  * Reconstructs the document header and initializes the base state.
@@ -353,6 +528,24 @@ function buildErrorResult(job, error, startTime) {
353
528
  duration: Date.now() - startTime
354
529
  };
355
530
  }
531
+ /**
532
+ * The error a refusal surfaces as. Both classes are already terminal in the job
533
+ * result handler, so a refusal never burns a retry.
534
+ */
535
+ function refusalError(reason, documentId, deletedAtUtcIso, action) {
536
+ if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
537
+ return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
538
+ }
539
+ /**
540
+ * Whether this operation is part of the document's creation. The create and the
541
+ * upgrade from version zero hold the first two indexes for the life of the
542
+ * document, so a reshuffle has to leave them where they are.
543
+ */
544
+ function isGenesisOperation(operation) {
545
+ if (operation.action.type === "CREATE_DOCUMENT") return true;
546
+ if (operation.action.type !== "UPGRADE_DOCUMENT") return false;
547
+ return operation.action.input.fromVersion === 0;
548
+ }
356
549
  //#endregion
357
550
  //#region src/cache/lru/lru-tracker.ts
358
551
  var LRUNode = class {
@@ -634,6 +827,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
634
827
  skip: op.skip,
635
828
  hash: op.hash,
636
829
  action: op.action,
830
+ deniedReason: op.deniedReason ?? null,
637
831
  sourceRemote: op.sourceRemote
638
832
  }));
639
833
  operationOrdinals = (await trx.insertInto("operation_index_operations").values(operationRows).returning("ordinal").execute()).map((row) => row.ordinal);
@@ -765,6 +959,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
765
959
  hash: row.hash,
766
960
  skip: row.skip,
767
961
  action: row.action,
962
+ deniedReason: row.deniedReason ?? void 0,
768
963
  id: row.opId
769
964
  },
770
965
  context: {
@@ -788,6 +983,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
788
983
  hash: row.hash,
789
984
  skip: row.skip,
790
985
  action: row.action,
986
+ deniedReason: row.deniedReason ?? void 0,
791
987
  id: row.opId,
792
988
  sourceRemote: row.sourceRemote
793
989
  };
@@ -873,11 +1069,55 @@ var RingBuffer = class {
873
1069
  }
874
1070
  };
875
1071
  //#endregion
1072
+ //#region src/cache/write-cache-types.ts
1073
+ /**
1074
+ * Where a snapshot sits in its stream.
1075
+ *
1076
+ * - `Head`: the newest revision of the stream when it was stored. Only these
1077
+ * can answer a read that asks for the head.
1078
+ * - `Historical`: state at an earlier revision. Usable as a starting point to
1079
+ * replay forward from, and as an answer to a read for that same revision.
1080
+ */
1081
+ let SnapshotPosition = /* @__PURE__ */ function(SnapshotPosition) {
1082
+ SnapshotPosition["Head"] = "head";
1083
+ SnapshotPosition["Historical"] = "historical";
1084
+ return SnapshotPosition;
1085
+ }({});
1086
+ //#endregion
876
1087
  //#region src/cache/kysely-write-cache.ts
1088
+ /**
1089
+ * The last operation index a keyframe's document reflects for the scope. A
1090
+ * keyframe only exists for a scope that has operations, so a missing entry
1091
+ * means the stored row is corrupt.
1092
+ */
1093
+ function keyframeRevision(keyframe, documentId, scope) {
1094
+ const nextIndex = keyframe.document.header.revision[scope];
1095
+ if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
1096
+ return nextIndex - 1;
1097
+ }
877
1098
  function extractModuleVersion(doc) {
878
1099
  const v = doc.state.document.version;
879
1100
  return v === 0 ? void 0 : v;
880
1101
  }
1102
+ /** The highest revision held, latest push winning a tie. */
1103
+ function highestRevision(snapshots) {
1104
+ let newest = void 0;
1105
+ for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
1106
+ return newest;
1107
+ }
1108
+ /**
1109
+ * Copies a document far enough that the caller cannot write through it. Inside
1110
+ * this class, callers only ever replace whole fields on these four, so one
1111
+ * level each is enough.
1112
+ */
1113
+ function copyDocument(document) {
1114
+ return {
1115
+ ...document,
1116
+ header: { ...document.header },
1117
+ state: { ...document.state },
1118
+ operations: { ...document.operations }
1119
+ };
1120
+ }
881
1121
  /**
882
1122
  * In-memory write cache with keyframe persistence for PHDocuments.
883
1123
  *
@@ -956,6 +1196,8 @@ var KyselyWriteCache = class KyselyWriteCache {
956
1196
  /**
957
1197
  * Retrieves document state at a specific revision from cache or rebuilds it.
958
1198
  *
1199
+ * Note: this returns a _shallow_ copy of the document.
1200
+ *
959
1201
  * Cache hit path: Returns cached snapshot if available (O(1))
960
1202
  * Warm miss path: Rebuilds from cached base revision + incremental ops
961
1203
  * Cold miss path: Rebuilds from keyframe or from scratch using all operations
@@ -979,30 +1221,35 @@ var KyselyWriteCache = class KyselyWriteCache {
979
1221
  if (stream) {
980
1222
  const snapshots = stream.ringBuffer.getAll();
981
1223
  if (targetRevision === void 0) {
982
- if (snapshots.length > 0) {
983
- const newest = snapshots[snapshots.length - 1];
1224
+ const newest = highestRevision(snapshots);
1225
+ if (newest?.position === SnapshotPosition.Head) {
984
1226
  this.lruTracker.touch(streamKey);
985
- return newest.document;
1227
+ return copyDocument(newest.document);
1228
+ }
1229
+ if (newest) {
1230
+ const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
1231
+ this.store(documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, SnapshotPosition.Head);
1232
+ this.lruTracker.touch(streamKey);
1233
+ return document;
986
1234
  }
987
1235
  } else {
988
- const exactMatch = snapshots.find((s) => s.revision === targetRevision);
1236
+ const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
989
1237
  if (exactMatch) {
990
1238
  this.lruTracker.touch(streamKey);
991
- return exactMatch.document;
1239
+ return copyDocument(exactMatch.document);
992
1240
  }
993
1241
  const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
994
1242
  if (newestOlder) {
995
1243
  const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
996
- this.putState(documentId, scope, branch, targetRevision, document);
1244
+ this.store(documentId, scope, branch, targetRevision, document, SnapshotPosition.Historical);
997
1245
  this.lruTracker.touch(streamKey);
998
1246
  return document;
999
1247
  }
1000
1248
  }
1001
1249
  }
1002
1250
  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);
1251
+ const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
1252
+ this.store(documentId, scope, branch, revision, document, targetRevision === void 0 ? SnapshotPosition.Head : SnapshotPosition.Historical);
1006
1253
  return document;
1007
1254
  }
1008
1255
  /**
@@ -1025,16 +1272,20 @@ var KyselyWriteCache = class KyselyWriteCache {
1025
1272
  * @param document - The document to cache
1026
1273
  * @throws {Error} If document serialization fails
1027
1274
  */
1028
- putState(documentId, scope, branch, revision, document) {
1275
+ putState(documentId, scope, branch, revision, document, position) {
1276
+ this.store(documentId, scope, branch, revision, document, position);
1277
+ }
1278
+ store(documentId, scope, branch, revision, document, position) {
1029
1279
  const streamKey = this.makeStreamKey(documentId, scope, branch);
1030
1280
  const stream = this.getOrCreateStream(streamKey);
1031
1281
  const snapshot = {
1032
1282
  revision,
1033
1283
  document: {
1034
- ...document,
1284
+ ...copyDocument(document),
1035
1285
  operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
1036
1286
  clipboard: []
1037
- }
1287
+ },
1288
+ position
1038
1289
  };
1039
1290
  stream.ringBuffer.push(snapshot);
1040
1291
  if (this.isKeyframeRevision(revision)) this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
@@ -1102,58 +1353,75 @@ var KyselyWriteCache = class KyselyWriteCache {
1102
1353
  }
1103
1354
  async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
1104
1355
  if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
1105
- return this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1356
+ const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1357
+ if (!keyframe) return;
1358
+ return {
1359
+ revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
1360
+ document: keyframe.document
1361
+ };
1106
1362
  }
1107
1363
  async coldMissRebuild(documentId, scope, branch, targetRevision, signal) {
1108
1364
  const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
1109
1365
  const keyframe = await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal);
1366
+ const documentScopeBound = scope === "document" ? targetRevision : void 0;
1110
1367
  let document;
1111
1368
  let startRevision;
1112
1369
  let documentType;
1113
1370
  const validatedUpgrades = [];
1371
+ let lastDocumentScopeOperation;
1114
1372
  if (keyframe) {
1115
1373
  document = keyframe.document;
1116
1374
  startRevision = keyframe.revision;
1117
1375
  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 });
1376
+ const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
1377
+ const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
1378
+ for (const operation of docScopeOpsAfterKeyframe.results) {
1379
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1380
+ lastDocumentScopeOperation = operation;
1381
+ if (operation.error || isDenied(operation)) continue;
1382
+ if (operation.action.type === "UPGRADE_DOCUMENT") {
1383
+ const upgradeAction = operation.action;
1384
+ const fromVersion = upgradeAction.input.fromVersion;
1385
+ const toVersion = upgradeAction.input.toVersion;
1386
+ if (fromVersion > 0 && fromVersion < toVersion) {
1387
+ let upgradePath;
1388
+ try {
1389
+ upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1390
+ } catch (err) {
1391
+ if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
1392
+ 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 });
1393
+ }
1394
+ validatedUpgrades.push({
1395
+ fromVersion,
1396
+ toVersion,
1397
+ revision: upgradeAction.input.revision,
1398
+ timestampUtcMs: operation.timestampUtcMs
1399
+ });
1400
+ document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1130
1401
  }
1131
- validatedUpgrades.push({
1132
- fromVersion,
1133
- toVersion,
1134
- revision: upgradeAction.input.revision,
1135
- timestampUtcMs: operation.timestampUtcMs
1136
- });
1137
- document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1138
- }
1139
- } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1402
+ } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1403
+ }
1140
1404
  } else {
1141
1405
  startRevision = -1;
1142
1406
  const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
1143
1407
  cursor: "0",
1144
1408
  limit: 1
1145
1409
  }, signal);
1146
- if (createOpResult.results.length === 0) throw new Error(`Failed to rebuild document ${documentId}: no CREATE_DOCUMENT operation found in document scope`);
1410
+ if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
1147
1411
  const createOp = createOpResult.results[0];
1148
1412
  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
1413
  const documentCreateAction = createOp.action;
1150
1414
  documentType = documentCreateAction.input.model;
1151
1415
  if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
1152
1416
  document = createDocumentFromAction(documentCreateAction);
1417
+ lastDocumentScopeOperation = createOp;
1153
1418
  let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1154
1419
  const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
1155
1420
  for (const operation of docScopeOps.results) {
1421
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1422
+ lastDocumentScopeOperation = operation;
1156
1423
  if (operation.index === 0) continue;
1424
+ if (operation.error || isDenied(operation)) continue;
1157
1425
  if (operation.action.type === "UPGRADE_DOCUMENT") {
1158
1426
  const upgradeAction = operation.action;
1159
1427
  const fromVersion = upgradeAction.input.fromVersion;
@@ -1177,7 +1445,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1177
1445
  docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1178
1446
  } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1179
1447
  else {
1180
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1448
+ const protocolVersion = baseReducerVersion(document.header);
1181
1449
  document = docModule.reducer(document, operation.action, void 0, {
1182
1450
  skip: operation.skip,
1183
1451
  protocolVersion
@@ -1185,6 +1453,21 @@ var KyselyWriteCache = class KyselyWriteCache {
1185
1453
  }
1186
1454
  }
1187
1455
  }
1456
+ if (scope === "document") {
1457
+ const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
1458
+ document.operations = {
1459
+ ...document.operations,
1460
+ document: last ? [last] : []
1461
+ };
1462
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1463
+ }
1464
+ if (keyframe) {
1465
+ const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
1466
+ if (resumeOperation) document.operations = {
1467
+ ...document.operations,
1468
+ [scope]: [resumeOperation]
1469
+ };
1470
+ }
1188
1471
  const moduleCache = /* @__PURE__ */ new Map();
1189
1472
  const getModuleCached = (version) => {
1190
1473
  const key = version ?? 0;
@@ -1209,11 +1492,14 @@ var KyselyWriteCache = class KyselyWriteCache {
1209
1492
  for (const operation of result.results) {
1210
1493
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1211
1494
  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
- });
1495
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1496
+ else {
1497
+ const protocolVersion = baseReducerVersion(document.header);
1498
+ document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1499
+ skip: operation.skip,
1500
+ protocolVersion
1501
+ });
1502
+ }
1217
1503
  }
1218
1504
  const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
1219
1505
  hasMorePages = Boolean(result.nextCursor) && !reachedTarget;
@@ -1222,11 +1508,31 @@ var KyselyWriteCache = class KyselyWriteCache {
1222
1508
  throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1223
1509
  }
1224
1510
  } while (hasMorePages);
1511
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1512
+ }
1513
+ /**
1514
+ * Copies the current document revisions onto the document. Overwrites the
1515
+ * requested scope revision with the target revision, if provided.
1516
+ */
1517
+ async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
1225
1518
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1226
1519
  document.header.revision = revisions.revision;
1520
+ if (targetRevision !== void 0) document.header.revision = {
1521
+ ...document.header.revision,
1522
+ [scope]: targetRevision + 1
1523
+ };
1227
1524
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1228
1525
  return document;
1229
1526
  }
1527
+ /** The stored operation at `index`, or undefined if it is no longer there. */
1528
+ async operationAt(documentId, scope, branch, index, signal) {
1529
+ if (index < 0) return;
1530
+ const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
1531
+ cursor: "0",
1532
+ limit: 1
1533
+ }, signal)).results[0];
1534
+ return operation && operation.index === index ? operation : void 0;
1535
+ }
1230
1536
  /**
1231
1537
  * Resolves which module version to use for a given operation in phase 2.
1232
1538
  *
@@ -1250,19 +1556,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1250
1556
  async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
1251
1557
  const documentType = baseDocument.header.documentType;
1252
1558
  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);
1559
+ 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
1560
  const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
1255
- let document = baseDocument;
1561
+ let document = copyDocument(baseDocument);
1256
1562
  try {
1257
1563
  const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
1258
1564
  for (const operation of pagedResults.results) {
1259
1565
  if (signal?.aborted) throw new Error("Operation aborted");
1260
1566
  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
- });
1567
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1568
+ else {
1569
+ const protocolVersion = baseReducerVersion(document.header);
1570
+ document = module.reducer(document, operation.action, void 0, {
1571
+ skip: operation.skip,
1572
+ protocolVersion
1573
+ });
1574
+ }
1266
1575
  if (targetRevision !== void 0 && operation.index === targetRevision) break;
1267
1576
  }
1268
1577
  } catch (err) {
@@ -1270,6 +1579,10 @@ var KyselyWriteCache = class KyselyWriteCache {
1270
1579
  }
1271
1580
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1272
1581
  document.header.revision = revisions.revision;
1582
+ if (targetRevision !== void 0) document.header.revision = {
1583
+ ...document.header.revision,
1584
+ [scope]: targetRevision + 1
1585
+ };
1273
1586
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1274
1587
  return document;
1275
1588
  }
@@ -1339,6 +1652,32 @@ var EventBus = class {
1339
1652
  }
1340
1653
  };
1341
1654
  //#endregion
1655
+ //#region src/core/feature-flags.ts
1656
+ /**
1657
+ * Every flag this reactor knows, with the flags it requires. A stage adds its
1658
+ * flag here when it ships, so asking an older reactor for a later stage's flag
1659
+ * is an unrecognized name rather than a flag that quietly does nothing.
1660
+ */
1661
+ const FLAG_PREREQUISITES = {
1662
+ documentDecisions: [],
1663
+ authEnforcement: ["documentDecisions"]
1664
+ };
1665
+ /**
1666
+ * Throws when the flags ask for enforcement the reactor cannot deliver. Either
1667
+ * failure would otherwise read as enforcement being on while the reactor
1668
+ * applies less than the caller asked for.
1669
+ */
1670
+ function validateFeatureFlags(flags, prerequisites) {
1671
+ const known = Object.keys(prerequisites);
1672
+ const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
1673
+ if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
1674
+ for (const name of known) {
1675
+ if (flags[name] !== true) continue;
1676
+ const missing = prerequisites[name].filter((required) => flags[required] !== true);
1677
+ if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
1678
+ }
1679
+ }
1680
+ //#endregion
1342
1681
  //#region src/executor/execution-scope.ts
1343
1682
  var DefaultExecutionScope = class {
1344
1683
  constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
@@ -1425,15 +1764,402 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
1425
1764
  if (shouldPrioritizeLogicalIndex) {
1426
1765
  if (logicalIndexDiff !== 0) return logicalIndexDiff;
1427
1766
  }
1428
- const actionIdDiff = (a.action?.id ?? "").localeCompare(b.action?.id ?? "");
1429
- if (actionIdDiff !== 0) return actionIdDiff;
1430
- if (!shouldPrioritizeLogicalIndex && logicalIndexDiff !== 0) return logicalIndexDiff;
1431
- return a.id.localeCompare(b.id);
1432
- }).map((op, i) => ({
1433
- ...op,
1434
- index: startIndex.index + i,
1435
- skip: i === 0 ? startIndex.skip : 0
1436
- }));
1767
+ const actionIdDiff = (a.action?.id ?? "").localeCompare(b.action?.id ?? "");
1768
+ if (actionIdDiff !== 0) return actionIdDiff;
1769
+ if (!shouldPrioritizeLogicalIndex && logicalIndexDiff !== 0) return logicalIndexDiff;
1770
+ return a.id.localeCompare(b.id);
1771
+ }).map((op, i) => ({
1772
+ ...op,
1773
+ index: startIndex.index + i,
1774
+ skip: i === 0 ? startIndex.skip : 0
1775
+ }));
1776
+ }
1777
+ //#endregion
1778
+ //#region src/decision/auth-decision-model.ts
1779
+ function refusalReason(refusal) {
1780
+ switch (refusal) {
1781
+ case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
1782
+ case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
1783
+ case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
1784
+ }
1785
+ }
1786
+ /** This decision model uses both the document and the auth streams. */
1787
+ function authDecisionModel(target) {
1788
+ return {
1789
+ projections: {
1790
+ document: {
1791
+ decidingActions: ["DELETE_DOCUMENT"],
1792
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
1793
+ ...document,
1794
+ state: { ...document.state }
1795
+ }, operation.action) : document,
1796
+ query: {
1797
+ documentId: target.documentId,
1798
+ branch: target.branch,
1799
+ scope: "document"
1800
+ }
1801
+ },
1802
+ auth: {
1803
+ decidingActions: [...AUTH_ACTION_TYPES],
1804
+ apply: (document, operation) => applyAuthAction(document, operation.action),
1805
+ query: {
1806
+ documentId: target.documentId,
1807
+ branch: target.branch,
1808
+ scope: "auth"
1809
+ }
1810
+ }
1811
+ },
1812
+ evaluatesScope() {
1813
+ return true;
1814
+ },
1815
+ decide(model, subject, request) {
1816
+ if (request.verb === "execute" && model.document.isDeleted) return {
1817
+ decision: "deny",
1818
+ reason: DOCUMENT_DELETED_REASON
1819
+ };
1820
+ const evaluation = evaluate(model.auth, subject, request);
1821
+ if (evaluation.decision === "allow") return { decision: "allow" };
1822
+ return {
1823
+ decision: "deny",
1824
+ reason: refusalReason(evaluation.refusal)
1825
+ };
1826
+ }
1827
+ };
1828
+ }
1829
+ //#endregion
1830
+ //#region src/decision/build-decision-model.ts
1831
+ /**
1832
+ * Reads each projection's stream through the write cache, recording the
1833
+ * revision observed. Static projections resolve first; derived projections
1834
+ * see only those and contribute a map from document id to state. Each
1835
+ * distinct stream is read once and yields one append condition entry.
1836
+ */
1837
+ async function buildDecisionModel(cache, definition, target, signal) {
1838
+ const decisionModel = definition(target);
1839
+ const projections = Object.entries(decisionModel.projections);
1840
+ const reads = /* @__PURE__ */ new Map();
1841
+ const model = {};
1842
+ for (const [key, projection] of projections) {
1843
+ if (typeof projection.query === "function") continue;
1844
+ model[key] = (await readStream(cache, projection.query, reads, signal)).state;
1845
+ }
1846
+ const staticModel = { ...model };
1847
+ for (const [key, projection] of projections) {
1848
+ if (typeof projection.query !== "function") continue;
1849
+ const queries = projection.query(staticModel);
1850
+ const value = {};
1851
+ for (const query of queries) {
1852
+ const read = await readStream(cache, query, reads, signal);
1853
+ value[query.documentId] = read.state;
1854
+ }
1855
+ model[key] = value;
1856
+ }
1857
+ return {
1858
+ model,
1859
+ appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
1860
+ };
1861
+ }
1862
+ async function readStream(cache, query, reads, signal) {
1863
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
1864
+ const existing = reads.get(key);
1865
+ if (existing) return existing;
1866
+ const document = await cache.getState(query.documentId, query.scope, query.branch, void 0, signal);
1867
+ const read = {
1868
+ state: document.state[query.scope],
1869
+ stream: {
1870
+ documentId: query.documentId,
1871
+ scope: query.scope,
1872
+ branch: query.branch,
1873
+ revision: observedRevision(document, query.scope)
1874
+ }
1875
+ };
1876
+ reads.set(key, read);
1877
+ return read;
1878
+ }
1879
+ /**
1880
+ * The highest operation index the document reflects for the scope, or -1 if
1881
+ * empty. `header.revision` is authoritative, not the rebuilt operation list.
1882
+ */
1883
+ function observedRevision(document, scope) {
1884
+ if (scope in document.header.revision) return document.header.revision[scope] - 1;
1885
+ if (scope in document.operations) {
1886
+ const operations = document.operations[scope];
1887
+ if (operations.length > 0) return operations[operations.length - 1].index;
1888
+ }
1889
+ if (!(scope in document.header.revision)) return -1;
1890
+ return document.header.revision[scope] - 1;
1891
+ }
1892
+ /**
1893
+ * The streams a model reads whose queries are known before it is built. A
1894
+ * derived query needs the statically-queried projections first, so it is not
1895
+ * included here.
1896
+ */
1897
+ function staticReadSet(definition) {
1898
+ const streams = [];
1899
+ for (const [name, projection] of Object.entries(definition.projections)) {
1900
+ if (typeof projection.query === "function") continue;
1901
+ streams.push({
1902
+ name,
1903
+ query: projection.query,
1904
+ decidingActions: projection.decidingActions,
1905
+ apply: projection.apply
1906
+ });
1907
+ }
1908
+ return streams;
1909
+ }
1910
+ //#endregion
1911
+ //#region src/decision/document-decision-model.ts
1912
+ /**
1913
+ * The simplest decision model: one projection over the document scope, which
1914
+ * rejects on a deleted document.
1915
+ */
1916
+ function documentDecisionModel(target) {
1917
+ return {
1918
+ projections: { document: {
1919
+ decidingActions: ["DELETE_DOCUMENT"],
1920
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
1921
+ ...document,
1922
+ state: { ...document.state }
1923
+ }, operation.action) : document,
1924
+ query: {
1925
+ documentId: target.documentId,
1926
+ branch: target.branch,
1927
+ scope: "document"
1928
+ }
1929
+ } },
1930
+ evaluatesScope() {
1931
+ return true;
1932
+ },
1933
+ decide(model) {
1934
+ return model.document.isDeleted ? {
1935
+ decision: "deny",
1936
+ reason: DOCUMENT_DELETED_REASON
1937
+ } : { decision: "allow" };
1938
+ }
1939
+ };
1940
+ }
1941
+ //#endregion
1942
+ //#region src/decision/registered-model.ts
1943
+ /**
1944
+ * Builds the model at the stream heads and decides one request against it. The
1945
+ * append condition it returns is the read-set the store enforces at write time.
1946
+ */
1947
+ async function decideAtHead(model, cache, target, subject, request, signal) {
1948
+ const built = await buildDecisionModel(cache, model, target, signal);
1949
+ return {
1950
+ evaluation: model(target).decide(built.model, subject, request, { scopeState: void 0 }),
1951
+ appendCondition: built.appendCondition,
1952
+ documentVersion: built.model.document.version,
1953
+ deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
1954
+ };
1955
+ }
1956
+ /**
1957
+ * The model this reactor enforces. With `authEnforcement` off the auth scope is
1958
+ * absent from every append condition and no load walks it.
1959
+ */
1960
+ function selectDecisionModel(flags) {
1961
+ return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
1962
+ }
1963
+ //#endregion
1964
+ //#region src/decision/merged-order.ts
1965
+ /** Identifies a stream within a walk. */
1966
+ function streamKey(query) {
1967
+ return `${query.documentId}:${query.scope}:${query.branch}`;
1968
+ }
1969
+ /**
1970
+ * Orders two operations from different streams by position. Timestamp decides;
1971
+ * an equal timestamp puts an auth operation first, and otherwise falls to the
1972
+ * action id and then the operation id, so that two replicas holding the same
1973
+ * operations agree on the order whatever order they happen to store them in.
1974
+ */
1975
+ function comparePositions(a, b) {
1976
+ const aTime = Date.parse(a.operation.timestampUtcMs);
1977
+ const bTime = Date.parse(b.operation.timestampUtcMs);
1978
+ if (aTime !== bTime) return aTime - bTime;
1979
+ if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
1980
+ const aAuth = a.scope === "auth";
1981
+ if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
1982
+ const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
1983
+ if (actionIds !== 0) return actionIds;
1984
+ return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
1985
+ }
1986
+ /**
1987
+ * Merges the read-set streams into one sequence by position. An operation's
1988
+ * place in the result is the bound a decision at that operation reads to: every
1989
+ * operation before it has been applied, and it has not.
1990
+ */
1991
+ function mergeByPosition(streams) {
1992
+ const merged = [];
1993
+ for (const stream of streams) for (const operation of stream.operations) merged.push({
1994
+ streamKey: stream.streamKey,
1995
+ scope: stream.scope,
1996
+ operation
1997
+ });
1998
+ return merged.sort(comparePositions);
1999
+ }
2000
+ /**
2001
+ * The skip that retracts everything from `firstRetractedIndex` up to where the
2002
+ * re-appended operation lands. It spans the indexes rather than counting the
2003
+ * operations, because a stream with a gap in it makes those differ.
2004
+ */
2005
+ function retractionSkip(nextIndex, firstRetractedIndex) {
2006
+ return nextIndex - firstRetractedIndex;
2007
+ }
2008
+ //#endregion
2009
+ //#region src/decision/walk.ts
2010
+ /**
2011
+ * A single forward pass is only correct while a stream's effective operations
2012
+ * are ordered.
2013
+ */
2014
+ function assertPositionOrder(streamKey, scope, operations) {
2015
+ for (let i = 1; i < operations.length; i++) {
2016
+ const previous = operations[i - 1];
2017
+ const current = operations[i];
2018
+ if (comparePositions({
2019
+ streamKey,
2020
+ scope,
2021
+ operation: previous
2022
+ }, {
2023
+ streamKey,
2024
+ scope,
2025
+ operation: current
2026
+ }) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
2027
+ }
2028
+ }
2029
+ /**
2030
+ * Visits every operation in the read-set once, in the order their positions
2031
+ * fall, and hands back the state each stream held just before it. That state is
2032
+ * what a decision at that operation reads.
2033
+ *
2034
+ * Skips are resolved first (i.e. this is performed on a garbage collected
2035
+ * stream), which means we can do a single forward pass.
2036
+ *
2037
+ * An operation that contributes no state, whether denied or holding a reducer
2038
+ * error, is visited but not applied (this matches the write cache's rebuild).
2039
+ *
2040
+ * The consumer sends back whether it refused the operation it was handed: a
2041
+ * refusal this pass produced must suppress it the same way a stored one does.
2042
+ */
2043
+ function* walkByPosition(streams) {
2044
+ const merged = mergeByPosition(streams.map((stream) => {
2045
+ const operations = garbageCollect(sortOperations([...stream.operations]));
2046
+ assertPositionOrder(stream.streamKey, stream.scope, operations);
2047
+ return {
2048
+ streamKey: stream.streamKey,
2049
+ scope: stream.scope,
2050
+ operations
2051
+ };
2052
+ }));
2053
+ const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
2054
+ const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
2055
+ for (const { streamKey, operation } of merged) {
2056
+ if ((yield {
2057
+ streamKey,
2058
+ operation,
2059
+ states: new Map(states)
2060
+ }) || operation.error !== void 0 || isDenied(operation)) continue;
2061
+ const stream = byKey.get(streamKey);
2062
+ const before = states.get(streamKey);
2063
+ if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
2064
+ states.set(streamKey, stream.apply(before, operation));
2065
+ }
2066
+ }
2067
+ //#endregion
2068
+ //#region src/decision/evaluation.ts
2069
+ /** The stream key for evaluated operations whose scope no projection reads. */
2070
+ const EVALUATED_ONLY = "evaluated";
2071
+ /**
2072
+ * Whether any stream the model reads declares this operation's action type as
2073
+ * one that can change an evaluation.
2074
+ */
2075
+ function isDecidingAction(operation, readSet) {
2076
+ return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
2077
+ }
2078
+ /**
2079
+ * Who an operation acts as. A replayed operation is evaluated as its own signer,
2080
+ * so an address-scoped policy does not deny its own author's history.
2081
+ */
2082
+ function subjectOf(operation) {
2083
+ const signer = operation.action.context?.signer;
2084
+ return {
2085
+ address: signer?.user.address,
2086
+ key: signer?.app.key
2087
+ };
2088
+ }
2089
+ /**
2090
+ * The model as the walk reached this operation: each projection's value is its
2091
+ * own scope's state, taken from the stream that projection reads.
2092
+ */
2093
+ function modelAt(readSet, states) {
2094
+ const model = {};
2095
+ for (const stream of readSet) {
2096
+ const document = states.get(streamKey(stream.query));
2097
+ if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
2098
+ model[stream.name] = document.state[stream.query.scope];
2099
+ }
2100
+ return model;
2101
+ }
2102
+ /**
2103
+ * Evaluates each operation at its own position and returns the refusals in an
2104
+ * array parallel to the operations, where undefined means allowed.
2105
+ *
2106
+ * A position is a timestamp, so an operation refused by a delete is one that
2107
+ * sorts after it, and the operations before it are left alone. That holds
2108
+ * whether the delete is already stored or is among the operations passed in.
2109
+ */
2110
+ async function evaluateByPosition(model, target, subject, stores, signal) {
2111
+ const { scope, operations } = subject;
2112
+ const { writeCache, operationStore } = stores;
2113
+ const definition = model(target);
2114
+ const readSet = staticReadSet(definition);
2115
+ if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
2116
+ const evaluating = new Set(operations.map((operation) => operation.id));
2117
+ const readStreams = await Promise.all(readSet.map(async (stream) => ({
2118
+ stream,
2119
+ 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))
2120
+ })));
2121
+ const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
2122
+ if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
2123
+ if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
2124
+ const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
2125
+ const walked = [];
2126
+ for (const read of readStreams) {
2127
+ const isWritten = read.stream === writtenProjection;
2128
+ const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, -1, signal);
2129
+ walked.push({
2130
+ streamKey: streamKey(read.stream.query),
2131
+ scope: read.stream.query.scope,
2132
+ document: before,
2133
+ operations: isWritten ? [...read.operations, ...operations] : read.operations,
2134
+ apply: read.stream.apply
2135
+ });
2136
+ }
2137
+ if (writtenProjection === void 0) walked.push({
2138
+ streamKey: EVALUATED_ONLY,
2139
+ scope,
2140
+ document: walked[0].document,
2141
+ operations,
2142
+ apply: (document) => document
2143
+ });
2144
+ const reasons = /* @__PURE__ */ new Map();
2145
+ const walk = walkByPosition(walked);
2146
+ let step = walk.next(false);
2147
+ while (!step.done) {
2148
+ const position = step.value;
2149
+ if (!evaluating.has(position.operation.id)) {
2150
+ step = walk.next(false);
2151
+ continue;
2152
+ }
2153
+ const evaluation = definition.decide(modelAt(readSet, position.states), subjectOf(position.operation), {
2154
+ verb: "execute",
2155
+ scope: position.operation.action.scope,
2156
+ operation: position.operation.action.type
2157
+ }, { scopeState: void 0 });
2158
+ const denied = evaluation.decision === "deny";
2159
+ reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
2160
+ step = walk.next(denied);
2161
+ }
2162
+ return operations.map((operation) => reasons.get(operation.id));
1437
2163
  }
1438
2164
  //#endregion
1439
2165
  //#region src/cache/operation-index-types.ts
@@ -1482,23 +2208,119 @@ var DriveCollectionId = class DriveCollectionId {
1482
2208
  //#endregion
1483
2209
  //#region src/executor/document-action-handler.ts
1484
2210
  var DocumentActionHandler = class {
1485
- constructor(registry, logger, driveContainerTypes) {
2211
+ constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel) {
1486
2212
  this.registry = registry;
1487
2213
  this.logger = logger;
1488
2214
  this.driveContainerTypes = driveContainerTypes;
1489
- }
1490
- async execute(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2215
+ this.featureFlags = featureFlags;
2216
+ this.decisionModel = decisionModel;
2217
+ }
2218
+ /** Whether the write arrives with its evaluation already decided. */
2219
+ alreadyEvaluated(executing) {
2220
+ return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
2221
+ }
2222
+ async execute(write, executing) {
2223
+ const { action } = write;
2224
+ if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
2225
+ const refusal = await this.refuseIfPolicyDenies(write, executing);
2226
+ if (refusal) return refusal;
1491
2227
  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);
2228
+ case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
2229
+ case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
2230
+ case "UPGRADE_DOCUMENT": return this.executeUpgrade(write, executing);
2231
+ case "ADD_RELATIONSHIP": return this.executeAddRelationship(write, executing);
2232
+ case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(write, executing);
2233
+ case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(write, executing);
2234
+ default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
2235
+ }
2236
+ }
2237
+ /**
2238
+ * Refuses a document-scope write the policy denies, or undefined to proceed.
2239
+ * Without this an `execute`-on-`document` grant is unenforceable.
2240
+ */
2241
+ async refuseIfPolicyDenies(write, executing) {
2242
+ const { action } = write;
2243
+ const { job, startTime, stores, signal } = executing;
2244
+ if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
2245
+ const documentId = targetDocumentId(action, job.documentId);
2246
+ let admission;
2247
+ try {
2248
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, {
2249
+ documentId,
2250
+ branch: job.branch
2251
+ }, {
2252
+ address: action.context?.signer?.user.address,
2253
+ key: action.context?.signer?.app.key
2254
+ }, {
2255
+ verb: "execute",
2256
+ scope: action.scope,
2257
+ operation: action.type
2258
+ }, signal);
2259
+ } catch (error) {
2260
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1499
2261
  }
2262
+ if (admission.evaluation.decision === "allow") return;
2263
+ return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
1500
2264
  }
1501
- async executeCreate(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2265
+ /** A refused operation holds a position in the stream but changes nothing. */
2266
+ async writeDenied(write, executing) {
2267
+ const { action, skip, sourceRemote, deniedReason } = write;
2268
+ const { job, startTime, indexTxn, stores, signal } = executing;
2269
+ let document;
2270
+ try {
2271
+ document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
2272
+ } catch (error) {
2273
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2274
+ }
2275
+ const index = getNextIndexForScope(document, job.scope);
2276
+ let standing = document;
2277
+ if (skip > 0) try {
2278
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
2279
+ } catch (error) {
2280
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2281
+ }
2282
+ let operation = createOperation(action, index, skip, {
2283
+ documentId: job.documentId,
2284
+ scope: job.scope,
2285
+ branch: job.branch
2286
+ });
2287
+ operation.deniedReason = deniedReason;
2288
+ operation.hash = hashDocumentStateForScope(standing, job.scope);
2289
+ const writeResult = await this.writeOperationToStore({
2290
+ documentId: job.documentId,
2291
+ documentType: document.header.documentType,
2292
+ scope: job.scope,
2293
+ branch: job.branch
2294
+ }, operation, executing);
2295
+ if (!Array.isArray(writeResult)) return writeResult;
2296
+ operation = writeResult[0];
2297
+ updateDocumentRevision(standing, job.scope, operation.index);
2298
+ standing.operations = {
2299
+ ...standing.operations,
2300
+ [job.scope]: [...standing.operations[job.scope] ?? [], operation]
2301
+ };
2302
+ stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, SnapshotPosition.Head);
2303
+ indexTxn.write([{
2304
+ ...operation,
2305
+ documentId: job.documentId,
2306
+ documentType: document.header.documentType,
2307
+ branch: job.branch,
2308
+ scope: job.scope,
2309
+ sourceRemote
2310
+ }]);
2311
+ stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
2312
+ state: standing.state.document,
2313
+ documentType: standing.header.documentType,
2314
+ documentScopeRevision: operation.index + 1
2315
+ });
2316
+ return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
2317
+ header: standing.header,
2318
+ document: standing.state.document
2319
+ }), startTime);
2320
+ }
2321
+ async executeCreate(write, executing) {
2322
+ const { action, skip, sourceRemote } = write;
2323
+ const { job, startTime, indexTxn, stores, signal } = executing;
1502
2324
  if (job.scope !== "document") return {
1503
2325
  job,
1504
2326
  success: false,
@@ -1516,7 +2338,12 @@ var DocumentActionHandler = class {
1516
2338
  ...document.state
1517
2339
  };
1518
2340
  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);
2341
+ const writeResult = await this.writeOperationToStore({
2342
+ documentId: document.header.id,
2343
+ documentType: document.header.documentType,
2344
+ scope: job.scope,
2345
+ branch: job.branch
2346
+ }, operation, executing);
1520
2347
  if (!Array.isArray(writeResult)) return writeResult;
1521
2348
  operation = writeResult[0];
1522
2349
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1524,7 +2351,7 @@ var DocumentActionHandler = class {
1524
2351
  ...document.operations,
1525
2352
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1526
2353
  };
1527
- stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document);
2354
+ stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1528
2355
  indexTxn.write([{
1529
2356
  ...operation,
1530
2357
  documentId: document.header.id,
@@ -1545,7 +2372,9 @@ var DocumentActionHandler = class {
1545
2372
  });
1546
2373
  return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
1547
2374
  }
1548
- async executeDelete(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
2375
+ async executeDelete(write, executing) {
2376
+ const { action, skip, sourceRemote } = write;
2377
+ const { job, startTime, indexTxn, stores, signal } = executing;
1549
2378
  const input = action.input;
1550
2379
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
1551
2380
  const documentId = input.documentId;
@@ -1556,8 +2385,8 @@ var DocumentActionHandler = class {
1556
2385
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
1557
2386
  }
1558
2387
  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, {
2388
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2389
+ let operation = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
1561
2390
  documentId,
1562
2391
  scope: job.scope,
1563
2392
  branch: job.branch
@@ -1568,7 +2397,12 @@ var DocumentActionHandler = class {
1568
2397
  document: document.state.document
1569
2398
  };
1570
2399
  const resultingState = JSON.stringify(resultingStateObj);
1571
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2400
+ const writeResult = await this.writeOperationToStore({
2401
+ documentId,
2402
+ documentType: document.header.documentType,
2403
+ scope: job.scope,
2404
+ branch: job.branch
2405
+ }, operation, executing);
1572
2406
  if (!Array.isArray(writeResult)) return writeResult;
1573
2407
  operation = writeResult[0];
1574
2408
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1576,7 +2410,7 @@ var DocumentActionHandler = class {
1576
2410
  ...document.operations,
1577
2411
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1578
2412
  };
1579
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2413
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1580
2414
  indexTxn.write([{
1581
2415
  ...operation,
1582
2416
  documentId,
@@ -1592,7 +2426,9 @@ var DocumentActionHandler = class {
1592
2426
  });
1593
2427
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1594
2428
  }
1595
- async executeUpgrade(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2429
+ async executeUpgrade(write, executing) {
2430
+ const { action, skip, sourceRemote } = write;
2431
+ const { job, startTime, indexTxn, stores, signal } = executing;
1596
2432
  const input = action.input;
1597
2433
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
1598
2434
  const documentId = input.documentId;
@@ -1605,7 +2441,7 @@ var DocumentActionHandler = class {
1605
2441
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
1606
2442
  }
1607
2443
  const documentState = document.state.document;
1608
- if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2444
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1609
2445
  const nextIndex = getNextIndexForScope(document, job.scope);
1610
2446
  let upgradePath;
1611
2447
  if (fromVersion > 0 && fromVersion < toVersion) try {
@@ -1635,7 +2471,12 @@ var DocumentActionHandler = class {
1635
2471
  ...document.state
1636
2472
  };
1637
2473
  const resultingState = JSON.stringify(resultingStateObj);
1638
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2474
+ const writeResult = await this.writeOperationToStore({
2475
+ documentId,
2476
+ documentType: document.header.documentType,
2477
+ scope: job.scope,
2478
+ branch: job.branch
2479
+ }, operation, executing);
1639
2480
  if (!Array.isArray(writeResult)) return writeResult;
1640
2481
  operation = writeResult[0];
1641
2482
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1643,7 +2484,7 @@ var DocumentActionHandler = class {
1643
2484
  ...document.operations,
1644
2485
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1645
2486
  };
1646
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2487
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1647
2488
  indexTxn.write([{
1648
2489
  ...operation,
1649
2490
  documentId,
@@ -1659,8 +2500,8 @@ var DocumentActionHandler = class {
1659
2500
  });
1660
2501
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1661
2502
  }
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 }) => {
2503
+ executeAddRelationship(write, executing) {
2504
+ 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
2505
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1665
2506
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1666
2507
  txn.addToCollection(collectionId, input.targetId);
@@ -1668,8 +2509,8 @@ var DocumentActionHandler = class {
1668
2509
  }
1669
2510
  });
1670
2511
  }
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 }) => {
2512
+ executeRemoveRelationship(write, executing) {
2513
+ return this.withRelationshipAction("REMOVE_RELATIONSHIP", write, executing, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
1673
2514
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1674
2515
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1675
2516
  txn.removeFromCollection(collectionId, input.targetId);
@@ -1677,10 +2518,12 @@ var DocumentActionHandler = class {
1677
2518
  }
1678
2519
  });
1679
2520
  }
1680
- executeUpdateRelationship(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
1681
- return this.withRelationshipAction("UPDATE_RELATIONSHIP", job, action, startTime, indexTxn, stores, sourceRemote, signal, null, null);
2521
+ executeUpdateRelationship(write, executing) {
2522
+ return this.withRelationshipAction("UPDATE_RELATIONSHIP", write, executing, null, null);
1682
2523
  }
1683
- async withRelationshipAction(actionTypeName, job, action, startTime, indexTxn, stores, sourceRemote, signal, preValidate, postWrite) {
2524
+ async withRelationshipAction(actionTypeName, write, executing, preValidate, postWrite) {
2525
+ const { action, skip, sourceRemote } = write;
2526
+ const { job, startTime, indexTxn, stores, signal } = executing;
1684
2527
  if (job.scope !== "document") return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} must be in "document" scope, got "${job.scope}"`), startTime);
1685
2528
  const input = action.input;
1686
2529
  if (!input.sourceId || !input.targetId || !input.relationshipType) return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} action requires sourceId, targetId, and relationshipType in input`), startTime);
@@ -1694,12 +2537,17 @@ var DocumentActionHandler = class {
1694
2537
  } catch (error) {
1695
2538
  return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName}: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
1696
2539
  }
1697
- let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), 0, {
2540
+ let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), skip, {
1698
2541
  documentId: input.sourceId,
1699
2542
  scope: job.scope,
1700
2543
  branch: job.branch
1701
2544
  });
1702
- const writeResult = await this.writeOperationToStore(input.sourceId, sourceDoc.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2545
+ const writeResult = await this.writeOperationToStore({
2546
+ documentId: input.sourceId,
2547
+ documentType: sourceDoc.header.documentType,
2548
+ scope: job.scope,
2549
+ branch: job.branch
2550
+ }, operation, executing);
1703
2551
  if (!Array.isArray(writeResult)) return writeResult;
1704
2552
  operation = writeResult[0];
1705
2553
  sourceDoc.header.lastModifiedAtUtcIso = operation.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString();
@@ -1714,7 +2562,7 @@ var DocumentActionHandler = class {
1714
2562
  [job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
1715
2563
  };
1716
2564
  const resultingState = JSON.stringify(resultingStateObj);
1717
- stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
2565
+ stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, SnapshotPosition.Head);
1718
2566
  indexTxn.write([{
1719
2567
  ...operation,
1720
2568
  documentId: input.sourceId,
@@ -1737,7 +2585,9 @@ var DocumentActionHandler = class {
1737
2585
  });
1738
2586
  return buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
1739
2587
  }
1740
- async writeOperationToStore(documentId, documentType, scope, branch, operation, job, startTime, stores, signal) {
2588
+ async writeOperationToStore(target, operation, executing) {
2589
+ const { documentId, documentType, scope, branch } = target;
2590
+ const { job, startTime, stores, signal } = executing;
1741
2591
  let storedOperations;
1742
2592
  try {
1743
2593
  storedOperations = await stores.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
@@ -1746,10 +2596,11 @@ var DocumentActionHandler = class {
1746
2596
  } catch (error) {
1747
2597
  this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
1748
2598
  stores.writeCache.invalidate(documentId, scope, branch);
2599
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
1749
2600
  return {
1750
2601
  job,
1751
2602
  success: false,
1752
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2603
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
1753
2604
  duration: Date.now() - startTime
1754
2605
  };
1755
2606
  }
@@ -1814,19 +2665,13 @@ function isValidISOTimestamp(value) {
1814
2665
  if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
1815
2666
  return !isNaN(new Date(value).getTime());
1816
2667
  }
1817
- const documentScopeActions = [
1818
- "CREATE_DOCUMENT",
1819
- "DELETE_DOCUMENT",
1820
- "UPGRADE_DOCUMENT",
1821
- "ADD_RELATIONSHIP",
1822
- "REMOVE_RELATIONSHIP",
1823
- "UPDATE_RELATIONSHIP"
1824
- ];
1825
2668
  /**
1826
2669
  * Simple job executor that processes a job by applying actions through document model reducers.
1827
2670
  */
1828
2671
  var SimpleJobExecutor = class {
1829
2672
  config;
2673
+ featureFlags;
2674
+ decisionModel;
1830
2675
  signatureVerifierModule;
1831
2676
  documentActionHandler;
1832
2677
  executionScope;
@@ -1841,6 +2686,7 @@ var SimpleJobExecutor = class {
1841
2686
  this.collectionMembershipCache = collectionMembershipCache;
1842
2687
  this.driveContainerTypes = driveContainerTypes;
1843
2688
  this.config = {
2689
+ featureFlags: config.featureFlags ?? {},
1844
2690
  maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
1845
2691
  maxConcurrency: config.maxConcurrency ?? 1,
1846
2692
  jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
@@ -1848,8 +2694,14 @@ var SimpleJobExecutor = class {
1848
2694
  retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
1849
2695
  yieldDeadlineMs: config.yieldDeadlineMs ?? 50
1850
2696
  };
2697
+ this.featureFlags = {
2698
+ documentDecisions: config.featureFlags?.documentDecisions ?? false,
2699
+ authEnforcement: config.featureFlags?.authEnforcement ?? false
2700
+ };
2701
+ validateFeatureFlags(this.featureFlags, FLAG_PREREQUISITES);
2702
+ this.decisionModel = selectDecisionModel(this.featureFlags);
1851
2703
  this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
1852
- this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes);
2704
+ this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel);
1853
2705
  this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
1854
2706
  }
1855
2707
  /**
@@ -1865,7 +2717,15 @@ var SimpleJobExecutor = class {
1865
2717
  result = await this.executionScope.run(async (stores) => {
1866
2718
  const indexTxn = stores.operationIndex.start();
1867
2719
  if (job.kind === "load") {
1868
- const loadResult = await this.executeLoadJob(job, startTime, indexTxn, stores, signal);
2720
+ const loadResult = await this.executeLoadJob({
2721
+ job,
2722
+ startTime,
2723
+ indexTxn,
2724
+ stores,
2725
+ signal,
2726
+ replayingAcceptedHistory: true,
2727
+ evaluatedByPosition: false
2728
+ });
1869
2729
  if (loadResult.success && loadResult.operationsWithContext) {
1870
2730
  for (const owc of loadResult.operationsWithContext) touchedCacheEntries.push({
1871
2731
  documentId: owc.context.documentId,
@@ -1884,7 +2744,18 @@ var SimpleJobExecutor = class {
1884
2744
  }
1885
2745
  return loadResult;
1886
2746
  }
1887
- const actionResult = await this.processActions(job, job.actions, startTime, indexTxn, stores, void 0, void 0, "", signal);
2747
+ const positioned = await this.positionByTimestamp(job, stores, signal);
2748
+ if (positioned.error) return buildErrorResult(job, positioned.error, startTime);
2749
+ const executing = {
2750
+ job,
2751
+ startTime,
2752
+ indexTxn,
2753
+ stores,
2754
+ signal,
2755
+ replayingAcceptedHistory: false,
2756
+ evaluatedByPosition: positioned.evaluatedByPosition
2757
+ };
2758
+ const actionResult = await this.processActions(positioned.writes, executing);
1888
2759
  if (!actionResult.success) return {
1889
2760
  job,
1890
2761
  success: false,
@@ -1896,6 +2767,16 @@ var SimpleJobExecutor = class {
1896
2767
  scope: owc.context.scope,
1897
2768
  branch: owc.context.branch
1898
2769
  });
2770
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
2771
+ scope: job.scope,
2772
+ operations: actionResult.generatedOperations
2773
+ }, executing);
2774
+ if (reevaluationError) return {
2775
+ job,
2776
+ success: false,
2777
+ error: reevaluationError,
2778
+ duration: Date.now() - startTime
2779
+ };
1899
2780
  const ordinals = await stores.operationIndex.commit(indexTxn, signal);
1900
2781
  if (actionResult.operationsWithContext.length > 0) {
1901
2782
  for (let i = 0; i < actionResult.operationsWithContext.length; i++) actionResult.operationsWithContext[i].context.ordinal = ordinals[i];
@@ -1931,7 +2812,9 @@ var SimpleJobExecutor = class {
1931
2812
  const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
1932
2813
  return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
1933
2814
  }
1934
- async processActions(job, actions, startTime, indexTxn, stores, skipValues, sourceOperations, sourceRemote = "", signal) {
2815
+ async processActions(writes, executing) {
2816
+ const { job, signal } = executing;
2817
+ const actions = writes.map((write) => write.action);
1935
2818
  const generatedOperations = [];
1936
2819
  const operationsWithContext = [];
1937
2820
  try {
@@ -1948,14 +2831,11 @@ var SimpleJobExecutor = class {
1948
2831
  success: false,
1949
2832
  generatedOperations,
1950
2833
  operationsWithContext,
1951
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${action.timestampUtcMs}" on action ${action.type} (id: ${action.id})`)
2834
+ error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
1952
2835
  };
1953
2836
  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);
2837
+ for (const write of writes) {
2838
+ const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
1959
2839
  const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
1960
2840
  if (error !== null) return {
1961
2841
  success: false,
@@ -1980,14 +2860,44 @@ var SimpleJobExecutor = class {
1980
2860
  operationsWithContext
1981
2861
  };
1982
2862
  }
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);
2863
+ async executeRegularAction(write, executing) {
2864
+ const { action, skip, sourceOperation, sourceRemote, deniedReason } = write;
2865
+ const { job, startTime, indexTxn, stores, signal } = executing;
2866
+ let appendCondition;
2867
+ let documentVersion;
2868
+ const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
2869
+ if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
2870
+ const target = {
2871
+ documentId: job.documentId,
2872
+ branch: job.branch
2873
+ };
2874
+ let admission;
2875
+ try {
2876
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
2877
+ address: action.context?.signer?.user.address,
2878
+ key: action.context?.signer?.app.key
2879
+ }, {
2880
+ verb: "execute",
2881
+ scope: action.scope,
2882
+ operation: action.type
2883
+ }, signal);
2884
+ } catch (error) {
2885
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2886
+ }
2887
+ if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
2888
+ appendCondition = admission.appendCondition;
2889
+ documentVersion = admission.documentVersion;
2890
+ } else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
2891
+ else {
2892
+ let docMeta;
2893
+ try {
2894
+ docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2895
+ } catch (error) {
2896
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2897
+ }
2898
+ if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2899
+ documentVersion = docMeta.state.version;
1989
2900
  }
1990
- if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
1991
2901
  if (isUndoRedo(action) || action.type === "PRUNE" || action.type === "NOOP" && skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
1992
2902
  let document;
1993
2903
  try {
@@ -1995,16 +2905,49 @@ var SimpleJobExecutor = class {
1995
2905
  } catch (error) {
1996
2906
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1997
2907
  }
2908
+ if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
2909
+ const subject = {
2910
+ address: write.action.context?.signer?.user.address,
2911
+ key: write.action.context?.signer?.app.key
2912
+ };
2913
+ if (decide(document.state.auth, subject, {
2914
+ verb: "execute",
2915
+ scope: action.scope,
2916
+ operation: action.type
2917
+ }) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
2918
+ }
1998
2919
  let module;
1999
2920
  try {
2000
- const moduleVersion = docMeta.state.version === 0 ? void 0 : docMeta.state.version;
2921
+ const moduleVersion = documentVersion === 0 ? void 0 : documentVersion;
2001
2922
  module = this.registry.getModule(document.header.documentType, moduleVersion);
2002
2923
  } catch (error) {
2003
2924
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2004
2925
  }
2005
2926
  let updatedDocument;
2006
- try {
2007
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
2927
+ if (deniedReason !== void 0) {
2928
+ const index = getNextIndexForScope(document, job.scope);
2929
+ const denied = createOperation(action, index, skip, {
2930
+ documentId: job.documentId,
2931
+ scope: job.scope,
2932
+ branch: job.branch
2933
+ });
2934
+ denied.deniedReason = deniedReason;
2935
+ let standing = document;
2936
+ if (skip > 0) try {
2937
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
2938
+ } catch (error) {
2939
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2940
+ }
2941
+ denied.hash = hashDocumentStateForScope(standing, job.scope);
2942
+ updatedDocument = {
2943
+ ...standing,
2944
+ operations: {
2945
+ ...standing.operations,
2946
+ [job.scope]: [...standing.operations[job.scope] ?? [], denied]
2947
+ }
2948
+ };
2949
+ } else try {
2950
+ const protocolVersion = baseReducerVersion(document.header);
2008
2951
  const reducerOptions = sourceOperation ? {
2009
2952
  skip,
2010
2953
  branch: job.branch,
@@ -2035,14 +2978,15 @@ var SimpleJobExecutor = class {
2035
2978
  try {
2036
2979
  storedOperations = await stores.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
2037
2980
  txn.addOperations(newOperation);
2038
- }, signal);
2981
+ }, signal, appendCondition);
2039
2982
  } catch (error) {
2040
2983
  this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
2041
2984
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2985
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
2042
2986
  return {
2043
2987
  job,
2044
2988
  success: false,
2045
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2989
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2046
2990
  duration: Date.now() - startTime
2047
2991
  };
2048
2992
  }
@@ -2051,7 +2995,7 @@ var SimpleJobExecutor = class {
2051
2995
  ...updatedDocument.header.revision,
2052
2996
  [scope]: storedOperation.index + 1
2053
2997
  };
2054
- stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument);
2998
+ stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument, SnapshotPosition.Head);
2055
2999
  indexTxn.write([{
2056
3000
  ...storedOperation,
2057
3001
  documentId: job.documentId,
@@ -2078,14 +3022,238 @@ var SimpleJobExecutor = class {
2078
3022
  duration: Date.now() - startTime
2079
3023
  };
2080
3024
  }
2081
- async executeLoadJob(job, startTime, indexTxn, stores, signal) {
3025
+ /**
3026
+ * Orders a write by timestamp and decides it where it lands. The caller
3027
+ * supplies the timestamp, so a write can belong before operations already
3028
+ * stored; those are re-appended alongside it, the way a load reshuffles.
3029
+ *
3030
+ * Deciding a backdated write at the stream heads instead of at its position
3031
+ * would overwrite the verdict every other replica computes for it.
3032
+ */
3033
+ async positionByTimestamp(job, stores, signal) {
3034
+ const plain = () => ({
3035
+ writes: job.actions.map((action) => ({
3036
+ action,
3037
+ skip: 0,
3038
+ sourceRemote: ""
3039
+ })),
3040
+ evaluatedByPosition: false
3041
+ });
3042
+ if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
3043
+ let earliest = job.actions[0].timestampUtcMs;
3044
+ let earliestAt = Date.parse(earliest);
3045
+ for (const action of job.actions) {
3046
+ const at = Date.parse(action.timestampUtcMs);
3047
+ if (at < earliestAt) {
3048
+ earliest = action.timestampUtcMs;
3049
+ earliestAt = at;
3050
+ }
3051
+ }
3052
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3053
+ const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
3054
+ if (this.featureFlags.authEnforcement && job.scope === "auth") {
3055
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3056
+ const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
3057
+ if (violation) return {
3058
+ writes: [],
3059
+ evaluatedByPosition: false,
3060
+ error: violation
3061
+ };
3062
+ if (!backdated) return plain();
3063
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3064
+ }
3065
+ if (!backdated) return plain();
3066
+ const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
3067
+ if (conflicting.length === 0) {
3068
+ if (!this.featureFlags.authEnforcement) return plain();
3069
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3070
+ }
3071
+ const nextIndex = revisions.revision[job.scope] ?? 0;
3072
+ let firstConflicting = conflicting[0].index;
3073
+ for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
3074
+ const incoming = job.actions.map((action, i) => ({
3075
+ id: action.id,
3076
+ index: nextIndex + i,
3077
+ skip: 0,
3078
+ hash: "",
3079
+ timestampUtcMs: action.timestampUtcMs,
3080
+ action
3081
+ }));
3082
+ const merged = reshuffleByTimestamp({
3083
+ index: nextIndex,
3084
+ skip: retractionSkip(nextIndex, firstConflicting)
3085
+ }, conflicting, incoming);
3086
+ stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
3087
+ if (!this.featureFlags.authEnforcement) return {
3088
+ writes: merged.map((operation) => ({
3089
+ action: operation.action,
3090
+ skip: operation.skip,
3091
+ sourceRemote: ""
3092
+ })),
3093
+ evaluatedByPosition: false
3094
+ };
3095
+ return this.evaluatePositioned(job, stores, merged, signal);
3096
+ }
3097
+ /**
3098
+ * Decides each operation where it lands and carries the verdict on it. A
3099
+ * refused submitted action is reported to the caller and nothing is stored; a
3100
+ * refused operation the reshuffle merely moved keeps its verdict, because it
3101
+ * already holds a position.
3102
+ *
3103
+ * The operations carry the indexes and skips they will be stored at, because
3104
+ * the walk resolves skips before it orders them.
3105
+ */
3106
+ async evaluatePositioned(job, stores, operations, signal) {
3107
+ const reasons = await evaluateByPosition(this.decisionModel, {
3108
+ documentId: job.documentId,
3109
+ branch: job.branch
3110
+ }, {
3111
+ scope: job.scope,
3112
+ operations
3113
+ }, stores, signal);
3114
+ const submitted = new Set(job.actions.map((action) => action.id));
3115
+ for (let i = 0; i < operations.length; i++) {
3116
+ const reason = reasons[i];
3117
+ if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
3118
+ writes: [],
3119
+ evaluatedByPosition: false,
3120
+ error: refusalError(reason, job.documentId, null, operations[i].action)
3121
+ };
3122
+ }
3123
+ return {
3124
+ writes: operations.map((operation, i) => ({
3125
+ action: operation.action,
3126
+ skip: operation.skip,
3127
+ sourceRemote: "",
3128
+ deniedReason: reasons[i]
3129
+ })),
3130
+ evaluatedByPosition: true
3131
+ };
3132
+ }
3133
+ /**
3134
+ * The scopes a re-evaluation pass visits, in a fixed order.
3135
+ *
3136
+ * The revisions map comes from a query with no ORDER BY, and the order is
3137
+ * load-bearing: each scope's pass re-reads the auth stream, and the walk skips
3138
+ * an operation by its stored denial, so a denial this pass just wrote is
3139
+ * visible to a later-visited scope and invisible to an earlier one. The model's
3140
+ * own projection order leads, then the rest sorted, so the pass is reproducible
3141
+ * across replicas and across runs.
3142
+ */
3143
+ evaluationOrder(target, revision) {
3144
+ const definition = this.decisionModel(target);
3145
+ const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
3146
+ const leading = [];
3147
+ for (const stream of staticReadSet(definition)) {
3148
+ const scope = stream.query.scope;
3149
+ if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
3150
+ }
3151
+ const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
3152
+ return [...leading, ...rest];
3153
+ }
3154
+ /**
3155
+ * The first timestamp in the batch that does not strictly exceed everything
3156
+ * ahead of it, or undefined when the whole batch is monotonic.
3157
+ *
3158
+ * The bound is carried forward rather than compared against one stored maximum,
3159
+ * because a single execute can carry several auth actions stamped in the same
3160
+ * millisecond. Letting a tie through would store a stream the position walk
3161
+ * then refuses to read, with no repair path.
3162
+ */
3163
+ firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
3164
+ let boundIso = newest;
3165
+ let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
3166
+ for (const entry of entries) {
3167
+ if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
3168
+ const at = Date.parse(entry.timestampUtcMs);
3169
+ if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
3170
+ bound = at;
3171
+ boundIso = entry.timestampUtcMs;
3172
+ }
3173
+ }
3174
+ /** The operations a batch of submitted actions appends at the scope's tail. */
3175
+ appendedOperations(job, nextIndex) {
3176
+ return job.actions.map((action, i) => ({
3177
+ id: action.id,
3178
+ index: nextIndex + i,
3179
+ skip: 0,
3180
+ hash: "",
3181
+ timestampUtcMs: action.timestampUtcMs,
3182
+ action
3183
+ }));
3184
+ }
3185
+ /**
3186
+ * Re-evaluates the document when a write meets both criteria: it was written
3187
+ * to a stream the model reads, and it is timestamped before an operation
3188
+ * already stored. The caller supplies the timestamp and the reactor does not replace
3189
+ * it, so a mutation job can write such an operation just as a load job can,
3190
+ * which is why both executeJob and executeLoadJob call this.
3191
+ */
3192
+ async reevaluateIfCriteriaMet(criteria, executing) {
3193
+ if (!this.featureFlags.documentDecisions) return;
3194
+ const { job, stores, signal } = executing;
3195
+ const target = {
3196
+ documentId: job.documentId,
3197
+ branch: job.branch
3198
+ };
3199
+ if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
3200
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3201
+ const latest = Date.parse(revisions.latestTimestamp);
3202
+ if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
3203
+ return this.reevaluateDocument(executing);
3204
+ }
3205
+ /**
3206
+ * Re-evaluates every scope the model evaluates. Where an operation's
3207
+ * evaluation differs from what is stored, the tail from that operation is
3208
+ * re-appended, carrying a skip that spans the indices it supersedes.
3209
+ */
3210
+ async reevaluateDocument(executing) {
3211
+ const { job, stores, signal } = executing;
3212
+ const target = {
3213
+ documentId: job.documentId,
3214
+ branch: job.branch
3215
+ };
3216
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3217
+ for (const scope of this.evaluationOrder(target, revisions.revision)) {
3218
+ const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
3219
+ const effective = garbageCollect(sortOperations([...stored]));
3220
+ if (effective.length === 0) continue;
3221
+ const reevaluated = await evaluateByPosition(this.decisionModel, target, {
3222
+ scope,
3223
+ operations: effective
3224
+ }, stores, signal);
3225
+ const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== reevaluated[i]);
3226
+ if (firstChange === -1) continue;
3227
+ const tail = effective.slice(firstChange);
3228
+ const nextIndex = revisions.revision[scope];
3229
+ stores.writeCache.invalidate(job.documentId, scope, job.branch);
3230
+ const result = await this.processActions(tail.map((operation, i) => ({
3231
+ action: operation.action,
3232
+ skip: i === 0 ? retractionSkip(nextIndex, tail[0].index) : 0,
3233
+ sourceRemote: "",
3234
+ deniedReason: reevaluated[firstChange + i]
3235
+ })), {
3236
+ ...executing,
3237
+ job: {
3238
+ ...job,
3239
+ scope
3240
+ },
3241
+ replayingAcceptedHistory: true,
3242
+ evaluatedByPosition: true
3243
+ });
3244
+ if (!result.success) return result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`);
3245
+ }
3246
+ }
3247
+ async executeLoadJob(executing) {
3248
+ const { job, startTime, indexTxn, stores, signal } = executing;
2082
3249
  if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
2083
3250
  let docMeta;
2084
3251
  try {
2085
3252
  docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2086
3253
  } catch {}
2087
- if (docMeta?.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
3254
+ if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2088
3255
  const scope = job.scope;
3256
+ const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
2089
3257
  let latestRevision;
2090
3258
  try {
2091
3259
  latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
@@ -2095,7 +3263,7 @@ var SimpleJobExecutor = class {
2095
3263
  for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
2096
3264
  job,
2097
3265
  success: false,
2098
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${operation.timestampUtcMs}" on operation (index: ${operation.index})`),
3266
+ error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
2099
3267
  duration: Date.now() - startTime
2100
3268
  };
2101
3269
  let minIncomingIndex = Number.POSITIVE_INFINITY;
@@ -2103,7 +3271,7 @@ var SimpleJobExecutor = class {
2103
3271
  for (const operation of job.operations) {
2104
3272
  minIncomingIndex = Math.min(minIncomingIndex, operation.index);
2105
3273
  const ts = operation.timestampUtcMs || "";
2106
- if (ts < minIncomingTimestamp) minIncomingTimestamp = ts;
3274
+ if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
2107
3275
  }
2108
3276
  let conflictingOps;
2109
3277
  try {
@@ -2128,11 +3296,14 @@ var SimpleJobExecutor = class {
2128
3296
  }
2129
3297
  return true;
2130
3298
  });
2131
- const existingOpsToReshuffle = nonSupersededOps;
2132
- if (existingOpsToReshuffle.length > this.config.maxSkipThreshold) return {
3299
+ const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
3300
+ const actionIdCounts = /* @__PURE__ */ new Map();
3301
+ for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
3302
+ const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
3303
+ if (reshuffleCost > this.config.maxSkipThreshold) return {
2133
3304
  job,
2134
3305
  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.`),
3306
+ error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
2136
3307
  duration: Date.now() - startTime
2137
3308
  };
2138
3309
  let skipCount = existingOpsToReshuffle.length;
@@ -2160,6 +3331,16 @@ var SimpleJobExecutor = class {
2160
3331
  operationsWithContext: [],
2161
3332
  duration: Date.now() - startTime
2162
3333
  };
3334
+ if (monotonicAuthStream) {
3335
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3336
+ const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
3337
+ if (violation) return {
3338
+ job,
3339
+ success: false,
3340
+ error: violation,
3341
+ duration: Date.now() - startTime
3342
+ };
3343
+ }
2163
3344
  const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
2164
3345
  ...operation,
2165
3346
  index: latestRevision + i
@@ -2171,10 +3352,31 @@ var SimpleJobExecutor = class {
2171
3352
  id: operation.id
2172
3353
  })));
2173
3354
  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);
3355
+ let deniedReasons;
3356
+ if (this.featureFlags.documentDecisions) try {
3357
+ deniedReasons = await evaluateByPosition(this.decisionModel, {
3358
+ documentId: job.documentId,
3359
+ branch: job.branch
3360
+ }, {
3361
+ scope,
3362
+ operations: reshuffledOperations
3363
+ }, stores, signal);
3364
+ } catch (error) {
3365
+ return {
3366
+ job,
3367
+ success: false,
3368
+ error: error instanceof Error ? error : new Error(String(error)),
3369
+ duration: Date.now() - startTime
3370
+ };
3371
+ }
2176
3372
  const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
2177
- const result = await this.processActions(job, actions, startTime, indexTxn, stores, skipValues, reshuffledOperations, effectiveSourceRemote, signal);
3373
+ const result = await this.processActions(reshuffledOperations.map((operation, i) => ({
3374
+ action: operation.action,
3375
+ skip: operation.skip,
3376
+ sourceOperation: operation,
3377
+ sourceRemote: effectiveSourceRemote,
3378
+ deniedReason: deniedReasons?.[i]
3379
+ })), executing);
2178
3380
  if (!result.success) return {
2179
3381
  job,
2180
3382
  success: false,
@@ -2183,6 +3385,16 @@ var SimpleJobExecutor = class {
2183
3385
  };
2184
3386
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2185
3387
  if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
3388
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
3389
+ scope,
3390
+ operations: result.generatedOperations
3391
+ }, executing);
3392
+ if (reevaluationError) return {
3393
+ job,
3394
+ success: false,
3395
+ error: reevaluationError,
3396
+ duration: Date.now() - startTime
3397
+ };
2186
3398
  return {
2187
3399
  job,
2188
3400
  success: true,
@@ -2419,36 +3631,6 @@ function paginateRows(rows, paging, cursorOf, toItem, refetch) {
2419
3631
  };
2420
3632
  }
2421
3633
  //#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
3634
  //#region src/storage/txn.ts
2453
3635
  var AtomicTransaction = class {
2454
3636
  operations = [];
@@ -2473,6 +3655,7 @@ var AtomicTransaction = class {
2473
3655
  action: JSON.stringify(op.action),
2474
3656
  skip: op.skip,
2475
3657
  error: op.error || null,
3658
+ deniedReason: op.deniedReason || null,
2476
3659
  hash: op.hash
2477
3660
  });
2478
3661
  }
@@ -2506,12 +3689,12 @@ var KyselyOperationStore = class KyselyOperationStore {
2506
3689
  instance.trx = trx;
2507
3690
  return instance;
2508
3691
  }
2509
- async apply(documentId, documentType, scope, branch, revision, fn, signal) {
3692
+ async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
2510
3693
  if (this.trx) {
2511
3694
  let executeResult = null;
2512
3695
  let uniqueCtx = null;
2513
3696
  try {
2514
- executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal);
3697
+ executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2515
3698
  } catch (error) {
2516
3699
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
2517
3700
  else throw error;
@@ -2523,7 +3706,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2523
3706
  let uniqueCtx = null;
2524
3707
  try {
2525
3708
  transactionResult = await this.db.transaction().execute(async (trx) => {
2526
- return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal);
3709
+ return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2527
3710
  });
2528
3711
  } catch (error) {
2529
3712
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
@@ -2542,12 +3725,13 @@ var KyselyOperationStore = class KyselyOperationStore {
2542
3725
  const op = ctx.stagedOps[0];
2543
3726
  throw new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
2544
3727
  }
2545
- async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal) {
3728
+ async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
2546
3729
  throwIfAborted(signal);
2547
3730
  const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
2548
3731
  await fn(atomicTxn);
2549
3732
  const operations = atomicTxn.getOperations();
2550
3733
  if (operations.length === 0) return [];
3734
+ if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
2551
3735
  const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
2552
3736
  const currentRevision = latestOp ? latestOp.index : -1;
2553
3737
  if (currentRevision !== revision - 1) {
@@ -2563,22 +3747,91 @@ var KyselyOperationStore = class KyselyOperationStore {
2563
3747
  op.prevOpId = prevOpId;
2564
3748
  prevOpId = op.opId;
2565
3749
  }
3750
+ let insertedCount = operations.length;
2566
3751
  try {
2567
- await trx.insertInto("Operation").values(operations).execute();
3752
+ if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
3753
+ else await trx.insertInto("Operation").values(operations).execute();
2568
3754
  } catch (error) {
2569
3755
  if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
2570
3756
  throw error;
2571
3757
  }
3758
+ if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
2572
3759
  return operations.map((op) => ({
2573
3760
  index: op.index,
2574
3761
  timestampUtcMs: op.timestampUtcMs.toISOString(),
2575
3762
  hash: op.hash,
2576
3763
  skip: op.skip,
2577
3764
  error: op.error || void 0,
3765
+ deniedReason: op.deniedReason || void 0,
2578
3766
  id: op.opId,
2579
3767
  action: JSON.parse(op.action)
2580
3768
  }));
2581
3769
  }
3770
+ /**
3771
+ * Locks the written stream and every read-set stream, in sorted key order
3772
+ * so that overlapping concurrent appends serialize rather than deadlock.
3773
+ * The locks are still taken one row at a time, so the query preserves that
3774
+ * order. It must stay separate from the guarded insert, which would
3775
+ * otherwise read a snapshot taken before the locks were held.
3776
+ */
3777
+ async acquireStreamLocks(trx, documentId, scope, branch, condition) {
3778
+ const keys = new Set([`${documentId}:${scope}:${branch}`]);
3779
+ for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
3780
+ await sql`
3781
+ with ordered as materialized (
3782
+ select key
3783
+ from unnest(array[${sql.join([...keys].sort())}]::text[]) with ordinality as t(key, ord)
3784
+ order by ord
3785
+ )
3786
+ select pg_advisory_xact_lock(hashtext(key)) from ordered
3787
+ `.execute(trx);
3788
+ }
3789
+ /**
3790
+ * Inserts the staged operations with the condition compiled in as a WHERE
3791
+ * NOT EXISTS guard, making the check and the append one statement. Returns
3792
+ * the rows inserted; zero means the guard failed and nothing was written.
3793
+ */
3794
+ async insertGuarded(trx, operations, condition) {
3795
+ const branches = operations.map((op) => trx.selectNoFrom([
3796
+ sql`${op.jobId}::text`.as("jobId"),
3797
+ sql`${op.opId}::text`.as("opId"),
3798
+ sql`${op.prevOpId}::text`.as("prevOpId"),
3799
+ sql`${op.documentId}::text`.as("documentId"),
3800
+ sql`${op.documentType}::text`.as("documentType"),
3801
+ sql`${op.scope}::text`.as("scope"),
3802
+ sql`${op.branch}::text`.as("branch"),
3803
+ sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
3804
+ sql`${op.index}::integer`.as("index"),
3805
+ sql`${op.action}::jsonb`.as("action"),
3806
+ sql`${op.skip}::integer`.as("skip"),
3807
+ sql`${op.error ?? null}::text`.as("error"),
3808
+ sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
3809
+ sql`${op.hash}::text`.as("hash")
3810
+ ]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
3811
+ web("Operation.documentId", "=", s.documentId),
3812
+ web("Operation.scope", "=", s.scope),
3813
+ web("Operation.branch", "=", s.branch),
3814
+ web("Operation.index", ">", s.revision)
3815
+ ]))))))));
3816
+ let expression = branches[0];
3817
+ for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
3818
+ return (await trx.insertInto("Operation").columns([
3819
+ "jobId",
3820
+ "opId",
3821
+ "prevOpId",
3822
+ "documentId",
3823
+ "documentType",
3824
+ "scope",
3825
+ "branch",
3826
+ "timestampUtcMs",
3827
+ "index",
3828
+ "action",
3829
+ "skip",
3830
+ "error",
3831
+ "deniedReason",
3832
+ "hash"
3833
+ ]).expression(expression).returning("id").execute()).length;
3834
+ }
2582
3835
  async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
2583
3836
  const minIndex = revision;
2584
3837
  const maxIndex = revision + stagedOps.length - 1;
@@ -2646,18 +3899,18 @@ var KyselyOperationStore = class KyselyOperationStore {
2646
3899
  "o1.index",
2647
3900
  "o1.timestampUtcMs"
2648
3901
  ]).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();
3902
+ const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("branch", "=", branch).executeTakeFirst();
2649
3903
  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
- }
3904
+ for (const row of scopeRevisions) revision[row.scope] = row.index + 1;
2656
3905
  return {
2657
3906
  revision,
2658
- latestTimestamp
3907
+ latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
2659
3908
  };
2660
3909
  }
3910
+ async getStreamLatestTimestamp(documentId, scope, branch, signal) {
3911
+ 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();
3912
+ return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
3913
+ }
2661
3914
  rowToOperation(row) {
2662
3915
  return {
2663
3916
  index: row.index,
@@ -2665,6 +3918,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2665
3918
  hash: row.hash,
2666
3919
  skip: row.skip,
2667
3920
  error: row.error || void 0,
3921
+ deniedReason: row.deniedReason || void 0,
2668
3922
  id: row.opId,
2669
3923
  action: row.action
2670
3924
  };
@@ -2750,8 +4004,8 @@ function createForwardingPoolInstrumentation(name) {
2750
4004
  }
2751
4005
  //#endregion
2752
4006
  //#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) {
4007
+ var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
4008
+ async function up$15(db) {
2755
4009
  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
4010
  "documentId",
2757
4011
  "scope",
@@ -2776,8 +4030,8 @@ async function up$13(db) {
2776
4030
  }
2777
4031
  //#endregion
2778
4032
  //#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) {
4033
+ var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
4034
+ async function up$14(db) {
2781
4035
  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
4036
  "documentId",
2783
4037
  "scope",
@@ -2793,14 +4047,14 @@ async function up$12(db) {
2793
4047
  }
2794
4048
  //#endregion
2795
4049
  //#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) {
4050
+ var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
4051
+ async function up$13(db) {
2798
4052
  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
4053
  }
2800
4054
  //#endregion
2801
4055
  //#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) {
4056
+ var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
4057
+ async function up$12(db) {
2804
4058
  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
4059
  "sourceId",
2806
4060
  "targetId",
@@ -2812,14 +4066,14 @@ async function up$10(db) {
2812
4066
  }
2813
4067
  //#endregion
2814
4068
  //#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) {
4069
+ var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
4070
+ async function up$11(db) {
2817
4071
  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
4072
  }
2819
4073
  //#endregion
2820
4074
  //#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) {
4075
+ var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
4076
+ async function up$10(db) {
2823
4077
  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
4078
  "documentId",
2825
4079
  "scope",
@@ -2840,8 +4094,8 @@ async function up$8(db) {
2840
4094
  }
2841
4095
  //#endregion
2842
4096
  //#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) {
4097
+ var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
4098
+ async function up$9(db) {
2845
4099
  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
4100
  "documentId",
2847
4101
  "scope",
@@ -2851,14 +4105,14 @@ async function up$7(db) {
2851
4105
  }
2852
4106
  //#endregion
2853
4107
  //#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) {
4108
+ var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
4109
+ async function up$8(db) {
2856
4110
  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
4111
  }
2858
4112
  //#endregion
2859
4113
  //#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) {
4114
+ var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
4115
+ async function up$7(db) {
2862
4116
  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
4117
  await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
2864
4118
  await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
@@ -2872,8 +4126,8 @@ async function up$5(db) {
2872
4126
  }
2873
4127
  //#endregion
2874
4128
  //#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) {
4129
+ var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
4130
+ async function up$6(db) {
2877
4131
  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
4132
  await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
2879
4133
  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 +4135,8 @@ async function up$4(db) {
2881
4135
  }
2882
4136
  //#endregion
2883
4137
  //#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) {
4138
+ var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
4139
+ async function up$5(db) {
2886
4140
  await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
2887
4141
  await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
2888
4142
  await db.schema.dropTable("sync_cursors").execute();
@@ -2891,24 +4145,60 @@ async function up$3(db) {
2891
4145
  }
2892
4146
  //#endregion
2893
4147
  //#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) {
4148
+ var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
4149
+ async function up$4(db) {
2896
4150
  await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
2897
4151
  }
2898
4152
  //#endregion
2899
4153
  //#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) {
4154
+ var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
4155
+ async function up$3(db) {
2902
4156
  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
4157
  await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
2904
4158
  }
2905
4159
  //#endregion
2906
4160
  //#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) {
4161
+ var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$2 });
4162
+ async function up$2(db) {
2909
4163
  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
4164
  }
2911
4165
  //#endregion
4166
+ //#region src/storage/migrations/015_add_operation_denied_reason.ts
4167
+ var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
4168
+ down: () => down$1,
4169
+ up: () => up$1
4170
+ });
4171
+ /**
4172
+ * Records why authorization refused an operation. Separate from `error` so a
4173
+ * denial is distinguishable from a reducer failure without matching on a
4174
+ * message. Null for every operation written before decisions were enforced.
4175
+ */
4176
+ async function up$1(db) {
4177
+ await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
4178
+ await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
4179
+ }
4180
+ async function down$1(db) {
4181
+ await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
4182
+ await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
4183
+ }
4184
+ //#endregion
4185
+ //#region src/storage/migrations/016_add_dead_letter_error_type.ts
4186
+ var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
4187
+ down: () => down,
4188
+ up: () => up
4189
+ });
4190
+ /**
4191
+ * The classification a dead letter falls into, stored because it decides whether
4192
+ * the document stays quarantined and the in-memory error is gone after a restart.
4193
+ * Defaulted rather than nullable, so a pre-existing row rehydrates.
4194
+ */
4195
+ async function up(db) {
4196
+ await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
4197
+ }
4198
+ async function down(db) {
4199
+ await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
4200
+ }
4201
+ //#endregion
2912
4202
  //#region src/storage/migrations/migrator.ts
2913
4203
  const REACTOR_SCHEMA = "reactor";
2914
4204
  const migrations = {
@@ -2925,7 +4215,9 @@ const migrations = {
2925
4215
  "011_add_cursor_type_column": _011_add_cursor_type_column_exports,
2926
4216
  "012_add_source_remote_column": _012_add_source_remote_column_exports,
2927
4217
  "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
4218
+ "014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
4219
+ "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
4220
+ "016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports
2929
4221
  };
2930
4222
  var ProgrammaticMigrationProvider = class {
2931
4223
  getMigrations() {
@@ -2979,6 +4271,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
2979
4271
  //#region src/core/drive-container-types.ts
2980
4272
  const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
2981
4273
  //#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 };
4274
+ export { OptimisticLockError as A, ExcessiveReshuffleError as B, DocumentMetaCache as C, APPEND_CONDITION_FAILED_PREFIX as D, CollectionMembershipCache as E, ModuleNotFoundError as F, __exportAll as G, matchesScope as H, AuthTimestampNotMonotonicError as I, AuthorizationDeniedError as L, DuplicateManifestError as M, DuplicateModuleError as N, AppendConditionFailedError as O, InvalidModuleError as P, DocumentDeletedError as R, KyselyOperationIndex as S, createEmptyConsistencyToken as T, parsePagingOptions as U, InvalidOperationTimestampError as V, throwIfAborted as W, KyselyExecutionScope as _, createForwardingPoolInstrumentation as a, EventBus as b, KyselyKeyframeStore as c, DriveCollectionId as d, decideAtHead as f, authDecisionModel as g, buildDecisionModel as h, runMigrations as i, RevisionMismatchError as j, DuplicateOperationError as k, DocumentModelRegistry as l, documentDecisionModel as m, REACTOR_SCHEMA as n, instrumentPgPool as o, selectDecisionModel as p, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, FLAG_PREREQUISITES as v, createConsistencyToken as w, KyselyWriteCache as x, validateFeatureFlags as y, DocumentNotFoundError as z };
2983
4275
 
2984
- //# sourceMappingURL=drive-container-types-DpJp2AmE.js.map
4276
+ //# sourceMappingURL=drive-container-types-BJCKXJwH.js.map