@optimystic/db-core 1.0.0-beta.3 → 1.1.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 (171) hide show
  1. package/dist/src/cluster/structs.d.ts +30 -6
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +439 -34
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +843 -148
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +124 -16
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +86 -11
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +106 -40
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  37. package/dist/src/matchmaking/wire.js +8 -2
  38. package/dist/src/matchmaking/wire.js.map +1 -1
  39. package/dist/src/network/durability.d.ts +45 -0
  40. package/dist/src/network/durability.d.ts.map +1 -0
  41. package/dist/src/network/durability.js +114 -0
  42. package/dist/src/network/durability.js.map +1 -0
  43. package/dist/src/network/i-key-network.d.ts +14 -6
  44. package/dist/src/network/i-key-network.d.ts.map +1 -1
  45. package/dist/src/network/index.d.ts +3 -0
  46. package/dist/src/network/index.d.ts.map +1 -1
  47. package/dist/src/network/index.js +3 -0
  48. package/dist/src/network/index.js.map +1 -1
  49. package/dist/src/network/lineage.d.ts +39 -0
  50. package/dist/src/network/lineage.d.ts.map +1 -0
  51. package/dist/src/network/lineage.js +61 -0
  52. package/dist/src/network/lineage.js.map +1 -0
  53. package/dist/src/network/routing-key.d.ts +24 -0
  54. package/dist/src/network/routing-key.d.ts.map +1 -0
  55. package/dist/src/network/routing-key.js +9 -0
  56. package/dist/src/network/routing-key.js.map +1 -0
  57. package/dist/src/network/stale-failure.d.ts +5 -1
  58. package/dist/src/network/stale-failure.d.ts.map +1 -1
  59. package/dist/src/network/stale-failure.js +5 -1
  60. package/dist/src/network/stale-failure.js.map +1 -1
  61. package/dist/src/network/struct.d.ts +151 -8
  62. package/dist/src/network/struct.d.ts.map +1 -1
  63. package/dist/src/network/struct.js.map +1 -1
  64. package/dist/src/reactivity/wire.d.ts.map +1 -1
  65. package/dist/src/reactivity/wire.js +8 -2
  66. package/dist/src/reactivity/wire.js.map +1 -1
  67. package/dist/src/testing/test-transactor.d.ts +64 -7
  68. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  69. package/dist/src/testing/test-transactor.js +150 -23
  70. package/dist/src/testing/test-transactor.js.map +1 -1
  71. package/dist/src/transaction/coordinator.d.ts +141 -21
  72. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  73. package/dist/src/transaction/coordinator.js +408 -122
  74. package/dist/src/transaction/coordinator.js.map +1 -1
  75. package/dist/src/transaction/errors.d.ts +39 -10
  76. package/dist/src/transaction/errors.d.ts.map +1 -1
  77. package/dist/src/transaction/errors.js +34 -9
  78. package/dist/src/transaction/errors.js.map +1 -1
  79. package/dist/src/transaction/index.d.ts +1 -0
  80. package/dist/src/transaction/index.d.ts.map +1 -1
  81. package/dist/src/transaction/index.js.map +1 -1
  82. package/dist/src/transactor/block-floors.d.ts +78 -0
  83. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  84. package/dist/src/transactor/block-floors.js +62 -0
  85. package/dist/src/transactor/block-floors.js.map +1 -0
  86. package/dist/src/transactor/change-notifier.d.ts +43 -0
  87. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  88. package/dist/src/transactor/change-notifier.js +3 -0
  89. package/dist/src/transactor/change-notifier.js.map +1 -1
  90. package/dist/src/transactor/index.d.ts +1 -0
  91. package/dist/src/transactor/index.d.ts.map +1 -1
  92. package/dist/src/transactor/index.js +1 -0
  93. package/dist/src/transactor/index.js.map +1 -1
  94. package/dist/src/transactor/network-transactor.d.ts +43 -6
  95. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  96. package/dist/src/transactor/network-transactor.js +279 -86
  97. package/dist/src/transactor/network-transactor.js.map +1 -1
  98. package/dist/src/transactor/transactor-source.d.ts +94 -4
  99. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  100. package/dist/src/transactor/transactor-source.js +120 -42
  101. package/dist/src/transactor/transactor-source.js.map +1 -1
  102. package/dist/src/transactor/transactor.d.ts +14 -1
  103. package/dist/src/transactor/transactor.d.ts.map +1 -1
  104. package/dist/src/transform/atomic.d.ts.map +1 -1
  105. package/dist/src/transform/atomic.js +12 -9
  106. package/dist/src/transform/atomic.js.map +1 -1
  107. package/dist/src/transform/base-pins.d.ts +50 -17
  108. package/dist/src/transform/base-pins.d.ts.map +1 -1
  109. package/dist/src/transform/base-pins.js +58 -11
  110. package/dist/src/transform/base-pins.js.map +1 -1
  111. package/dist/src/transform/cache-source.d.ts +75 -11
  112. package/dist/src/transform/cache-source.d.ts.map +1 -1
  113. package/dist/src/transform/cache-source.js +162 -36
  114. package/dist/src/transform/cache-source.js.map +1 -1
  115. package/dist/src/transform/digest.d.ts +8 -1
  116. package/dist/src/transform/digest.d.ts.map +1 -1
  117. package/dist/src/transform/digest.js +19 -9
  118. package/dist/src/transform/digest.js.map +1 -1
  119. package/dist/src/transform/helpers.d.ts +6 -2
  120. package/dist/src/transform/helpers.d.ts.map +1 -1
  121. package/dist/src/transform/helpers.js +8 -3
  122. package/dist/src/transform/helpers.js.map +1 -1
  123. package/dist/src/transform/tracker.d.ts +94 -14
  124. package/dist/src/transform/tracker.d.ts.map +1 -1
  125. package/dist/src/transform/tracker.js +222 -68
  126. package/dist/src/transform/tracker.js.map +1 -1
  127. package/dist/src/utility/structural-equals.d.ts +38 -0
  128. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  129. package/dist/src/utility/structural-equals.js +77 -0
  130. package/dist/src/utility/structural-equals.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/cluster/structs.ts +28 -7
  133. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  134. package/src/cohort-topic/wire/codec.ts +222 -216
  135. package/src/collection/collection.ts +960 -157
  136. package/src/collection/struct.ts +99 -4
  137. package/src/collections/diary/diary.ts +71 -68
  138. package/src/collections/tree/struct.ts +155 -23
  139. package/src/collections/tree/tree.ts +127 -40
  140. package/src/index.ts +1 -1
  141. package/src/matchmaking/wire.ts +8 -2
  142. package/src/network/durability.ts +123 -0
  143. package/src/network/i-key-network.ts +60 -52
  144. package/src/network/index.ts +10 -7
  145. package/src/network/lineage.ts +80 -0
  146. package/src/network/routing-key.ts +27 -0
  147. package/src/network/stale-failure.ts +5 -1
  148. package/src/network/struct.ts +156 -8
  149. package/src/reactivity/wire.ts +230 -224
  150. package/src/testing/test-transactor.ts +152 -24
  151. package/src/transaction/coordinator.ts +470 -131
  152. package/src/transaction/errors.ts +34 -9
  153. package/src/transaction/index.ts +1 -0
  154. package/src/transactor/block-floors.ts +105 -0
  155. package/src/transactor/change-notifier.ts +129 -80
  156. package/src/transactor/index.ts +1 -0
  157. package/src/transactor/network-transactor.ts +297 -89
  158. package/src/transactor/transactor-source.ts +131 -42
  159. package/src/transactor/transactor.ts +13 -1
  160. package/src/transform/atomic.ts +25 -22
  161. package/src/transform/base-pins.ts +76 -18
  162. package/src/transform/cache-source.ts +327 -194
  163. package/src/transform/digest.ts +21 -10
  164. package/src/transform/helpers.ts +8 -3
  165. package/src/transform/tracker.ts +423 -270
  166. package/src/utility/structural-equals.ts +72 -0
  167. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  168. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  169. package/dist/src/utility/block-id-to-bytes.js +0 -7
  170. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  171. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,8 +1,9 @@
1
1
  import { isConflictFailure } from "../network/stale-failure.js";
2
+ import { TornActionError } from "../collection/struct.js";
2
3
  import { isTransactionExpired, clampPriority } from "./transaction.js";
3
4
  import { Log } from "../log/log.js";
4
- import { blockIdsForTransforms } from "../transform/helpers.js";
5
- import { computeBlockContentDigests, blockDigestsField } from "../transform/digest.js";
5
+ import { blockIdsForTransforms, copyTransforms } from "../transform/helpers.js";
6
+ import { computeBlockContentDigests, blockDigestsField, baseRevsField } from "../transform/digest.js";
6
7
  import { collectOperations, hashOperations } from "./operations-hash.js";
7
8
  import { CoordinatorConcurrentStampError, CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
8
9
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
@@ -83,9 +84,27 @@ export class TransactionCoordinator {
83
84
  * appears in `this.collections` (see {@link captureUncaptured}), so a collection registered
84
85
  * mid-transaction is still captured before anything stages into it. */
85
86
  stampData = new Map();
86
- constructor(transactor, collections) {
87
+ /** See {@link PendValidationMode}; fixed for the coordinator's lifetime, so every pend it
88
+ * sends has one shape. */
89
+ pendValidation;
90
+ constructor(transactor, collections, options = {}) {
87
91
  this.transactor = transactor;
88
92
  this.collections = collections;
93
+ this.pendValidation = options.pendValidation ?? 'transaction';
94
+ }
95
+ /**
96
+ * The validation pair every pend of this attempt carries, or `undefined` when this coordinator
97
+ * sends unvalidated pends (see {@link PendValidationMode}). Built once per attempt, AFTER the
98
+ * log append: the hash covers ALL operations across ALL collections, which a validator
99
+ * recomputes from its own re-execution and compares. The shared operations-hash module
100
+ * canonicalises (sort + canonical JSON), so the fingerprint is order-independent. Skipped
101
+ * entirely — not computed and discarded — when no pend will carry it.
102
+ */
103
+ async pendValidationFor(transaction, collectionTransforms) {
104
+ if (this.pendValidation === 'none') {
105
+ return undefined;
106
+ }
107
+ return { transaction, operationsHash: await hashOperations(collectOperations(collectionTransforms)) };
89
108
  }
90
109
  /**
91
110
  * Apply actions to collections (called by engines during statement execution).
@@ -189,21 +208,43 @@ export class TransactionCoordinator {
189
208
  /**
190
209
  * Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
191
210
  *
192
- * The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
193
- * fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
194
- * durably committed, every tracker restored to its pre-append state). Before each re-attempt it
195
- * re-reads each collection to fresh revisions (so the retry pends against current state rather
196
- * than immediately re-failing stale), then backs off with the same jitter policy as
211
+ * The single-attempt work lives in {@link commitOnce}; {@link commitAttempts} re-drives it when
212
+ * the attempt fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} —
213
+ * nothing durably committed, every tracker restored to its pre-append state). Before each
214
+ * re-attempt it re-reads each collection to fresh revisions (so the retry pends against current
215
+ * state rather than immediately re-failing stale), then backs off with the same jitter policy as
197
216
  * {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
198
217
  * `deadlineMs`, and honours an abort `signal`.
199
218
  *
200
- * A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
201
- * is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
202
- * Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
203
- * propagates without retry only genuine clean stale losses are re-driven.
219
+ * A PARTIAL landing — some collection durably committed while a sibling's commit was refused —
220
+ * is re-driven too when every refusal was a returned one (a member behind on the block's base,
221
+ * a rival that took the revision slot): the committed collections are recorded as saved and the
222
+ * next attempt commits only what is still staged, so the durable half is never re-logged and the
223
+ * refused collection gets the same refresh-and-finish recovery a clean loss gets. A caller must
224
+ * still never re-drive a partial landing itself. A partial landing on a HARD failure (the
225
+ * transport budget spent, a structural rejection) escapes at once as
226
+ * {@link CoordinatorPartialCommitError}, and any other failure (expired transaction,
227
+ * unavailable transactor, unreachable cluster) also propagates without retry.
228
+ *
229
+ * A participant can also be SAVED between attempts: an attempt that reported a loss may still
230
+ * have stored that participant's log tail, and the refresh before the next attempt then finishes
231
+ * and consumes the participant's own entry (see {@link refreshBetweenAttempts}). From that moment
232
+ * the commit can no longer fail cleanly, so EVERY failure that escapes afterwards — a torn sibling,
233
+ * a sibling that keeps losing until the budget runs out, an abort, an expiry, a hard error — is
234
+ * rethrown as {@link CoordinatorPartialCommitError}, counting the refresh-saved participants as
235
+ * committed and carrying the original error as its `reason` (see {@link reportSaved}). A commit in
236
+ * which nothing was saved fails with exactly the error it failed with.
204
237
  *
205
238
  * Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
206
239
  *
240
+ * NOTE: this returns nothing, so a multi-collection commit reports NO durability — unlike
241
+ * `Collection.sync`, which now answers with the {@link WriteDurability} of what it committed. The
242
+ * gap is deliberate and tracked in `tickets/backlog/feat-multi-collection-commit-reports-durability.md`:
243
+ * folding a class across collections that may individually have committed or been dropped (see
244
+ * {@link CoordinatorPartialCommitError}) is its own design problem, not a pass-through. Until that
245
+ * lands, a caller that needs to know whether a write is fully held has to use the single-collection
246
+ * path; do not infer one here from a plain successful return.
247
+ *
207
248
  * @param transaction - The transaction to commit
208
249
  * @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
209
250
  */
@@ -217,6 +258,31 @@ export class TransactionCoordinator {
217
258
  if (open !== undefined) {
218
259
  throw new CoordinatorConcurrentStampError(open, transaction.stamp.id);
219
260
  }
261
+ const cycle = {
262
+ inFlightDisposers: [],
263
+ participants: new Set(),
264
+ saved: new Set(),
265
+ };
266
+ try {
267
+ await this.commitAttempts(transaction, cycle, options);
268
+ }
269
+ catch (err) {
270
+ // The ONE place a failure leaves this commit, so no exit — present or future — can skip
271
+ // reporting a participant that is already saved.
272
+ throw this.reportSaved(transaction, cycle, err);
273
+ }
274
+ finally {
275
+ // The in-flight marks must outlive each individual attempt (see CommitCycle), so they are
276
+ // cleared here, on every exit: return, stale-loss exhaustion, a partial landing, a hard
277
+ // error, an abort.
278
+ for (const dispose of cycle.inFlightDisposers) {
279
+ dispose();
280
+ }
281
+ }
282
+ }
283
+ /** The retry loop behind {@link commit}: every attempt, and the refresh between them. Records
284
+ * what it learns in `cycle`; {@link commit} owns turning a failure into the right report. */
285
+ async commitAttempts(transaction, cycle, options) {
220
286
  const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
221
287
  const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
222
288
  const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
@@ -228,73 +294,195 @@ export class TransactionCoordinator {
228
294
  // re-drive a losing transaction before surfacing a terminal error.
229
295
  let staleLosses = 0;
230
296
  let lastLoss;
231
- // Disposers for the in-flight marks {@link commitOnce} sets on each participant it latches.
232
- // They must outlive the individual attempt: the inter-attempt refresh below is the ONLY
233
- // reader of the mark, and it deliberately runs after the commit span released its latches
234
- // (`Latches` is non-reentrant), so a clear tied to the latch would already have run. Hence
235
- // the finally spans the WHOLE retry loop every exit clears: return, stale-loss exhaustion,
236
- // a partial landing, a hard error, an abort. Each disposer is id-guarded, so re-marking on a
237
- // later attempt is harmless and a stale disposer cannot wipe a newer mark.
238
- const inFlightDisposers = [];
239
- try {
240
- for (;;) {
241
- if (signal?.aborted) {
242
- throw makeAbortError(signal);
243
- }
244
- // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
245
- // wall-clock deadline passed (independent of the attempt cap).
246
- if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
247
- throw lastLoss;
248
- }
249
- // Age the transaction's advisory priority by the number of losses taken so far, so a
250
- // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
251
- // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
252
- // signature, so bumping it here does not churn identity. Left untouched on the first
253
- // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
254
- if (staleLosses > 0) {
255
- transaction.priority = clampPriority(staleLosses);
297
+ for (;;) {
298
+ if (signal?.aborted) {
299
+ throw makeAbortError(signal);
300
+ }
301
+ // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
302
+ // wall-clock deadline passed (independent of the attempt cap).
303
+ if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
304
+ throw lastLoss;
305
+ }
306
+ // Age the transaction's advisory priority by the number of losses taken so far, so a
307
+ // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
308
+ // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
309
+ // signature, so bumping it here does not churn identity. Left untouched on the first
310
+ // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
311
+ if (staleLosses > 0) {
312
+ transaction.priority = clampPriority(staleLosses);
313
+ }
314
+ try {
315
+ await this.commitOnce(transaction, cycle);
316
+ return;
317
+ }
318
+ catch (err) {
319
+ // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
320
+ // unavailable transactor, etc. all propagate unchanged.
321
+ if (!(err instanceof CoordinatorStaleLossError)) {
322
+ throw err;
256
323
  }
257
- try {
258
- await this.commitOnce(transaction, inFlightDisposers);
259
- return;
324
+ lastLoss = err;
325
+ staleLosses++;
326
+ if (staleLosses >= maxAttempts) {
327
+ throw err;
260
328
  }
261
- catch (err) {
262
- // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
263
- // unavailable transactor, etc. all propagate unchanged.
264
- if (!(err instanceof CoordinatorStaleLossError)) {
265
- throw err;
266
- }
267
- lastLoss = err;
268
- staleLosses++;
269
- if (staleLosses >= maxAttempts) {
270
- throw err;
271
- }
329
+ // Re-read fresh state before re-attempting so the next commit pends against current
330
+ // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
331
+ //
332
+ // A participant whose log tail landed despite the reported loss finds its own entry
333
+ // here and FINISHES that action before consuming it (Collection.completeOwnEntry,
334
+ // from the attempt commitOnceLatched retained). When finishing is refused for a cause
335
+ // that can clear, the refresh — not the commit — is what gets retried: commitOnce
336
+ // would rebuild the participant's log entry with a fresh timestamp and send a second
337
+ // version of a log tail already stored under this transaction's id and revision.
338
+ // Each such round counts against the same budget as a stale loss; a permanent
339
+ // refusal (a rival holds the revision) escapes as the named TornActionError at once.
340
+ // Re-refreshing collections an earlier round already refreshed is a no-op.
341
+ // The round that would spend the last of the budget is told so, which makes each
342
+ // participant SETTLE a half-landed write rather than ask for another round: the
343
+ // error thrown below then says whether the write can still land (`final`). A
344
+ // deadline cannot be foreseen the same way; a write given up on it stays unsettled.
345
+ for (;;) {
272
346
  const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
273
347
  await abortableDelay(delay, signal);
274
- // Re-read fresh state before re-attempting so the next commit pends against current
275
- // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
276
- // NOTE: refreshes EVERY registered collection, not only the participants of this
277
- // transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
278
- // if its header momentarily reads absent while it holds a committed revision, aborting
279
- // this retry. The registered set is small today; if that (or retry latency) ever bites,
280
- // narrow this to the transaction's participating collections.
281
- for (const collection of this.collections.values()) {
282
- await collection.update();
348
+ const refused = await this.refreshBetweenAttempts(cycle, staleLosses + 1 >= maxAttempts);
349
+ if (refused === undefined) {
350
+ break;
351
+ }
352
+ staleLosses++;
353
+ if (staleLosses >= maxAttempts
354
+ || (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs)) {
355
+ throw refused;
283
356
  }
284
357
  }
358
+ if (cycle.saved.size > 0 && this.stagedCollections().length === 0) {
359
+ // The refresh saved what was left: nothing is staged any more, so this transaction
360
+ // is committed. Return now rather than go round again, where an abort, the deadline
361
+ // or an expiry check could still report failure for a transaction that is entirely
362
+ // saved. The refresh's consume and replay already gave each saved participant the
363
+ // success path's local treatment, so only the stamp is left to release.
364
+ // Tested on what is still STAGED, not on "every participant saved": a participant
365
+ // can be saved and still hold actions staged after the attempt began, and those
366
+ // the next attempt must commit.
367
+ this.stampData.delete(transaction.stamp.id);
368
+ return;
369
+ }
285
370
  }
286
371
  }
287
- finally {
288
- for (const dispose of inFlightDisposers) {
289
- dispose();
372
+ }
373
+ /**
374
+ * Refresh every registered collection between attempts, recording in `cycle.saved` each one
375
+ * whose refresh finished this transaction's own log entry (see {@link Collection.refreshInFlight}).
376
+ *
377
+ * Visits EVERY collection even after one throws. Stopping at the first throw would leave a
378
+ * participant later in the map unfinished whenever an earlier one is torn — its log tail stored
379
+ * and its other blocks never landed, the silent loss `Collection.completeOwnEntry` exists to
380
+ * prevent — and unreported. Each refresh is independent (its own latch, its own instance state),
381
+ * so one failing says nothing about the next.
382
+ *
383
+ * NOTE: refreshes EVERY registered collection, not only the participants of this transaction.
384
+ * Not free: a non-participant's refresh throws CollectionHeaderVanishedError if its header
385
+ * momentarily reads absent while it holds a committed revision, aborting this retry. And because
386
+ * a throw no longer stops the round, an unreachable cluster costs one failed read per registered
387
+ * collection rather than one. The registered set is small today; if that (or retry latency) ever
388
+ * bites, narrow this to the transaction's participants.
389
+ *
390
+ * @param lastChance - this is the round that spends the last of the retry budget, so a
391
+ * participant whose half-landed write is refused again settles it (`Collection.completeOwnEntry`)
392
+ * rather than asking for a round it will not get; the error returned then carries `final`.
393
+ * @returns the first `completion-refused` {@link TornActionError} when that is the only way any
394
+ * refresh failed — the round is worth retrying; `undefined` when every refresh succeeded.
395
+ * @throws the first other error, once every collection has been visited.
396
+ */
397
+ async refreshBetweenAttempts(cycle, lastChance) {
398
+ let terminal;
399
+ let refused;
400
+ for (const [collectionId, collection] of this.collections) {
401
+ const report = {};
402
+ try {
403
+ await collection.refreshInFlight(report, lastChance);
404
+ }
405
+ catch (err) {
406
+ if (err instanceof TornActionError && err.reason === 'completion-refused') {
407
+ refused ??= err;
408
+ }
409
+ else {
410
+ terminal ??= { error: err };
411
+ }
290
412
  }
413
+ // Read after the try, not only on success: a refresh can throw AFTER it finished the
414
+ // entry, and that participant is saved either way.
415
+ if (report.ownEntryFinished !== undefined) {
416
+ cycle.saved.add(collectionId);
417
+ }
418
+ }
419
+ if (terminal !== undefined) {
420
+ throw terminal.error;
291
421
  }
422
+ return refused;
423
+ }
424
+ /** Every registered collection with something to push — the participants an attempt starting
425
+ * now would commit. The predicate is {@link Collection.hasUnsyncedChanges}, the one
426
+ * `Collection.sync` loops on: staged actions OR uncommitted tracker transforms. Not the
427
+ * transforms alone: an action can be staged and have changed no block — a delete of an index
428
+ * entry this instance's stale view never held (the shape `blind-index-delete-is-logged-but-
429
+ * never-applied` fixed for the single-collection path) — and such a collection MUST still take
430
+ * part. Its stale pend is refused, and the refresh between attempts replays the action against
431
+ * the adopted revision, where it finds the entry and produces the delete the retry commits.
432
+ * Filtering on transforms dropped it from the batch entirely, so the row went and its index
433
+ * entry stayed behind, on every node. */
434
+ stagedCollections() {
435
+ return Array.from(this.collections.entries())
436
+ .filter(([, collection]) => collection.hasUnsyncedChanges())
437
+ .map(([collectionId, collection]) => ({
438
+ collectionId,
439
+ collection,
440
+ transforms: collection.tracker.transforms
441
+ }));
442
+ }
443
+ /**
444
+ * What a failure escaping {@link commit} must be reported as. When nothing is saved, the error
445
+ * itself, untouched. When a refresh between attempts already saved a participant, the commit
446
+ * half-landed whatever the error says — a {@link CoordinatorStaleLossError}'s "nothing durably
447
+ * committed, safe to re-drive" would be false, and a re-drive would apply the saved half twice —
448
+ * so it becomes a {@link CoordinatorPartialCommitError}:
449
+ *
450
+ * - `committedCollections`: every refresh-saved participant, plus the committed set of `err`
451
+ * when it is itself a partial landing (merged, never wrapped twice — its `reason` is kept);
452
+ * - `failedCollections`: every other participant of any attempt. Empty when a failure came after
453
+ * every participant was saved (a refresh that finished its entry and then threw, say);
454
+ * - `reason`: the original error.
455
+ *
456
+ * Local state already matches that report: a saved participant was consumed by its refresh, and
457
+ * an unsaved one kept its staged actions (a failed finish throws before touching anything, and
458
+ * commitOnceLatched restores every participant it did not commit). The stamp is released, as on
459
+ * the existing partial path, because the transaction can no longer be cleanly rolled back.
460
+ */
461
+ reportSaved(transaction, cycle, err) {
462
+ if (cycle.saved.size === 0) {
463
+ return err;
464
+ }
465
+ const committed = new Set(cycle.saved);
466
+ let reason = err;
467
+ if (err instanceof CoordinatorPartialCommitError) {
468
+ for (const id of err.committedCollections)
469
+ committed.add(id);
470
+ reason = err.reason;
471
+ }
472
+ // NOTE: a participant is in exactly one list, so one saved by a refresh that ALSO held actions
473
+ // staged after its attempt began (and a later attempt failed to commit them) is listed only as
474
+ // committed. Unreachable while a session stages and commits on one call path; if staging ever
475
+ // races a commit, report such a participant in both lists.
476
+ const failed = [...cycle.participants].filter(id => !committed.has(id));
477
+ log('commit:partial-after-refresh tx=%s committed=%o failed=%o reason=%s', transaction.id, [...committed], failed, reason instanceof Error ? reason.name : String(reason));
478
+ this.stampData.delete(transaction.stamp.id);
479
+ return new CoordinatorPartialCommitError([...committed], failed, reason);
292
480
  }
293
481
  /**
294
482
  * Commit a transaction (single attempt): materialise a log entry from each collection's staged
295
483
  * pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
296
484
  *
297
- * Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
485
+ * Called by {@link commitAttempts} (the backoff+jitter retry loop behind {@link commit}). The
298
486
  * staged mutations already live in each collection's tracker — applied either via
299
487
  * applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
300
488
  * (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
@@ -302,33 +490,34 @@ export class TransactionCoordinator {
302
490
  * and folds the committed transforms back into each collection's read cache.
303
491
  *
304
492
  * On a clean stale loss (nothing durable, every tracker restored) it throws
305
- * {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
493
+ * {@link CoordinatorStaleLossError} so the caller can retry. On a partial landing it throws the
494
+ * same, after recording the committed collections in `cycle.saved`, when every refusal was a
495
+ * returned one (the retry finishes the refused collection); on a hard failure it throws
306
496
  * {@link CoordinatorPartialCommitError} (not retryable).
307
497
  *
308
498
  * @param transaction - The transaction to commit
309
- * @param inFlightDisposers - Collects one disposer per participant marked in flight under this
310
- * transaction's id (see {@link Collection.beginInFlightAction}). REQUIRED, so a future caller
311
- * cannot silently reintroduce the unmarked refresh this parameter exists to prevent: a caller
312
- * that never refreshes between attempts passes a throwaway array and simply ignores it. The
313
- * caller owns clearing them, because the mark has to survive past this attempt see the
314
- * array's declaration in {@link commit}.
499
+ * @param cycle - The state this commit keeps across attempts (see {@link CommitCycle}). This
500
+ * attempt adds its participants to `cycle.participants` and one disposer per participant it marks
501
+ * in flight under this transaction's id (see {@link Collection.beginInFlightAction}) to
502
+ * `cycle.inFlightDisposers`. REQUIRED, so a future caller cannot silently reintroduce the
503
+ * unmarked refresh the disposers exist to prevent, nor lose track of who took part. The caller
504
+ * owns clearing the marks, because they have to survive past this attempt.
315
505
  */
316
- async commitOnce(transaction, inFlightDisposers) {
506
+ async commitOnce(transaction, cycle) {
317
507
  if (isTransactionExpired(transaction.stamp)) {
318
508
  throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
319
509
  }
320
510
  // Collect collections with staged (un-synced) changes.
321
- const collectionData = Array.from(this.collections.entries())
322
- .map(([collectionId, collection]) => ({
323
- collectionId,
324
- collection,
325
- transforms: collection.tracker.transforms
326
- }))
327
- .filter(({ transforms }) => Object.keys(transforms.inserts ?? {}).length +
328
- Object.keys(transforms.updates ?? {}).length +
329
- (transforms.deletes?.length ?? 0) > 0);
511
+ const collectionData = this.stagedCollections();
330
512
  if (collectionData.length === 0) {
331
- return; // Nothing to commit
513
+ // Nothing to commit is a successful commit: release the stamp as the success path does,
514
+ // or a read-only transaction that opened one (the empty pre-stage barrier) wedges every
515
+ // later stamp on this coordinator.
516
+ this.stampData.delete(transaction.stamp.id);
517
+ return;
518
+ }
519
+ for (const { collectionId } of collectionData) {
520
+ cycle.participants.add(collectionId);
332
521
  }
333
522
  // NOTE: this selection reads each tracker BEFORE the latches below are held, so a stage
334
523
  // that lands between the filter and the acquisition is simply not part of this commit.
@@ -344,7 +533,7 @@ export class TransactionCoordinator {
344
533
  // (db-p2p/src/storage/block-latch.ts), so two concurrent commits over overlapping
345
534
  // participant sets cannot deadlock. `Latches` is non-reentrant, so nothing inside the
346
535
  // held span may call a latched Collection method (act/update/sync/updateAndSync) on a
347
- // participant — the retry loop's blanket collection.update() in commit() runs OUTSIDE
536
+ // participant — the retry loop's blanket refresh in commitAttempts runs OUTSIDE
348
537
  // this span, after release.
349
538
  // NOTE: the span covers the pend/commit consensus round trips, so every latched method on
350
539
  // a participant instance (act/update/sync) queues for as long as the transactor takes.
@@ -357,15 +546,16 @@ export class TransactionCoordinator {
357
546
  for (const { collection } of latchOrder) {
358
547
  latchReleases.push(await collection.acquireLatch());
359
548
  // Mark THIS attempt's action id on each participant while its latch is held, so the
360
- // inter-attempt refresh in commit() recognises a log entry this transaction itself
361
- // made durable (a torn commit: header and log tail committed, a later sweep block
362
- // reported the conflict) and consumes it instead of replaying it into a second entry
363
- // under the same id. `transaction.id` is stable across retries, so re-marking on a
549
+ // inter-attempt refresh in commitAttempts recognises a log entry this transaction itself
550
+ // stored (a torn commit: the log tail landed, and either its own answer or a later
551
+ // sweep block's reported failure), finishes that action's remaining blocks, and then
552
+ // consumes the entry instead of replaying it into a second entry under the same id
553
+ // (Collection.completeOwnEntry). `transaction.id` is stable across retries, so re-marking on a
364
554
  // later attempt re-states the same fact. Only participants are marked; a registered
365
555
  // non-participant is left unmarked and its refresh behaves exactly as a reader's.
366
- inFlightDisposers.push(collection.beginInFlightAction(transaction.id));
556
+ cycle.inFlightDisposers.push(collection.beginInFlightAction(transaction.id));
367
557
  }
368
- await this.commitOnceLatched(transaction, collectionData);
558
+ await this.commitOnceLatched(transaction, collectionData, cycle);
369
559
  }
370
560
  finally {
371
561
  for (const release of latchReleases.reverse()) {
@@ -379,8 +569,12 @@ export class TransactionCoordinator {
379
569
  * participant's latch — every Collection member it touches (snapshotPending, getPendingActions,
380
570
  * recordCommitted, applyCommittedToCache, restorePending, clearPendingActions, tracker.reset)
381
571
  * is latch-free by contract.
572
+ *
573
+ * `cycle` is written only on a partial landing that is retried forward: the participants this
574
+ * attempt committed go into `cycle.saved`, so the retry loop reports every later failure as the
575
+ * partial landing it is (see {@link reportSaved}).
382
576
  */
383
- async commitOnceLatched(transaction, collectionData) {
577
+ async commitOnceLatched(transaction, collectionData, cycle) {
384
578
  // Append each collection's staged actions to its log, then collect the
385
579
  // resulting transforms + critical (log-tail) block for consensus.
386
580
  //
@@ -400,6 +594,14 @@ export class TransactionCoordinator {
400
594
  // append in applyActionsToCollection, the single legitimate capture point — and threaded
401
595
  // through pend, commit, and the local recordCommitted, so all four name the same number.
402
596
  const pendedRevs = new Map();
597
+ // A participant is never pended over a base that moved under its staged operations
598
+ // (Collection.restageIfBasesMoved). Each re-stages its own queue only. BEFORE the snapshots
599
+ // below, so a failed attempt restores transforms that agree with the tracker's pins; and
600
+ // before the append loop, since a replay resets the tracker and would drop the appended
601
+ // log entry.
602
+ for (const { collection } of collectionData) {
603
+ await collection.restageIfBasesMoved();
604
+ }
403
605
  // Snapshot EVERY participating collection's staged state (transforms + pending
404
606
  // queue) BEFORE the append loop mutates any tracker. The loop appends log
405
607
  // entries sequentially, so a failure on the Nth collection must also undo the
@@ -423,13 +625,10 @@ export class TransactionCoordinator {
423
625
  criticalBlocks.set(collectionId, applyResult.logTailBlockId);
424
626
  pendedRevs.set(collectionId, applyResult.rev);
425
627
  }
426
- // Compute hash of ALL operations across ALL collections (post-log-append).
427
- // Validators re-execute the transaction and compare their computed hash.
428
- // The shared operations-hash module canonicalises (sort + canonical JSON) so
429
- // this order-independent fingerprint matches what a validator recomputes.
430
- const operationsHash = await hashOperations(collectOperations(collectionTransforms));
628
+ // What each pend carries for members to re-check (post-log-append) — see pendValidationFor.
629
+ const validation = await this.pendValidationFor(transaction, collectionTransforms);
431
630
  // Execute consensus phases (GATHER, PEND, COMMIT)
432
- coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs);
631
+ coordResult = await this.coordinateTransaction(transaction, validation, collectionTransforms, criticalBlocks, pendedRevs);
433
632
  }
434
633
  catch (err) {
435
634
  // A throw here means the failure happened BEFORE any collection could
@@ -444,6 +643,25 @@ export class TransactionCoordinator {
444
643
  }
445
644
  if (!coordResult.success) {
446
645
  const committed = coordResult.committedCollections ?? new Set();
646
+ // Keep exactly what each participant that did NOT commit sent, BEFORE the restores below
647
+ // swap its tracker out. A refused commit is not proof nothing landed: a participant's log
648
+ // tail is committed first and can be stored while the answer is still a failure, and
649
+ // cancelPhase has just dropped the pending records of every block that did not land. If
650
+ // the next refresh of that participant finds this transaction's own log entry, these are
651
+ // the transforms that finish it (Collection.completeOwnEntry) — without them the refresh
652
+ // can only refuse the write as torn, never save it. Copied, because until the restore
653
+ // this is the participant's LIVE tracker state. No block digests are retained: they are
654
+ // computed per commit from the live tracker (commitCollection), which is gone by the time
655
+ // a refresh re-sends, and an undeclared block falls back to member-side corroboration.
656
+ for (const { collectionId, collection } of collectionData) {
657
+ if (committed.has(collectionId))
658
+ continue;
659
+ collection.retainInFlightAttempt(transaction.id, {
660
+ rev: pendedRevs.get(collectionId),
661
+ transforms: copyTransforms(collectionTransforms.get(collectionId)),
662
+ tailId: criticalBlocks.get(collectionId),
663
+ });
664
+ }
447
665
  if (committed.size > 0) {
448
666
  // PARTIAL COMMIT: at least one collection durably committed via consensus
449
667
  // while another failed permanently. A uniform pre-append restore would
@@ -471,11 +689,39 @@ export class TransactionCoordinator {
471
689
  collection.restorePending(preCommitSnapshots.get(collectionId));
472
690
  }
473
691
  }
474
- // The transaction half-landed, so it is neither cleanly retryable nor
475
- // cleanly abortable: drop its stamp tracking (the success path does the
476
- // same at the end) and surface the structured signal for reconciliation.
692
+ const failed = [...(coordResult.failedCollections ?? new Set())];
693
+ if (coordResult.staleLoss) {
694
+ // Every failure was a RETURNED refusal: a member behind on the block's base (the
695
+ // durability gate's `commit-not-durable`, from a replica that has not caught up —
696
+ // the writer's own, on a node that has just joined), or a rival that took the
697
+ // revision slot. The siblings above have landed and cannot be rolled back, so
698
+ // FORWARD recovery — finishing this collection — is the only way back to
699
+ // all-or-nothing, and the retry loop already does it: the refresh between attempts
700
+ // finds this transaction's own log entry where the tail landed and re-sends the
701
+ // retained attempt at the same revision (Collection.completeOwnEntry), or the next
702
+ // attempt re-pends afresh when nothing of it landed. The committed siblings are
703
+ // recorded as saved, so every failure that still escapes is reported as the partial
704
+ // landing it is (reportSaved), and the next attempt commits only what is still
705
+ // staged (stagedCollections) — the durable half is never re-logged. The stamp stays
706
+ // open for the retry; reportSaved or the success path releases it.
707
+ // The committed siblings keep their in-flight mark until the cycle ends, so the
708
+ // blanket refresh between attempts WALKS their log (the mark disables the tail
709
+ // shortcut) but cannot find their own entry: recordCommitted above advanced each
710
+ // one's held revision to the entry's own, and Log.getFrom returns only entries
711
+ // ABOVE the held revision. Collection.consumeOwnEntry is therefore unreachable for
712
+ // a sibling committed here, and no attempt was retained for it (the loop above
713
+ // skips committed participants) — the refresh is a reader's, at a cost of one walk.
714
+ for (const collectionId of committed)
715
+ cycle.saved.add(collectionId);
716
+ log('commit:partial-retry tx=%s committed=%o failed=%o reason=%s', transaction.id, [...committed], failed, coordResult.error);
717
+ throw new CoordinatorStaleLossError(failed, coordResult.error);
718
+ }
719
+ // Half-landed on a HARD failure (the transport budget spent on a thrown fault, a
720
+ // structural rejection): neither cleanly retryable nor cleanly abortable, so drop the
721
+ // stamp tracking (the success path does the same at the end) and surface the
722
+ // structured signal for reconciliation at once.
477
723
  this.stampData.delete(transaction.stamp.id);
478
- throw new CoordinatorPartialCommitError([...committed], [...(coordResult.failedCollections ?? new Set())], coordResult.error);
724
+ throw new CoordinatorPartialCommitError([...committed], failed, coordResult.error);
479
725
  }
480
726
  // EMPTY committed set: PEND failed, or the whole commit failed cleanly with
481
727
  // nothing durable. Restore every tracker so each is pristine for retry.
@@ -778,6 +1024,11 @@ export class TransactionCoordinator {
778
1024
  latchReleases.push(await collection.acquireLatch());
779
1025
  }
780
1026
  }
1027
+ // Same pre-pend rule as commitOnceLatched: no participant is pended over a moved base.
1028
+ // Under the latches and before the append loop, which a replay's tracker reset would undo.
1029
+ for (const collectionId of allCollectionIds) {
1030
+ await this.collections.get(collectionId)?.restageIfBasesMoved();
1031
+ }
781
1032
  for (const [collectionId, actions] of batches) {
782
1033
  const applyResult = await this.applyActionsToCollection({ collectionId, actions }, transaction, allCollectionIds);
783
1034
  if (!applyResult.success) {
@@ -788,12 +1039,12 @@ export class TransactionCoordinator {
788
1039
  actionResults.set(collectionId, applyResult.results);
789
1040
  pendedRevs.set(collectionId, applyResult.rev);
790
1041
  }
791
- // 3. Compute operations hash for validation (order-independent; see commit()).
792
- const operationsHash = await hashOperations(collectOperations(collectionTransforms));
1042
+ // 3. What each pend carries for members to re-check (see pendValidationFor).
1043
+ const validation = await this.pendValidationFor(transaction, collectionTransforms);
793
1044
  const applyMs = Date.now() - tApply;
794
1045
  // 4. Coordinate (GATHER if multi-collection)
795
1046
  const tCoord = Date.now();
796
- const coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs);
1047
+ const coordResult = await this.coordinateTransaction(transaction, validation, collectionTransforms, criticalBlocks, pendedRevs);
797
1048
  const coordMs = Date.now() - tCoord;
798
1049
  if (!coordResult.success) {
799
1050
  log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
@@ -806,6 +1057,14 @@ export class TransactionCoordinator {
806
1057
  // unsafe all-or-nothing undo handle survives (rollback() would rewind the winner).
807
1058
  // Kept await-free for the same reason the success fold is — restorePending is
808
1059
  // synchronous and latch-free by contract, so it is safe inside this latched span.
1060
+ // NOTE: execute() has no retry loop at all, so a returned refusal after a sibling
1061
+ // committed is reported here at once, where commit() would re-drive the refused
1062
+ // collection forward (the partial-retry branch of commitOnceLatched). No production
1063
+ // caller reaches this today — the plugin's session mode stages through applyActions
1064
+ // and commits through commit() — so the split window is documented, not closed
1065
+ // (docs/transactions.md). If a host ever commits multi-collection transactions
1066
+ // through execute(), route its coordination through commit()'s retry rather than
1067
+ // duplicating that recovery here.
809
1068
  const committed = coordResult.committedCollections ?? new Set();
810
1069
  if (committed.size > 0) {
811
1070
  for (const collectionId of batches.keys()) {
@@ -921,9 +1180,10 @@ export class TransactionCoordinator {
921
1180
  // which collection's log the dependent landed in.
922
1181
  // NOTE: `allCollectionIds` names the participants of THIS attempt, and a retry's participant
923
1182
  // set can be SMALLER than the first attempt's. After a torn commit, the participant whose
924
- // entry landed durably consumes that entry on the inter-attempt refresh
925
- // (Collection.inFlightActionId), empties its pending queue and resets its tracker, so
926
- // commitOnce's non-empty-transforms filter drops it from the next attempt. The retry's
1183
+ // log entry landed finishes that action on the inter-attempt refresh — landing the blocks
1184
+ // the refused commit left behind, at the same id and revision and only then consumes the
1185
+ // entry (Collection.completeOwnEntry), emptying its pending queue and resetting its tracker,
1186
+ // so commitOnce's non-empty-transforms filter drops it from the next attempt. The retry's
927
1187
  // entries therefore list only the REMAINING participants, while the torn participant's
928
1188
  // already-durable entry lists them all — one transaction id, two different
929
1189
  // `allCollectionIds` values across its entries. Nothing today keys off that list for
@@ -944,14 +1204,15 @@ export class TransactionCoordinator {
944
1204
  * Coordinate a transaction across multiple collections.
945
1205
  *
946
1206
  * @param transaction - The transaction to coordinate
947
- * @param operationsHash - Hash of all operations for validation
1207
+ * @param validation - What every pend carries for members to re-check, or undefined for an
1208
+ * unvalidated pend (see {@link pendValidationFor})
948
1209
  * @param collectionTransforms - Map of collectionId to its transforms
949
1210
  * @param criticalBlocks - Map of collectionId to its log tail blockId
950
1211
  * @param pendedRevs - Per collection, the revision its log entry was stamped with (from
951
1212
  * applyActionsToCollection) — repeated verbatim on the pend and commit requests so storage
952
1213
  * and the local record name the same number.
953
1214
  */
954
- async coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs) {
1215
+ async coordinateTransaction(transaction, validation, collectionTransforms, criticalBlocks, pendedRevs) {
955
1216
  const trxId = transaction.id;
956
1217
  const t0 = Date.now();
957
1218
  // 1. GATHER phase: collect critical cluster nominees (skip if single collection)
@@ -961,7 +1222,7 @@ export class TransactionCoordinator {
961
1222
  const gatherMs = Date.now() - tGather;
962
1223
  // 2. PEND phase: distribute to all block clusters
963
1224
  const tPend = Date.now();
964
- const pendResult = await this.pendPhase(transaction, operationsHash, collectionTransforms, pendedRevs, superclusterNominees);
1225
+ const pendResult = await this.pendPhase(transaction, validation, collectionTransforms, pendedRevs, superclusterNominees);
965
1226
  const pendMs = Date.now() - tPend;
966
1227
  if (!pendResult.success) {
967
1228
  log('trx:phases trxId=%s gather=%dms pend=%dms (failed) total=%dms', trxId, gatherMs, pendMs, Date.now() - t0);
@@ -1025,15 +1286,17 @@ export class TransactionCoordinator {
1025
1286
  /**
1026
1287
  * PEND phase: Distribute transaction to all affected block clusters.
1027
1288
  *
1028
- * @param transaction - The full transaction for replay/validation
1029
- * @param operationsHash - Hash of all operations for validation
1289
+ * @param transaction - The transaction being pended (its id names the action; its aged
1290
+ * priority rides on an unvalidated pend)
1291
+ * @param validation - What every pend carries for members to re-check, or undefined for an
1292
+ * unvalidated pend (see {@link pendValidationFor})
1030
1293
  * @param collectionTransforms - Map of collectionId to its transforms
1031
1294
  * @param pendedRevs - Per collection, the revision its log entry was stamped with — the pend
1032
1295
  * request repeats it verbatim rather than recomputing from the collection (a recompute after
1033
1296
  * the append could name a different number if the collection refreshed in between).
1034
1297
  * @param superclusterNominees - Nominees for multi-collection consensus (null for single-collection)
1035
1298
  */
1036
- async pendPhase(transaction, operationsHash, collectionTransforms, pendedRevs, superclusterNominees) {
1299
+ async pendPhase(transaction, validation, collectionTransforms, pendedRevs, superclusterNominees) {
1037
1300
  if (collectionTransforms.size === 0) {
1038
1301
  return { success: false, error: 'No transforms to pend' };
1039
1302
  }
@@ -1044,7 +1307,7 @@ export class TransactionCoordinator {
1044
1307
  // NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
1045
1308
  // Transactions touch few collections today; if one ever spans very many, bound this
1046
1309
  // with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
1047
- const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, operationsHash, collectionId, transforms, pendedRevs.get(collectionId), actionId, nominees)));
1310
+ const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, validation, collectionId, transforms, pendedRevs.get(collectionId), actionId, nominees)));
1048
1311
  // Partition settled results: every collection that DID pend (keyed with its block
1049
1312
  // ids), plus the first failure reason if any collection failed.
1050
1313
  const pendedBlockIds = new Map();
@@ -1085,7 +1348,7 @@ export class TransactionCoordinator {
1085
1348
  * pended block ids on success; throws with a per-collection reason on failure so the
1086
1349
  * fan-out in {@link pendPhase} can settle it as a rejection.
1087
1350
  */
1088
- async pendCollection(transaction, operationsHash, collectionId, transforms,
1351
+ async pendCollection(transaction, validation, collectionId, transforms,
1089
1352
  /** The revision the log entry was stamped with (threaded from applyActionsToCollection),
1090
1353
  * NOT recomputed here: a `getNextRev()` after the append round trips could name a number
1091
1354
  * a concurrent refresh already moved past. */
@@ -1094,14 +1357,29 @@ export class TransactionCoordinator {
1094
1357
  if (!collection) {
1095
1358
  throw new Error(`Collection not found: ${collectionId}`);
1096
1359
  }
1097
- // Create pend request with the validation payload (transaction + operations hash) —
1098
- // always BOTH, as one pair: this is the only producer of PendRequest.validation.
1360
+ // Create the pend request. This is the only producer of PendRequest.validation, and it
1361
+ // always sends the pair whole (transaction + operations hash) or not at all. The aged
1362
+ // priority has one carrier per shape — inside the transaction when the pend carries it,
1363
+ // on the pend itself when it does not — because a member's race resolution reads the
1364
+ // transaction's priority first and falls back to the pend's (recordPriority in db-p2p's
1365
+ // race-resolution.ts); an unvalidated pend that left both empty would never age. Omitted
1366
+ // at 0, exactly as the single-collection path sends it (TransactorSource.transact), so
1367
+ // the common first-attempt pend is indistinguishable from a Collection.sync pend.
1368
+ const priority = clampPriority(transaction.priority);
1369
+ // The base each update-only block's operations were computed against, read from the same
1370
+ // tracker the commit side digests (commitCollection); it still holds this transaction's staged
1371
+ // transforms and pins. Storage keeps it with the pending record so the record is never applied
1372
+ // to a different version of the block (see PendRequest.baseRevs). Omitted when empty.
1373
+ const baseRevs = collection.tracker.stagedBaseRevs(blockIdsForTransforms(transforms));
1099
1374
  const pendRequest = {
1100
1375
  actionId,
1101
1376
  rev,
1102
1377
  transforms,
1103
1378
  policy: 'r', // Return policy: fail but return pending actions
1104
- validation: { transaction, operationsHash },
1379
+ ...(validation === undefined
1380
+ ? (priority > 0 ? { priority } : {})
1381
+ : { validation }),
1382
+ ...baseRevsField(baseRevs),
1105
1383
  superclusterNominees: nominees
1106
1384
  };
1107
1385
  const pendResult = await this.transactor.pend(pendRequest);
@@ -1202,10 +1480,14 @@ export class TransactionCoordinator {
1202
1480
  ...blockDigestsField(blockDigests)
1203
1481
  };
1204
1482
  // Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
1205
- // A returned { success:false } is a permanent stale loss (someone committed a newer rev);
1206
- // the identical request can never win, so return immediately without retrying. Either way
1207
- // cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
1208
- // exactly once commit itself no longer self-cancels.
1483
+ // A returned { success:false } is a refusal the IDENTICAL request can never win (a rival
1484
+ // committed a newer rev, or a member behind on the base refused and dropped its pending
1485
+ // record), so return immediately without retrying it here. The refusal is retried at the
1486
+ // coordinator level instead, as a DIFFERENT request: the retry loop refreshes, finishes the
1487
+ // collection's own log entry where the tail landed, or re-pends afresh — and it does so even
1488
+ // when a sibling collection has already committed (the partial-retry branch of
1489
+ // commitOnceLatched). Either way cancelPhase (run by coordinateTransaction on commitPhase
1490
+ // failure) releases the pend exactly once — commit itself no longer self-cancels.
1209
1491
  let lastTransientError;
1210
1492
  for (let attempt = 0; attempt < 3; attempt++) {
1211
1493
  try {
@@ -1217,7 +1499,11 @@ export class TransactionCoordinator {
1217
1499
  // mark it retryable at the coordinator level (after a re-read advances the rev).
1218
1500
  // NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
1219
1501
  // like the pend path does. Once the pend succeeded, a returned commit failure means
1220
- // the revision slot moved. One commit producer DOES set `conflict` now
1502
+ // the revision slot moved, or a member behind on the base refused the durability
1503
+ // gate's `commit-not-durable` (which does set `conflict`) or a solo StorageRepo's
1504
+ // `missing-base-revision` (a bare `reason`, no `conflict`). All of these clear on a
1505
+ // re-drive, so gating on `conflict` here would fail the last of them fast. One commit
1506
+ // producer DOES set `conflict` now —
1221
1507
  // db-p2p's CoordinatorRepo.commit returns lost commit-consensus races and classified
1222
1508
  // stale-commit rejections as `{ success:false, conflict:true }` (returning, not
1223
1509
  // throwing, is what keeps them out of the verbatim retry above) — but every returned