@powerhousedao/reactor 6.2.2-dev.3 → 6.2.2-dev.31

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 { DowngradeNotSupportedError, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, 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,27 @@ 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
+ /**
75
96
  * Error thrown when an operation has an invalid signature.
76
97
  */
77
98
  var InvalidSignatureError = class InvalidSignatureError extends Error {
@@ -181,6 +202,57 @@ var InvalidUpgradeStepError = class extends Error {
181
202
  }
182
203
  };
183
204
  //#endregion
205
+ //#region src/storage/interfaces.ts
206
+ /**
207
+ * Thrown when an operation with the same identity already exists in the store.
208
+ */
209
+ var DuplicateOperationError = class extends Error {
210
+ constructor(description) {
211
+ super(`Duplicate operation: ${description}`);
212
+ this.name = "DuplicateOperationError";
213
+ }
214
+ };
215
+ /**
216
+ * Thrown when a concurrent write conflict is detected during an atomic apply.
217
+ */
218
+ var OptimisticLockError = class extends Error {
219
+ constructor(message) {
220
+ super(message);
221
+ this.name = "OptimisticLockError";
222
+ }
223
+ };
224
+ /**
225
+ * Thrown when the caller-provided revision does not match the current
226
+ * stored revision, indicating a stale read.
227
+ */
228
+ var RevisionMismatchError = class extends Error {
229
+ constructor(expected, actual) {
230
+ super(`Revision mismatch: expected ${expected}, got ${actual}`);
231
+ this.name = "RevisionMismatchError";
232
+ }
233
+ };
234
+ /** Error history keeps messages, not classes, so failures match by prefix. */
235
+ const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
236
+ /**
237
+ * A read-set stream grew before the append committed. A concurrency
238
+ * conflict, not a fault: the caller retries against the new stream heads.
239
+ */
240
+ var AppendConditionFailedError = class extends Error {
241
+ constructor(condition) {
242
+ const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
243
+ super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
244
+ this.condition = condition;
245
+ this.name = "AppendConditionFailedError";
246
+ }
247
+ static isError(error) {
248
+ return Error.isError(error) && error.name === "AppendConditionFailedError";
249
+ }
250
+ /** True when a recorded error message is an append-condition failure. */
251
+ static isFailureMessage(message) {
252
+ return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
253
+ }
254
+ };
255
+ //#endregion
184
256
  //#region src/cache/collection-membership-cache.ts
185
257
  var CollectionMembershipCache = class CollectionMembershipCache {
186
258
  cache = /* @__PURE__ */ new Map();
@@ -634,6 +706,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
634
706
  skip: op.skip,
635
707
  hash: op.hash,
636
708
  action: op.action,
709
+ deniedReason: op.deniedReason ?? null,
637
710
  sourceRemote: op.sourceRemote
638
711
  }));
639
712
  operationOrdinals = (await trx.insertInto("operation_index_operations").values(operationRows).returning("ordinal").execute()).map((row) => row.ordinal);
@@ -765,6 +838,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
765
838
  hash: row.hash,
766
839
  skip: row.skip,
767
840
  action: row.action,
841
+ deniedReason: row.deniedReason ?? void 0,
768
842
  id: row.opId
769
843
  },
770
844
  context: {
@@ -788,6 +862,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
788
862
  hash: row.hash,
789
863
  skip: row.skip,
790
864
  action: row.action,
865
+ deniedReason: row.deniedReason ?? void 0,
791
866
  id: row.opId,
792
867
  sourceRemote: row.sourceRemote
793
868
  };
@@ -873,11 +948,69 @@ var RingBuffer = class {
873
948
  }
874
949
  };
875
950
  //#endregion
951
+ //#region src/cache/write-cache-types.ts
952
+ /**
953
+ * Where a snapshot sits in its stream.
954
+ *
955
+ * - `Head`: the newest revision of the stream when it was stored. Only these
956
+ * can answer a read that asks for the head.
957
+ * - `Historical`: state at an earlier revision. Usable as a starting point to
958
+ * replay forward from, and as an answer to a read for that same revision.
959
+ */
960
+ let SnapshotPosition = /* @__PURE__ */ function(SnapshotPosition) {
961
+ SnapshotPosition["Head"] = "head";
962
+ SnapshotPosition["Historical"] = "historical";
963
+ return SnapshotPosition;
964
+ }({});
965
+ //#endregion
876
966
  //#region src/cache/kysely-write-cache.ts
967
+ /**
968
+ * The last operation index a keyframe's document reflects for the scope. A
969
+ * keyframe only exists for a scope that has operations, so a missing entry
970
+ * means the stored row is corrupt.
971
+ */
972
+ function keyframeRevision(keyframe, documentId, scope) {
973
+ const nextIndex = keyframe.document.header.revision[scope];
974
+ if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
975
+ return nextIndex - 1;
976
+ }
977
+ /**
978
+ * Records a denied operation in the history without applying it. This is used
979
+ * for auth-rejected operations, as these need to be recorded without being
980
+ * applied.
981
+ */
982
+ function appendWithoutApplying(document, scope, operation) {
983
+ return {
984
+ ...document,
985
+ operations: {
986
+ ...document.operations,
987
+ [scope]: [...document.operations[scope] ?? [], operation]
988
+ }
989
+ };
990
+ }
877
991
  function extractModuleVersion(doc) {
878
992
  const v = doc.state.document.version;
879
993
  return v === 0 ? void 0 : v;
880
994
  }
995
+ /** The highest revision held, latest push winning a tie. */
996
+ function highestRevision(snapshots) {
997
+ let newest = void 0;
998
+ for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
999
+ return newest;
1000
+ }
1001
+ /**
1002
+ * Copies a document far enough that the caller cannot write through it. Inside
1003
+ * this class, callers only ever replace whole fields on these four, so one
1004
+ * level each is enough.
1005
+ */
1006
+ function copyDocument(document) {
1007
+ return {
1008
+ ...document,
1009
+ header: { ...document.header },
1010
+ state: { ...document.state },
1011
+ operations: { ...document.operations }
1012
+ };
1013
+ }
881
1014
  /**
882
1015
  * In-memory write cache with keyframe persistence for PHDocuments.
883
1016
  *
@@ -956,6 +1089,8 @@ var KyselyWriteCache = class KyselyWriteCache {
956
1089
  /**
957
1090
  * Retrieves document state at a specific revision from cache or rebuilds it.
958
1091
  *
1092
+ * Note: this returns a _shallow_ copy of the document.
1093
+ *
959
1094
  * Cache hit path: Returns cached snapshot if available (O(1))
960
1095
  * Warm miss path: Rebuilds from cached base revision + incremental ops
961
1096
  * Cold miss path: Rebuilds from keyframe or from scratch using all operations
@@ -979,30 +1114,35 @@ var KyselyWriteCache = class KyselyWriteCache {
979
1114
  if (stream) {
980
1115
  const snapshots = stream.ringBuffer.getAll();
981
1116
  if (targetRevision === void 0) {
982
- if (snapshots.length > 0) {
983
- const newest = snapshots[snapshots.length - 1];
1117
+ const newest = highestRevision(snapshots);
1118
+ if (newest?.position === SnapshotPosition.Head) {
984
1119
  this.lruTracker.touch(streamKey);
985
- return newest.document;
1120
+ return copyDocument(newest.document);
1121
+ }
1122
+ if (newest) {
1123
+ const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
1124
+ this.store(documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, SnapshotPosition.Head);
1125
+ this.lruTracker.touch(streamKey);
1126
+ return document;
986
1127
  }
987
1128
  } else {
988
- const exactMatch = snapshots.find((s) => s.revision === targetRevision);
1129
+ const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
989
1130
  if (exactMatch) {
990
1131
  this.lruTracker.touch(streamKey);
991
- return exactMatch.document;
1132
+ return copyDocument(exactMatch.document);
992
1133
  }
993
1134
  const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
994
1135
  if (newestOlder) {
995
1136
  const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
996
- this.putState(documentId, scope, branch, targetRevision, document);
1137
+ this.store(documentId, scope, branch, targetRevision, document, SnapshotPosition.Historical);
997
1138
  this.lruTracker.touch(streamKey);
998
1139
  return document;
999
1140
  }
1000
1141
  }
1001
1142
  }
1002
1143
  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);
1144
+ const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
1145
+ this.store(documentId, scope, branch, revision, document, targetRevision === void 0 ? SnapshotPosition.Head : SnapshotPosition.Historical);
1006
1146
  return document;
1007
1147
  }
1008
1148
  /**
@@ -1025,16 +1165,20 @@ var KyselyWriteCache = class KyselyWriteCache {
1025
1165
  * @param document - The document to cache
1026
1166
  * @throws {Error} If document serialization fails
1027
1167
  */
1028
- putState(documentId, scope, branch, revision, document) {
1168
+ putState(documentId, scope, branch, revision, document, position) {
1169
+ this.store(documentId, scope, branch, revision, document, position);
1170
+ }
1171
+ store(documentId, scope, branch, revision, document, position) {
1029
1172
  const streamKey = this.makeStreamKey(documentId, scope, branch);
1030
1173
  const stream = this.getOrCreateStream(streamKey);
1031
1174
  const snapshot = {
1032
1175
  revision,
1033
1176
  document: {
1034
- ...document,
1177
+ ...copyDocument(document),
1035
1178
  operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
1036
1179
  clipboard: []
1037
- }
1180
+ },
1181
+ position
1038
1182
  };
1039
1183
  stream.ringBuffer.push(snapshot);
1040
1184
  if (this.isKeyframeRevision(revision)) this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
@@ -1102,58 +1246,75 @@ var KyselyWriteCache = class KyselyWriteCache {
1102
1246
  }
1103
1247
  async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
1104
1248
  if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
1105
- return this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1249
+ const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1250
+ if (!keyframe) return;
1251
+ return {
1252
+ revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
1253
+ document: keyframe.document
1254
+ };
1106
1255
  }
1107
1256
  async coldMissRebuild(documentId, scope, branch, targetRevision, signal) {
1108
1257
  const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
1109
1258
  const keyframe = await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal);
1259
+ const documentScopeBound = scope === "document" ? targetRevision : void 0;
1110
1260
  let document;
1111
1261
  let startRevision;
1112
1262
  let documentType;
1113
1263
  const validatedUpgrades = [];
1264
+ let lastDocumentScopeOperation;
1114
1265
  if (keyframe) {
1115
1266
  document = keyframe.document;
1116
1267
  startRevision = keyframe.revision;
1117
1268
  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 });
1269
+ const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
1270
+ const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
1271
+ for (const operation of docScopeOpsAfterKeyframe.results) {
1272
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1273
+ lastDocumentScopeOperation = operation;
1274
+ if (operation.error || isDenied(operation)) continue;
1275
+ if (operation.action.type === "UPGRADE_DOCUMENT") {
1276
+ const upgradeAction = operation.action;
1277
+ const fromVersion = upgradeAction.input.fromVersion;
1278
+ const toVersion = upgradeAction.input.toVersion;
1279
+ if (fromVersion > 0 && fromVersion < toVersion) {
1280
+ let upgradePath;
1281
+ try {
1282
+ upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1283
+ } catch (err) {
1284
+ if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
1285
+ 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 });
1286
+ }
1287
+ validatedUpgrades.push({
1288
+ fromVersion,
1289
+ toVersion,
1290
+ revision: upgradeAction.input.revision,
1291
+ timestampUtcMs: operation.timestampUtcMs
1292
+ });
1293
+ document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1130
1294
  }
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);
1295
+ } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1296
+ }
1140
1297
  } else {
1141
1298
  startRevision = -1;
1142
1299
  const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
1143
1300
  cursor: "0",
1144
1301
  limit: 1
1145
1302
  }, signal);
1146
- if (createOpResult.results.length === 0) throw new Error(`Failed to rebuild document ${documentId}: no CREATE_DOCUMENT operation found in document scope`);
1303
+ if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
1147
1304
  const createOp = createOpResult.results[0];
1148
1305
  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
1306
  const documentCreateAction = createOp.action;
1150
1307
  documentType = documentCreateAction.input.model;
1151
1308
  if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
1152
1309
  document = createDocumentFromAction(documentCreateAction);
1310
+ lastDocumentScopeOperation = createOp;
1153
1311
  let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1154
1312
  const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
1155
1313
  for (const operation of docScopeOps.results) {
1314
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1315
+ lastDocumentScopeOperation = operation;
1156
1316
  if (operation.index === 0) continue;
1317
+ if (operation.error || isDenied(operation)) continue;
1157
1318
  if (operation.action.type === "UPGRADE_DOCUMENT") {
1158
1319
  const upgradeAction = operation.action;
1159
1320
  const fromVersion = upgradeAction.input.fromVersion;
@@ -1177,7 +1338,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1177
1338
  docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1178
1339
  } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1179
1340
  else {
1180
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1341
+ const protocolVersion = baseReducerVersion(document.header);
1181
1342
  document = docModule.reducer(document, operation.action, void 0, {
1182
1343
  skip: operation.skip,
1183
1344
  protocolVersion
@@ -1185,6 +1346,21 @@ var KyselyWriteCache = class KyselyWriteCache {
1185
1346
  }
1186
1347
  }
1187
1348
  }
1349
+ if (scope === "document") {
1350
+ const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
1351
+ document.operations = {
1352
+ ...document.operations,
1353
+ document: last ? [last] : []
1354
+ };
1355
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1356
+ }
1357
+ if (keyframe) {
1358
+ const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
1359
+ if (resumeOperation) document.operations = {
1360
+ ...document.operations,
1361
+ [scope]: [resumeOperation]
1362
+ };
1363
+ }
1188
1364
  const moduleCache = /* @__PURE__ */ new Map();
1189
1365
  const getModuleCached = (version) => {
1190
1366
  const key = version ?? 0;
@@ -1209,11 +1385,14 @@ var KyselyWriteCache = class KyselyWriteCache {
1209
1385
  for (const operation of result.results) {
1210
1386
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1211
1387
  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
- });
1388
+ if (isDenied(operation)) document = appendWithoutApplying(document, scope, operation);
1389
+ else {
1390
+ const protocolVersion = baseReducerVersion(document.header);
1391
+ document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1392
+ skip: operation.skip,
1393
+ protocolVersion
1394
+ });
1395
+ }
1217
1396
  }
1218
1397
  const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
1219
1398
  hasMorePages = Boolean(result.nextCursor) && !reachedTarget;
@@ -1222,11 +1401,31 @@ var KyselyWriteCache = class KyselyWriteCache {
1222
1401
  throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1223
1402
  }
1224
1403
  } while (hasMorePages);
1404
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1405
+ }
1406
+ /**
1407
+ * Copies the current document revisions onto the document. Overwrites the
1408
+ * requested scope revision with the target revision, if provided.
1409
+ */
1410
+ async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
1225
1411
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1226
1412
  document.header.revision = revisions.revision;
1413
+ if (targetRevision !== void 0) document.header.revision = {
1414
+ ...document.header.revision,
1415
+ [scope]: targetRevision + 1
1416
+ };
1227
1417
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1228
1418
  return document;
1229
1419
  }
1420
+ /** The stored operation at `index`, or undefined if it is no longer there. */
1421
+ async operationAt(documentId, scope, branch, index, signal) {
1422
+ if (index < 0) return;
1423
+ const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
1424
+ cursor: "0",
1425
+ limit: 1
1426
+ }, signal)).results[0];
1427
+ return operation && operation.index === index ? operation : void 0;
1428
+ }
1230
1429
  /**
1231
1430
  * Resolves which module version to use for a given operation in phase 2.
1232
1431
  *
@@ -1250,19 +1449,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1250
1449
  async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
1251
1450
  const documentType = baseDocument.header.documentType;
1252
1451
  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);
1452
+ 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
1453
  const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
1255
- let document = baseDocument;
1454
+ let document = copyDocument(baseDocument);
1256
1455
  try {
1257
1456
  const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
1258
1457
  for (const operation of pagedResults.results) {
1259
1458
  if (signal?.aborted) throw new Error("Operation aborted");
1260
1459
  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
- });
1460
+ if (isDenied(operation)) document = appendWithoutApplying(document, scope, operation);
1461
+ else {
1462
+ const protocolVersion = baseReducerVersion(document.header);
1463
+ document = module.reducer(document, operation.action, void 0, {
1464
+ skip: operation.skip,
1465
+ protocolVersion
1466
+ });
1467
+ }
1266
1468
  if (targetRevision !== void 0 && operation.index === targetRevision) break;
1267
1469
  }
1268
1470
  } catch (err) {
@@ -1270,6 +1472,10 @@ var KyselyWriteCache = class KyselyWriteCache {
1270
1472
  }
1271
1473
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1272
1474
  document.header.revision = revisions.revision;
1475
+ if (targetRevision !== void 0) document.header.revision = {
1476
+ ...document.header.revision,
1477
+ [scope]: targetRevision + 1
1478
+ };
1273
1479
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1274
1480
  return document;
1275
1481
  }
@@ -1436,6 +1642,260 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
1436
1642
  }));
1437
1643
  }
1438
1644
  //#endregion
1645
+ //#region src/decision/build-decision-model.ts
1646
+ /**
1647
+ * Reads each projection's stream through the write cache, recording the
1648
+ * revision observed. Static projections resolve first; derived projections
1649
+ * see only those and contribute a map from document id to state. Each
1650
+ * distinct stream is read once and yields one append condition entry.
1651
+ */
1652
+ async function buildDecisionModel(cache, definition, target, signal) {
1653
+ const decisionModel = definition(target);
1654
+ const projections = Object.entries(decisionModel.projections);
1655
+ const reads = /* @__PURE__ */ new Map();
1656
+ const model = {};
1657
+ for (const [key, projection] of projections) {
1658
+ if (typeof projection.query === "function") continue;
1659
+ model[key] = (await readStream(cache, projection.query, reads, signal)).state;
1660
+ }
1661
+ const staticModel = { ...model };
1662
+ for (const [key, projection] of projections) {
1663
+ if (typeof projection.query !== "function") continue;
1664
+ const queries = projection.query(staticModel);
1665
+ const value = {};
1666
+ for (const query of queries) {
1667
+ const read = await readStream(cache, query, reads, signal);
1668
+ value[query.documentId] = read.state;
1669
+ }
1670
+ model[key] = value;
1671
+ }
1672
+ return {
1673
+ model,
1674
+ appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
1675
+ };
1676
+ }
1677
+ async function readStream(cache, query, reads, signal) {
1678
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
1679
+ const existing = reads.get(key);
1680
+ if (existing) return existing;
1681
+ const document = await cache.getState(query.documentId, query.scope, query.branch, void 0, signal);
1682
+ const read = {
1683
+ state: document.state[query.scope],
1684
+ stream: {
1685
+ documentId: query.documentId,
1686
+ scope: query.scope,
1687
+ branch: query.branch,
1688
+ revision: observedRevision(document, query.scope)
1689
+ }
1690
+ };
1691
+ reads.set(key, read);
1692
+ return read;
1693
+ }
1694
+ /**
1695
+ * The highest operation index the document reflects for the scope, or -1 if
1696
+ * empty. `header.revision` is authoritative, not the rebuilt operation list.
1697
+ */
1698
+ function observedRevision(document, scope) {
1699
+ if (scope in document.header.revision) return document.header.revision[scope] - 1;
1700
+ if (scope in document.operations) {
1701
+ const operations = document.operations[scope];
1702
+ if (operations.length > 0) return operations[operations.length - 1].index;
1703
+ }
1704
+ if (!(scope in document.header.revision)) return -1;
1705
+ return document.header.revision[scope] - 1;
1706
+ }
1707
+ /**
1708
+ * The streams a model reads whose queries are known before it is built. A
1709
+ * derived query needs the statically-queried projections first, so it is not
1710
+ * included here.
1711
+ */
1712
+ function staticReadSet(definition) {
1713
+ const streams = [];
1714
+ for (const projection of Object.values(definition.projections)) {
1715
+ if (typeof projection.query === "function") continue;
1716
+ streams.push({
1717
+ query: projection.query,
1718
+ decidingActions: projection.decidingActions,
1719
+ apply: projection.apply
1720
+ });
1721
+ }
1722
+ return streams;
1723
+ }
1724
+ //#endregion
1725
+ //#region src/decision/document-decision-model.ts
1726
+ /** Why the model refused an operation. */
1727
+ const DOCUMENT_DELETED_REASON = "document deleted";
1728
+ /**
1729
+ * The simplest decision model: one projection over the document scope, which
1730
+ * rejects on a deleted document.
1731
+ */
1732
+ function documentDecisionModel(target) {
1733
+ return {
1734
+ projections: { document: {
1735
+ decidingActions: ["DELETE_DOCUMENT"],
1736
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
1737
+ ...document,
1738
+ state: { ...document.state }
1739
+ }, operation.action) : document,
1740
+ query: {
1741
+ documentId: target.documentId,
1742
+ branch: target.branch,
1743
+ scope: "document"
1744
+ }
1745
+ } },
1746
+ judgesScope() {
1747
+ return true;
1748
+ },
1749
+ decide(model) {
1750
+ return model.document.isDeleted ? "deny" : "allow";
1751
+ }
1752
+ };
1753
+ }
1754
+ //#endregion
1755
+ //#region src/decision/merged-order.ts
1756
+ /** Identifies a stream within a walk. */
1757
+ function streamKey(query) {
1758
+ return `${query.documentId}:${query.scope}:${query.branch}`;
1759
+ }
1760
+ /**
1761
+ * Orders two operations from different streams by position. Timestamp decides;
1762
+ * an equal timestamp falls to the action id and then the operation id, so that
1763
+ * two replicas holding the same operations agree on the order whatever order
1764
+ * they happen to store them in.
1765
+ */
1766
+ function comparePositions(a, b) {
1767
+ const aTime = Date.parse(a.operation.timestampUtcMs);
1768
+ const bTime = Date.parse(b.operation.timestampUtcMs);
1769
+ if (aTime !== bTime) return aTime - bTime;
1770
+ const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
1771
+ if (actionIds !== 0) return actionIds;
1772
+ return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
1773
+ }
1774
+ /**
1775
+ * Merges the read-set streams into one sequence by position. Each stream keeps
1776
+ * its stored order; only how far to go is decided by the timestamp, so a stream
1777
+ * whose stored order disagrees with its timestamp order still applies in the
1778
+ * order it is stored.
1779
+ *
1780
+ * An operation's position in the result is the bound a decision at that
1781
+ * operation reads to: every operation before it has been applied, and it has
1782
+ * not.
1783
+ */
1784
+ function mergeByPosition(streams) {
1785
+ const merged = [];
1786
+ for (const stream of streams) for (const operation of stream.operations) merged.push({
1787
+ streamKey: stream.streamKey,
1788
+ operation
1789
+ });
1790
+ return merged.sort(comparePositions);
1791
+ }
1792
+ /**
1793
+ * For auth-related reshuffles, we may need to retract previous operations.
1794
+ * This function calculates the needed skip value.
1795
+ */
1796
+ function retractionSkip(nextIndex, firstRetractedIndex) {
1797
+ return nextIndex - firstRetractedIndex;
1798
+ }
1799
+ //#endregion
1800
+ //#region src/decision/walk.ts
1801
+ /**
1802
+ * Visits every operation in the read-set once, in the order their positions
1803
+ * fall, and hands back the state each stream held just before it. That state is
1804
+ * what a decision at that operation reads: everything ahead of it has been
1805
+ * applied and it has not.
1806
+ *
1807
+ * Skips are resolved first (i.e. this is performed on a garbage collected
1808
+ * stream), which means we can do a single forward pass.
1809
+ *
1810
+ * A denied operation is visited but not applied.
1811
+ */
1812
+ function* walkByPosition(streams) {
1813
+ const merged = mergeByPosition(streams.map((stream) => ({
1814
+ streamKey: stream.streamKey,
1815
+ operations: garbageCollect(sortOperations([...stream.operations]))
1816
+ })));
1817
+ const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
1818
+ for (const { streamKey, operation } of merged) {
1819
+ yield {
1820
+ streamKey,
1821
+ operation,
1822
+ states: new Map(states)
1823
+ };
1824
+ if (isDenied(operation)) continue;
1825
+ const stream = streams.find((candidate) => candidate.streamKey === streamKey);
1826
+ const before = states.get(streamKey);
1827
+ if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
1828
+ states.set(streamKey, stream.apply(before, operation));
1829
+ }
1830
+ }
1831
+ //#endregion
1832
+ //#region src/decision/deletion-verdicts.ts
1833
+ const WRITTEN = "written";
1834
+ /** Whether a read stream counts this action as one that changes a verdict. */
1835
+ function canRefuseOthers(operation, readSet) {
1836
+ return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
1837
+ }
1838
+ /** The deleted one of these, or the first if none of them is deleted. */
1839
+ function firstDeleted(candidates) {
1840
+ for (const candidate of candidates) if (candidate?.state.document.isDeleted) return candidate.state.document;
1841
+ return candidates[0].state.document;
1842
+ }
1843
+ /**
1844
+ * This function determines which operations will be refused because of
1845
+ * deletes, returning reasons in a parallel array (undefined means the
1846
+ * operation is not refused).
1847
+ *
1848
+ * A deletion already in the stream refuses the operations timestamped after it
1849
+ * and leaves the earlier ones alone. A deletion among the ones passed in does
1850
+ * the same to those after it.
1851
+ */
1852
+ async function deletionVerdictsByPosition(documentId, scope, branch, operations, writeCache, operationStore, signal) {
1853
+ const definition = documentDecisionModel({
1854
+ documentId,
1855
+ branch
1856
+ });
1857
+ const readSet = staticReadSet(definition);
1858
+ if (!definition.judgesScope(scope)) return operations.map(() => void 0);
1859
+ const readStreams = await Promise.all(readSet.map(async (stream) => ({
1860
+ stream,
1861
+ operations: (await operationStore.getSince(stream.query.documentId, stream.query.scope, stream.query.branch, 0, { actionTypes: stream.decidingActions }, void 0, signal)).results
1862
+ })));
1863
+ const decidingWritten = operations.filter((operation) => canRefuseOthers(operation, readSet));
1864
+ if (readStreams.every((read) => read.operations.length === 0) && decidingWritten.length === 0) return operations.map(() => void 0);
1865
+ const walked = [];
1866
+ for (const read of readStreams) {
1867
+ const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, 0, signal);
1868
+ walked.push({
1869
+ streamKey: streamKey(read.stream.query),
1870
+ document: before,
1871
+ operations: read.operations,
1872
+ apply: read.stream.apply
1873
+ });
1874
+ }
1875
+ const writtenStreamIsRead = readSet.find((stream) => stream.query.scope === scope);
1876
+ walked.push({
1877
+ streamKey: WRITTEN,
1878
+ document: walked[0].document,
1879
+ operations,
1880
+ apply: writtenStreamIsRead?.apply ?? ((document) => document)
1881
+ });
1882
+ const reasons = /* @__PURE__ */ new Map();
1883
+ for (const step of walkByPosition(walked)) {
1884
+ if (step.streamKey !== WRITTEN) continue;
1885
+ const document = firstDeleted([...step.states.values()]);
1886
+ const decision = definition.decide({ document }, {
1887
+ address: void 0,
1888
+ key: void 0
1889
+ }, {
1890
+ verb: "execute",
1891
+ scope,
1892
+ operation: step.operation.action.type
1893
+ }, { scopeState: void 0 });
1894
+ reasons.set(step.operation.id, decision === "deny" ? DOCUMENT_DELETED_REASON : void 0);
1895
+ }
1896
+ return operations.map((operation) => reasons.get(operation.id));
1897
+ }
1898
+ //#endregion
1439
1899
  //#region src/cache/operation-index-types.ts
1440
1900
  const DRIVE_COLLECTION_PREFIX = "drive.";
1441
1901
  /**
@@ -1487,11 +1947,11 @@ var DocumentActionHandler = class {
1487
1947
  this.logger = logger;
1488
1948
  this.driveContainerTypes = driveContainerTypes;
1489
1949
  }
1490
- async execute(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
1950
+ async execute(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal, verdictAlreadyDecided = false) {
1491
1951
  switch (action.type) {
1492
1952
  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);
1953
+ case "DELETE_DOCUMENT": return this.executeDelete(job, action, startTime, indexTxn, stores, sourceRemote, signal, verdictAlreadyDecided);
1954
+ case "UPGRADE_DOCUMENT": return this.executeUpgrade(job, action, startTime, indexTxn, stores, skip, sourceRemote, signal, verdictAlreadyDecided);
1495
1955
  case "ADD_RELATIONSHIP": return this.executeAddRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1496
1956
  case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
1497
1957
  case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(job, action, startTime, indexTxn, stores, sourceRemote, signal);
@@ -1524,7 +1984,7 @@ var DocumentActionHandler = class {
1524
1984
  ...document.operations,
1525
1985
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1526
1986
  };
1527
- stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document);
1987
+ stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1528
1988
  indexTxn.write([{
1529
1989
  ...operation,
1530
1990
  documentId: document.header.id,
@@ -1545,7 +2005,7 @@ var DocumentActionHandler = class {
1545
2005
  });
1546
2006
  return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
1547
2007
  }
1548
- async executeDelete(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
2008
+ async executeDelete(job, action, startTime, indexTxn, stores, sourceRemote = "", signal, verdictAlreadyDecided = false) {
1549
2009
  const input = action.input;
1550
2010
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
1551
2011
  const documentId = input.documentId;
@@ -1556,7 +2016,7 @@ var DocumentActionHandler = class {
1556
2016
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
1557
2017
  }
1558
2018
  const documentState = document.state.document;
1559
- if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2019
+ if (documentState.isDeleted && !verdictAlreadyDecided) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1560
2020
  let operation = createOperation(action, getNextIndexForScope(document, job.scope), 0, {
1561
2021
  documentId,
1562
2022
  scope: job.scope,
@@ -1576,7 +2036,7 @@ var DocumentActionHandler = class {
1576
2036
  ...document.operations,
1577
2037
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1578
2038
  };
1579
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2039
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1580
2040
  indexTxn.write([{
1581
2041
  ...operation,
1582
2042
  documentId,
@@ -1592,7 +2052,7 @@ var DocumentActionHandler = class {
1592
2052
  });
1593
2053
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1594
2054
  }
1595
- async executeUpgrade(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2055
+ async executeUpgrade(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal, verdictAlreadyDecided = false) {
1596
2056
  const input = action.input;
1597
2057
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
1598
2058
  const documentId = input.documentId;
@@ -1605,7 +2065,7 @@ var DocumentActionHandler = class {
1605
2065
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
1606
2066
  }
1607
2067
  const documentState = document.state.document;
1608
- if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2068
+ if (documentState.isDeleted && !verdictAlreadyDecided) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1609
2069
  const nextIndex = getNextIndexForScope(document, job.scope);
1610
2070
  let upgradePath;
1611
2071
  if (fromVersion > 0 && fromVersion < toVersion) try {
@@ -1643,7 +2103,7 @@ var DocumentActionHandler = class {
1643
2103
  ...document.operations,
1644
2104
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1645
2105
  };
1646
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2106
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1647
2107
  indexTxn.write([{
1648
2108
  ...operation,
1649
2109
  documentId,
@@ -1714,7 +2174,7 @@ var DocumentActionHandler = class {
1714
2174
  [job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
1715
2175
  };
1716
2176
  const resultingState = JSON.stringify(resultingStateObj);
1717
- stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
2177
+ stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, SnapshotPosition.Head);
1718
2178
  indexTxn.write([{
1719
2179
  ...operation,
1720
2180
  documentId: input.sourceId,
@@ -1746,10 +2206,11 @@ var DocumentActionHandler = class {
1746
2206
  } catch (error) {
1747
2207
  this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
1748
2208
  stores.writeCache.invalidate(documentId, scope, branch);
2209
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
1749
2210
  return {
1750
2211
  job,
1751
2212
  success: false,
1752
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2213
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
1753
2214
  duration: Date.now() - startTime
1754
2215
  };
1755
2216
  }
@@ -1827,6 +2288,7 @@ const documentScopeActions = [
1827
2288
  */
1828
2289
  var SimpleJobExecutor = class {
1829
2290
  config;
2291
+ featureFlags;
1830
2292
  signatureVerifierModule;
1831
2293
  documentActionHandler;
1832
2294
  executionScope;
@@ -1841,6 +2303,7 @@ var SimpleJobExecutor = class {
1841
2303
  this.collectionMembershipCache = collectionMembershipCache;
1842
2304
  this.driveContainerTypes = driveContainerTypes;
1843
2305
  this.config = {
2306
+ featureFlags: config.featureFlags ?? {},
1844
2307
  maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
1845
2308
  maxConcurrency: config.maxConcurrency ?? 1,
1846
2309
  jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
@@ -1848,6 +2311,7 @@ var SimpleJobExecutor = class {
1848
2311
  retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
1849
2312
  yieldDeadlineMs: config.yieldDeadlineMs ?? 50
1850
2313
  };
2314
+ this.featureFlags = { documentDecisions: config.featureFlags?.documentDecisions ?? false };
1851
2315
  this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
1852
2316
  this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes);
1853
2317
  this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
@@ -1931,7 +2395,7 @@ var SimpleJobExecutor = class {
1931
2395
  const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
1932
2396
  return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
1933
2397
  }
1934
- async processActions(job, actions, startTime, indexTxn, stores, skipValues, sourceOperations, sourceRemote = "", signal) {
2398
+ async processActions(job, actions, startTime, indexTxn, stores, skipValues, sourceOperations, sourceRemote = "", signal, deniedReasons) {
1935
2399
  const generatedOperations = [];
1936
2400
  const operationsWithContext = [];
1937
2401
  try {
@@ -1955,7 +2419,8 @@ var SimpleJobExecutor = class {
1955
2419
  const action = actions[actionIndex];
1956
2420
  const skip = skipValues?.[actionIndex] ?? 0;
1957
2421
  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);
2422
+ const deniedReason = deniedReasons?.[actionIndex];
2423
+ const result = documentScopeActions.includes(action.type) ? await this.documentActionHandler.execute(job, action, startTime, indexTxn, stores, skip, sourceRemote, signal, this.featureFlags.documentDecisions && job.kind === "load") : await this.executeRegularAction(job, action, startTime, indexTxn, stores, skip, sourceOperation, sourceRemote, signal, deniedReason);
1959
2424
  const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
1960
2425
  if (error !== null) return {
1961
2426
  success: false,
@@ -1980,14 +2445,43 @@ var SimpleJobExecutor = class {
1980
2445
  operationsWithContext
1981
2446
  };
1982
2447
  }
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);
2448
+ async executeRegularAction(job, action, startTime, indexTxn, stores, skip = 0, sourceOperation, sourceRemote = "", signal, deniedReason) {
2449
+ let appendCondition;
2450
+ let documentVersion;
2451
+ const verdictAlreadyDecided = this.featureFlags.documentDecisions && job.kind === "load";
2452
+ if (this.featureFlags.documentDecisions && !verdictAlreadyDecided) {
2453
+ const target = {
2454
+ documentId: job.documentId,
2455
+ branch: job.branch
2456
+ };
2457
+ const definition = documentDecisionModel(target);
2458
+ let built;
2459
+ try {
2460
+ built = await buildDecisionModel(stores.writeCache, () => definition, target, signal);
2461
+ } catch (error) {
2462
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2463
+ }
2464
+ if (definition.decide(built.model, {
2465
+ address: action.context?.signer?.user.address,
2466
+ key: action.context?.signer?.app.key
2467
+ }, {
2468
+ verb: "execute",
2469
+ scope: action.scope,
2470
+ operation: action.type
2471
+ }, { scopeState: void 0 }) === "deny") return buildErrorResult(job, new DocumentDeletedError(job.documentId, built.model.document.deletedAtUtcIso), startTime);
2472
+ appendCondition = built.appendCondition;
2473
+ documentVersion = built.model.document.version;
2474
+ } else if (verdictAlreadyDecided) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
2475
+ else {
2476
+ let docMeta;
2477
+ try {
2478
+ docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2479
+ } catch (error) {
2480
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2481
+ }
2482
+ if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2483
+ documentVersion = docMeta.state.version;
1989
2484
  }
1990
- if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
1991
2485
  if (isUndoRedo(action) || action.type === "PRUNE" || action.type === "NOOP" && skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
1992
2486
  let document;
1993
2487
  try {
@@ -1995,16 +2489,42 @@ var SimpleJobExecutor = class {
1995
2489
  } catch (error) {
1996
2490
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1997
2491
  }
2492
+ if (job.kind !== "load") {
2493
+ const subject = {
2494
+ address: action.context?.signer?.user.address,
2495
+ key: action.context?.signer?.app.key
2496
+ };
2497
+ if (decide(document.state.auth, subject, {
2498
+ verb: "execute",
2499
+ scope: action.scope,
2500
+ operation: action.type
2501
+ }) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
2502
+ }
1998
2503
  let module;
1999
2504
  try {
2000
- const moduleVersion = docMeta.state.version === 0 ? void 0 : docMeta.state.version;
2505
+ const moduleVersion = documentVersion === 0 ? void 0 : documentVersion;
2001
2506
  module = this.registry.getModule(document.header.documentType, moduleVersion);
2002
2507
  } catch (error) {
2003
2508
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2004
2509
  }
2005
2510
  let updatedDocument;
2006
- try {
2007
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
2511
+ if (deniedReason !== void 0) {
2512
+ const denied = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
2513
+ documentId: job.documentId,
2514
+ scope: job.scope,
2515
+ branch: job.branch
2516
+ });
2517
+ denied.deniedReason = deniedReason;
2518
+ denied.hash = hashDocumentStateForScope(document, job.scope);
2519
+ updatedDocument = {
2520
+ ...document,
2521
+ operations: {
2522
+ ...document.operations,
2523
+ [job.scope]: [...document.operations[job.scope] ?? [], denied]
2524
+ }
2525
+ };
2526
+ } else try {
2527
+ const protocolVersion = baseReducerVersion(document.header);
2008
2528
  const reducerOptions = sourceOperation ? {
2009
2529
  skip,
2010
2530
  branch: job.branch,
@@ -2035,14 +2555,15 @@ var SimpleJobExecutor = class {
2035
2555
  try {
2036
2556
  storedOperations = await stores.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
2037
2557
  txn.addOperations(newOperation);
2038
- }, signal);
2558
+ }, signal, appendCondition);
2039
2559
  } catch (error) {
2040
2560
  this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
2041
2561
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2562
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
2042
2563
  return {
2043
2564
  job,
2044
2565
  success: false,
2045
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2566
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2046
2567
  duration: Date.now() - startTime
2047
2568
  };
2048
2569
  }
@@ -2051,7 +2572,7 @@ var SimpleJobExecutor = class {
2051
2572
  ...updatedDocument.header.revision,
2052
2573
  [scope]: storedOperation.index + 1
2053
2574
  };
2054
- stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument);
2575
+ stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument, SnapshotPosition.Head);
2055
2576
  indexTxn.write([{
2056
2577
  ...storedOperation,
2057
2578
  documentId: job.documentId,
@@ -2078,13 +2599,43 @@ var SimpleJobExecutor = class {
2078
2599
  duration: Date.now() - startTime
2079
2600
  };
2080
2601
  }
2602
+ /**
2603
+ * If an operation happened that could change a decision model verdict, we
2604
+ * need to re-evaluate across the streams that could be affected. Previously
2605
+ * approved operations may need to be denied, which will apply new denied
2606
+ * operations with appropriate skip.
2607
+ */
2608
+ async reevaluateReadingScopes(job, startTime, indexTxn, stores, signal) {
2609
+ const definition = documentDecisionModel({
2610
+ documentId: job.documentId,
2611
+ branch: job.branch
2612
+ });
2613
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
2614
+ for (const scope of Object.keys(revisions.revision)) {
2615
+ if (!definition.judgesScope(scope)) continue;
2616
+ const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
2617
+ const effective = garbageCollect(sortOperations([...stored]));
2618
+ if (effective.length === 0) continue;
2619
+ const recomputed = await deletionVerdictsByPosition(job.documentId, scope, job.branch, effective, stores.writeCache, stores.operationStore, signal);
2620
+ const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== recomputed[i]);
2621
+ if (firstChange === -1) continue;
2622
+ const tail = effective.slice(firstChange);
2623
+ const nextIndex = revisions.revision[scope];
2624
+ stores.writeCache.invalidate(job.documentId, scope, job.branch);
2625
+ const result = await this.processActions({
2626
+ ...job,
2627
+ scope
2628
+ }, tail.map((operation) => operation.action), startTime, indexTxn, stores, tail.map((_, i) => i === 0 ? retractionSkip(nextIndex, tail[0].index) : 0), void 0, "", signal, recomputed.slice(firstChange));
2629
+ if (!result.success) return result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`);
2630
+ }
2631
+ }
2081
2632
  async executeLoadJob(job, startTime, indexTxn, stores, signal) {
2082
2633
  if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
2083
2634
  let docMeta;
2084
2635
  try {
2085
2636
  docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2086
2637
  } catch {}
2087
- if (docMeta?.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2638
+ if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2088
2639
  const scope = job.scope;
2089
2640
  let latestRevision;
2090
2641
  try {
@@ -2173,8 +2724,19 @@ var SimpleJobExecutor = class {
2173
2724
  for (const operation of reshuffledOperations) if (operation.action.type === "NOOP") operation.skip = 1;
2174
2725
  const actions = reshuffledOperations.map((operation) => operation.action);
2175
2726
  const skipValues = reshuffledOperations.map((operation) => operation.skip);
2727
+ let deniedReasons;
2728
+ if (this.featureFlags.documentDecisions) try {
2729
+ deniedReasons = await deletionVerdictsByPosition(job.documentId, scope, job.branch, reshuffledOperations, stores.writeCache, stores.operationStore, signal);
2730
+ } catch (error) {
2731
+ return {
2732
+ job,
2733
+ success: false,
2734
+ error: error instanceof Error ? error : new Error(String(error)),
2735
+ duration: Date.now() - startTime
2736
+ };
2737
+ }
2176
2738
  const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
2177
- const result = await this.processActions(job, actions, startTime, indexTxn, stores, skipValues, reshuffledOperations, effectiveSourceRemote, signal);
2739
+ const result = await this.processActions(job, actions, startTime, indexTxn, stores, skipValues, reshuffledOperations, effectiveSourceRemote, signal, deniedReasons);
2178
2740
  if (!result.success) return {
2179
2741
  job,
2180
2742
  success: false,
@@ -2183,6 +2745,23 @@ var SimpleJobExecutor = class {
2183
2745
  };
2184
2746
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2185
2747
  if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
2748
+ const readsThisStream = staticReadSet(documentDecisionModel({
2749
+ documentId: job.documentId,
2750
+ branch: job.branch
2751
+ })).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === scope && stream.query.branch === job.branch);
2752
+ if (this.featureFlags.documentDecisions && readsThisStream) {
2753
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
2754
+ const latest = Date.parse(revisions.latestTimestamp);
2755
+ if (result.generatedOperations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) {
2756
+ const error = await this.reevaluateReadingScopes(job, startTime, indexTxn, stores, signal);
2757
+ if (error) return {
2758
+ job,
2759
+ success: false,
2760
+ error,
2761
+ duration: Date.now() - startTime
2762
+ };
2763
+ }
2764
+ }
2186
2765
  return {
2187
2766
  job,
2188
2767
  success: true,
@@ -2419,36 +2998,6 @@ function paginateRows(rows, paging, cursorOf, toItem, refetch) {
2419
2998
  };
2420
2999
  }
2421
3000
  //#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
3001
  //#region src/storage/txn.ts
2453
3002
  var AtomicTransaction = class {
2454
3003
  operations = [];
@@ -2473,6 +3022,7 @@ var AtomicTransaction = class {
2473
3022
  action: JSON.stringify(op.action),
2474
3023
  skip: op.skip,
2475
3024
  error: op.error || null,
3025
+ deniedReason: op.deniedReason || null,
2476
3026
  hash: op.hash
2477
3027
  });
2478
3028
  }
@@ -2506,12 +3056,12 @@ var KyselyOperationStore = class KyselyOperationStore {
2506
3056
  instance.trx = trx;
2507
3057
  return instance;
2508
3058
  }
2509
- async apply(documentId, documentType, scope, branch, revision, fn, signal) {
3059
+ async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
2510
3060
  if (this.trx) {
2511
3061
  let executeResult = null;
2512
3062
  let uniqueCtx = null;
2513
3063
  try {
2514
- executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal);
3064
+ executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2515
3065
  } catch (error) {
2516
3066
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
2517
3067
  else throw error;
@@ -2523,7 +3073,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2523
3073
  let uniqueCtx = null;
2524
3074
  try {
2525
3075
  transactionResult = await this.db.transaction().execute(async (trx) => {
2526
- return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal);
3076
+ return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2527
3077
  });
2528
3078
  } catch (error) {
2529
3079
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
@@ -2542,12 +3092,13 @@ var KyselyOperationStore = class KyselyOperationStore {
2542
3092
  const op = ctx.stagedOps[0];
2543
3093
  throw new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
2544
3094
  }
2545
- async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal) {
3095
+ async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
2546
3096
  throwIfAborted(signal);
2547
3097
  const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
2548
3098
  await fn(atomicTxn);
2549
3099
  const operations = atomicTxn.getOperations();
2550
3100
  if (operations.length === 0) return [];
3101
+ if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
2551
3102
  const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
2552
3103
  const currentRevision = latestOp ? latestOp.index : -1;
2553
3104
  if (currentRevision !== revision - 1) {
@@ -2563,22 +3114,91 @@ var KyselyOperationStore = class KyselyOperationStore {
2563
3114
  op.prevOpId = prevOpId;
2564
3115
  prevOpId = op.opId;
2565
3116
  }
3117
+ let insertedCount = operations.length;
2566
3118
  try {
2567
- await trx.insertInto("Operation").values(operations).execute();
3119
+ if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
3120
+ else await trx.insertInto("Operation").values(operations).execute();
2568
3121
  } catch (error) {
2569
3122
  if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
2570
3123
  throw error;
2571
3124
  }
3125
+ if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
2572
3126
  return operations.map((op) => ({
2573
3127
  index: op.index,
2574
3128
  timestampUtcMs: op.timestampUtcMs.toISOString(),
2575
3129
  hash: op.hash,
2576
3130
  skip: op.skip,
2577
3131
  error: op.error || void 0,
3132
+ deniedReason: op.deniedReason || void 0,
2578
3133
  id: op.opId,
2579
3134
  action: JSON.parse(op.action)
2580
3135
  }));
2581
3136
  }
3137
+ /**
3138
+ * Locks the written stream and every read-set stream, in sorted key order
3139
+ * so that overlapping concurrent appends serialize rather than deadlock.
3140
+ * The locks are still taken one row at a time, so the query preserves that
3141
+ * order. It must stay separate from the guarded insert, which would
3142
+ * otherwise read a snapshot taken before the locks were held.
3143
+ */
3144
+ async acquireStreamLocks(trx, documentId, scope, branch, condition) {
3145
+ const keys = new Set([`${documentId}:${scope}:${branch}`]);
3146
+ for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
3147
+ await sql`
3148
+ with ordered as materialized (
3149
+ select key
3150
+ from unnest(array[${sql.join([...keys].sort())}]::text[]) with ordinality as t(key, ord)
3151
+ order by ord
3152
+ )
3153
+ select pg_advisory_xact_lock(hashtext(key)) from ordered
3154
+ `.execute(trx);
3155
+ }
3156
+ /**
3157
+ * Inserts the staged operations with the condition compiled in as a WHERE
3158
+ * NOT EXISTS guard, making the check and the append one statement. Returns
3159
+ * the rows inserted; zero means the guard failed and nothing was written.
3160
+ */
3161
+ async insertGuarded(trx, operations, condition) {
3162
+ const branches = operations.map((op) => trx.selectNoFrom([
3163
+ sql`${op.jobId}::text`.as("jobId"),
3164
+ sql`${op.opId}::text`.as("opId"),
3165
+ sql`${op.prevOpId}::text`.as("prevOpId"),
3166
+ sql`${op.documentId}::text`.as("documentId"),
3167
+ sql`${op.documentType}::text`.as("documentType"),
3168
+ sql`${op.scope}::text`.as("scope"),
3169
+ sql`${op.branch}::text`.as("branch"),
3170
+ sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
3171
+ sql`${op.index}::integer`.as("index"),
3172
+ sql`${op.action}::jsonb`.as("action"),
3173
+ sql`${op.skip}::integer`.as("skip"),
3174
+ sql`${op.error ?? null}::text`.as("error"),
3175
+ sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
3176
+ sql`${op.hash}::text`.as("hash")
3177
+ ]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
3178
+ web("Operation.documentId", "=", s.documentId),
3179
+ web("Operation.scope", "=", s.scope),
3180
+ web("Operation.branch", "=", s.branch),
3181
+ web("Operation.index", ">", s.revision)
3182
+ ]))))))));
3183
+ let expression = branches[0];
3184
+ for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
3185
+ return (await trx.insertInto("Operation").columns([
3186
+ "jobId",
3187
+ "opId",
3188
+ "prevOpId",
3189
+ "documentId",
3190
+ "documentType",
3191
+ "scope",
3192
+ "branch",
3193
+ "timestampUtcMs",
3194
+ "index",
3195
+ "action",
3196
+ "skip",
3197
+ "error",
3198
+ "deniedReason",
3199
+ "hash"
3200
+ ]).expression(expression).returning("id").execute()).length;
3201
+ }
2582
3202
  async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
2583
3203
  const minIndex = revision;
2584
3204
  const maxIndex = revision + stagedOps.length - 1;
@@ -2665,6 +3285,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2665
3285
  hash: row.hash,
2666
3286
  skip: row.skip,
2667
3287
  error: row.error || void 0,
3288
+ deniedReason: row.deniedReason || void 0,
2668
3289
  id: row.opId,
2669
3290
  action: row.action
2670
3291
  };
@@ -2750,8 +3371,8 @@ function createForwardingPoolInstrumentation(name) {
2750
3371
  }
2751
3372
  //#endregion
2752
3373
  //#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) {
3374
+ var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
3375
+ async function up$14(db) {
2755
3376
  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
3377
  "documentId",
2757
3378
  "scope",
@@ -2776,8 +3397,8 @@ async function up$13(db) {
2776
3397
  }
2777
3398
  //#endregion
2778
3399
  //#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) {
3400
+ var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
3401
+ async function up$13(db) {
2781
3402
  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
3403
  "documentId",
2783
3404
  "scope",
@@ -2793,14 +3414,14 @@ async function up$12(db) {
2793
3414
  }
2794
3415
  //#endregion
2795
3416
  //#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) {
3417
+ var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
3418
+ async function up$12(db) {
2798
3419
  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
3420
  }
2800
3421
  //#endregion
2801
3422
  //#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) {
3423
+ var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
3424
+ async function up$11(db) {
2804
3425
  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
3426
  "sourceId",
2806
3427
  "targetId",
@@ -2812,14 +3433,14 @@ async function up$10(db) {
2812
3433
  }
2813
3434
  //#endregion
2814
3435
  //#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) {
3436
+ var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
3437
+ async function up$10(db) {
2817
3438
  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
3439
  }
2819
3440
  //#endregion
2820
3441
  //#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) {
3442
+ var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
3443
+ async function up$9(db) {
2823
3444
  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
3445
  "documentId",
2825
3446
  "scope",
@@ -2840,8 +3461,8 @@ async function up$8(db) {
2840
3461
  }
2841
3462
  //#endregion
2842
3463
  //#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) {
3464
+ var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
3465
+ async function up$8(db) {
2845
3466
  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
3467
  "documentId",
2847
3468
  "scope",
@@ -2851,14 +3472,14 @@ async function up$7(db) {
2851
3472
  }
2852
3473
  //#endregion
2853
3474
  //#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) {
3475
+ var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
3476
+ async function up$7(db) {
2856
3477
  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
3478
  }
2858
3479
  //#endregion
2859
3480
  //#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) {
3481
+ var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
3482
+ async function up$6(db) {
2862
3483
  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
3484
  await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
2864
3485
  await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
@@ -2872,8 +3493,8 @@ async function up$5(db) {
2872
3493
  }
2873
3494
  //#endregion
2874
3495
  //#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) {
3496
+ var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
3497
+ async function up$5(db) {
2877
3498
  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
3499
  await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
2879
3500
  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 +3502,8 @@ async function up$4(db) {
2881
3502
  }
2882
3503
  //#endregion
2883
3504
  //#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) {
3505
+ var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
3506
+ async function up$4(db) {
2886
3507
  await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
2887
3508
  await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
2888
3509
  await db.schema.dropTable("sync_cursors").execute();
@@ -2891,24 +3512,43 @@ async function up$3(db) {
2891
3512
  }
2892
3513
  //#endregion
2893
3514
  //#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) {
3515
+ var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
3516
+ async function up$3(db) {
2896
3517
  await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
2897
3518
  }
2898
3519
  //#endregion
2899
3520
  //#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) {
3521
+ var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$2 });
3522
+ async function up$2(db) {
2902
3523
  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
3524
  await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
2904
3525
  }
2905
3526
  //#endregion
2906
3527
  //#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) {
3528
+ var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$1 });
3529
+ async function up$1(db) {
2909
3530
  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
3531
  }
2911
3532
  //#endregion
3533
+ //#region src/storage/migrations/015_add_operation_denied_reason.ts
3534
+ var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
3535
+ down: () => down,
3536
+ up: () => up
3537
+ });
3538
+ /**
3539
+ * Records why authorization refused an operation. Separate from `error` so a
3540
+ * denial is distinguishable from a reducer failure without matching on a
3541
+ * message. Null for every operation written before decisions were enforced.
3542
+ */
3543
+ async function up(db) {
3544
+ await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
3545
+ await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
3546
+ }
3547
+ async function down(db) {
3548
+ await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
3549
+ await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
3550
+ }
3551
+ //#endregion
2912
3552
  //#region src/storage/migrations/migrator.ts
2913
3553
  const REACTOR_SCHEMA = "reactor";
2914
3554
  const migrations = {
@@ -2925,7 +3565,8 @@ const migrations = {
2925
3565
  "011_add_cursor_type_column": _011_add_cursor_type_column_exports,
2926
3566
  "012_add_source_remote_column": _012_add_source_remote_column_exports,
2927
3567
  "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
3568
+ "014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
3569
+ "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports
2929
3570
  };
2930
3571
  var ProgrammaticMigrationProvider = class {
2931
3572
  getMigrations() {
@@ -2979,6 +3620,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
2979
3620
  //#region src/core/drive-container-types.ts
2980
3621
  const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
2981
3622
  //#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 };
3623
+ export { AuthorizationDeniedError as A, DuplicateOperationError as C, DuplicateModuleError as D, DuplicateManifestError as E, throwIfAborted as F, __exportAll as I, DocumentNotFoundError as M, matchesScope as N, InvalidModuleError as O, parsePagingOptions as P, AppendConditionFailedError as S, RevisionMismatchError as T, DocumentMetaCache as _, createForwardingPoolInstrumentation as a, CollectionMembershipCache as b, KyselyKeyframeStore as c, DriveCollectionId as d, buildDecisionModel as f, KyselyOperationIndex as g, KyselyWriteCache as h, runMigrations as i, DocumentDeletedError as j, ModuleNotFoundError as k, DocumentModelRegistry as l, EventBus as m, REACTOR_SCHEMA as n, instrumentPgPool as o, KyselyExecutionScope as p, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, createConsistencyToken as v, OptimisticLockError as w, APPEND_CONDITION_FAILED_PREFIX as x, createEmptyConsistencyToken as y };
2983
3624
 
2984
- //# sourceMappingURL=drive-container-types-DpJp2AmE.js.map
3625
+ //# sourceMappingURL=drive-container-types-BNwEHEXP.js.map