@optimystic/db-core 0.25.1 → 0.26.0

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.
Files changed (104) hide show
  1. package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
  2. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  3. package/dist/src/cohort-topic/membership/verifier.js +17 -0
  4. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  5. package/dist/src/cohort-topic/promotion.d.ts +25 -0
  6. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/promotion.js +58 -11
  8. package/dist/src/cohort-topic/promotion.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
  10. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
  11. package/dist/src/cohort-topic/wire/primitives.js +6 -0
  12. package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
  13. package/dist/src/cohort-topic/wire/types.d.ts +5 -0
  14. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  16. package/dist/src/cohort-topic/wire/validate.js +10 -19
  17. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  18. package/dist/src/collection/collection.d.ts +8 -1
  19. package/dist/src/collection/collection.d.ts.map +1 -1
  20. package/dist/src/collection/collection.js +19 -3
  21. package/dist/src/collection/collection.js.map +1 -1
  22. package/dist/src/matchmaking/wire.d.ts +2 -2
  23. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  24. package/dist/src/matchmaking/wire.js +3 -3
  25. package/dist/src/matchmaking/wire.js.map +1 -1
  26. package/dist/src/network/struct.d.ts +5 -5
  27. package/dist/src/network/struct.d.ts.map +1 -1
  28. package/dist/src/testing/index.d.ts +1 -0
  29. package/dist/src/testing/index.d.ts.map +1 -1
  30. package/dist/src/testing/index.js +1 -0
  31. package/dist/src/testing/index.js.map +1 -1
  32. package/dist/src/testing/refresh-probe.d.ts +31 -0
  33. package/dist/src/testing/refresh-probe.d.ts.map +1 -0
  34. package/dist/src/testing/refresh-probe.js +39 -0
  35. package/dist/src/testing/refresh-probe.js.map +1 -0
  36. package/dist/src/testing/test-transactor.d.ts +58 -5
  37. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  38. package/dist/src/testing/test-transactor.js +87 -4
  39. package/dist/src/testing/test-transactor.js.map +1 -1
  40. package/dist/src/transaction/coordinator.d.ts +90 -6
  41. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  42. package/dist/src/transaction/coordinator.js +311 -90
  43. package/dist/src/transaction/coordinator.js.map +1 -1
  44. package/dist/src/transaction/errors.d.ts +42 -0
  45. package/dist/src/transaction/errors.d.ts.map +1 -1
  46. package/dist/src/transaction/errors.js +50 -0
  47. package/dist/src/transaction/errors.js.map +1 -1
  48. package/dist/src/transaction/index.d.ts +1 -1
  49. package/dist/src/transaction/index.d.ts.map +1 -1
  50. package/dist/src/transaction/index.js +1 -1
  51. package/dist/src/transaction/index.js.map +1 -1
  52. package/dist/src/transaction/session.d.ts +7 -3
  53. package/dist/src/transaction/session.d.ts.map +1 -1
  54. package/dist/src/transaction/session.js +7 -3
  55. package/dist/src/transaction/session.js.map +1 -1
  56. package/dist/src/transaction/transaction.d.ts +12 -2
  57. package/dist/src/transaction/transaction.d.ts.map +1 -1
  58. package/dist/src/transaction/transaction.js.map +1 -1
  59. package/dist/src/transactor/network-transactor.d.ts +1 -1
  60. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  61. package/dist/src/transactor/network-transactor.js +6 -26
  62. package/dist/src/transactor/network-transactor.js.map +1 -1
  63. package/dist/src/transactor/transactor-source.d.ts +4 -6
  64. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  65. package/dist/src/transactor/transactor-source.js +4 -6
  66. package/dist/src/transactor/transactor-source.js.map +1 -1
  67. package/dist/src/transform/atomic.d.ts.map +1 -1
  68. package/dist/src/transform/atomic.js +7 -0
  69. package/dist/src/transform/atomic.js.map +1 -1
  70. package/dist/src/transform/base-pins.d.ts +48 -0
  71. package/dist/src/transform/base-pins.d.ts.map +1 -0
  72. package/dist/src/transform/base-pins.js +65 -0
  73. package/dist/src/transform/base-pins.js.map +1 -0
  74. package/dist/src/transform/digest.d.ts +4 -2
  75. package/dist/src/transform/digest.d.ts.map +1 -1
  76. package/dist/src/transform/digest.js +14 -19
  77. package/dist/src/transform/digest.js.map +1 -1
  78. package/dist/src/transform/tracker.d.ts +31 -3
  79. package/dist/src/transform/tracker.d.ts.map +1 -1
  80. package/dist/src/transform/tracker.js +105 -13
  81. package/dist/src/transform/tracker.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/cohort-topic/membership/verifier.ts +487 -461
  84. package/src/cohort-topic/promotion.ts +86 -14
  85. package/src/cohort-topic/wire/primitives.ts +195 -188
  86. package/src/cohort-topic/wire/types.ts +480 -475
  87. package/src/cohort-topic/wire/validate.ts +504 -512
  88. package/src/collection/collection.ts +19 -3
  89. package/src/matchmaking/wire.ts +605 -603
  90. package/src/network/struct.ts +5 -5
  91. package/src/testing/index.ts +1 -0
  92. package/src/testing/refresh-probe.ts +52 -0
  93. package/src/testing/test-transactor.ts +100 -4
  94. package/src/transaction/coordinator.ts +312 -99
  95. package/src/transaction/errors.ts +141 -91
  96. package/src/transaction/index.ts +1 -1
  97. package/src/transaction/session.ts +7 -3
  98. package/src/transaction/transaction.ts +12 -2
  99. package/src/transactor/network-transactor.ts +1025 -1045
  100. package/src/transactor/transactor-source.ts +170 -172
  101. package/src/transform/atomic.ts +6 -0
  102. package/src/transform/base-pins.ts +83 -0
  103. package/src/transform/digest.ts +14 -19
  104. package/src/transform/tracker.ts +108 -15
@@ -4,7 +4,7 @@ import { Log } from "../log/log.js";
4
4
  import { blockIdsForTransforms } from "../transform/helpers.js";
5
5
  import { computeBlockContentDigests, blockDigestsField } from "../transform/digest.js";
6
6
  import { collectOperations, hashOperations } from "./operations-hash.js";
7
- import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
7
+ import { CoordinatorConcurrentStampError, CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
8
8
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
9
9
  import { createLogger } from "../logger.js";
10
10
  const log = createLogger('trx:coordinator');
@@ -48,9 +48,41 @@ class PendRejectedError extends Error {
48
48
  export class TransactionCoordinator {
49
49
  transactor;
50
50
  collections;
51
- /** Per-stampId tracking: snapshot before first apply + accumulated actions for replay */
51
+ /** Per-stampId tracking: the pre-staging snapshot of every collection the stamp may have
52
+ * staged into, which is everything {@link rollback} needs to rewind it.
53
+ *
54
+ * INVARIANT: `stampData.size <= 1` — at most ONE open stamp per coordinator, enforced by the
55
+ * guards in {@link applyActions} and {@link commit} (which throw
56
+ * {@link CoordinatorConcurrentStampError}) and {@link execute} (which returns it as a failure
57
+ * result). The registered collections hold exactly one staged state — one tracker transform
58
+ * set and one pending action queue each, shared by every open stamp — so two concurrent stamps
59
+ * would read each other's uncommitted rows and a commit of either would write the other's
60
+ * staged actions into its own durable log entry. A stamp is released by commit (success or
61
+ * partial) or by rollback; a CLEAN commit failure keeps its entry so `rollback(stampId)`
62
+ * stays a complete recovery, meaning an abandoned failed commit holds the coordinator against
63
+ * new stamps until it is rolled back. The guard sees only what opens a stamp, so two
64
+ * coordinators sharing collection instances — and writers that stage only via Tree.stage /
65
+ * Collection.act, which open none — stay the caller's contract ("own bridge per writer" —
66
+ * docs/transactions.md), not this map's.
67
+ *
68
+ * Each snapshot holds BOTH halves of a collection's staged state — tracker transforms AND
69
+ * the pending action queue — as one {@link Collection.snapshotPending} value. Restoring only
70
+ * the transforms would leave a rolled-back stamp's actions queued, where the next commit on
71
+ * that collection would write them into ITS durable log entry (and a conflicting sync's
72
+ * `replayActions` would re-apply them into the tracker as live data). Keeping the pair in one
73
+ * value makes half-restored staged state unrepresentable here.
74
+ *
75
+ * `preSnapshot` is keyed by the {@link Collection} INSTANCE, not by {@link CollectionId}: the
76
+ * collection map this coordinator is handed is owned by its caller (the Quereus adapter's
77
+ * registry), which may replace the instance stored under an id when a table re-initializes.
78
+ * An id-keyed snapshot would then restore the OLD instance's staged state onto the NEW one;
79
+ * instance keys make that unrepresentable — the new instance simply gets its own capture at
80
+ * the next reconcile, and the stale entry restores a detached object nobody reads.
81
+ *
82
+ * Capture is LAZY: a collection enters the map at the first {@link applyActions} after it
83
+ * appears in `this.collections` (see {@link captureUncaptured}), so a collection registered
84
+ * mid-transaction is still captured before anything stages into it. */
52
85
  stampData = new Map();
53
- nextStampOrder = 0;
54
86
  constructor(transactor, collections) {
55
87
  this.transactor = transactor;
56
88
  this.collections = collections;
@@ -66,23 +98,81 @@ export class TransactionCoordinator {
66
98
  * @param stampId - The transaction stamp ID to tag actions with
67
99
  */
68
100
  async applyActions(actions, stampId) {
69
- // On first call for this stampId, snapshot all collections for potential rollback
70
- if (!this.stampData.has(stampId)) {
71
- const snapshot = new Map();
72
- for (const [id, col] of this.collections) {
73
- snapshot.set(id, structuredClone(col.tracker.transforms));
101
+ let data = this.stampData.get(stampId);
102
+ if (!data) {
103
+ // At most one open stamp (see the invariant on stampData). Checked inside this method's
104
+ // await-free prologue — the check, the entry creation, and the capture below run as one
105
+ // atomic step, so a second stamp cannot register between a TRACKED stamp's commit guard
106
+ // and its log append: that stamp's entry stays in the map for the whole commit span, so
107
+ // this check keeps seeing it. An UNTRACKED commit (the Tree.stage / Collection.act path,
108
+ // which creates no entry) leaves the map empty and so cannot be interlocked this way —
109
+ // that is the documented out-of-scope case, not a hole this guard closes.
110
+ const open = this.openStampOtherThan(stampId);
111
+ if (open !== undefined) {
112
+ throw new CoordinatorConcurrentStampError(open, stampId);
74
113
  }
75
- this.stampData.set(stampId, {
76
- order: this.nextStampOrder++,
77
- preSnapshot: snapshot,
78
- actionBatches: []
79
- });
114
+ data = { preSnapshot: new Map() };
115
+ this.stampData.set(stampId, data);
80
116
  }
81
- this.stampData.get(stampId).actionBatches.push(actions);
117
+ // Runs on EVERY call, and stays await-free through to applyActionsRaw — see the method doc.
118
+ this.captureUncaptured(data.preSnapshot);
82
119
  await this.applyActionsRaw(actions, stampId);
83
120
  }
121
+ /** The id of the open stamp {@link stampData} tracks when it is not `stampId`; undefined when
122
+ * no OTHER stamp is open. The single-open-stamp invariant on {@link stampData} is what makes
123
+ * "the" (singular) correct here. */
124
+ openStampOtherThan(stampId) {
125
+ for (const open of this.stampData.keys()) {
126
+ if (open !== stampId)
127
+ return open;
128
+ }
129
+ return undefined;
130
+ }
131
+ /**
132
+ * Capture the pre-staging state of every registered collection `into` does not already hold.
133
+ *
134
+ * Called on EVERY {@link applyActions}, not only a stamp's first. `this.collections` is owned
135
+ * by the caller (the Quereus adapter's registry) and grows as tables open mid-transaction; a
136
+ * collection registered after the stamp's first call would otherwise be visible to commit but
137
+ * absent from every snapshot, so {@link rollback} would never visit it and its staged state
138
+ * would survive into the next transaction's durable log entry.
139
+ *
140
+ * `applyActions` is the right capture point because it precedes any staging that call performs
141
+ * — and on the Quereus path, where the vtab stages directly into the trees, the awaited
142
+ * empty-actions applyActions call exists precisely to be that pre-stage barrier (see
143
+ * optimystic-module.ts's "applyActions before any collection.stage" invariant).
144
+ *
145
+ * The already-captured guard is load-bearing: registration is idempotent (the adapter
146
+ * re-registers already-open index trees), and re-capturing a collection this stamp already
147
+ * staged into would record a DIRTY state as "before", making rollback preserve the very
148
+ * actions it must discard.
149
+ *
150
+ * Synchronous and await-free BY CONTRACT, for the same reason execute()'s pre-stage snapshot
151
+ * loop is: an interleaved stage would land inside a snapshot. Callers must not await between
152
+ * this call and the staging it guards.
153
+ *
154
+ * NOTE: this runs per applyActions call rather than once per transaction. Per-call cost for an
155
+ * already-captured collection is one Map.has; snapshotPending (which deep-copies the transforms)
156
+ * still runs at most once per collection per stamp. Unmeasured. If the registered-collection
157
+ * count ever grows large enough for the scan itself to matter, track a per-stamp "collections
158
+ * map size at last capture" rather than dropping the reconcile.
159
+ */
160
+ captureUncaptured(into) {
161
+ for (const col of this.collections.values()) {
162
+ if (into.has(col))
163
+ continue;
164
+ into.set(col, col.snapshotPending());
165
+ }
166
+ }
84
167
  /**
85
- * Apply actions without tracking (used internally and for replay during rollback).
168
+ * Stage each action against its collection, tagged `transaction: stampId`, without touching
169
+ * the {@link stampData} bookkeeping.
170
+ *
171
+ * That tag is a contract, not an implementation detail: it is the provenance the rollback tests
172
+ * read (`expectNoActionsFromStamp` in coordinator-rollback-pending.spec.ts) to tell one stamp's
173
+ * queued actions from another's. Only {@link applyActions} calls this; the split exists just to
174
+ * keep the single-open-stamp guard and the pre-stage capture readable apart from the staging
175
+ * loop.
86
176
  */
87
177
  async applyActionsRaw(actions, stampId) {
88
178
  for (const { collectionId, actions: collectionActions } of actions) {
@@ -118,6 +208,15 @@ export class TransactionCoordinator {
118
208
  * @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
119
209
  */
120
210
  async commit(transaction, options) {
211
+ // At most one open stamp (see the invariant on stampData). Checked once, BEFORE the retry
212
+ // loop — this is a hard failure, never retryable: the open stamp must be committed or
213
+ // rolled back first. This guard is what catches a caller that staged directly (Tree.stage /
214
+ // Collection.act, which creates no stampData entry) and commits while a sibling stamp is
215
+ // tracked — such a commit would sweep the sibling's queued actions into its own log entry.
216
+ const open = this.openStampOtherThan(transaction.stamp.id);
217
+ if (open !== undefined) {
218
+ throw new CoordinatorConcurrentStampError(open, transaction.stamp.id);
219
+ }
121
220
  const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
122
221
  const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
123
222
  const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
@@ -408,60 +507,59 @@ export class TransactionCoordinator {
408
507
  collection.tracker.reset();
409
508
  collection.clearPendingActions();
410
509
  }
411
- // Clean up stamp tracking data
510
+ // Clean up stamp tracking data. The reset+clear above is collection-wide, which is safe
511
+ // precisely because the coordinator enforces at most ONE open stamp (the invariant on
512
+ // stampData): no sibling stamp's snapshot exists to go stale against this commit.
412
513
  this.stampData.delete(transaction.stamp.id);
413
514
  }
414
515
  /**
415
516
  * Rollback a transaction (undo only the given stampId's applied actions).
416
517
  *
417
- * Restores tracker state to the snapshot taken before the stampId's first
418
- * applyActions call, then replays any later stamps' actions to preserve
419
- * other sessions' transforms.
518
+ * Restores each collection's staged state tracker transforms and the pending action
519
+ * queue to the snapshot taken before the stampId's first applyActions call. Because the
520
+ * coordinator allows at most ONE open stamp (the invariant on {@link stampData}), there is
521
+ * never a sibling stamp whose staged state that restore could clobber, so rewinding is the
522
+ * whole of rollback.
523
+ *
524
+ * Rolling back a stamp with no tracked entry — never opened, or already released by a commit
525
+ * or an earlier rollback — is a no-op.
526
+ *
527
+ * NOTE: this method is complete only while that invariant holds. If the guard is ever relaxed
528
+ * to permit concurrent stamps, a plain rewind stops being enough — it would discard a sibling
529
+ * stamp's staged state along with this one — and the per-collection-earliest-capture plus
530
+ * survivor-replay this method used to carry has to come back. Recover it from commit 26718f8a
531
+ * (`ticket(implement): coordinator-drop-multi-stamp-replay-machinery`) rather than re-deriving.
420
532
  *
421
533
  * @param stampId - The transaction stamp ID to rollback
422
534
  */
423
535
  async rollback(stampId) {
424
- // NOTE: unlike the commit path, this resets and replays into participant trackers WITHOUT
425
- // holding their instance latches. Safe today because a session drives abort and commit from
426
- // one call path, so a rollback cannot overlap a commit span on the same collections. If
427
- // rollback ever becomes reachable concurrently with a commit (a background abort, a second
428
- // session sharing collection instances), latch the participants here the way commitOnce does.
536
+ // NOTE: unlike the commit path, this overwrites BOTH halves of each participant's staged
537
+ // state tracker transforms AND the pending action queue WITHOUT holding their instance
538
+ // latches. Safe today because a session drives abort and commit from one call path, so a
539
+ // rollback cannot overlap a commit span on the same collections. The hazard class is the
540
+ // same one the transforms-only reset already carried: a concurrent act/sync would already
541
+ // have raced `tracker.reset`. If rollback ever becomes reachable concurrently with a commit
542
+ // (a background abort, a second coordinator sharing collection instances), latch the
543
+ // participants here the way commitOnce does.
429
544
  const data = this.stampData.get(stampId);
430
545
  if (!data)
431
546
  return;
432
547
  this.stampData.delete(stampId);
433
- // Collect all remaining stamps to replay
434
- const toReplay = [...this.stampData.entries()]
435
- .sort(([, a], [, b]) => a.order - b.order);
436
- // Find the earliest snapshot among the rolled-back stamp and all remaining stamps.
437
- // This is necessary because interleaved execution means a lower-order stamp
438
- // may have batches applied after a higher-order stamp's snapshot was taken.
439
- let earliestSnapshot = data.preSnapshot;
440
- let earliestOrder = data.order;
441
- for (const [, d] of toReplay) {
442
- if (d.order < earliestOrder) {
443
- earliestSnapshot = d.preSnapshot;
444
- earliestOrder = d.order;
445
- }
446
- }
447
- // Restore to the earliest snapshot
448
- for (const [collectionId, transforms] of earliestSnapshot) {
449
- const collection = this.collections.get(collectionId);
450
- if (collection) {
451
- collection.tracker.reset(structuredClone(transforms));
452
- }
453
- }
454
- // Replay all remaining stamps' batches in order
455
- for (const [replayStampId, replayData] of toReplay) {
456
- // Update the snapshot to reflect current (post-replay) state
457
- const newSnapshot = new Map();
458
- for (const [id, col] of this.collections) {
459
- newSnapshot.set(id, structuredClone(col.tracker.transforms));
460
- }
461
- replayData.preSnapshot = newSnapshot;
462
- for (const actionBatch of replayData.actionBatches) {
463
- await this.applyActionsRaw(actionBatch, replayStampId);
464
- }
548
+ // Restore each captured collection transforms AND pending queue together, so the
549
+ // rolled-back stamp's actions leave the queue instead of being written into the next
550
+ // transaction's durable log entry. `restorePending` deep-copies the transforms itself,
551
+ // so no structuredClone here. Restoring through the CAPTURED instance (rather than
552
+ // re-looking-up by id) is what keeps a mid-transaction instance swap under one id from
553
+ // pushing the old instance's staged state onto the new one.
554
+ // NOTE: this also discards actions staged OUTSIDE any tracked stamp (the Tree.stage /
555
+ // deferred-DML path, which calls Collection.act directly and creates no stampData entry)
556
+ // that landed after that collection's capture — including on a collection registered
557
+ // mid-transaction, whose capture is simply later than the eagerly-captured ones.
558
+ // Accepted: the tracker half already discards their transforms, and symmetric is the safer
559
+ // state — leaving such an action queued while its transforms are gone is exactly the
560
+ // phantom that a conflicting sync's replayActions would resurrect as live data.
561
+ for (const [collection, snapshot] of data.preSnapshot) {
562
+ collection.restorePending(snapshot);
465
563
  }
466
564
  }
467
565
  /**
@@ -541,6 +639,23 @@ export class TransactionCoordinator {
541
639
  if (!result.actions || result.actions.length === 0) {
542
640
  return { success: true }; // Nothing to do
543
641
  }
642
+ // At most one open stamp (see the invariant on stampData). execute() reports failures as
643
+ // results rather than throws (matching its conversion of applyActions' "Collection not
644
+ // found" throw below), so the refusal is a failure result here where applyActions and
645
+ // commit throw.
646
+ //
647
+ // Deliberately BELOW the empty-actions short-circuit above: a transaction that stages
648
+ // nothing cannot mix its state with the open stamp's, so a read-only execute is allowed to
649
+ // run alongside one. Hoisting this to the top of the method would refuse those too — see
650
+ // the "does not refuse a second stamp that stages nothing" case in
651
+ // coordinator-single-stamp.spec.ts. The engine has already run by this point, which costs
652
+ // nothing here: reaching this line means the engine was a pure translator (a
653
+ // side-effecting engine returns EMPTY actions and short-circuits above, and its own
654
+ // staging goes through applyActions, which guards).
655
+ const openStamp = this.openStampOtherThan(transaction.stamp.id);
656
+ if (openStamp !== undefined) {
657
+ return { success: false, error: new CoordinatorConcurrentStampError(openStamp, transaction.stamp.id).message };
658
+ }
544
659
  // 1b. Stage the returned actions into the collection trackers.
545
660
  //
546
661
  // Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
@@ -552,11 +667,41 @@ export class TransactionCoordinator {
552
667
  // method merely re-read the already-staged trackers; that side effect is gone, so
553
668
  // the application must happen explicitly here. A side-effecting engine that
554
669
  // applied internally would instead return EMPTY actions and short-circuit at the
555
- // guard above.)
670
+ // empty-actions check above.)
556
671
  //
557
672
  // applyActions() throws if a referenced collection is not registered — the same
558
673
  // "Collection not found" the engine's side-effecting apply used to surface. Convert
559
674
  // it back into a failure result so execute() keeps its return contract.
675
+ // Pre-staging snapshot, one per DISTINCT participating collection, captured here so the
676
+ // partial-commit branch below can unwind the collections that did NOT land. The unwind
677
+ // point is "before execute() staged anything", not "before the log append": unlike
678
+ // commit(), a re-drive of execute() re-runs the engine and re-stages every collection it
679
+ // names, so leaving this attempt's actions in the pending queue would double-stage them.
680
+ // That is also exactly the state rollback() would have restored, which is why dropping
681
+ // the stamp on that branch loses nothing.
682
+ //
683
+ // snapshotPending() is synchronous and latch-free — keep it that way: NO await may sit
684
+ // between this loop and the applyActions() call below, or an interleaved stage would be
685
+ // captured inside the snapshot. Because the whole loop runs before ANY staging, an engine
686
+ // naming one collection in two batches yields the state before EITHER batch whichever
687
+ // batch wins the key — so the dedupe is a COST guard (snapshotPending deep-copies the
688
+ // transforms and structuredClones the action context), not a correctness one. An
689
+ // unregistered collection is skipped; applyActions throws on it moments later and the
690
+ // catch below converts that to a failure result, so the map is never read.
691
+ //
692
+ // NOTE: this runs unconditionally, so the overwhelmingly common all-succeed execute() pays
693
+ // one deep transforms copy + one structuredClone of the action context per participant to
694
+ // serve a rare branch. Unmeasured, and symmetric with commitOnceLatched, which pays the
695
+ // same on every commit. If execute() ever shows up hot in a profile, the way out is a
696
+ // copy-on-first-stage snapshot, not dropping the restore.
697
+ const preStageSnapshots = new Map();
698
+ for (const { collectionId } of result.actions) {
699
+ if (preStageSnapshots.has(collectionId))
700
+ continue;
701
+ const collection = this.collections.get(collectionId);
702
+ if (collection)
703
+ preStageSnapshots.set(collectionId, collection.snapshotPending());
704
+ }
560
705
  try {
561
706
  await this.applyActions(result.actions, transaction.stamp.id);
562
707
  }
@@ -567,46 +712,81 @@ export class TransactionCoordinator {
567
712
  }
568
713
  // 2. Build a log entry per collection from the now-staged tracker transforms.
569
714
  //
570
- // NOTE: like commit(), this loop appends a log entry into each collection's
571
- // tracker and these failure returns do NOT restore that state — so a partially
572
- // applied engine transaction leaves appended-but-uncommitted entries in the
573
- // trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
574
- // is, because execute()'s asymmetry makes it lower risk: it is not the retryable
575
- // session.commit() entry point (a failed execute() is not re-driven through the
576
- // same loop), and its actions were tracked via applyActions() so rollback(stampId)
577
- // CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
578
- // becomes retryable, mirror the commit() snapshot/restore fix here.
715
+ // NOTE: like commit(), this loop appends a log entry into each collection's tracker.
716
+ // What happens to that appended-but-uncommitted state on failure depends on whether
717
+ // anything landed durably:
718
+ //
719
+ // - Failure BEFORE any durable commit (engine failure, log-append failure, or a
720
+ // coordination failure with an empty committed set): the failure returns below do NOT
721
+ // restore, so the appended entries survive in the trackers and `stampData` is kept.
722
+ // That is deliberate the actions were tracked via applyActions(), so rollback(stampId)
723
+ // is a valid AND complete recovery there: its capture predates this staging and covers
724
+ // every registered collection, including ones this call never touched. Do not add blind
725
+ // restores to these paths.
726
+ // - PARTIAL landing (some collections durably committed, others lost): the committed half
727
+ // gets the success-path fold, the failed half is restored to the pre-staging snapshot
728
+ // captured above, and `stampData` is dropped — because an all-or-nothing rollback() run
729
+ // after a partial landing would rewind the collection that DID commit, re-staging
730
+ // already-durable actions. Same disposition as commitOnceLatched. See that branch below.
579
731
  const tApply = Date.now();
580
732
  const collectionTransforms = new Map();
581
733
  const criticalBlocks = new Map();
582
734
  const actionResults = new Map();
583
- const allCollectionIds = result.actions.map(ca => ca.collectionId);
735
+ // Coalesce the engine's batches to ONE per collection. Nothing stops an engine from
736
+ // emitting two `CollectionActions` entries naming the same collection (with the built-in
737
+ // ActionsEngine that is just two statements against one table), but everything downstream
738
+ // of here is written per-PARTICIPANT, not per-batch: the apply loop would append two log
739
+ // entries stamped with the same revision (the revision only advances at recordCommitted),
740
+ // the id-keyed maps below would discard the first batch's entry, and both folds would call
741
+ // recordCommitted twice — the second throwing on the revision it already advanced past,
742
+ // AFTER the transaction committed durably. One log entry per collection per transaction is
743
+ // the invariant commitOnceLatched holds by construction (it appends one entry carrying that
744
+ // collection's whole pending queue), so match it here by grouping instead.
745
+ //
746
+ // First-appearance collection order, and each collection's own actions in the order the
747
+ // engine emitted them. The STAGING above deliberately ran on the original, un-coalesced
748
+ // list: staging order is what a re-executing validator reproduces, so it stays exactly as
749
+ // the engine emitted it. Grouping is only for the log-append and fold phase below.
750
+ const batches = new Map();
751
+ for (const { collectionId, actions } of result.actions) {
752
+ const existing = batches.get(collectionId);
753
+ if (existing)
754
+ existing.push(...actions);
755
+ else
756
+ batches.set(collectionId, [...actions]);
757
+ }
758
+ const allCollectionIds = [...batches.keys()];
584
759
  // Same single-capture rev threading as commitOnce: stamped at the log append below,
585
760
  // named again at pend, commit, and recordCommitted.
586
761
  const pendedRevs = new Map();
587
762
  // Hold each participating collection's instance latch for the commit span, same
588
763
  // discipline as commitOnce (sorted acquisition; see the comment there). Acquired only
589
764
  // AFTER applyActions above: collection.act takes the same non-reentrant instance latch
590
- // itself, so latching earlier would deadlock. Deduped before acquiring — taking one
591
- // instance's latch twice would also deadlock. Released in the finally: execute has
592
- // early failure returns.
765
+ // itself, so latching earlier would deadlock. The list must be DISTINCT — taking one
766
+ // instance's latch twice would also deadlock which the grouping above now guarantees
767
+ // upstream (`allCollectionIds` is `batches.keys()`) instead of at the acquisition.
768
+ // Released in the finally: execute has early failure returns.
593
769
  const latchReleases = [];
594
770
  try {
595
- for (const collectionId of [...new Set(allCollectionIds)].sort()) {
771
+ // NOTE: deadlock-freedom against a concurrent commitOnce needs BOTH paths to acquire in
772
+ // the SAME order, not merely each in a sorted one. `CollectionId` is a string, so this
773
+ // default `.sort()` and commitOnce's explicit `<`/`>` comparator agree today. If either
774
+ // spelling changes — a custom comparator, a non-string id — change both together.
775
+ for (const collectionId of [...allCollectionIds].sort()) {
596
776
  const collection = this.collections.get(collectionId);
597
777
  if (collection) {
598
778
  latchReleases.push(await collection.acquireLatch());
599
779
  }
600
780
  }
601
- for (const collectionActions of result.actions) {
602
- const applyResult = await this.applyActionsToCollection(collectionActions, transaction, allCollectionIds);
781
+ for (const [collectionId, actions] of batches) {
782
+ const applyResult = await this.applyActionsToCollection({ collectionId, actions }, transaction, allCollectionIds);
603
783
  if (!applyResult.success) {
604
784
  return { success: false, error: applyResult.error };
605
785
  }
606
- collectionTransforms.set(collectionActions.collectionId, applyResult.transforms);
607
- criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId);
608
- actionResults.set(collectionActions.collectionId, applyResult.results);
609
- pendedRevs.set(collectionActions.collectionId, applyResult.rev);
786
+ collectionTransforms.set(collectionId, applyResult.transforms);
787
+ criticalBlocks.set(collectionId, applyResult.logTailBlockId);
788
+ actionResults.set(collectionId, applyResult.results);
789
+ pendedRevs.set(collectionId, applyResult.rev);
610
790
  }
611
791
  // 3. Compute operations hash for validation (order-independent; see commit()).
612
792
  const operationsHash = await hashOperations(collectOperations(collectionTransforms));
@@ -618,19 +798,45 @@ export class TransactionCoordinator {
618
798
  if (!coordResult.success) {
619
799
  log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
620
800
  // Stop lying to the caller about a partial commit: if some collections durably
621
- // committed, surface that set. execute() is not snapshot/restore-wrapped (see the
622
- // note above), but the committed subset must still get the success-path local
623
- // treatment (recordCommitted + tracker.reset, as on the success path below) so its
624
- // trackers aren't left mis-tracking already-durable state.
801
+ // committed, surface that set. The committed subset gets the full success-path
802
+ // local treatment (the same four steps as commitOnceLatched's partial-commit fold,
803
+ // in the same order see the success fold below) so its trackers, read caches and
804
+ // pending queues aren't left mis-tracking already-durable state; the failed subset
805
+ // is unwound to its pre-staging snapshot, and the stamp tracking is dropped so no
806
+ // unsafe all-or-nothing undo handle survives (rollback() would rewind the winner).
807
+ // Kept await-free for the same reason the success fold is — restorePending is
808
+ // synchronous and latch-free by contract, so it is safe inside this latched span.
625
809
  const committed = coordResult.committedCollections ?? new Set();
626
810
  if (committed.size > 0) {
627
- for (const collectionActions of result.actions) {
628
- const collection = this.collections.get(collectionActions.collectionId);
629
- if (collection && committed.has(collectionActions.collectionId)) {
630
- collection.recordCommitted(transaction.id, pendedRevs.get(collectionActions.collectionId));
811
+ for (const collectionId of batches.keys()) {
812
+ const collection = this.collections.get(collectionId);
813
+ if (!collection)
814
+ continue;
815
+ if (committed.has(collectionId)) {
816
+ const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId));
817
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
631
818
  collection.tracker.reset();
819
+ collection.clearPendingActions();
820
+ }
821
+ else {
822
+ // NOTE: blind overwrite of this collection's staged state — anything
823
+ // staged on it between the pre-stage snapshot above and here is discarded.
824
+ // No sibling stamp can be that writer (at most one stamp is open), so the
825
+ // only source is the untracked Tree.stage / Collection.act path, and this
826
+ // is the same single-call-path assumption rollback() documents. It cannot
827
+ // be softened by re-applying those actions here: collection.act takes the
828
+ // same non-reentrant instance latch this span already holds.
829
+ const snapshot = preStageSnapshots.get(collectionId);
830
+ if (snapshot)
831
+ collection.restorePending(snapshot);
632
832
  }
633
833
  }
834
+ // The undo handle is now unsafe: rollback() is all-or-nothing and would rewind
835
+ // the collections that DID durably commit. Drop it, matching commitOnceLatched.
836
+ // Dropping outright (no tombstone) is safe because the coordinator enforces at
837
+ // most ONE open stamp (the invariant on stampData): no sibling stamp exists,
838
+ // so nothing else can later look for this entry and be misled by its absence.
839
+ this.stampData.delete(transaction.stamp.id);
634
840
  }
635
841
  return {
636
842
  success: false,
@@ -639,17 +845,32 @@ export class TransactionCoordinator {
639
845
  failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
640
846
  };
641
847
  }
642
- // 5. Update actionContext and reset trackers after successful commit
643
- for (const collectionActions of result.actions) {
644
- const collection = this.collections.get(collectionActions.collectionId);
848
+ // 5. Advance actionContext, fold the committed transforms into each collection's read
849
+ // cache, reset the tracker, and drop the now-committed pending actions — the same four
850
+ // steps, in the same order, as commitOnceLatched's success fold. Order matters: cache
851
+ // the committed blocks BEFORE resetting the tracker (the transforms are read live), so a
852
+ // collection with prior committed state serves the new revision instead of the stale
853
+ // cached one. Clearing pending keeps a subsequent commit() on this same collection from
854
+ // re-logging these already-durable actions.
855
+ // NOTE: this fold loop must stay await-free, for the same reason commitOnce's does —
856
+ // session-mode publish relies on it being event-loop-atomic across collections.
857
+ for (const collectionId of batches.keys()) {
858
+ const collection = this.collections.get(collectionId);
645
859
  if (collection) {
646
- collection.recordCommitted(transaction.id, pendedRevs.get(collectionActions.collectionId));
860
+ const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId));
861
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
647
862
  collection.tracker.reset();
863
+ collection.clearPendingActions();
648
864
  }
649
865
  }
650
866
  // Clean up stamp tracking data
651
867
  this.stampData.delete(transaction.stamp.id);
652
- // 6. Return results from actions
868
+ // 6. Return results from actions.
869
+ // `actions` is the engine's ORIGINAL batch list, un-coalesced — the field is documented
870
+ // as "the actions produced by executing the transaction", and the caller's own shape is
871
+ // the honest answer to that. `results` is keyed by collection id, so the two are NOT
872
+ // positionally aligned when an engine named one collection in more than one batch; join
873
+ // them on collectionId, never by index.
653
874
  log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
654
875
  return {
655
876
  success: true,