@powerhousedao/reactor 6.2.2-dev.5 → 6.2.2-dev.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
2
- import { DowngradeNotSupportedError, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, createPresignedHeader, defaultBaseState, deriveOperationId, isUndoRedo } from "@powerhousedao/shared/document-model";
2
+ import { AUTH_ACTION_TYPES, AUTH_DENIED_BY_GRANT_REASON, AUTH_NO_GRANT_REASON, AUTH_VERSION_UNSUPPORTED_REASON, DOCUMENT_DELETED_REASON, DowngradeNotSupportedError as DowngradeNotSupportedError$1, appendWithoutApplying, applyAuthAction, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, evaluate, garbageCollect, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, isDenied, isUndoRedo, mentionedGroupIds, normalizeDocumentModelVersion, referencedGroupIds, sortOperations } from "@powerhousedao/shared/document-model";
3
3
  import { v4 } from "uuid";
4
4
  import { Migrator, sql } from "kysely";
5
5
  //#region \0rolldown/runtime.js
@@ -72,6 +72,102 @@ var DocumentDeletedError = class DocumentDeletedError extends Error {
72
72
  }
73
73
  };
74
74
  /**
75
+ * Error thrown when the auth policy denies an action at the executor gate.
76
+ */
77
+ var AuthorizationDeniedError = class AuthorizationDeniedError extends Error {
78
+ documentId;
79
+ scope;
80
+ operation;
81
+ subject;
82
+ constructor(documentId, scope, operation, subject) {
83
+ super(`Authorization denied: ${subject ?? "anonymous"} may not execute ${operation} in scope "${scope}" of document ${documentId}`);
84
+ this.name = "AuthorizationDeniedError";
85
+ this.documentId = documentId;
86
+ this.scope = scope;
87
+ this.operation = operation;
88
+ this.subject = subject;
89
+ Error.captureStackTrace(this, AuthorizationDeniedError);
90
+ }
91
+ static isError(error) {
92
+ return Error.isError(error) && error.name === "AuthorizationDeniedError";
93
+ }
94
+ };
95
+ /**
96
+ * An auth operation did not strictly exceed the newest timestamp in its stream.
97
+ *
98
+ * Terminal and asymmetric by design: no ordering rule can reconcile two replicas
99
+ * that each accepted an auth operation offline, because either order hands one
100
+ * authority the other never granted, so the replica ahead holds the arrival.
101
+ */
102
+ var AuthTimestampNotMonotonicError = class AuthTimestampNotMonotonicError extends Error {
103
+ documentId;
104
+ branch;
105
+ timestampUtcMs;
106
+ newestTimestampUtcMs;
107
+ constructor(documentId, branch, timestampUtcMs, newestTimestampUtcMs) {
108
+ super(`Auth timestamp not monotonic: ${timestampUtcMs} does not exceed ${newestTimestampUtcMs} in the auth stream of document ${documentId} on branch ${branch}`);
109
+ this.name = "AuthTimestampNotMonotonicError";
110
+ this.documentId = documentId;
111
+ this.branch = branch;
112
+ this.timestampUtcMs = timestampUtcMs;
113
+ this.newestTimestampUtcMs = newestTimestampUtcMs;
114
+ Error.captureStackTrace(this, AuthTimestampNotMonotonicError);
115
+ }
116
+ static isError(error) {
117
+ return Error.isError(error) && error.name === "AuthTimestampNotMonotonicError";
118
+ }
119
+ };
120
+ /**
121
+ * An operation or action carried a timestamp that is not an ISO-8601 UTC
122
+ * instant.
123
+ *
124
+ * Terminal rather than retryable: the value does not change between attempts,
125
+ * so a retry re-runs the whole job to fail identically. Quarantining, unlike a
126
+ * held auth operation — this is malformed data rather than two replicas
127
+ * disagreeing, and nothing further from that source should be trusted until it
128
+ * is looked at.
129
+ */
130
+ var InvalidOperationTimestampError = class InvalidOperationTimestampError extends Error {
131
+ documentId;
132
+ scope;
133
+ timestampUtcMs;
134
+ constructor(documentId, scope, timestampUtcMs, context) {
135
+ super(`Invalid timestamp "${timestampUtcMs}" on ${context} in scope "${scope}" of document ${documentId}`);
136
+ this.name = "InvalidOperationTimestampError";
137
+ this.documentId = documentId;
138
+ this.scope = scope;
139
+ this.timestampUtcMs = timestampUtcMs;
140
+ Error.captureStackTrace(this, InvalidOperationTimestampError);
141
+ }
142
+ static isError(error) {
143
+ return Error.isError(error) && error.name === "InvalidOperationTimestampError";
144
+ }
145
+ };
146
+ /**
147
+ * A load would move more operations than the bound allows, indicating a real
148
+ * divergence between local and incoming history. Counts only first-time moves,
149
+ * so a re-evaluation pass's re-appends do not make busy documents
150
+ * revocation-proof. Terminal: the condition is deterministic.
151
+ */
152
+ var ExcessiveReshuffleError = class ExcessiveReshuffleError extends Error {
153
+ documentId;
154
+ scope;
155
+ count;
156
+ threshold;
157
+ constructor(documentId, scope, count, threshold) {
158
+ super(`Excessive reshuffle detected: ${count} operations in scope "${scope}" of document ${documentId} exceeds the threshold of ${threshold}. This indicates a significant divergence between local and incoming operations.`);
159
+ this.name = "ExcessiveReshuffleError";
160
+ this.documentId = documentId;
161
+ this.scope = scope;
162
+ this.count = count;
163
+ this.threshold = threshold;
164
+ Error.captureStackTrace(this, ExcessiveReshuffleError);
165
+ }
166
+ static isError(error) {
167
+ return Error.isError(error) && error.name === "ExcessiveReshuffleError";
168
+ }
169
+ };
170
+ /**
75
171
  * Error thrown when an operation has an invalid signature.
76
172
  */
77
173
  var InvalidSignatureError = class InvalidSignatureError extends Error {
@@ -86,6 +182,28 @@ var InvalidSignatureError = class InvalidSignatureError extends Error {
86
182
  }
87
183
  };
88
184
  /**
185
+ * An UPGRADE_DOCUMENT action's preconditions (fromVersion and the per-scope
186
+ * revision snapshot) did not match the document state the executor loaded.
187
+ *
188
+ * Terminal rather than retryable: the action carries the client's snapshot,
189
+ * which stays stale no matter how often the job re-runs. The client is
190
+ * expected to re-read the document and submit a fresh action instead.
191
+ */
192
+ var UpgradePreconditionFailedError = class UpgradePreconditionFailedError extends Error {
193
+ documentId;
194
+ detail;
195
+ constructor(documentId, detail) {
196
+ super(`Upgrade precondition failed for document ${documentId}: ${detail}`);
197
+ this.name = "UpgradePreconditionFailedError";
198
+ this.documentId = documentId;
199
+ this.detail = detail;
200
+ Error.captureStackTrace(this, UpgradePreconditionFailedError);
201
+ }
202
+ static isError(error) {
203
+ return Error.isError(error) && error.name === "UpgradePreconditionFailedError";
204
+ }
205
+ };
206
+ /**
89
207
  * Error thrown when a document is not found (no operations exist for the document ID).
90
208
  */
91
209
  var DocumentNotFoundError = class DocumentNotFoundError extends Error {
@@ -101,6 +219,357 @@ var DocumentNotFoundError = class DocumentNotFoundError extends Error {
101
219
  }
102
220
  };
103
221
  //#endregion
222
+ //#region src/decision/build-decision-model.ts
223
+ /**
224
+ * Reads each projection's stream through the supplied reader, recording the
225
+ * revision observed. Static projections resolve first; derived projections
226
+ * see only those and contribute a map from document id to state. Each
227
+ * distinct stream is read once and yields one append condition entry.
228
+ */
229
+ async function buildDecisionModel(reader, definition, target, signal) {
230
+ const decisionModel = definition(target);
231
+ const projections = Object.entries(decisionModel.projections);
232
+ const reads = /* @__PURE__ */ new Map();
233
+ const model = {};
234
+ for (const [key, projection] of projections) {
235
+ if (typeof projection.query === "function") continue;
236
+ model[key] = (await readStream(reader, projection.query, reads, signal)).state;
237
+ }
238
+ const staticModel = { ...model };
239
+ for (const [key, projection] of projections) {
240
+ if (typeof projection.query !== "function") continue;
241
+ const queries = projection.query(staticModel);
242
+ const value = {};
243
+ for (const query of queries) {
244
+ let read;
245
+ try {
246
+ read = await readStream(reader, query, reads, signal);
247
+ } catch (error) {
248
+ if (error instanceof DocumentNotFoundError) {
249
+ recordEmptyStream(query, reads);
250
+ continue;
251
+ }
252
+ throw error;
253
+ }
254
+ value[query.documentId] = read.state;
255
+ }
256
+ model[key] = value;
257
+ }
258
+ return {
259
+ model,
260
+ appendCondition: { streams: [...reads.values()].map((read) => read.stream) }
261
+ };
262
+ }
263
+ /** Guards a stream that holds nothing yet: any operation appearing is growth. */
264
+ function recordEmptyStream(query, reads) {
265
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
266
+ if (reads.has(key)) return;
267
+ reads.set(key, {
268
+ state: void 0,
269
+ stream: {
270
+ documentId: query.documentId,
271
+ scope: query.scope,
272
+ branch: query.branch,
273
+ revision: -1
274
+ }
275
+ });
276
+ }
277
+ async function readStream(reader, query, reads, signal) {
278
+ const key = `${query.documentId}:${query.scope}:${query.branch}`;
279
+ const existing = reads.get(key);
280
+ if (existing) return existing;
281
+ const document = await reader.getState(query.documentId, query.scope, query.branch, void 0, signal);
282
+ const read = {
283
+ state: document.state[query.scope],
284
+ stream: {
285
+ documentId: query.documentId,
286
+ scope: query.scope,
287
+ branch: query.branch,
288
+ revision: observedRevision(document, query.scope)
289
+ }
290
+ };
291
+ reads.set(key, read);
292
+ return read;
293
+ }
294
+ /**
295
+ * The highest operation index the document reflects for the scope, or -1 if
296
+ * empty. `header.revision` is authoritative, not the rebuilt operation list.
297
+ */
298
+ function observedRevision(document, scope) {
299
+ if (scope in document.header.revision) return document.header.revision[scope] - 1;
300
+ if (scope in document.operations) {
301
+ const operations = document.operations[scope];
302
+ if (operations.length > 0) return operations[operations.length - 1].index;
303
+ }
304
+ if (!(scope in document.header.revision)) return -1;
305
+ return document.header.revision[scope] - 1;
306
+ }
307
+ /**
308
+ * The projections whose queries depend on folded state. A positional walk
309
+ * resolves their streams through `queryOverHistory`; a projection without one
310
+ * contributes no streams to a walk.
311
+ */
312
+ function derivedReadSet(definition) {
313
+ const projections = [];
314
+ for (const [name, projection] of Object.entries(definition.projections)) {
315
+ if (typeof projection.query !== "function") continue;
316
+ projections.push({
317
+ name,
318
+ decidingActions: projection.decidingActions,
319
+ apply: projection.apply,
320
+ queryOverHistory: projection.queryOverHistory
321
+ });
322
+ }
323
+ return projections;
324
+ }
325
+ /**
326
+ * The streams a model reads whose queries are known before it is built. A
327
+ * derived query needs the statically-queried projections first, so it is not
328
+ * included here.
329
+ */
330
+ function staticReadSet(definition) {
331
+ const streams = [];
332
+ for (const [name, projection] of Object.entries(definition.projections)) {
333
+ if (typeof projection.query === "function") continue;
334
+ streams.push({
335
+ name,
336
+ query: projection.query,
337
+ decidingActions: projection.decidingActions,
338
+ apply: projection.apply
339
+ });
340
+ }
341
+ return streams;
342
+ }
343
+ //#endregion
344
+ //#region src/decision/auth-decision-model.ts
345
+ function refusalReason(refusal) {
346
+ switch (refusal) {
347
+ case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
348
+ case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
349
+ case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
350
+ }
351
+ }
352
+ function decideAuthModel(model, subject, request, groups, conditions) {
353
+ if (request.verb === "execute" && model.document.isDeleted) return {
354
+ decision: "deny",
355
+ reason: DOCUMENT_DELETED_REASON
356
+ };
357
+ const evaluation = evaluate(model.auth, subject, request, groups, conditions);
358
+ if (evaluation.decision === "allow") return { decision: "allow" };
359
+ return {
360
+ decision: "deny",
361
+ reason: refusalReason(evaluation.refusal)
362
+ };
363
+ }
364
+ function documentProjection(target) {
365
+ return {
366
+ decidingActions: ["DELETE_DOCUMENT"],
367
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
368
+ ...document,
369
+ state: { ...document.state }
370
+ }, operation.action) : document,
371
+ query: {
372
+ documentId: target.documentId,
373
+ branch: target.branch,
374
+ scope: "document"
375
+ }
376
+ };
377
+ }
378
+ function authProjection(target) {
379
+ return {
380
+ decidingActions: [...AUTH_ACTION_TYPES],
381
+ apply: (document, operation) => applyAuthAction(document, operation.action),
382
+ query: {
383
+ documentId: target.documentId,
384
+ branch: target.branch,
385
+ scope: "auth"
386
+ }
387
+ };
388
+ }
389
+ /** This decision model uses both the document and the auth streams. */
390
+ function authDecisionModel(target) {
391
+ return {
392
+ projections: {
393
+ document: documentProjection(target),
394
+ auth: authProjection(target)
395
+ },
396
+ evaluatesScope() {
397
+ return true;
398
+ },
399
+ decide(model, subject, request) {
400
+ return decideAuthModel(model, subject, request);
401
+ }
402
+ };
403
+ }
404
+ /**
405
+ * Folds one group-stream operation with the registered group model's reducer.
406
+ * A reactor without the module registered folds nothing, so the member list
407
+ * stays as read and a missing reducer never widens access.
408
+ */
409
+ function applyGroupOperation(registry, document, operation) {
410
+ let reducer;
411
+ try {
412
+ reducer = registry.getModule(groupDocumentType).reducer;
413
+ } catch {
414
+ return document;
415
+ }
416
+ return reducer(document, operation.action);
417
+ }
418
+ /**
419
+ * Folds one evaluated-scope operation with the reducer registered for the
420
+ * document's own type, at the document's stamped version. A reactor without
421
+ * that module folds nothing, so conditions read the base state and an
422
+ * unresolvable reducer never widens access.
423
+ */
424
+ function applyModelOperation(registry, document, operation) {
425
+ let reducer;
426
+ try {
427
+ const version = normalizeDocumentModelVersion(document.state.document?.version);
428
+ reducer = registry.getModule(document.header.documentType, version).reducer;
429
+ } catch {
430
+ return document;
431
+ }
432
+ return reducer(document, operation.action);
433
+ }
434
+ /**
435
+ * The auth model extended with a derived groups projection: the streams it
436
+ * reads are the group documents the folded grant list names, so adding a
437
+ * grant that names a new group pulls that group's stream into the read-set.
438
+ * Group queries pin the main branch, because a group's member list lives on
439
+ * its main branch no matter which branch the referencing document is on.
440
+ */
441
+ function groupsProjection(registry) {
442
+ return {
443
+ decidingActions: [...groupMembershipActionTypes],
444
+ apply: (document, operation) => applyGroupOperation(registry, document, operation),
445
+ query: (model) => referencedGroupIds(model.auth?.grants ?? []).map((id) => ({
446
+ documentId: id,
447
+ branch: "main",
448
+ scope: "global"
449
+ })),
450
+ queryOverHistory: (reads) => {
451
+ const ids = [];
452
+ for (const read of reads) {
453
+ if (read.name !== "auth") continue;
454
+ for (const operation of read.operations) for (const id of mentionedGroupIds(operation.action)) if (!ids.includes(id)) ids.push(id);
455
+ }
456
+ return ids.map((id) => ({
457
+ documentId: id,
458
+ branch: "main",
459
+ scope: "global"
460
+ }));
461
+ }
462
+ };
463
+ }
464
+ function authGroupsDecisionModel(registry) {
465
+ return (target) => ({
466
+ projections: {
467
+ document: documentProjection(target),
468
+ auth: authProjection(target),
469
+ groups: groupsProjection(registry)
470
+ },
471
+ evaluatesScope() {
472
+ return true;
473
+ },
474
+ decide(model, subject, request) {
475
+ return decideAuthModel(model, subject, request, model.groups);
476
+ }
477
+ });
478
+ }
479
+ /**
480
+ * The groups model with conditions live: decide hands the executing scope's
481
+ * state and the action input through to the evaluator, so `where` clauses
482
+ * and { match } principals apply. The model folds the evaluated scope during
483
+ * a positional walk, so a condition reads the state as it stood at each
484
+ * operation's position.
485
+ */
486
+ function authConditionsDecisionModel(registry) {
487
+ return (target) => ({
488
+ projections: {
489
+ document: documentProjection(target),
490
+ auth: authProjection(target),
491
+ groups: groupsProjection(registry)
492
+ },
493
+ foldEvaluatedScope: (document, operation) => applyModelOperation(registry, document, operation),
494
+ evaluatesScope() {
495
+ return true;
496
+ },
497
+ decide(model, subject, request, ctx) {
498
+ return decideAuthModel(model, subject, request, model.groups, {
499
+ scopeState: ctx.scopeState,
500
+ actionInput: ctx.actionInput
501
+ });
502
+ }
503
+ });
504
+ }
505
+ //#endregion
506
+ //#region src/decision/document-decision-model.ts
507
+ /**
508
+ * The simplest decision model: one projection over the document scope, which
509
+ * rejects on a deleted document.
510
+ */
511
+ function documentDecisionModel(target) {
512
+ return {
513
+ projections: { document: {
514
+ decidingActions: ["DELETE_DOCUMENT"],
515
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
516
+ ...document,
517
+ state: { ...document.state }
518
+ }, operation.action) : document,
519
+ query: {
520
+ documentId: target.documentId,
521
+ branch: target.branch,
522
+ scope: "document"
523
+ }
524
+ } },
525
+ evaluatesScope() {
526
+ return true;
527
+ },
528
+ decide(model, subject, request) {
529
+ return request.verb === "execute" && model.document.isDeleted ? {
530
+ decision: "deny",
531
+ reason: DOCUMENT_DELETED_REASON
532
+ } : { decision: "allow" };
533
+ }
534
+ };
535
+ }
536
+ //#endregion
537
+ //#region src/decision/registered-model.ts
538
+ /**
539
+ * Builds the model at the stream heads and decides one request against it. The
540
+ * append condition it returns is the read-set the store enforces at write time.
541
+ *
542
+ * With `conditions` supplied, the executing scope's state is read at the head
543
+ * for `doc.<scope>.*` paths. That read carries no append-condition entry of
544
+ * its own: the written stream's expected-revision check already refuses a
545
+ * write whose scope grew between the read and the append.
546
+ */
547
+ async function decideAtHead(model, cache, target, subject, request, signal, conditions) {
548
+ const built = await buildDecisionModel(cache, model, target, signal);
549
+ let scopeState;
550
+ if (conditions !== void 0) scopeState = (await cache.getState(target.documentId, request.scope, target.branch, void 0, signal)).state[request.scope];
551
+ return {
552
+ evaluation: model(target).decide(built.model, subject, request, {
553
+ scopeState,
554
+ actionInput: conditions?.actionInput
555
+ }),
556
+ appendCondition: built.appendCondition,
557
+ documentVersion: built.model.document.version,
558
+ deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
559
+ };
560
+ }
561
+ /**
562
+ * The model this reactor enforces. With `authEnforcement` off the auth scope is
563
+ * absent from every append condition and no load walks it; with `authGroups`
564
+ * on, the group documents the grant list names join the read-set and the
565
+ * registry supplies the reducer that folds them.
566
+ */
567
+ function selectDecisionModel(flags, registry) {
568
+ if (flags.authConditions) return authConditionsDecisionModel(registry);
569
+ if (flags.authGroups) return authGroupsDecisionModel(registry);
570
+ return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
571
+ }
572
+ //#endregion
104
573
  //#region src/registry/errors.ts
105
574
  /**
106
575
  * Error thrown when a document model module is not found in the registry.
@@ -181,6 +650,57 @@ var InvalidUpgradeStepError = class extends Error {
181
650
  }
182
651
  };
183
652
  //#endregion
653
+ //#region src/storage/interfaces.ts
654
+ /**
655
+ * Thrown when an operation with the same identity already exists in the store.
656
+ */
657
+ var DuplicateOperationError = class extends Error {
658
+ constructor(description) {
659
+ super(`Duplicate operation: ${description}`);
660
+ this.name = "DuplicateOperationError";
661
+ }
662
+ };
663
+ /**
664
+ * Thrown when a concurrent write conflict is detected during an atomic apply.
665
+ */
666
+ var OptimisticLockError = class extends Error {
667
+ constructor(message) {
668
+ super(message);
669
+ this.name = "OptimisticLockError";
670
+ }
671
+ };
672
+ /**
673
+ * Thrown when the caller-provided revision does not match the current
674
+ * stored revision, indicating a stale read.
675
+ */
676
+ var RevisionMismatchError = class extends Error {
677
+ constructor(expected, actual) {
678
+ super(`Revision mismatch: expected ${expected}, got ${actual}`);
679
+ this.name = "RevisionMismatchError";
680
+ }
681
+ };
682
+ /** Error history keeps messages, not classes, so failures match by prefix. */
683
+ const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
684
+ /**
685
+ * A read-set stream grew before the append committed. A concurrency
686
+ * conflict, not a fault: the caller retries against the new stream heads.
687
+ */
688
+ var AppendConditionFailedError = class extends Error {
689
+ constructor(condition) {
690
+ const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
691
+ super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
692
+ this.condition = condition;
693
+ this.name = "AppendConditionFailedError";
694
+ }
695
+ static isError(error) {
696
+ return Error.isError(error) && error.name === "AppendConditionFailedError";
697
+ }
698
+ /** True when a recorded error message is an append-condition failure. */
699
+ static isFailureMessage(message) {
700
+ return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
701
+ }
702
+ };
703
+ //#endregion
184
704
  //#region src/cache/collection-membership-cache.ts
185
705
  var CollectionMembershipCache = class CollectionMembershipCache {
186
706
  cache = /* @__PURE__ */ new Map();
@@ -216,6 +736,34 @@ var CollectionMembershipCache = class CollectionMembershipCache {
216
736
  };
217
737
  //#endregion
218
738
  //#region src/executor/util.ts
739
+ /** Actions the reactor reduces itself, onto the document scope. */
740
+ const DOCUMENT_SCOPE_ACTIONS = new Set([
741
+ "CREATE_DOCUMENT",
742
+ "DELETE_DOCUMENT",
743
+ "UPGRADE_DOCUMENT",
744
+ "ADD_RELATIONSHIP",
745
+ "REMOVE_RELATIONSHIP",
746
+ "UPDATE_RELATIONSHIP"
747
+ ]);
748
+ /**
749
+ * `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
750
+ * so building a decision model would throw and defer the job forever.
751
+ */
752
+ const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
753
+ /**
754
+ * The document a document-scope action writes to, which is not always the job's
755
+ * own document: delete and upgrade name it in `input.documentId`, and the
756
+ * relationship actions in `input.sourceId`. `execute` only checks that a batch
757
+ * shares one scope, so a caller can submit an action whose target is a document
758
+ * other than the one the job is keyed by. The policy gate has to follow the
759
+ * action rather than the job, or it decides against a policy the caller may
760
+ * control instead of the one guarding the write.
761
+ */
762
+ function targetDocumentId(action, fallback) {
763
+ const input = action.input;
764
+ 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;
765
+ return typeof input?.documentId === "string" && input.documentId.length > 0 ? input.documentId : fallback;
766
+ }
219
767
  /**
220
768
  * Creates a PHDocument from a CREATE_DOCUMENT action input.
221
769
  * Reconstructs the document header and initializes the base state.
@@ -353,6 +901,24 @@ function buildErrorResult(job, error, startTime) {
353
901
  duration: Date.now() - startTime
354
902
  };
355
903
  }
904
+ /**
905
+ * The error a refusal surfaces as. Both classes are already terminal in the job
906
+ * result handler, so a refusal never burns a retry.
907
+ */
908
+ function refusalError(reason, documentId, deletedAtUtcIso, action) {
909
+ if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
910
+ return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
911
+ }
912
+ /**
913
+ * Whether this operation is part of the document's creation. The create and the
914
+ * upgrade from version zero hold the first two indexes for the life of the
915
+ * document, so a reshuffle has to leave them where they are.
916
+ */
917
+ function isGenesisOperation(operation) {
918
+ if (operation.action.type === "CREATE_DOCUMENT") return true;
919
+ if (operation.action.type !== "UPGRADE_DOCUMENT") return false;
920
+ return operation.action.input.fromVersion === 0;
921
+ }
356
922
  //#endregion
357
923
  //#region src/cache/lru/lru-tracker.ts
358
924
  var LRUNode = class {
@@ -543,6 +1109,7 @@ var KyselyOperationIndexTxn = class {
543
1109
  collections = [];
544
1110
  collectionMemberships = [];
545
1111
  collectionRemovals = [];
1112
+ groupReferences = [];
546
1113
  operations = [];
547
1114
  createCollection(collectionId) {
548
1115
  this.collections.push(collectionId);
@@ -565,12 +1132,25 @@ var KyselyOperationIndexTxn = class {
565
1132
  operationIndex: lastOpIndex
566
1133
  });
567
1134
  }
1135
+ recordGroupReferences(documentId, groupIds) {
1136
+ const lastOpIndex = this.operations.length - 1;
1137
+ if (lastOpIndex < 0) throw new Error("recordGroupReferences must be called after write() - no operations in transaction");
1138
+ if (groupIds.length === 0) return;
1139
+ this.groupReferences.push({
1140
+ documentId,
1141
+ groupIds,
1142
+ operationIndex: lastOpIndex
1143
+ });
1144
+ }
568
1145
  write(operations) {
569
1146
  this.operations.push(...operations);
570
1147
  }
571
1148
  getCollections() {
572
1149
  return this.collections;
573
1150
  }
1151
+ getGroupReferenceRecords() {
1152
+ return this.groupReferences;
1153
+ }
574
1154
  getCollectionMembershipRecords() {
575
1155
  return this.collectionMemberships;
576
1156
  }
@@ -607,10 +1187,27 @@ var KyselyOperationIndex = class KyselyOperationIndex {
607
1187
  });
608
1188
  return resultOrdinals;
609
1189
  }
1190
+ /**
1191
+ * A policy-driven join: keeps the earliest join so a rediscovered reference
1192
+ * never shrinks a backfill window remotes already rely on, and reopens a
1193
+ * closed membership because a policy reference is not a removable one.
1194
+ */
1195
+ async joinKeepingEarliest(trx, documentId, collectionId, ordinal) {
1196
+ await trx.insertInto("document_collections").values({
1197
+ documentId,
1198
+ collectionId,
1199
+ joinedOrdinal: ordinal,
1200
+ leftOrdinal: null
1201
+ }).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
1202
+ joinedOrdinal: sql`LEAST("document_collections"."joinedOrdinal", EXCLUDED."joinedOrdinal")`,
1203
+ leftOrdinal: null
1204
+ })).execute();
1205
+ }
610
1206
  async executeCommit(trx, kyselyTxn) {
611
1207
  const collections = kyselyTxn.getCollections();
612
1208
  const memberships = kyselyTxn.getCollectionMembershipRecords();
613
1209
  const removals = kyselyTxn.getCollectionRemovals();
1210
+ const groupReferences = kyselyTxn.getGroupReferenceRecords();
614
1211
  const operations = kyselyTxn.getOperations();
615
1212
  if (collections.length > 0) {
616
1213
  const collectionRows = collections.map((collectionId) => ({
@@ -634,6 +1231,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
634
1231
  skip: op.skip,
635
1232
  hash: op.hash,
636
1233
  action: op.action,
1234
+ deniedReason: op.deniedReason ?? null,
637
1235
  sourceRemote: op.sourceRemote
638
1236
  }));
639
1237
  operationOrdinals = (await trx.insertInto("operation_index_operations").values(operationRows).returning("ordinal").execute()).map((row) => row.ordinal);
@@ -649,13 +1247,28 @@ var KyselyOperationIndex = class KyselyOperationIndex {
649
1247
  joinedOrdinal: BigInt(ordinal),
650
1248
  leftOrdinal: null
651
1249
  })).execute();
1250
+ const references = await trx.selectFrom("group_references").select("groupId").where("documentId", "=", m.documentId).execute();
1251
+ for (const { groupId } of references) await this.joinKeepingEarliest(trx, groupId, m.collectionId, BigInt(ordinal));
652
1252
  }
653
1253
  if (removals.length > 0) for (const r of removals) {
654
1254
  const ordinal = operationOrdinals[r.operationIndex];
655
1255
  await trx.updateTable("document_collections").set({ leftOrdinal: BigInt(ordinal) }).where("collectionId", "=", r.collectionId).where("documentId", "=", r.documentId).where("leftOrdinal", "is", null).execute();
656
1256
  }
1257
+ if (groupReferences.length > 0) for (const record of groupReferences) {
1258
+ const ordinal = operationOrdinals[record.operationIndex];
1259
+ await trx.insertInto("group_references").values(record.groupIds.map((groupId) => ({
1260
+ documentId: record.documentId,
1261
+ groupId
1262
+ }))).onConflict((oc) => oc.doNothing()).execute();
1263
+ const rows = await trx.selectFrom("document_collections").select("collectionId").where("documentId", "=", record.documentId).execute();
1264
+ for (const groupId of record.groupIds) for (const { collectionId } of rows) await this.joinKeepingEarliest(trx, groupId, collectionId, BigInt(ordinal));
1265
+ }
657
1266
  return operationOrdinals;
658
1267
  }
1268
+ async getGroupReferencers(groupId, signal) {
1269
+ if (signal?.aborted) throw new Error("Operation aborted");
1270
+ return (await this.queryExecutor.selectFrom("group_references").select("documentId").where("groupId", "=", groupId).orderBy("documentId").execute()).map((row) => row.documentId);
1271
+ }
659
1272
  async find(collectionId, cursor, view, paging, signal) {
660
1273
  if (signal?.aborted) throw new Error("Operation aborted");
661
1274
  const outerCursor = cursor ?? -1;
@@ -765,6 +1378,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
765
1378
  hash: row.hash,
766
1379
  skip: row.skip,
767
1380
  action: row.action,
1381
+ deniedReason: row.deniedReason ?? void 0,
768
1382
  id: row.opId
769
1383
  },
770
1384
  context: {
@@ -788,6 +1402,7 @@ var KyselyOperationIndex = class KyselyOperationIndex {
788
1402
  hash: row.hash,
789
1403
  skip: row.skip,
790
1404
  action: row.action,
1405
+ deniedReason: row.deniedReason ?? void 0,
791
1406
  id: row.opId,
792
1407
  sourceRemote: row.sourceRemote
793
1408
  };
@@ -873,10 +1488,54 @@ var RingBuffer = class {
873
1488
  }
874
1489
  };
875
1490
  //#endregion
1491
+ //#region src/cache/write-cache-types.ts
1492
+ /**
1493
+ * Where a snapshot sits in its stream.
1494
+ *
1495
+ * - `Head`: the newest revision of the stream when it was stored. Only these
1496
+ * can answer a read that asks for the head.
1497
+ * - `Historical`: state at an earlier revision. Usable as a starting point to
1498
+ * replay forward from, and as an answer to a read for that same revision.
1499
+ */
1500
+ let SnapshotPosition = /* @__PURE__ */ function(SnapshotPosition) {
1501
+ SnapshotPosition["Head"] = "head";
1502
+ SnapshotPosition["Historical"] = "historical";
1503
+ return SnapshotPosition;
1504
+ }({});
1505
+ //#endregion
876
1506
  //#region src/cache/kysely-write-cache.ts
1507
+ /**
1508
+ * The last operation index a keyframe's document reflects for the scope. A
1509
+ * keyframe only exists for a scope that has operations, so a missing entry
1510
+ * means the stored row is corrupt.
1511
+ */
1512
+ function keyframeRevision(keyframe, documentId, scope) {
1513
+ const nextIndex = keyframe.document.header.revision[scope];
1514
+ if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
1515
+ return nextIndex - 1;
1516
+ }
877
1517
  function extractModuleVersion(doc) {
878
1518
  const v = doc.state.document.version;
879
- return v === 0 ? void 0 : v;
1519
+ return normalizeDocumentModelVersion(v);
1520
+ }
1521
+ /** The highest revision held, latest push winning a tie. */
1522
+ function highestRevision(snapshots) {
1523
+ let newest = void 0;
1524
+ for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
1525
+ return newest;
1526
+ }
1527
+ /**
1528
+ * Copies a document far enough that the caller cannot write through it. Inside
1529
+ * this class, callers only ever replace whole fields on these four, so one
1530
+ * level each is enough.
1531
+ */
1532
+ function copyDocument(document) {
1533
+ return {
1534
+ ...document,
1535
+ header: { ...document.header },
1536
+ state: { ...document.state },
1537
+ operations: { ...document.operations }
1538
+ };
880
1539
  }
881
1540
  /**
882
1541
  * In-memory write cache with keyframe persistence for PHDocuments.
@@ -956,6 +1615,8 @@ var KyselyWriteCache = class KyselyWriteCache {
956
1615
  /**
957
1616
  * Retrieves document state at a specific revision from cache or rebuilds it.
958
1617
  *
1618
+ * Note: this returns a _shallow_ copy of the document.
1619
+ *
959
1620
  * Cache hit path: Returns cached snapshot if available (O(1))
960
1621
  * Warm miss path: Rebuilds from cached base revision + incremental ops
961
1622
  * Cold miss path: Rebuilds from keyframe or from scratch using all operations
@@ -979,30 +1640,35 @@ var KyselyWriteCache = class KyselyWriteCache {
979
1640
  if (stream) {
980
1641
  const snapshots = stream.ringBuffer.getAll();
981
1642
  if (targetRevision === void 0) {
982
- if (snapshots.length > 0) {
983
- const newest = snapshots[snapshots.length - 1];
1643
+ const newest = highestRevision(snapshots);
1644
+ if (newest?.position === SnapshotPosition.Head) {
984
1645
  this.lruTracker.touch(streamKey);
985
- return newest.document;
1646
+ return copyDocument(newest.document);
1647
+ }
1648
+ if (newest) {
1649
+ const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
1650
+ this.store(documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, SnapshotPosition.Head);
1651
+ this.lruTracker.touch(streamKey);
1652
+ return document;
986
1653
  }
987
1654
  } else {
988
- const exactMatch = snapshots.find((s) => s.revision === targetRevision);
1655
+ const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
989
1656
  if (exactMatch) {
990
1657
  this.lruTracker.touch(streamKey);
991
- return exactMatch.document;
1658
+ return copyDocument(exactMatch.document);
992
1659
  }
993
1660
  const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
994
1661
  if (newestOlder) {
995
1662
  const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
996
- this.putState(documentId, scope, branch, targetRevision, document);
1663
+ this.store(documentId, scope, branch, targetRevision, document, SnapshotPosition.Historical);
997
1664
  this.lruTracker.touch(streamKey);
998
1665
  return document;
999
1666
  }
1000
1667
  }
1001
1668
  }
1002
1669
  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);
1670
+ const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
1671
+ this.store(documentId, scope, branch, revision, document, targetRevision === void 0 ? SnapshotPosition.Head : SnapshotPosition.Historical);
1006
1672
  return document;
1007
1673
  }
1008
1674
  /**
@@ -1025,16 +1691,20 @@ var KyselyWriteCache = class KyselyWriteCache {
1025
1691
  * @param document - The document to cache
1026
1692
  * @throws {Error} If document serialization fails
1027
1693
  */
1028
- putState(documentId, scope, branch, revision, document) {
1694
+ putState(documentId, scope, branch, revision, document, position) {
1695
+ this.store(documentId, scope, branch, revision, document, position);
1696
+ }
1697
+ store(documentId, scope, branch, revision, document, position) {
1029
1698
  const streamKey = this.makeStreamKey(documentId, scope, branch);
1030
1699
  const stream = this.getOrCreateStream(streamKey);
1031
1700
  const snapshot = {
1032
1701
  revision,
1033
1702
  document: {
1034
- ...document,
1703
+ ...copyDocument(document),
1035
1704
  operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
1036
1705
  clipboard: []
1037
- }
1706
+ },
1707
+ position
1038
1708
  };
1039
1709
  stream.ringBuffer.push(snapshot);
1040
1710
  if (this.isKeyframeRevision(revision)) this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
@@ -1102,64 +1772,102 @@ var KyselyWriteCache = class KyselyWriteCache {
1102
1772
  }
1103
1773
  async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
1104
1774
  if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
1105
- return this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1775
+ const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
1776
+ if (!keyframe) return;
1777
+ return {
1778
+ revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
1779
+ document: keyframe.document
1780
+ };
1106
1781
  }
1782
+ /**
1783
+ * Rebuilds a scope from a keyframe or from the whole operation history.
1784
+ *
1785
+ * The document scope is always rebuilt first, because it carries the type,
1786
+ * the upgrades and the deletion marker. Its version-changing upgrades are not
1787
+ * applied there though: an upgrade reducer must see the state the requested
1788
+ * scope has reached at that upgrade's boundary, so each one is held back and
1789
+ * applied when the replay below crosses the boundary that
1790
+ * resolveModuleVersionForOp derives from it. Upgrades whose boundary lies past
1791
+ * the last replayed operation are applied at the end. Creation-time 0->N seed
1792
+ * upgrades carry the initial state, so they still apply immediately.
1793
+ */
1107
1794
  async coldMissRebuild(documentId, scope, branch, targetRevision, signal) {
1108
1795
  const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
1109
1796
  const keyframe = await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal);
1797
+ const documentScopeBound = scope === "document" ? targetRevision : void 0;
1110
1798
  let document;
1111
1799
  let startRevision;
1112
1800
  let documentType;
1113
1801
  const validatedUpgrades = [];
1802
+ const pendingUpgrades = [];
1803
+ let lastDocumentScopeOperation;
1114
1804
  if (keyframe) {
1115
1805
  document = keyframe.document;
1116
1806
  startRevision = keyframe.revision;
1117
1807
  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 });
1808
+ const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
1809
+ const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
1810
+ for (const operation of docScopeOpsAfterKeyframe.results) {
1811
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1812
+ lastDocumentScopeOperation = operation;
1813
+ if (operation.error || isDenied(operation)) continue;
1814
+ if (operation.action.type === "UPGRADE_DOCUMENT") {
1815
+ const upgradeAction = operation.action;
1816
+ const fromVersion = upgradeAction.input.fromVersion;
1817
+ const toVersion = upgradeAction.input.toVersion;
1818
+ if (fromVersion > 0 && fromVersion < toVersion) {
1819
+ let upgradePath;
1820
+ try {
1821
+ upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1822
+ } catch (err) {
1823
+ if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
1824
+ 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 });
1825
+ }
1826
+ validatedUpgrades.push({
1827
+ fromVersion,
1828
+ toVersion,
1829
+ revision: upgradeAction.input.revision,
1830
+ timestampUtcMs: operation.timestampUtcMs
1831
+ });
1832
+ pendingUpgrades.push({
1833
+ action: upgradeAction,
1834
+ upgradePath,
1835
+ index: operation.index,
1836
+ subsequentDeletes: []
1837
+ });
1130
1838
  }
1131
- validatedUpgrades.push({
1132
- fromVersion,
1133
- toVersion,
1134
- revision: upgradeAction.input.revision,
1135
- timestampUtcMs: operation.timestampUtcMs
1136
- });
1137
- document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1839
+ } else if (operation.action.type === "DELETE_DOCUMENT") {
1840
+ applyDeleteDocumentAction(document, operation.action);
1841
+ for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
1138
1842
  }
1139
- } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1843
+ }
1140
1844
  } else {
1141
1845
  startRevision = -1;
1142
1846
  const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
1143
1847
  cursor: "0",
1144
1848
  limit: 1
1145
1849
  }, signal);
1146
- if (createOpResult.results.length === 0) throw new Error(`Failed to rebuild document ${documentId}: no CREATE_DOCUMENT operation found in document scope`);
1850
+ if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
1147
1851
  const createOp = createOpResult.results[0];
1148
1852
  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
1853
  const documentCreateAction = createOp.action;
1150
1854
  documentType = documentCreateAction.input.model;
1151
1855
  if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
1152
1856
  document = createDocumentFromAction(documentCreateAction);
1857
+ lastDocumentScopeOperation = createOp;
1153
1858
  let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1154
1859
  const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
1155
1860
  for (const operation of docScopeOps.results) {
1861
+ if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
1862
+ lastDocumentScopeOperation = operation;
1156
1863
  if (operation.index === 0) continue;
1864
+ if (operation.error || isDenied(operation)) continue;
1157
1865
  if (operation.action.type === "UPGRADE_DOCUMENT") {
1158
1866
  const upgradeAction = operation.action;
1159
1867
  const fromVersion = upgradeAction.input.fromVersion;
1160
1868
  const toVersion = upgradeAction.input.toVersion;
1161
- let upgradePath;
1162
1869
  if (fromVersion > 0 && fromVersion < toVersion) {
1870
+ let upgradePath;
1163
1871
  try {
1164
1872
  upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
1165
1873
  } catch (err) {
@@ -1172,12 +1880,19 @@ var KyselyWriteCache = class KyselyWriteCache {
1172
1880
  revision: upgradeAction.input.revision,
1173
1881
  timestampUtcMs: operation.timestampUtcMs
1174
1882
  });
1175
- }
1176
- document = applyUpgradeDocumentAction(document, upgradeAction, upgradePath);
1177
- docModule = this.registry.getModule(documentType, extractModuleVersion(document));
1178
- } else if (operation.action.type === "DELETE_DOCUMENT") applyDeleteDocumentAction(document, operation.action);
1179
- else {
1180
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1883
+ pendingUpgrades.push({
1884
+ action: upgradeAction,
1885
+ upgradePath,
1886
+ index: operation.index,
1887
+ subsequentDeletes: []
1888
+ });
1889
+ } else document = applyUpgradeDocumentAction(document, upgradeAction, void 0);
1890
+ docModule = this.registry.getModule(documentType, normalizeDocumentModelVersion(toVersion));
1891
+ } else if (operation.action.type === "DELETE_DOCUMENT") {
1892
+ applyDeleteDocumentAction(document, operation.action);
1893
+ for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
1894
+ } else {
1895
+ const protocolVersion = baseReducerVersion(document.header);
1181
1896
  document = docModule.reducer(document, operation.action, void 0, {
1182
1897
  skip: operation.skip,
1183
1898
  protocolVersion
@@ -1185,6 +1900,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1185
1900
  }
1186
1901
  }
1187
1902
  }
1903
+ if (scope === "document") {
1904
+ document = this.applyPendingUpgrades(document, pendingUpgrades, Number.MAX_SAFE_INTEGER);
1905
+ const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
1906
+ document.operations = {
1907
+ ...document.operations,
1908
+ document: last ? [last] : []
1909
+ };
1910
+ return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1911
+ }
1912
+ if (keyframe) {
1913
+ const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
1914
+ if (resumeOperation) document.operations = {
1915
+ ...document.operations,
1916
+ [scope]: [resumeOperation]
1917
+ };
1918
+ }
1188
1919
  const moduleCache = /* @__PURE__ */ new Map();
1189
1920
  const getModuleCached = (version) => {
1190
1921
  const key = version ?? 0;
@@ -1195,6 +1926,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1195
1926
  }
1196
1927
  return mod;
1197
1928
  };
1929
+ const finalVersion = validatedUpgrades.at(-1)?.toVersion ?? extractModuleVersion(document);
1198
1930
  let cursor = void 0;
1199
1931
  const pageSize = 100;
1200
1932
  let hasMorePages;
@@ -1208,12 +1940,16 @@ var KyselyWriteCache = class KyselyWriteCache {
1208
1940
  const result = await this.operationStore.getSince(documentId, scope, branch, startRevision, void 0, paging, signal);
1209
1941
  for (const operation of result.results) {
1210
1942
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1211
- const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, extractModuleVersion(document));
1212
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
1213
- document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1214
- skip: operation.skip,
1215
- protocolVersion
1216
- });
1943
+ const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, finalVersion);
1944
+ document = this.applyPendingUpgrades(document, pendingUpgrades, moduleVersion ?? Number.MAX_SAFE_INTEGER);
1945
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1946
+ else {
1947
+ const protocolVersion = baseReducerVersion(document.header);
1948
+ document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
1949
+ skip: operation.skip,
1950
+ protocolVersion
1951
+ });
1952
+ }
1217
1953
  }
1218
1954
  const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
1219
1955
  hasMorePages = Boolean(result.nextCursor) && !reachedTarget;
@@ -1222,11 +1958,86 @@ var KyselyWriteCache = class KyselyWriteCache {
1222
1958
  throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1223
1959
  }
1224
1960
  } while (hasMorePages);
1961
+ document = this.applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision);
1962
+ document = await this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
1963
+ if (pendingUpgrades.length > 0) {
1964
+ const firstHeldBack = pendingUpgrades[0];
1965
+ const stamped = document.header.revision["document"] ?? 0;
1966
+ document.header.revision = {
1967
+ ...document.header.revision,
1968
+ document: Math.min(stamped, firstHeldBack.index)
1969
+ };
1970
+ }
1971
+ return document;
1972
+ }
1973
+ /**
1974
+ * Applies and removes every held-back upgrade whose target version is at or
1975
+ * below `throughVersion`, in the order the document scope recorded them.
1976
+ */
1977
+ applyPendingUpgrades(document, pendingUpgrades, throughVersion) {
1978
+ while (pendingUpgrades.length > 0) {
1979
+ const pending = pendingUpgrades[0];
1980
+ if (throughVersion < pending.action.input.toVersion) break;
1981
+ pendingUpgrades.shift();
1982
+ document = this.applyPendingUpgrade(document, pending);
1983
+ }
1984
+ return document;
1985
+ }
1986
+ /**
1987
+ * Applies the remaining held-back upgrades after the requested scope's
1988
+ * replay has finished. A head read applies them all. A positional read
1989
+ * applies only those whose boundary for this scope lies at or before the
1990
+ * target position: applying a later one would label migrated state with a
1991
+ * pre-upgrade revision, and a keyframe stored from that poisons every
1992
+ * rebuild that resumes from it. Boundaries come from the upgrade's revision
1993
+ * snapshot; an upgrade without one records no position for this scope, and
1994
+ * the replay loop not having crossed it already places it past the target.
1995
+ */
1996
+ applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision) {
1997
+ while (pendingUpgrades.length > 0) {
1998
+ const pending = pendingUpgrades[0];
1999
+ if (targetRevision !== void 0) {
2000
+ const snapshot = pending.action.input.revision;
2001
+ if (snapshot === void 0) break;
2002
+ if ((snapshot[scope] ?? 0) > targetRevision) break;
2003
+ }
2004
+ pendingUpgrades.shift();
2005
+ document = this.applyPendingUpgrade(document, pending);
2006
+ }
2007
+ return document;
2008
+ }
2009
+ /**
2010
+ * Applies one held-back upgrade, then re-applies the deletes the document
2011
+ * scope recorded after it so the hold-back cannot invert their order.
2012
+ */
2013
+ applyPendingUpgrade(document, pending) {
2014
+ document = applyUpgradeDocumentAction(document, pending.action, pending.upgradePath);
2015
+ for (const deleteAction of pending.subsequentDeletes) document = applyDeleteDocumentAction(document, deleteAction);
2016
+ return document;
2017
+ }
2018
+ /**
2019
+ * Copies the current document revisions onto the document. Overwrites the
2020
+ * requested scope revision with the target revision, if provided.
2021
+ */
2022
+ async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
1225
2023
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1226
2024
  document.header.revision = revisions.revision;
2025
+ if (targetRevision !== void 0) document.header.revision = {
2026
+ ...document.header.revision,
2027
+ [scope]: targetRevision + 1
2028
+ };
1227
2029
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1228
2030
  return document;
1229
2031
  }
2032
+ /** The stored operation at `index`, or undefined if it is no longer there. */
2033
+ async operationAt(documentId, scope, branch, index, signal) {
2034
+ if (index < 0) return;
2035
+ const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
2036
+ cursor: "0",
2037
+ limit: 1
2038
+ }, signal)).results[0];
2039
+ return operation && operation.index === index ? operation : void 0;
2040
+ }
1230
2041
  /**
1231
2042
  * Resolves which module version to use for a given operation in phase 2.
1232
2043
  *
@@ -1250,19 +2061,22 @@ var KyselyWriteCache = class KyselyWriteCache {
1250
2061
  async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
1251
2062
  const documentType = baseDocument.header.documentType;
1252
2063
  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);
2064
+ 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
2065
  const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
1255
- let document = baseDocument;
2066
+ let document = copyDocument(baseDocument);
1256
2067
  try {
1257
2068
  const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
1258
2069
  for (const operation of pagedResults.results) {
1259
2070
  if (signal?.aborted) throw new Error("Operation aborted");
1260
2071
  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
- });
2072
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
2073
+ else {
2074
+ const protocolVersion = baseReducerVersion(document.header);
2075
+ document = module.reducer(document, operation.action, void 0, {
2076
+ skip: operation.skip,
2077
+ protocolVersion
2078
+ });
2079
+ }
1266
2080
  if (targetRevision !== void 0 && operation.index === targetRevision) break;
1267
2081
  }
1268
2082
  } catch (err) {
@@ -1270,6 +2084,10 @@ var KyselyWriteCache = class KyselyWriteCache {
1270
2084
  }
1271
2085
  const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
1272
2086
  document.header.revision = revisions.revision;
2087
+ if (targetRevision !== void 0) document.header.revision = {
2088
+ ...document.header.revision,
2089
+ [scope]: targetRevision + 1
2090
+ };
1273
2091
  document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
1274
2092
  return document;
1275
2093
  }
@@ -1339,6 +2157,49 @@ var EventBus = class {
1339
2157
  }
1340
2158
  };
1341
2159
  //#endregion
2160
+ //#region src/core/feature-flags.ts
2161
+ /**
2162
+ * Every flag this reactor knows, with the flags it requires. A stage adds its
2163
+ * flag here when it ships, so asking an older reactor for a later stage's flag
2164
+ * is an unrecognized name rather than a flag that quietly does nothing.
2165
+ */
2166
+ const FLAG_PREREQUISITES = {
2167
+ documentDecisions: [],
2168
+ authEnforcement: ["documentDecisions"],
2169
+ authGroups: ["authEnforcement"],
2170
+ authConditions: ["authGroups"]
2171
+ };
2172
+ /**
2173
+ * The flags as plain booleans, with anything unset off, validated. Callers hold
2174
+ * a partial set, because that is what crosses to a pooled worker, and every
2175
+ * consumer needs the same resolution of it.
2176
+ */
2177
+ function resolveFeatureFlags(flags = {}) {
2178
+ const resolved = {
2179
+ documentDecisions: flags.documentDecisions ?? false,
2180
+ authEnforcement: flags.authEnforcement ?? false,
2181
+ authGroups: flags.authGroups ?? false,
2182
+ authConditions: flags.authConditions ?? false
2183
+ };
2184
+ validateFeatureFlags(flags, FLAG_PREREQUISITES);
2185
+ return resolved;
2186
+ }
2187
+ /**
2188
+ * Throws when the flags ask for enforcement the reactor cannot deliver. Either
2189
+ * failure would otherwise read as enforcement being on while the reactor
2190
+ * applies less than the caller asked for.
2191
+ */
2192
+ function validateFeatureFlags(flags, prerequisites) {
2193
+ const known = Object.keys(prerequisites);
2194
+ const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
2195
+ if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
2196
+ for (const name of known) {
2197
+ if (flags[name] !== true) continue;
2198
+ const missing = prerequisites[name].filter((required) => flags[required] !== true);
2199
+ if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
2200
+ }
2201
+ }
2202
+ //#endregion
1342
2203
  //#region src/executor/execution-scope.ts
1343
2204
  var DefaultExecutionScope = class {
1344
2205
  constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
@@ -1436,6 +2297,273 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
1436
2297
  }));
1437
2298
  }
1438
2299
  //#endregion
2300
+ //#region src/decision/merged-order.ts
2301
+ /** Identifies a stream within a walk. */
2302
+ function streamKey(query) {
2303
+ return `${query.documentId}:${query.scope}:${query.branch}`;
2304
+ }
2305
+ /**
2306
+ * Orders two operations from different streams by position. Timestamp decides;
2307
+ * an equal timestamp puts an auth operation first, and otherwise falls to the
2308
+ * action id and then the operation id, so that two replicas holding the same
2309
+ * operations agree on the order whatever order they happen to store them in.
2310
+ */
2311
+ function comparePositions(a, b) {
2312
+ const aTime = Date.parse(a.operation.timestampUtcMs);
2313
+ const bTime = Date.parse(b.operation.timestampUtcMs);
2314
+ if (aTime !== bTime) return aTime - bTime;
2315
+ if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
2316
+ const aAuth = a.scope === "auth";
2317
+ if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
2318
+ const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
2319
+ if (actionIds !== 0) return actionIds;
2320
+ return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
2321
+ }
2322
+ /**
2323
+ * Merges the read-set streams into one sequence by position. An operation's
2324
+ * place in the result is the bound a decision at that operation reads to: every
2325
+ * operation before it has been applied, and it has not.
2326
+ */
2327
+ function mergeByPosition(streams) {
2328
+ const merged = [];
2329
+ for (const stream of streams) for (const operation of stream.operations) merged.push({
2330
+ streamKey: stream.streamKey,
2331
+ scope: stream.scope,
2332
+ operation
2333
+ });
2334
+ return merged.sort(comparePositions);
2335
+ }
2336
+ /**
2337
+ * The skip that retracts everything from `firstRetractedIndex` up to where the
2338
+ * re-appended operation lands. It spans the indexes rather than counting the
2339
+ * operations, because a stream with a gap in it makes those differ.
2340
+ */
2341
+ function retractionSkip(nextIndex, firstRetractedIndex) {
2342
+ return nextIndex - firstRetractedIndex;
2343
+ }
2344
+ //#endregion
2345
+ //#region src/decision/walk.ts
2346
+ /**
2347
+ * A single forward pass is only correct while a stream's effective operations
2348
+ * are ordered.
2349
+ */
2350
+ function assertPositionOrder(streamKey, scope, operations) {
2351
+ for (let i = 1; i < operations.length; i++) {
2352
+ const previous = operations[i - 1];
2353
+ const current = operations[i];
2354
+ if (comparePositions({
2355
+ streamKey,
2356
+ scope,
2357
+ operation: previous
2358
+ }, {
2359
+ streamKey,
2360
+ scope,
2361
+ operation: current
2362
+ }) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
2363
+ }
2364
+ }
2365
+ /**
2366
+ * Visits every operation in the read-set once, in the order their positions
2367
+ * fall, and hands back the state each stream held just before it. That state is
2368
+ * what a decision at that operation reads.
2369
+ *
2370
+ * Skips are resolved first (i.e. this is performed on a garbage collected
2371
+ * stream), which means we can do a single forward pass.
2372
+ *
2373
+ * An operation that contributes no state, whether denied or holding a reducer
2374
+ * error, is visited but not applied (this matches the write cache's rebuild).
2375
+ *
2376
+ * The consumer sends back whether it refused the operation it was handed: a
2377
+ * refusal this pass produced must suppress it the same way a stored one does.
2378
+ */
2379
+ function* walkByPosition(streams) {
2380
+ const merged = mergeByPosition(streams.map((stream) => {
2381
+ const operations = garbageCollect(sortOperations([...stream.operations]));
2382
+ assertPositionOrder(stream.streamKey, stream.scope, operations);
2383
+ return {
2384
+ streamKey: stream.streamKey,
2385
+ scope: stream.scope,
2386
+ operations
2387
+ };
2388
+ }));
2389
+ const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
2390
+ const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
2391
+ for (const { streamKey, operation } of merged) {
2392
+ if ((yield {
2393
+ streamKey,
2394
+ operation,
2395
+ states: new Map(states)
2396
+ }) || operation.error !== void 0 || isDenied(operation)) continue;
2397
+ const stream = byKey.get(streamKey);
2398
+ const before = states.get(streamKey);
2399
+ if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
2400
+ states.set(streamKey, stream.apply(before, operation));
2401
+ }
2402
+ }
2403
+ //#endregion
2404
+ //#region src/decision/evaluation.ts
2405
+ /** The stream key for evaluated operations whose scope no projection reads. */
2406
+ const EVALUATED_ONLY = "evaluated";
2407
+ /**
2408
+ * Whether any stream the model reads declares this operation's action type as
2409
+ * one that can change an evaluation.
2410
+ */
2411
+ function isDecidingAction(operation, readSet) {
2412
+ return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
2413
+ }
2414
+ /**
2415
+ * Who an operation acts as. A replayed operation is evaluated as its own signer,
2416
+ * so an address-scoped policy does not deny its own author's history.
2417
+ */
2418
+ function subjectOf(operation) {
2419
+ const signer = operation.action.context?.signer;
2420
+ return {
2421
+ address: signer?.user.address,
2422
+ key: signer?.app.key
2423
+ };
2424
+ }
2425
+ /**
2426
+ * The model as the walk reached this operation: each static projection's value
2427
+ * is its own scope's state, and each derived projection's value maps document
2428
+ * id to that document's state, holding only the streams this replica walked. A
2429
+ * derived stream it does not hold stays out of the map, which fails closed.
2430
+ */
2431
+ function modelAt(readSet, derivedNames, derived, states) {
2432
+ const model = {};
2433
+ for (const stream of readSet) {
2434
+ const document = states.get(streamKey(stream.query));
2435
+ if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
2436
+ model[stream.name] = document.state[stream.query.scope];
2437
+ }
2438
+ for (const name of derivedNames) model[name] = {};
2439
+ for (const entry of derived) {
2440
+ const map = model[entry.name];
2441
+ const document = states.get(streamKey(entry.query));
2442
+ if (document !== void 0) map[entry.query.documentId] = document.state[entry.query.scope];
2443
+ }
2444
+ return model;
2445
+ }
2446
+ /**
2447
+ * Evaluates each operation at its own position and returns the refusals in an
2448
+ * array parallel to the operations, where undefined means allowed.
2449
+ *
2450
+ * A position is a timestamp, so an operation refused by a delete is one that
2451
+ * sorts after it, and the operations before it are left alone. That holds
2452
+ * whether the delete is already stored or is among the operations passed in.
2453
+ */
2454
+ async function evaluateByPosition(model, target, subject, stores, signal) {
2455
+ const { scope, operations } = subject;
2456
+ const { writeCache, operationStore } = stores;
2457
+ const definition = model(target);
2458
+ const readSet = staticReadSet(definition);
2459
+ const derivedSet = derivedReadSet(definition);
2460
+ if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
2461
+ const evaluating = new Set(operations.map((operation) => operation.id));
2462
+ const readStreams = await Promise.all(readSet.map(async (stream) => ({
2463
+ stream,
2464
+ 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))
2465
+ })));
2466
+ const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
2467
+ if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
2468
+ if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
2469
+ const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
2470
+ const walked = [];
2471
+ const histories = [];
2472
+ for (const read of readStreams) {
2473
+ const streamOperations = read.stream === writtenProjection ? [...read.operations, ...operations] : read.operations;
2474
+ const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, -1, signal);
2475
+ walked.push({
2476
+ streamKey: streamKey(read.stream.query),
2477
+ scope: read.stream.query.scope,
2478
+ document: before,
2479
+ operations: streamOperations,
2480
+ apply: read.stream.apply
2481
+ });
2482
+ histories.push({
2483
+ name: read.stream.name,
2484
+ operations: streamOperations
2485
+ });
2486
+ }
2487
+ let evaluatedStateKey;
2488
+ if (writtenProjection !== void 0) evaluatedStateKey = streamKey(writtenProjection.query);
2489
+ else if (definition.foldEvaluatedScope !== void 0) {
2490
+ const query = {
2491
+ documentId: target.documentId,
2492
+ scope,
2493
+ branch: target.branch
2494
+ };
2495
+ const storedOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, void 0, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
2496
+ const before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
2497
+ evaluatedStateKey = streamKey(query);
2498
+ walked.push({
2499
+ streamKey: evaluatedStateKey,
2500
+ scope,
2501
+ document: before,
2502
+ operations: [...storedOperations, ...operations],
2503
+ apply: definition.foldEvaluatedScope
2504
+ });
2505
+ } else walked.push({
2506
+ streamKey: EVALUATED_ONLY,
2507
+ scope,
2508
+ document: walked[0].document,
2509
+ operations,
2510
+ apply: (document) => document
2511
+ });
2512
+ const derivedEntries = [];
2513
+ const walkedKeys = new Set(walked.map((stream) => stream.streamKey));
2514
+ for (const projection of derivedSet) {
2515
+ const queries = projection.queryOverHistory?.(histories) ?? [];
2516
+ for (const query of queries) {
2517
+ const key = streamKey(query);
2518
+ if (walkedKeys.has(key)) continue;
2519
+ let before;
2520
+ try {
2521
+ before = await writeCache.getState(query.documentId, query.scope, query.branch, -1, signal);
2522
+ } catch (error) {
2523
+ if (error instanceof DocumentNotFoundError) continue;
2524
+ throw error;
2525
+ }
2526
+ const streamOperations = (await operationStore.getSince(query.documentId, query.scope, query.branch, -1, { actionTypes: projection.decidingActions }, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id));
2527
+ walkedKeys.add(key);
2528
+ walked.push({
2529
+ streamKey: key,
2530
+ scope: query.scope,
2531
+ document: before,
2532
+ operations: streamOperations,
2533
+ apply: projection.apply
2534
+ });
2535
+ derivedEntries.push({
2536
+ name: projection.name,
2537
+ query
2538
+ });
2539
+ }
2540
+ }
2541
+ const reasons = /* @__PURE__ */ new Map();
2542
+ const walk = walkByPosition(walked);
2543
+ let step = walk.next(false);
2544
+ while (!step.done) {
2545
+ const position = step.value;
2546
+ if (!evaluating.has(position.operation.id)) {
2547
+ step = walk.next(false);
2548
+ continue;
2549
+ }
2550
+ const evaluatedDocument = evaluatedStateKey === void 0 ? void 0 : position.states.get(evaluatedStateKey);
2551
+ const scopeState = evaluatedDocument === void 0 ? void 0 : evaluatedDocument.state[scope];
2552
+ const evaluation = definition.decide(modelAt(readSet, derivedSet.map((projection) => projection.name), derivedEntries, position.states), subjectOf(position.operation), {
2553
+ verb: "execute",
2554
+ scope: position.operation.action.scope,
2555
+ operation: position.operation.action.type
2556
+ }, {
2557
+ scopeState,
2558
+ actionInput: position.operation.action.input
2559
+ });
2560
+ const denied = evaluation.decision === "deny";
2561
+ reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
2562
+ step = walk.next(denied);
2563
+ }
2564
+ return operations.map((operation) => reasons.get(operation.id));
2565
+ }
2566
+ //#endregion
1439
2567
  //#region src/cache/operation-index-types.ts
1440
2568
  const DRIVE_COLLECTION_PREFIX = "drive.";
1441
2569
  /**
@@ -1482,23 +2610,119 @@ var DriveCollectionId = class DriveCollectionId {
1482
2610
  //#endregion
1483
2611
  //#region src/executor/document-action-handler.ts
1484
2612
  var DocumentActionHandler = class {
1485
- constructor(registry, logger, driveContainerTypes) {
2613
+ constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel) {
1486
2614
  this.registry = registry;
1487
2615
  this.logger = logger;
1488
2616
  this.driveContainerTypes = driveContainerTypes;
1489
- }
1490
- async execute(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2617
+ this.featureFlags = featureFlags;
2618
+ this.decisionModel = decisionModel;
2619
+ }
2620
+ /** Whether the write arrives with its evaluation already decided. */
2621
+ alreadyEvaluated(executing) {
2622
+ return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
2623
+ }
2624
+ async execute(write, executing) {
2625
+ const { action } = write;
2626
+ if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
2627
+ const refusal = await this.refuseIfPolicyDenies(write, executing);
2628
+ if (refusal) return refusal;
1491
2629
  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);
2630
+ case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
2631
+ case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
2632
+ case "UPGRADE_DOCUMENT": return this.executeUpgrade(write, executing);
2633
+ case "ADD_RELATIONSHIP": return this.executeAddRelationship(write, executing);
2634
+ case "REMOVE_RELATIONSHIP": return this.executeRemoveRelationship(write, executing);
2635
+ case "UPDATE_RELATIONSHIP": return this.executeUpdateRelationship(write, executing);
2636
+ default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
2637
+ }
2638
+ }
2639
+ /**
2640
+ * Refuses a document-scope write the policy denies, or undefined to proceed.
2641
+ * Without this an `execute`-on-`document` grant is unenforceable.
2642
+ */
2643
+ async refuseIfPolicyDenies(write, executing) {
2644
+ const { action } = write;
2645
+ const { job, startTime, stores, signal } = executing;
2646
+ if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
2647
+ const documentId = targetDocumentId(action, job.documentId);
2648
+ let admission;
2649
+ try {
2650
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, {
2651
+ documentId,
2652
+ branch: job.branch
2653
+ }, {
2654
+ address: action.context?.signer?.user.address,
2655
+ key: action.context?.signer?.app.key
2656
+ }, {
2657
+ verb: "execute",
2658
+ scope: action.scope,
2659
+ operation: action.type
2660
+ }, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
2661
+ } catch (error) {
2662
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1499
2663
  }
2664
+ if (admission.evaluation.decision === "allow") return;
2665
+ return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
1500
2666
  }
1501
- async executeCreate(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2667
+ /** A refused operation holds a position in the stream but changes nothing. */
2668
+ async writeDenied(write, executing) {
2669
+ const { action, skip, sourceRemote, deniedReason } = write;
2670
+ const { job, startTime, indexTxn, stores, signal } = executing;
2671
+ let document;
2672
+ try {
2673
+ document = await stores.writeCache.getState(job.documentId, job.scope, job.branch, void 0, signal);
2674
+ } catch (error) {
2675
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2676
+ }
2677
+ const index = getNextIndexForScope(document, job.scope);
2678
+ let standing = document;
2679
+ if (skip > 0) try {
2680
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
2681
+ } catch (error) {
2682
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2683
+ }
2684
+ let operation = createOperation(action, index, skip, {
2685
+ documentId: job.documentId,
2686
+ scope: job.scope,
2687
+ branch: job.branch
2688
+ });
2689
+ operation.deniedReason = deniedReason;
2690
+ operation.hash = hashDocumentStateForScope(standing, job.scope);
2691
+ const writeResult = await this.writeOperationToStore({
2692
+ documentId: job.documentId,
2693
+ documentType: document.header.documentType,
2694
+ scope: job.scope,
2695
+ branch: job.branch
2696
+ }, operation, executing);
2697
+ if (!Array.isArray(writeResult)) return writeResult;
2698
+ operation = writeResult[0];
2699
+ updateDocumentRevision(standing, job.scope, operation.index);
2700
+ standing.operations = {
2701
+ ...standing.operations,
2702
+ [job.scope]: [...standing.operations[job.scope] ?? [], operation]
2703
+ };
2704
+ stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, SnapshotPosition.Head);
2705
+ indexTxn.write([{
2706
+ ...operation,
2707
+ documentId: job.documentId,
2708
+ documentType: document.header.documentType,
2709
+ branch: job.branch,
2710
+ scope: job.scope,
2711
+ sourceRemote
2712
+ }]);
2713
+ stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
2714
+ state: standing.state.document,
2715
+ documentType: standing.header.documentType,
2716
+ documentScopeRevision: operation.index + 1
2717
+ });
2718
+ return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
2719
+ header: standing.header,
2720
+ document: standing.state.document
2721
+ }), startTime);
2722
+ }
2723
+ async executeCreate(write, executing) {
2724
+ const { action, skip, sourceRemote } = write;
2725
+ const { job, startTime, indexTxn, stores, signal } = executing;
1502
2726
  if (job.scope !== "document") return {
1503
2727
  job,
1504
2728
  success: false,
@@ -1516,7 +2740,12 @@ var DocumentActionHandler = class {
1516
2740
  ...document.state
1517
2741
  };
1518
2742
  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);
2743
+ const writeResult = await this.writeOperationToStore({
2744
+ documentId: document.header.id,
2745
+ documentType: document.header.documentType,
2746
+ scope: job.scope,
2747
+ branch: job.branch
2748
+ }, operation, executing);
1520
2749
  if (!Array.isArray(writeResult)) return writeResult;
1521
2750
  operation = writeResult[0];
1522
2751
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1524,7 +2753,7 @@ var DocumentActionHandler = class {
1524
2753
  ...document.operations,
1525
2754
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1526
2755
  };
1527
- stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document);
2756
+ stores.writeCache.putState(document.header.id, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1528
2757
  indexTxn.write([{
1529
2758
  ...operation,
1530
2759
  documentId: document.header.id,
@@ -1545,7 +2774,9 @@ var DocumentActionHandler = class {
1545
2774
  });
1546
2775
  return buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
1547
2776
  }
1548
- async executeDelete(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
2777
+ async executeDelete(write, executing) {
2778
+ const { action, skip, sourceRemote } = write;
2779
+ const { job, startTime, indexTxn, stores, signal } = executing;
1549
2780
  const input = action.input;
1550
2781
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
1551
2782
  const documentId = input.documentId;
@@ -1556,8 +2787,8 @@ var DocumentActionHandler = class {
1556
2787
  return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
1557
2788
  }
1558
2789
  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, {
2790
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
2791
+ let operation = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
1561
2792
  documentId,
1562
2793
  scope: job.scope,
1563
2794
  branch: job.branch
@@ -1568,7 +2799,12 @@ var DocumentActionHandler = class {
1568
2799
  document: document.state.document
1569
2800
  };
1570
2801
  const resultingState = JSON.stringify(resultingStateObj);
1571
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2802
+ const writeResult = await this.writeOperationToStore({
2803
+ documentId,
2804
+ documentType: document.header.documentType,
2805
+ scope: job.scope,
2806
+ branch: job.branch
2807
+ }, operation, executing);
1572
2808
  if (!Array.isArray(writeResult)) return writeResult;
1573
2809
  operation = writeResult[0];
1574
2810
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1576,7 +2812,7 @@ var DocumentActionHandler = class {
1576
2812
  ...document.operations,
1577
2813
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1578
2814
  };
1579
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2815
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
1580
2816
  indexTxn.write([{
1581
2817
  ...operation,
1582
2818
  documentId,
@@ -1592,7 +2828,9 @@ var DocumentActionHandler = class {
1592
2828
  });
1593
2829
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1594
2830
  }
1595
- async executeUpgrade(job, action, startTime, indexTxn, stores, skip = 0, sourceRemote = "", signal) {
2831
+ async executeUpgrade(write, executing) {
2832
+ const { action, skip, sourceRemote } = write;
2833
+ const { job, startTime, indexTxn, stores, signal } = executing;
1596
2834
  const input = action.input;
1597
2835
  if (!input.documentId) return buildErrorResult(job, /* @__PURE__ */ new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
1598
2836
  const documentId = input.documentId;
@@ -1602,17 +2840,10 @@ var DocumentActionHandler = class {
1602
2840
  try {
1603
2841
  document = await stores.writeCache.getState(documentId, job.scope, job.branch, void 0, signal);
1604
2842
  } catch (error) {
1605
- return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
1606
- }
1607
- const documentState = document.state.document;
1608
- if (documentState.isDeleted) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1609
- const nextIndex = getNextIndexForScope(document, job.scope);
1610
- let upgradePath;
1611
- if (fromVersion > 0 && fromVersion < toVersion) try {
1612
- upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
1613
- } catch (error) {
1614
- return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2843
+ return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
1615
2844
  }
2845
+ const documentState = document.state.document;
2846
+ if (documentState.isDeleted && !this.alreadyEvaluated(executing)) return buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
1616
2847
  if (fromVersion === toVersion && fromVersion > 0) return {
1617
2848
  job,
1618
2849
  success: true,
@@ -1620,6 +2851,48 @@ var DocumentActionHandler = class {
1620
2851
  operationsWithContext: [],
1621
2852
  duration: Date.now() - startTime
1622
2853
  };
2854
+ const arrivesDecided = executing.replayingAcceptedHistory || executing.evaluatedByPosition;
2855
+ if (fromVersion > 0 && !arrivesDecided) {
2856
+ const stampedVersion = normalizeDocumentModelVersion(documentState.version);
2857
+ if (fromVersion !== stampedVersion) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `fromVersion ${fromVersion} does not match the document's version ${stampedVersion}`), startTime);
2858
+ if (input.revision !== void 0) {
2859
+ let actualRevisions;
2860
+ try {
2861
+ actualRevisions = (await stores.operationStore.getRevisions(documentId, job.branch, signal)).revision;
2862
+ } catch (error) {
2863
+ return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch revisions for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
2864
+ }
2865
+ const revisionScopes = new Set([...Object.keys(input.revision), ...Object.keys(actualRevisions)]);
2866
+ for (const revisionScope of revisionScopes) {
2867
+ const snapshot = input.revision[revisionScope] ?? 0;
2868
+ const actual = actualRevisions[revisionScope] ?? 0;
2869
+ if (snapshot !== actual) return buildErrorResult(job, new UpgradePreconditionFailedError(documentId, `revision snapshot for scope "${revisionScope}" is ${snapshot} but the document is at ${actual}`), startTime);
2870
+ }
2871
+ }
2872
+ }
2873
+ let upgradePath;
2874
+ if (fromVersion > 0 && fromVersion < toVersion) try {
2875
+ upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
2876
+ } catch (error) {
2877
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2878
+ }
2879
+ const otherScopes = Object.keys(document.state).filter((scope) => scope !== job.scope);
2880
+ if (fromVersion > 0) for (const scope of otherScopes) {
2881
+ let scopedDocument;
2882
+ try {
2883
+ scopedDocument = await stores.writeCache.getState(documentId, scope, job.branch, void 0, signal);
2884
+ } catch (error) {
2885
+ return buildErrorResult(job, /* @__PURE__ */ new Error(`Failed to fetch ${scope} scope for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
2886
+ }
2887
+ document = {
2888
+ ...document,
2889
+ state: {
2890
+ ...document.state,
2891
+ [scope]: scopedDocument.state[scope]
2892
+ }
2893
+ };
2894
+ }
2895
+ const nextIndex = getNextIndexForScope(document, job.scope);
1623
2896
  try {
1624
2897
  document = applyUpgradeDocumentAction$1(document, action, upgradePath);
1625
2898
  } catch (error) {
@@ -1634,8 +2907,14 @@ var DocumentActionHandler = class {
1634
2907
  header: document.header,
1635
2908
  ...document.state
1636
2909
  };
2910
+ if (fromVersion > 0) resultingStateObj.__migrated = true;
1637
2911
  const resultingState = JSON.stringify(resultingStateObj);
1638
- const writeResult = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2912
+ const writeResult = await this.writeOperationToStore({
2913
+ documentId,
2914
+ documentType: document.header.documentType,
2915
+ scope: job.scope,
2916
+ branch: job.branch
2917
+ }, operation, executing);
1639
2918
  if (!Array.isArray(writeResult)) return writeResult;
1640
2919
  operation = writeResult[0];
1641
2920
  updateDocumentRevision(document, job.scope, operation.index);
@@ -1643,7 +2922,12 @@ var DocumentActionHandler = class {
1643
2922
  ...document.operations,
1644
2923
  [job.scope]: [...document.operations[job.scope] ?? [], operation]
1645
2924
  };
1646
- stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document);
2925
+ stores.writeCache.putState(documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
2926
+ for (const scope of otherScopes) executing.postCommitInvalidations.push({
2927
+ documentId,
2928
+ scope,
2929
+ branch: job.branch
2930
+ });
1647
2931
  indexTxn.write([{
1648
2932
  ...operation,
1649
2933
  documentId,
@@ -1659,8 +2943,8 @@ var DocumentActionHandler = class {
1659
2943
  });
1660
2944
  return buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
1661
2945
  }
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 }) => {
2946
+ executeAddRelationship(write, executing) {
2947
+ 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
2948
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1665
2949
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1666
2950
  txn.addToCollection(collectionId, input.targetId);
@@ -1668,8 +2952,8 @@ var DocumentActionHandler = class {
1668
2952
  }
1669
2953
  });
1670
2954
  }
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 }) => {
2955
+ executeRemoveRelationship(write, executing) {
2956
+ return this.withRelationshipAction("REMOVE_RELATIONSHIP", write, executing, null, ({ indexTxn: txn, stores: s, sourceDoc, input, job: j }) => {
1673
2957
  if (this.driveContainerTypes.has(sourceDoc.header.documentType)) {
1674
2958
  const collectionId = DriveCollectionId.forDrive(input.sourceId, j.branch).key;
1675
2959
  txn.removeFromCollection(collectionId, input.targetId);
@@ -1677,10 +2961,12 @@ var DocumentActionHandler = class {
1677
2961
  }
1678
2962
  });
1679
2963
  }
1680
- executeUpdateRelationship(job, action, startTime, indexTxn, stores, sourceRemote = "", signal) {
1681
- return this.withRelationshipAction("UPDATE_RELATIONSHIP", job, action, startTime, indexTxn, stores, sourceRemote, signal, null, null);
2964
+ executeUpdateRelationship(write, executing) {
2965
+ return this.withRelationshipAction("UPDATE_RELATIONSHIP", write, executing, null, null);
1682
2966
  }
1683
- async withRelationshipAction(actionTypeName, job, action, startTime, indexTxn, stores, sourceRemote, signal, preValidate, postWrite) {
2967
+ async withRelationshipAction(actionTypeName, write, executing, preValidate, postWrite) {
2968
+ const { action, skip, sourceRemote } = write;
2969
+ const { job, startTime, indexTxn, stores, signal } = executing;
1684
2970
  if (job.scope !== "document") return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName} must be in "document" scope, got "${job.scope}"`), startTime);
1685
2971
  const input = action.input;
1686
2972
  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 +2980,17 @@ var DocumentActionHandler = class {
1694
2980
  } catch (error) {
1695
2981
  return buildErrorResult(job, /* @__PURE__ */ new Error(`${actionTypeName}: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
1696
2982
  }
1697
- let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), 0, {
2983
+ let operation = createOperation(action, getNextIndexForScope(sourceDoc, job.scope), skip, {
1698
2984
  documentId: input.sourceId,
1699
2985
  scope: job.scope,
1700
2986
  branch: job.branch
1701
2987
  });
1702
- const writeResult = await this.writeOperationToStore(input.sourceId, sourceDoc.header.documentType, job.scope, job.branch, operation, job, startTime, stores, signal);
2988
+ const writeResult = await this.writeOperationToStore({
2989
+ documentId: input.sourceId,
2990
+ documentType: sourceDoc.header.documentType,
2991
+ scope: job.scope,
2992
+ branch: job.branch
2993
+ }, operation, executing);
1703
2994
  if (!Array.isArray(writeResult)) return writeResult;
1704
2995
  operation = writeResult[0];
1705
2996
  sourceDoc.header.lastModifiedAtUtcIso = operation.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString();
@@ -1714,7 +3005,7 @@ var DocumentActionHandler = class {
1714
3005
  [job.scope]: scopeState === void 0 ? {} : structuredClone(scopeState)
1715
3006
  };
1716
3007
  const resultingState = JSON.stringify(resultingStateObj);
1717
- stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
3008
+ stores.writeCache.putState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc, SnapshotPosition.Head);
1718
3009
  indexTxn.write([{
1719
3010
  ...operation,
1720
3011
  documentId: input.sourceId,
@@ -1737,7 +3028,9 @@ var DocumentActionHandler = class {
1737
3028
  });
1738
3029
  return buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
1739
3030
  }
1740
- async writeOperationToStore(documentId, documentType, scope, branch, operation, job, startTime, stores, signal) {
3031
+ async writeOperationToStore(target, operation, executing) {
3032
+ const { documentId, documentType, scope, branch } = target;
3033
+ const { job, startTime, stores, signal } = executing;
1741
3034
  let storedOperations;
1742
3035
  try {
1743
3036
  storedOperations = await stores.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
@@ -1746,10 +3039,11 @@ var DocumentActionHandler = class {
1746
3039
  } catch (error) {
1747
3040
  this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
1748
3041
  stores.writeCache.invalidate(documentId, scope, branch);
3042
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
1749
3043
  return {
1750
3044
  job,
1751
3045
  success: false,
1752
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
3046
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
1753
3047
  duration: Date.now() - startTime
1754
3048
  };
1755
3049
  }
@@ -1814,19 +3108,13 @@ function isValidISOTimestamp(value) {
1814
3108
  if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
1815
3109
  return !isNaN(new Date(value).getTime());
1816
3110
  }
1817
- const documentScopeActions = [
1818
- "CREATE_DOCUMENT",
1819
- "DELETE_DOCUMENT",
1820
- "UPGRADE_DOCUMENT",
1821
- "ADD_RELATIONSHIP",
1822
- "REMOVE_RELATIONSHIP",
1823
- "UPDATE_RELATIONSHIP"
1824
- ];
1825
3111
  /**
1826
3112
  * Simple job executor that processes a job by applying actions through document model reducers.
1827
3113
  */
1828
3114
  var SimpleJobExecutor = class {
1829
3115
  config;
3116
+ featureFlags;
3117
+ decisionModel;
1830
3118
  signatureVerifierModule;
1831
3119
  documentActionHandler;
1832
3120
  executionScope;
@@ -1841,6 +3129,7 @@ var SimpleJobExecutor = class {
1841
3129
  this.collectionMembershipCache = collectionMembershipCache;
1842
3130
  this.driveContainerTypes = driveContainerTypes;
1843
3131
  this.config = {
3132
+ featureFlags: config.featureFlags ?? {},
1844
3133
  maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
1845
3134
  maxConcurrency: config.maxConcurrency ?? 1,
1846
3135
  jobTimeoutMs: config.jobTimeoutMs ?? 3e4,
@@ -1848,8 +3137,10 @@ var SimpleJobExecutor = class {
1848
3137
  retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
1849
3138
  yieldDeadlineMs: config.yieldDeadlineMs ?? 50
1850
3139
  };
3140
+ this.featureFlags = resolveFeatureFlags(config.featureFlags);
3141
+ this.decisionModel = selectDecisionModel(this.featureFlags, registry);
1851
3142
  this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
1852
- this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes);
3143
+ this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel);
1853
3144
  this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
1854
3145
  }
1855
3146
  /**
@@ -1859,13 +3150,23 @@ var SimpleJobExecutor = class {
1859
3150
  async executeJob(job, signal) {
1860
3151
  const startTime = Date.now();
1861
3152
  const touchedCacheEntries = [];
3153
+ const postCommitInvalidations = [];
1862
3154
  let pendingEvent;
1863
3155
  let result;
1864
3156
  try {
1865
3157
  result = await this.executionScope.run(async (stores) => {
1866
3158
  const indexTxn = stores.operationIndex.start();
1867
3159
  if (job.kind === "load") {
1868
- const loadResult = await this.executeLoadJob(job, startTime, indexTxn, stores, signal);
3160
+ const loadResult = await this.executeLoadJob({
3161
+ job,
3162
+ startTime,
3163
+ indexTxn,
3164
+ stores,
3165
+ signal,
3166
+ replayingAcceptedHistory: true,
3167
+ evaluatedByPosition: false,
3168
+ postCommitInvalidations
3169
+ });
1869
3170
  if (loadResult.success && loadResult.operationsWithContext) {
1870
3171
  for (const owc of loadResult.operationsWithContext) touchedCacheEntries.push({
1871
3172
  documentId: owc.context.documentId,
@@ -1884,7 +3185,50 @@ var SimpleJobExecutor = class {
1884
3185
  }
1885
3186
  return loadResult;
1886
3187
  }
1887
- const actionResult = await this.processActions(job, job.actions, startTime, indexTxn, stores, void 0, void 0, "", signal);
3188
+ if (job.kind === "reevaluation") {
3189
+ const reevalResult = await this.executeReevaluationJob({
3190
+ job,
3191
+ startTime,
3192
+ indexTxn,
3193
+ stores,
3194
+ signal,
3195
+ replayingAcceptedHistory: false,
3196
+ evaluatedByPosition: false,
3197
+ postCommitInvalidations
3198
+ });
3199
+ if (reevalResult.success && reevalResult.operationsWithContext) {
3200
+ for (const owc of reevalResult.operationsWithContext) touchedCacheEntries.push({
3201
+ documentId: owc.context.documentId,
3202
+ scope: owc.context.scope,
3203
+ branch: owc.context.branch
3204
+ });
3205
+ const ordinals = await stores.operationIndex.commit(indexTxn, signal);
3206
+ for (let i = 0; i < reevalResult.operationsWithContext.length; i++) reevalResult.operationsWithContext[i].context.ordinal = ordinals[i];
3207
+ if (reevalResult.operationsWithContext.length > 0) {
3208
+ const collectionMemberships = await this.getCollectionMembershipsForOperations(reevalResult.operationsWithContext, stores);
3209
+ pendingEvent = {
3210
+ jobId: job.id,
3211
+ operations: reevalResult.operationsWithContext,
3212
+ jobMeta: job.meta,
3213
+ collectionMemberships
3214
+ };
3215
+ }
3216
+ }
3217
+ return reevalResult;
3218
+ }
3219
+ const positioned = await this.positionByTimestamp(job, stores, signal);
3220
+ if (positioned.error) return buildErrorResult(job, positioned.error, startTime);
3221
+ const executing = {
3222
+ job,
3223
+ startTime,
3224
+ indexTxn,
3225
+ stores,
3226
+ signal,
3227
+ replayingAcceptedHistory: false,
3228
+ evaluatedByPosition: positioned.evaluatedByPosition,
3229
+ postCommitInvalidations
3230
+ };
3231
+ const actionResult = await this.processActions(positioned.writes, executing);
1888
3232
  if (!actionResult.success) return {
1889
3233
  job,
1890
3234
  success: false,
@@ -1896,6 +3240,16 @@ var SimpleJobExecutor = class {
1896
3240
  scope: owc.context.scope,
1897
3241
  branch: owc.context.branch
1898
3242
  });
3243
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
3244
+ scope: job.scope,
3245
+ operations: actionResult.generatedOperations
3246
+ }, executing);
3247
+ if (reevaluationError) return {
3248
+ job,
3249
+ success: false,
3250
+ error: reevaluationError,
3251
+ duration: Date.now() - startTime
3252
+ };
1899
3253
  const ordinals = await stores.operationIndex.commit(indexTxn, signal);
1900
3254
  if (actionResult.operationsWithContext.length > 0) {
1901
3255
  for (let i = 0; i < actionResult.operationsWithContext.length; i++) actionResult.operationsWithContext[i].context.ordinal = ordinals[i];
@@ -1922,6 +3276,7 @@ var SimpleJobExecutor = class {
1922
3276
  }
1923
3277
  throw error;
1924
3278
  }
3279
+ if (result.success) for (const entry of postCommitInvalidations) this.writeCache.invalidate(entry.documentId, entry.scope, entry.branch);
1925
3280
  if (pendingEvent) this.eventBus.emit(ReactorEventTypes.JOB_WRITE_READY, pendingEvent).catch((error) => {
1926
3281
  this.logger.error("Failed to emit JOB_WRITE_READY event: @Event : @Error", pendingEvent, error);
1927
3282
  });
@@ -1931,7 +3286,9 @@ var SimpleJobExecutor = class {
1931
3286
  const documentIds = [...new Set(operations.map((op) => op.context.documentId))];
1932
3287
  return stores.collectionMembershipCache.getCollectionsForDocuments(documentIds);
1933
3288
  }
1934
- async processActions(job, actions, startTime, indexTxn, stores, skipValues, sourceOperations, sourceRemote = "", signal) {
3289
+ async processActions(writes, executing) {
3290
+ const { job, signal } = executing;
3291
+ const actions = writes.map((write) => write.action);
1935
3292
  const generatedOperations = [];
1936
3293
  const operationsWithContext = [];
1937
3294
  try {
@@ -1948,14 +3305,11 @@ var SimpleJobExecutor = class {
1948
3305
  success: false,
1949
3306
  generatedOperations,
1950
3307
  operationsWithContext,
1951
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${action.timestampUtcMs}" on action ${action.type} (id: ${action.id})`)
3308
+ error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
1952
3309
  };
1953
3310
  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);
3311
+ for (const write of writes) {
3312
+ const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
1959
3313
  const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
1960
3314
  if (error !== null) return {
1961
3315
  success: false,
@@ -1980,14 +3334,44 @@ var SimpleJobExecutor = class {
1980
3334
  operationsWithContext
1981
3335
  };
1982
3336
  }
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);
3337
+ async executeRegularAction(write, executing) {
3338
+ const { action, skip, sourceOperation, sourceRemote, deniedReason } = write;
3339
+ const { job, startTime, indexTxn, stores, signal } = executing;
3340
+ let appendCondition;
3341
+ let documentVersion;
3342
+ const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
3343
+ if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
3344
+ const target = {
3345
+ documentId: job.documentId,
3346
+ branch: job.branch
3347
+ };
3348
+ let admission;
3349
+ try {
3350
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
3351
+ address: action.context?.signer?.user.address,
3352
+ key: action.context?.signer?.app.key
3353
+ }, {
3354
+ verb: "execute",
3355
+ scope: action.scope,
3356
+ operation: action.type
3357
+ }, signal, this.featureFlags.authConditions ? { actionInput: action.input } : void 0);
3358
+ } catch (error) {
3359
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3360
+ }
3361
+ if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
3362
+ appendCondition = admission.appendCondition;
3363
+ documentVersion = admission.documentVersion;
3364
+ } else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
3365
+ else {
3366
+ let docMeta;
3367
+ try {
3368
+ docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
3369
+ } catch (error) {
3370
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3371
+ }
3372
+ if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
3373
+ documentVersion = docMeta.state.version;
1989
3374
  }
1990
- if (docMeta.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
1991
3375
  if (isUndoRedo(action) || action.type === "PRUNE" || action.type === "NOOP" && skip > 0) stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
1992
3376
  let document;
1993
3377
  try {
@@ -1995,16 +3379,48 @@ var SimpleJobExecutor = class {
1995
3379
  } catch (error) {
1996
3380
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
1997
3381
  }
3382
+ if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
3383
+ const subject = {
3384
+ address: write.action.context?.signer?.user.address,
3385
+ key: write.action.context?.signer?.app.key
3386
+ };
3387
+ if (decide(document.state.auth, subject, {
3388
+ verb: "execute",
3389
+ scope: action.scope,
3390
+ operation: action.type
3391
+ }) === "deny") return buildErrorResult(job, new AuthorizationDeniedError(job.documentId, action.scope, action.type, subject.address), startTime);
3392
+ }
1998
3393
  let module;
1999
3394
  try {
2000
- const moduleVersion = docMeta.state.version === 0 ? void 0 : docMeta.state.version;
2001
- module = this.registry.getModule(document.header.documentType, moduleVersion);
3395
+ module = this.registry.getModule(document.header.documentType, normalizeDocumentModelVersion(documentVersion));
2002
3396
  } catch (error) {
2003
3397
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2004
3398
  }
2005
3399
  let updatedDocument;
2006
- try {
2007
- const protocolVersion = document.header.protocolVersions?.["base-reducer"] ?? 1;
3400
+ if (deniedReason !== void 0) {
3401
+ const index = getNextIndexForScope(document, job.scope);
3402
+ const denied = createOperation(action, index, skip, {
3403
+ documentId: job.documentId,
3404
+ scope: job.scope,
3405
+ branch: job.branch
3406
+ });
3407
+ denied.deniedReason = deniedReason;
3408
+ let standing = document;
3409
+ if (skip > 0) try {
3410
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
3411
+ } catch (error) {
3412
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
3413
+ }
3414
+ denied.hash = hashDocumentStateForScope(standing, job.scope);
3415
+ updatedDocument = {
3416
+ ...standing,
3417
+ operations: {
3418
+ ...standing.operations,
3419
+ [job.scope]: [...standing.operations[job.scope] ?? [], denied]
3420
+ }
3421
+ };
3422
+ } else try {
3423
+ const protocolVersion = baseReducerVersion(document.header);
2008
3424
  const reducerOptions = sourceOperation ? {
2009
3425
  skip,
2010
3426
  branch: job.branch,
@@ -2035,14 +3451,15 @@ var SimpleJobExecutor = class {
2035
3451
  try {
2036
3452
  storedOperations = await stores.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
2037
3453
  txn.addOperations(newOperation);
2038
- }, signal);
3454
+ }, signal, appendCondition);
2039
3455
  } catch (error) {
2040
3456
  this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
2041
3457
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
3458
+ if (AppendConditionFailedError.isError(error)) for (const stream of error.condition.streams) stores.writeCache.invalidate(stream.documentId, stream.scope, stream.branch);
2042
3459
  return {
2043
3460
  job,
2044
3461
  success: false,
2045
- error: /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
3462
+ error: AppendConditionFailedError.isError(error) ? error : /* @__PURE__ */ new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
2046
3463
  duration: Date.now() - startTime
2047
3464
  };
2048
3465
  }
@@ -2051,7 +3468,7 @@ var SimpleJobExecutor = class {
2051
3468
  ...updatedDocument.header.revision,
2052
3469
  [scope]: storedOperation.index + 1
2053
3470
  };
2054
- stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument);
3471
+ stores.writeCache.putState(job.documentId, scope, job.branch, storedOperation.index, updatedDocument, SnapshotPosition.Head);
2055
3472
  indexTxn.write([{
2056
3473
  ...storedOperation,
2057
3474
  documentId: job.documentId,
@@ -2060,6 +3477,7 @@ var SimpleJobExecutor = class {
2060
3477
  scope,
2061
3478
  sourceRemote
2062
3479
  }]);
3480
+ if (scope === "auth") indexTxn.recordGroupReferences(job.documentId, mentionedGroupIds(action));
2063
3481
  return {
2064
3482
  job,
2065
3483
  success: true,
@@ -2078,14 +3496,291 @@ var SimpleJobExecutor = class {
2078
3496
  duration: Date.now() - startTime
2079
3497
  };
2080
3498
  }
2081
- async executeLoadJob(job, startTime, indexTxn, stores, signal) {
3499
+ /**
3500
+ * Orders a write by timestamp and decides it where it lands. The caller
3501
+ * supplies the timestamp, so a write can belong before operations already
3502
+ * stored; those are re-appended alongside it, the way a load reshuffles.
3503
+ *
3504
+ * Deciding a backdated write at the stream heads instead of at its position
3505
+ * would overwrite the verdict every other replica computes for it.
3506
+ */
3507
+ async positionByTimestamp(job, stores, signal) {
3508
+ const plain = () => ({
3509
+ writes: job.actions.map((action) => ({
3510
+ action,
3511
+ skip: 0,
3512
+ sourceRemote: ""
3513
+ })),
3514
+ evaluatedByPosition: false
3515
+ });
3516
+ if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
3517
+ let earliest = job.actions[0].timestampUtcMs;
3518
+ let earliestAt = Date.parse(earliest);
3519
+ for (const action of job.actions) {
3520
+ const at = Date.parse(action.timestampUtcMs);
3521
+ if (at < earliestAt) {
3522
+ earliest = action.timestampUtcMs;
3523
+ earliestAt = at;
3524
+ }
3525
+ }
3526
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3527
+ const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
3528
+ if (this.featureFlags.authEnforcement && job.scope === "auth") {
3529
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3530
+ const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
3531
+ if (violation) return {
3532
+ writes: [],
3533
+ evaluatedByPosition: false,
3534
+ error: violation
3535
+ };
3536
+ if (!backdated) return plain();
3537
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3538
+ }
3539
+ if (!backdated) return plain();
3540
+ const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
3541
+ if (conflicting.length === 0) {
3542
+ if (!this.featureFlags.authEnforcement) return plain();
3543
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3544
+ }
3545
+ const nextIndex = revisions.revision[job.scope] ?? 0;
3546
+ let firstConflicting = conflicting[0].index;
3547
+ for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
3548
+ const incoming = job.actions.map((action, i) => ({
3549
+ id: action.id,
3550
+ index: nextIndex + i,
3551
+ skip: 0,
3552
+ hash: "",
3553
+ timestampUtcMs: action.timestampUtcMs,
3554
+ action
3555
+ }));
3556
+ const merged = reshuffleByTimestamp({
3557
+ index: nextIndex,
3558
+ skip: retractionSkip(nextIndex, firstConflicting)
3559
+ }, conflicting, incoming);
3560
+ stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
3561
+ if (!this.featureFlags.authEnforcement) return {
3562
+ writes: merged.map((operation) => ({
3563
+ action: operation.action,
3564
+ skip: operation.skip,
3565
+ sourceRemote: ""
3566
+ })),
3567
+ evaluatedByPosition: false
3568
+ };
3569
+ return this.evaluatePositioned(job, stores, merged, signal);
3570
+ }
3571
+ /**
3572
+ * Decides each operation where it lands and carries the verdict on it. A
3573
+ * refused submitted action is reported to the caller and nothing is stored; a
3574
+ * refused operation the reshuffle merely moved keeps its verdict, because it
3575
+ * already holds a position.
3576
+ *
3577
+ * The operations carry the indexes and skips they will be stored at, because
3578
+ * the walk resolves skips before it orders them.
3579
+ */
3580
+ async evaluatePositioned(job, stores, operations, signal) {
3581
+ const reasons = await evaluateByPosition(this.decisionModel, {
3582
+ documentId: job.documentId,
3583
+ branch: job.branch
3584
+ }, {
3585
+ scope: job.scope,
3586
+ operations
3587
+ }, stores, signal);
3588
+ const submitted = new Set(job.actions.map((action) => action.id));
3589
+ for (let i = 0; i < operations.length; i++) {
3590
+ const reason = reasons[i];
3591
+ if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
3592
+ writes: [],
3593
+ evaluatedByPosition: false,
3594
+ error: refusalError(reason, job.documentId, null, operations[i].action)
3595
+ };
3596
+ }
3597
+ return {
3598
+ writes: operations.map((operation, i) => ({
3599
+ action: operation.action,
3600
+ skip: operation.skip,
3601
+ sourceRemote: "",
3602
+ deniedReason: reasons[i]
3603
+ })),
3604
+ evaluatedByPosition: true
3605
+ };
3606
+ }
3607
+ /**
3608
+ * The scopes a re-evaluation pass visits, in a fixed order.
3609
+ *
3610
+ * The revisions map comes from a query with no ORDER BY, and the order is
3611
+ * load-bearing: each scope's pass re-reads the auth stream, and the walk skips
3612
+ * an operation by its stored denial, so a denial this pass just wrote is
3613
+ * visible to a later-visited scope and invisible to an earlier one. The model's
3614
+ * own projection order leads, then the rest sorted, so the pass is reproducible
3615
+ * across replicas and across runs.
3616
+ */
3617
+ evaluationOrder(target, revision) {
3618
+ const definition = this.decisionModel(target);
3619
+ const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
3620
+ const leading = [];
3621
+ for (const stream of staticReadSet(definition)) {
3622
+ const scope = stream.query.scope;
3623
+ if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
3624
+ }
3625
+ const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
3626
+ return [...leading, ...rest];
3627
+ }
3628
+ /**
3629
+ * The first timestamp in the batch that does not strictly exceed everything
3630
+ * ahead of it, or undefined when the whole batch is monotonic.
3631
+ *
3632
+ * The bound is carried forward rather than compared against one stored maximum,
3633
+ * because a single execute can carry several auth actions stamped in the same
3634
+ * millisecond. Letting a tie through would store a stream the position walk
3635
+ * then refuses to read, with no repair path.
3636
+ */
3637
+ firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
3638
+ let boundIso = newest;
3639
+ let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
3640
+ for (const entry of entries) {
3641
+ if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
3642
+ const at = Date.parse(entry.timestampUtcMs);
3643
+ if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
3644
+ bound = at;
3645
+ boundIso = entry.timestampUtcMs;
3646
+ }
3647
+ }
3648
+ /** The operations a batch of submitted actions appends at the scope's tail. */
3649
+ appendedOperations(job, nextIndex) {
3650
+ return job.actions.map((action, i) => ({
3651
+ id: action.id,
3652
+ index: nextIndex + i,
3653
+ skip: 0,
3654
+ hash: "",
3655
+ timestampUtcMs: action.timestampUtcMs,
3656
+ action
3657
+ }));
3658
+ }
3659
+ /**
3660
+ * Re-evaluates the document when a write meets both criteria: it was written
3661
+ * to a stream the model reads, and it is timestamped before an operation
3662
+ * already stored. The caller supplies the timestamp and the reactor does not replace
3663
+ * it, so a mutation job can write such an operation just as a load job can,
3664
+ * which is why both executeJob and executeLoadJob call this.
3665
+ */
3666
+ async reevaluateIfCriteriaMet(criteria, executing) {
3667
+ if (!this.featureFlags.documentDecisions) return;
3668
+ const { job, stores, signal } = executing;
3669
+ const target = {
3670
+ documentId: job.documentId,
3671
+ branch: job.branch
3672
+ };
3673
+ if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
3674
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3675
+ const latest = Date.parse(revisions.latestTimestamp);
3676
+ if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
3677
+ return (await this.reevaluateDocument(executing)).error;
3678
+ }
3679
+ /**
3680
+ * Re-evaluates every scope the model evaluates. Where an operation's
3681
+ * evaluation differs from what is stored, the tail from that operation is
3682
+ * re-appended, carrying a skip that spans the indices it supersedes.
3683
+ */
3684
+ async reevaluateDocument(executing) {
3685
+ const { job, stores, signal } = executing;
3686
+ const target = {
3687
+ documentId: job.documentId,
3688
+ branch: job.branch
3689
+ };
3690
+ const reappended = [];
3691
+ const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
3692
+ for (const scope of this.evaluationOrder(target, revisions.revision)) {
3693
+ const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
3694
+ const effective = garbageCollect(sortOperations([...stored]));
3695
+ if (effective.length === 0) continue;
3696
+ const reevaluated = await evaluateByPosition(this.decisionModel, target, {
3697
+ scope,
3698
+ operations: effective
3699
+ }, stores, signal);
3700
+ const firstChange = effective.findIndex((operation, i) => operation.deniedReason !== reevaluated[i]);
3701
+ if (firstChange === -1) continue;
3702
+ const tail = effective.slice(firstChange);
3703
+ const nextIndex = revisions.revision[scope];
3704
+ stores.writeCache.invalidate(job.documentId, scope, job.branch);
3705
+ const result = await this.processActions(tail.map((operation, i) => ({
3706
+ action: operation.action,
3707
+ skip: i === 0 ? retractionSkip(nextIndex, tail[0].index) : 0,
3708
+ sourceRemote: "",
3709
+ deniedReason: reevaluated[firstChange + i]
3710
+ })), {
3711
+ ...executing,
3712
+ job: {
3713
+ ...job,
3714
+ scope
3715
+ },
3716
+ replayingAcceptedHistory: true,
3717
+ evaluatedByPosition: true
3718
+ });
3719
+ if (!result.success) return {
3720
+ error: result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`),
3721
+ operationsWithContext: reappended
3722
+ };
3723
+ reappended.push(...result.operationsWithContext);
3724
+ }
3725
+ return { operationsWithContext: reappended };
3726
+ }
3727
+ /**
3728
+ * Re-judges a document's stored operations because a read-set stream in
3729
+ * another document (a group) gained an operation. The trigger timestamp
3730
+ * bounds the work: an operation later than everything this document holds
3731
+ * cannot change any evaluation, so the pass is skipped.
3732
+ */
3733
+ async executeReevaluationJob(executing) {
3734
+ const { job, startTime, stores, signal } = executing;
3735
+ if (!this.featureFlags.documentDecisions) return {
3736
+ job,
3737
+ success: true,
3738
+ operations: [],
3739
+ operationsWithContext: [],
3740
+ duration: Date.now() - startTime
3741
+ };
3742
+ const trigger = job.meta.triggerTimestampUtcMs;
3743
+ if (typeof trigger === "string") {
3744
+ let latestTimestamp;
3745
+ try {
3746
+ latestTimestamp = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).latestTimestamp;
3747
+ } catch {
3748
+ return {
3749
+ job,
3750
+ success: true,
3751
+ operations: [],
3752
+ operationsWithContext: [],
3753
+ duration: Date.now() - startTime
3754
+ };
3755
+ }
3756
+ if (Date.parse(trigger) > Date.parse(latestTimestamp)) return {
3757
+ job,
3758
+ success: true,
3759
+ operations: [],
3760
+ operationsWithContext: [],
3761
+ duration: Date.now() - startTime
3762
+ };
3763
+ }
3764
+ const outcome = await this.reevaluateDocument(executing);
3765
+ if (outcome.error) return buildErrorResult(job, outcome.error, startTime);
3766
+ return {
3767
+ job,
3768
+ success: true,
3769
+ operations: outcome.operationsWithContext.map((owc) => owc.operation),
3770
+ operationsWithContext: outcome.operationsWithContext,
3771
+ duration: Date.now() - startTime
3772
+ };
3773
+ }
3774
+ async executeLoadJob(executing) {
3775
+ const { job, startTime, indexTxn, stores, signal } = executing;
2082
3776
  if (job.operations.length === 0) return buildErrorResult(job, /* @__PURE__ */ new Error("Load job must include at least one operation"), startTime);
2083
3777
  let docMeta;
2084
3778
  try {
2085
3779
  docMeta = await stores.documentMetaCache.getDocumentMeta(job.documentId, job.branch, signal);
2086
3780
  } catch {}
2087
- if (docMeta?.state.isDeleted) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
3781
+ if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2088
3782
  const scope = job.scope;
3783
+ const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
2089
3784
  let latestRevision;
2090
3785
  try {
2091
3786
  latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
@@ -2095,7 +3790,7 @@ var SimpleJobExecutor = class {
2095
3790
  for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
2096
3791
  job,
2097
3792
  success: false,
2098
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${operation.timestampUtcMs}" on operation (index: ${operation.index})`),
3793
+ error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
2099
3794
  duration: Date.now() - startTime
2100
3795
  };
2101
3796
  let minIncomingIndex = Number.POSITIVE_INFINITY;
@@ -2103,7 +3798,7 @@ var SimpleJobExecutor = class {
2103
3798
  for (const operation of job.operations) {
2104
3799
  minIncomingIndex = Math.min(minIncomingIndex, operation.index);
2105
3800
  const ts = operation.timestampUtcMs || "";
2106
- if (ts < minIncomingTimestamp) minIncomingTimestamp = ts;
3801
+ if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
2107
3802
  }
2108
3803
  let conflictingOps;
2109
3804
  try {
@@ -2128,11 +3823,14 @@ var SimpleJobExecutor = class {
2128
3823
  }
2129
3824
  return true;
2130
3825
  });
2131
- const existingOpsToReshuffle = nonSupersededOps;
2132
- if (existingOpsToReshuffle.length > this.config.maxSkipThreshold) return {
3826
+ const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
3827
+ const actionIdCounts = /* @__PURE__ */ new Map();
3828
+ for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
3829
+ const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
3830
+ if (reshuffleCost > this.config.maxSkipThreshold) return {
2133
3831
  job,
2134
3832
  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.`),
3833
+ error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
2136
3834
  duration: Date.now() - startTime
2137
3835
  };
2138
3836
  let skipCount = existingOpsToReshuffle.length;
@@ -2160,6 +3858,16 @@ var SimpleJobExecutor = class {
2160
3858
  operationsWithContext: [],
2161
3859
  duration: Date.now() - startTime
2162
3860
  };
3861
+ if (monotonicAuthStream) {
3862
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3863
+ const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
3864
+ if (violation) return {
3865
+ job,
3866
+ success: false,
3867
+ error: violation,
3868
+ duration: Date.now() - startTime
3869
+ };
3870
+ }
2163
3871
  const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
2164
3872
  ...operation,
2165
3873
  index: latestRevision + i
@@ -2171,10 +3879,31 @@ var SimpleJobExecutor = class {
2171
3879
  id: operation.id
2172
3880
  })));
2173
3881
  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);
3882
+ let deniedReasons;
3883
+ if (this.featureFlags.documentDecisions) try {
3884
+ deniedReasons = await evaluateByPosition(this.decisionModel, {
3885
+ documentId: job.documentId,
3886
+ branch: job.branch
3887
+ }, {
3888
+ scope,
3889
+ operations: reshuffledOperations
3890
+ }, stores, signal);
3891
+ } catch (error) {
3892
+ return {
3893
+ job,
3894
+ success: false,
3895
+ error: error instanceof Error ? error : new Error(String(error)),
3896
+ duration: Date.now() - startTime
3897
+ };
3898
+ }
2176
3899
  const effectiveSourceRemote = skipCount > 0 ? "" : job.meta.sourceRemote || "";
2177
- const result = await this.processActions(job, actions, startTime, indexTxn, stores, skipValues, reshuffledOperations, effectiveSourceRemote, signal);
3900
+ const result = await this.processActions(reshuffledOperations.map((operation, i) => ({
3901
+ action: operation.action,
3902
+ skip: operation.skip,
3903
+ sourceOperation: operation,
3904
+ sourceRemote: effectiveSourceRemote,
3905
+ deniedReason: deniedReasons?.[i]
3906
+ })), executing);
2178
3907
  if (!result.success) return {
2179
3908
  job,
2180
3909
  success: false,
@@ -2183,6 +3912,16 @@ var SimpleJobExecutor = class {
2183
3912
  };
2184
3913
  stores.writeCache.invalidate(job.documentId, scope, job.branch);
2185
3914
  if (scope === "document") stores.documentMetaCache.invalidate(job.documentId, job.branch);
3915
+ const reevaluationError = await this.reevaluateIfCriteriaMet({
3916
+ scope,
3917
+ operations: result.generatedOperations
3918
+ }, executing);
3919
+ if (reevaluationError) return {
3920
+ job,
3921
+ success: false,
3922
+ error: reevaluationError,
3923
+ duration: Date.now() - startTime
3924
+ };
2186
3925
  return {
2187
3926
  job,
2188
3927
  success: true,
@@ -2315,7 +4054,7 @@ var DocumentModelRegistry = class {
2315
4054
  }
2316
4055
  computeUpgradePath(documentType, fromVersion, toVersion) {
2317
4056
  if (fromVersion === toVersion) return [];
2318
- if (toVersion < fromVersion) throw new DowngradeNotSupportedError(documentType, fromVersion, toVersion);
4057
+ if (toVersion < fromVersion) throw new DowngradeNotSupportedError$1(documentType, fromVersion, toVersion);
2319
4058
  const manifest = this.getUpgradeManifest(documentType);
2320
4059
  const path = [];
2321
4060
  for (let v = fromVersion + 1; v <= toVersion; v++) {
@@ -2419,36 +4158,6 @@ function paginateRows(rows, paging, cursorOf, toItem, refetch) {
2419
4158
  };
2420
4159
  }
2421
4160
  //#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
4161
  //#region src/storage/txn.ts
2453
4162
  var AtomicTransaction = class {
2454
4163
  operations = [];
@@ -2473,6 +4182,7 @@ var AtomicTransaction = class {
2473
4182
  action: JSON.stringify(op.action),
2474
4183
  skip: op.skip,
2475
4184
  error: op.error || null,
4185
+ deniedReason: op.deniedReason || null,
2476
4186
  hash: op.hash
2477
4187
  });
2478
4188
  }
@@ -2506,12 +4216,12 @@ var KyselyOperationStore = class KyselyOperationStore {
2506
4216
  instance.trx = trx;
2507
4217
  return instance;
2508
4218
  }
2509
- async apply(documentId, documentType, scope, branch, revision, fn, signal) {
4219
+ async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
2510
4220
  if (this.trx) {
2511
4221
  let executeResult = null;
2512
4222
  let uniqueCtx = null;
2513
4223
  try {
2514
- executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal);
4224
+ executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2515
4225
  } catch (error) {
2516
4226
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
2517
4227
  else throw error;
@@ -2523,7 +4233,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2523
4233
  let uniqueCtx = null;
2524
4234
  try {
2525
4235
  transactionResult = await this.db.transaction().execute(async (trx) => {
2526
- return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal);
4236
+ return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
2527
4237
  });
2528
4238
  } catch (error) {
2529
4239
  if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
@@ -2542,12 +4252,13 @@ var KyselyOperationStore = class KyselyOperationStore {
2542
4252
  const op = ctx.stagedOps[0];
2543
4253
  throw new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
2544
4254
  }
2545
- async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal) {
4255
+ async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
2546
4256
  throwIfAborted(signal);
2547
4257
  const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
2548
4258
  await fn(atomicTxn);
2549
4259
  const operations = atomicTxn.getOperations();
2550
4260
  if (operations.length === 0) return [];
4261
+ if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
2551
4262
  const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
2552
4263
  const currentRevision = latestOp ? latestOp.index : -1;
2553
4264
  if (currentRevision !== revision - 1) {
@@ -2563,22 +4274,91 @@ var KyselyOperationStore = class KyselyOperationStore {
2563
4274
  op.prevOpId = prevOpId;
2564
4275
  prevOpId = op.opId;
2565
4276
  }
4277
+ let insertedCount = operations.length;
2566
4278
  try {
2567
- await trx.insertInto("Operation").values(operations).execute();
4279
+ if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
4280
+ else await trx.insertInto("Operation").values(operations).execute();
2568
4281
  } catch (error) {
2569
4282
  if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
2570
4283
  throw error;
2571
4284
  }
4285
+ if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
2572
4286
  return operations.map((op) => ({
2573
4287
  index: op.index,
2574
4288
  timestampUtcMs: op.timestampUtcMs.toISOString(),
2575
4289
  hash: op.hash,
2576
4290
  skip: op.skip,
2577
4291
  error: op.error || void 0,
4292
+ deniedReason: op.deniedReason || void 0,
2578
4293
  id: op.opId,
2579
4294
  action: JSON.parse(op.action)
2580
4295
  }));
2581
4296
  }
4297
+ /**
4298
+ * Locks the written stream and every read-set stream, in sorted key order
4299
+ * so that overlapping concurrent appends serialize rather than deadlock.
4300
+ * The locks are still taken one row at a time, so the query preserves that
4301
+ * order. It must stay separate from the guarded insert, which would
4302
+ * otherwise read a snapshot taken before the locks were held.
4303
+ */
4304
+ async acquireStreamLocks(trx, documentId, scope, branch, condition) {
4305
+ const keys = new Set([`${documentId}:${scope}:${branch}`]);
4306
+ for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
4307
+ await sql`
4308
+ with ordered as materialized (
4309
+ select key
4310
+ from unnest(array[${sql.join([...keys].sort())}]::text[]) with ordinality as t(key, ord)
4311
+ order by ord
4312
+ )
4313
+ select pg_advisory_xact_lock(hashtext(key)) from ordered
4314
+ `.execute(trx);
4315
+ }
4316
+ /**
4317
+ * Inserts the staged operations with the condition compiled in as a WHERE
4318
+ * NOT EXISTS guard, making the check and the append one statement. Returns
4319
+ * the rows inserted; zero means the guard failed and nothing was written.
4320
+ */
4321
+ async insertGuarded(trx, operations, condition) {
4322
+ const branches = operations.map((op) => trx.selectNoFrom([
4323
+ sql`${op.jobId}::text`.as("jobId"),
4324
+ sql`${op.opId}::text`.as("opId"),
4325
+ sql`${op.prevOpId}::text`.as("prevOpId"),
4326
+ sql`${op.documentId}::text`.as("documentId"),
4327
+ sql`${op.documentType}::text`.as("documentType"),
4328
+ sql`${op.scope}::text`.as("scope"),
4329
+ sql`${op.branch}::text`.as("branch"),
4330
+ sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
4331
+ sql`${op.index}::integer`.as("index"),
4332
+ sql`${op.action}::jsonb`.as("action"),
4333
+ sql`${op.skip}::integer`.as("skip"),
4334
+ sql`${op.error ?? null}::text`.as("error"),
4335
+ sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
4336
+ sql`${op.hash}::text`.as("hash")
4337
+ ]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
4338
+ web("Operation.documentId", "=", s.documentId),
4339
+ web("Operation.scope", "=", s.scope),
4340
+ web("Operation.branch", "=", s.branch),
4341
+ web("Operation.index", ">", s.revision)
4342
+ ]))))))));
4343
+ let expression = branches[0];
4344
+ for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
4345
+ return (await trx.insertInto("Operation").columns([
4346
+ "jobId",
4347
+ "opId",
4348
+ "prevOpId",
4349
+ "documentId",
4350
+ "documentType",
4351
+ "scope",
4352
+ "branch",
4353
+ "timestampUtcMs",
4354
+ "index",
4355
+ "action",
4356
+ "skip",
4357
+ "error",
4358
+ "deniedReason",
4359
+ "hash"
4360
+ ]).expression(expression).returning("id").execute()).length;
4361
+ }
2582
4362
  async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
2583
4363
  const minIndex = revision;
2584
4364
  const maxIndex = revision + stagedOps.length - 1;
@@ -2646,18 +4426,18 @@ var KyselyOperationStore = class KyselyOperationStore {
2646
4426
  "o1.index",
2647
4427
  "o1.timestampUtcMs"
2648
4428
  ]).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();
4429
+ const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("branch", "=", branch).executeTakeFirst();
2649
4430
  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
- }
4431
+ for (const row of scopeRevisions) revision[row.scope] = row.index + 1;
2656
4432
  return {
2657
4433
  revision,
2658
- latestTimestamp
4434
+ latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
2659
4435
  };
2660
4436
  }
4437
+ async getStreamLatestTimestamp(documentId, scope, branch, signal) {
4438
+ 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();
4439
+ return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
4440
+ }
2661
4441
  rowToOperation(row) {
2662
4442
  return {
2663
4443
  index: row.index,
@@ -2665,6 +4445,7 @@ var KyselyOperationStore = class KyselyOperationStore {
2665
4445
  hash: row.hash,
2666
4446
  skip: row.skip,
2667
4447
  error: row.error || void 0,
4448
+ deniedReason: row.deniedReason || void 0,
2668
4449
  id: row.opId,
2669
4450
  action: row.action
2670
4451
  };
@@ -2750,8 +4531,8 @@ function createForwardingPoolInstrumentation(name) {
2750
4531
  }
2751
4532
  //#endregion
2752
4533
  //#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) {
4534
+ var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$16 });
4535
+ async function up$16(db) {
2755
4536
  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
4537
  "documentId",
2757
4538
  "scope",
@@ -2776,8 +4557,8 @@ async function up$13(db) {
2776
4557
  }
2777
4558
  //#endregion
2778
4559
  //#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) {
4560
+ var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
4561
+ async function up$15(db) {
2781
4562
  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
4563
  "documentId",
2783
4564
  "scope",
@@ -2793,14 +4574,14 @@ async function up$12(db) {
2793
4574
  }
2794
4575
  //#endregion
2795
4576
  //#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) {
4577
+ var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
4578
+ async function up$14(db) {
2798
4579
  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
4580
  }
2800
4581
  //#endregion
2801
4582
  //#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) {
4583
+ var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
4584
+ async function up$13(db) {
2804
4585
  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
4586
  "sourceId",
2806
4587
  "targetId",
@@ -2812,14 +4593,14 @@ async function up$10(db) {
2812
4593
  }
2813
4594
  //#endregion
2814
4595
  //#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) {
4596
+ var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
4597
+ async function up$12(db) {
2817
4598
  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
4599
  }
2819
4600
  //#endregion
2820
4601
  //#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) {
4602
+ var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
4603
+ async function up$11(db) {
2823
4604
  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
4605
  "documentId",
2825
4606
  "scope",
@@ -2840,8 +4621,8 @@ async function up$8(db) {
2840
4621
  }
2841
4622
  //#endregion
2842
4623
  //#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) {
4624
+ var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
4625
+ async function up$10(db) {
2845
4626
  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
4627
  "documentId",
2847
4628
  "scope",
@@ -2851,14 +4632,14 @@ async function up$7(db) {
2851
4632
  }
2852
4633
  //#endregion
2853
4634
  //#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) {
4635
+ var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
4636
+ async function up$9(db) {
2856
4637
  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
4638
  }
2858
4639
  //#endregion
2859
4640
  //#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) {
4641
+ var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
4642
+ async function up$8(db) {
2862
4643
  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
4644
  await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
2864
4645
  await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
@@ -2872,8 +4653,8 @@ async function up$5(db) {
2872
4653
  }
2873
4654
  //#endregion
2874
4655
  //#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) {
4656
+ var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
4657
+ async function up$7(db) {
2877
4658
  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
4659
  await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
2879
4660
  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 +4662,8 @@ async function up$4(db) {
2881
4662
  }
2882
4663
  //#endregion
2883
4664
  //#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) {
4665
+ var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
4666
+ async function up$6(db) {
2886
4667
  await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
2887
4668
  await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
2888
4669
  await db.schema.dropTable("sync_cursors").execute();
@@ -2891,24 +4672,82 @@ async function up$3(db) {
2891
4672
  }
2892
4673
  //#endregion
2893
4674
  //#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) {
4675
+ var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
4676
+ async function up$5(db) {
2896
4677
  await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
2897
4678
  }
2898
4679
  //#endregion
2899
4680
  //#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) {
4681
+ var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
4682
+ async function up$4(db) {
2902
4683
  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
4684
  await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
2904
4685
  }
2905
4686
  //#endregion
2906
4687
  //#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) {
4688
+ var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
4689
+ async function up$3(db) {
2909
4690
  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
4691
  }
2911
4692
  //#endregion
4693
+ //#region src/storage/migrations/015_add_operation_denied_reason.ts
4694
+ var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
4695
+ down: () => down$2,
4696
+ up: () => up$2
4697
+ });
4698
+ /**
4699
+ * Records why authorization refused an operation. Separate from `error` so a
4700
+ * denial is distinguishable from a reducer failure without matching on a
4701
+ * message. Null for every operation written before decisions were enforced.
4702
+ */
4703
+ async function up$2(db) {
4704
+ await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
4705
+ await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
4706
+ }
4707
+ async function down$2(db) {
4708
+ await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
4709
+ await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
4710
+ }
4711
+ //#endregion
4712
+ //#region src/storage/migrations/016_add_dead_letter_error_type.ts
4713
+ var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
4714
+ down: () => down$1,
4715
+ up: () => up$1
4716
+ });
4717
+ /**
4718
+ * The classification a dead letter falls into, stored because it decides whether
4719
+ * the document stays quarantined and the in-memory error is gone after a restart.
4720
+ * Defaulted rather than nullable, so a pre-existing row rehydrates.
4721
+ */
4722
+ async function up$1(db) {
4723
+ await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
4724
+ }
4725
+ async function down$1(db) {
4726
+ await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
4727
+ }
4728
+ //#endregion
4729
+ //#region src/storage/migrations/017_create_group_references.ts
4730
+ var _017_create_group_references_exports = /* @__PURE__ */ __exportAll({
4731
+ down: () => down,
4732
+ up: () => up
4733
+ });
4734
+ /**
4735
+ * One row per (document, group) reference ever discovered from an auth
4736
+ * operation's input. Rows are never updated or deleted: auth evaluation is
4737
+ * positional, so a grant that named a group at any position keeps that
4738
+ * group's stream in the document's read-set even after a later operation
4739
+ * removes the reference. Read by documentId for the groups a document
4740
+ * requires (sync), and by groupId for the documents a group change affects
4741
+ * (re-evaluation).
4742
+ */
4743
+ async function up(db) {
4744
+ await db.schema.createTable("group_references").addColumn("documentId", "text", (col) => col.notNull()).addColumn("groupId", "text", (col) => col.notNull()).addPrimaryKeyConstraint("group_references_pkey", ["documentId", "groupId"]).execute();
4745
+ await db.schema.createIndex("idx_group_references_groupId").on("group_references").column("groupId").execute();
4746
+ }
4747
+ async function down(db) {
4748
+ await db.schema.dropTable("group_references").execute();
4749
+ }
4750
+ //#endregion
2912
4751
  //#region src/storage/migrations/migrator.ts
2913
4752
  const REACTOR_SCHEMA = "reactor";
2914
4753
  const migrations = {
@@ -2925,7 +4764,10 @@ const migrations = {
2925
4764
  "011_add_cursor_type_column": _011_add_cursor_type_column_exports,
2926
4765
  "012_add_source_remote_column": _012_add_source_remote_column_exports,
2927
4766
  "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
4767
+ "014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
4768
+ "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
4769
+ "016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports,
4770
+ "017_create_group_references": _017_create_group_references_exports
2929
4771
  };
2930
4772
  var ProgrammaticMigrationProvider = class {
2931
4773
  getMigrations() {
@@ -2979,6 +4821,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
2979
4821
  //#region src/core/drive-container-types.ts
2980
4822
  const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
2981
4823
  //#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 };
4824
+ export { decideAtHead as A, InvalidOperationTimestampError as B, DuplicateOperationError as C, DuplicateModuleError as D, DuplicateManifestError as E, AuthTimestampNotMonotonicError as F, __exportAll as G, matchesScope as H, AuthorizationDeniedError as I, DocumentDeletedError as L, documentDecisionModel as M, authDecisionModel as N, InvalidModuleError as O, buildDecisionModel as P, DocumentNotFoundError as R, AppendConditionFailedError as S, RevisionMismatchError as T, parsePagingOptions as U, UpgradePreconditionFailedError as V, throwIfAborted as W, DocumentMetaCache as _, createForwardingPoolInstrumentation as a, CollectionMembershipCache as b, KyselyKeyframeStore as c, DriveCollectionId as d, KyselyExecutionScope as f, KyselyOperationIndex as g, KyselyWriteCache as h, runMigrations as i, selectDecisionModel as j, ModuleNotFoundError as k, DocumentModelRegistry as l, EventBus as m, REACTOR_SCHEMA as n, instrumentPgPool as o, resolveFeatureFlags 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, ExcessiveReshuffleError as z };
2983
4825
 
2984
- //# sourceMappingURL=drive-container-types-DpJp2AmE.js.map
4826
+ //# sourceMappingURL=drive-container-types-BoY5t12r.js.map