@optimystic/db-core 1.0.0-beta.2 → 1.0.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 (180) hide show
  1. package/dist/src/cluster/structs.d.ts +62 -16
  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 +467 -35
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +879 -149
  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 +192 -5
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +134 -0
  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 +109 -6
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +2 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +2 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logger-registry.d.ts +57 -0
  37. package/dist/src/logger-registry.d.ts.map +1 -0
  38. package/dist/src/logger-registry.js +168 -0
  39. package/dist/src/logger-registry.js.map +1 -0
  40. package/dist/src/logger.d.ts.map +1 -1
  41. package/dist/src/logger.js +3 -0
  42. package/dist/src/logger.js.map +1 -1
  43. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  44. package/dist/src/matchmaking/wire.js +8 -2
  45. package/dist/src/matchmaking/wire.js.map +1 -1
  46. package/dist/src/network/durability.d.ts +45 -0
  47. package/dist/src/network/durability.d.ts.map +1 -0
  48. package/dist/src/network/durability.js +114 -0
  49. package/dist/src/network/durability.js.map +1 -0
  50. package/dist/src/network/i-key-network.d.ts +14 -6
  51. package/dist/src/network/i-key-network.d.ts.map +1 -1
  52. package/dist/src/network/index.d.ts +3 -0
  53. package/dist/src/network/index.d.ts.map +1 -1
  54. package/dist/src/network/index.js +3 -0
  55. package/dist/src/network/index.js.map +1 -1
  56. package/dist/src/network/lineage.d.ts +39 -0
  57. package/dist/src/network/lineage.d.ts.map +1 -0
  58. package/dist/src/network/lineage.js +61 -0
  59. package/dist/src/network/lineage.js.map +1 -0
  60. package/dist/src/network/routing-key.d.ts +24 -0
  61. package/dist/src/network/routing-key.d.ts.map +1 -0
  62. package/dist/src/network/routing-key.js +9 -0
  63. package/dist/src/network/routing-key.js.map +1 -0
  64. package/dist/src/network/stale-failure.d.ts +5 -1
  65. package/dist/src/network/stale-failure.d.ts.map +1 -1
  66. package/dist/src/network/stale-failure.js +5 -1
  67. package/dist/src/network/stale-failure.js.map +1 -1
  68. package/dist/src/network/struct.d.ts +151 -8
  69. package/dist/src/network/struct.d.ts.map +1 -1
  70. package/dist/src/network/struct.js.map +1 -1
  71. package/dist/src/reactivity/wire.d.ts.map +1 -1
  72. package/dist/src/reactivity/wire.js +8 -2
  73. package/dist/src/reactivity/wire.js.map +1 -1
  74. package/dist/src/testing/test-transactor.d.ts +64 -7
  75. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  76. package/dist/src/testing/test-transactor.js +150 -23
  77. package/dist/src/testing/test-transactor.js.map +1 -1
  78. package/dist/src/transaction/coordinator.d.ts +141 -21
  79. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  80. package/dist/src/transaction/coordinator.js +408 -122
  81. package/dist/src/transaction/coordinator.js.map +1 -1
  82. package/dist/src/transaction/errors.d.ts +39 -10
  83. package/dist/src/transaction/errors.d.ts.map +1 -1
  84. package/dist/src/transaction/errors.js +34 -9
  85. package/dist/src/transaction/errors.js.map +1 -1
  86. package/dist/src/transaction/index.d.ts +1 -0
  87. package/dist/src/transaction/index.d.ts.map +1 -1
  88. package/dist/src/transaction/index.js.map +1 -1
  89. package/dist/src/transactor/block-floors.d.ts +78 -0
  90. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  91. package/dist/src/transactor/block-floors.js +62 -0
  92. package/dist/src/transactor/block-floors.js.map +1 -0
  93. package/dist/src/transactor/change-notifier.d.ts +43 -0
  94. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  95. package/dist/src/transactor/change-notifier.js +3 -0
  96. package/dist/src/transactor/change-notifier.js.map +1 -1
  97. package/dist/src/transactor/index.d.ts +1 -0
  98. package/dist/src/transactor/index.d.ts.map +1 -1
  99. package/dist/src/transactor/index.js +1 -0
  100. package/dist/src/transactor/index.js.map +1 -1
  101. package/dist/src/transactor/network-transactor.d.ts +43 -6
  102. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  103. package/dist/src/transactor/network-transactor.js +279 -81
  104. package/dist/src/transactor/network-transactor.js.map +1 -1
  105. package/dist/src/transactor/transactor-source.d.ts +94 -4
  106. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  107. package/dist/src/transactor/transactor-source.js +120 -42
  108. package/dist/src/transactor/transactor-source.js.map +1 -1
  109. package/dist/src/transactor/transactor.d.ts +14 -1
  110. package/dist/src/transactor/transactor.d.ts.map +1 -1
  111. package/dist/src/transform/atomic.d.ts.map +1 -1
  112. package/dist/src/transform/atomic.js +12 -9
  113. package/dist/src/transform/atomic.js.map +1 -1
  114. package/dist/src/transform/base-pins.d.ts +50 -17
  115. package/dist/src/transform/base-pins.d.ts.map +1 -1
  116. package/dist/src/transform/base-pins.js +58 -11
  117. package/dist/src/transform/base-pins.js.map +1 -1
  118. package/dist/src/transform/cache-source.d.ts +75 -11
  119. package/dist/src/transform/cache-source.d.ts.map +1 -1
  120. package/dist/src/transform/cache-source.js +162 -36
  121. package/dist/src/transform/cache-source.js.map +1 -1
  122. package/dist/src/transform/digest.d.ts +8 -1
  123. package/dist/src/transform/digest.d.ts.map +1 -1
  124. package/dist/src/transform/digest.js +19 -9
  125. package/dist/src/transform/digest.js.map +1 -1
  126. package/dist/src/transform/helpers.d.ts +6 -2
  127. package/dist/src/transform/helpers.d.ts.map +1 -1
  128. package/dist/src/transform/helpers.js +8 -3
  129. package/dist/src/transform/helpers.js.map +1 -1
  130. package/dist/src/transform/tracker.d.ts +94 -14
  131. package/dist/src/transform/tracker.d.ts.map +1 -1
  132. package/dist/src/transform/tracker.js +222 -68
  133. package/dist/src/transform/tracker.js.map +1 -1
  134. package/dist/src/utility/structural-equals.d.ts +38 -0
  135. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  136. package/dist/src/utility/structural-equals.js +77 -0
  137. package/dist/src/utility/structural-equals.js.map +1 -0
  138. package/package.json +2 -2
  139. package/src/cluster/structs.ts +298 -255
  140. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  141. package/src/cohort-topic/wire/codec.ts +222 -216
  142. package/src/collection/collection.ts +996 -158
  143. package/src/collection/struct.ts +99 -4
  144. package/src/collections/diary/diary.ts +71 -68
  145. package/src/collections/tree/struct.ts +248 -26
  146. package/src/collections/tree/tree.ts +130 -9
  147. package/src/index.ts +2 -1
  148. package/src/logger-registry.ts +224 -0
  149. package/src/logger.ts +4 -0
  150. package/src/matchmaking/wire.ts +8 -2
  151. package/src/network/durability.ts +123 -0
  152. package/src/network/i-key-network.ts +60 -52
  153. package/src/network/index.ts +10 -7
  154. package/src/network/lineage.ts +80 -0
  155. package/src/network/routing-key.ts +27 -0
  156. package/src/network/stale-failure.ts +5 -1
  157. package/src/network/struct.ts +156 -8
  158. package/src/reactivity/wire.ts +230 -224
  159. package/src/testing/test-transactor.ts +152 -24
  160. package/src/transaction/coordinator.ts +470 -131
  161. package/src/transaction/errors.ts +34 -9
  162. package/src/transaction/index.ts +1 -0
  163. package/src/transactor/block-floors.ts +105 -0
  164. package/src/transactor/change-notifier.ts +129 -80
  165. package/src/transactor/index.ts +1 -0
  166. package/src/transactor/network-transactor.ts +297 -84
  167. package/src/transactor/transactor-source.ts +131 -42
  168. package/src/transactor/transactor.ts +13 -1
  169. package/src/transform/atomic.ts +25 -22
  170. package/src/transform/base-pins.ts +76 -18
  171. package/src/transform/cache-source.ts +327 -194
  172. package/src/transform/digest.ts +21 -10
  173. package/src/transform/helpers.ts +8 -3
  174. package/src/transform/tracker.ts +423 -270
  175. package/src/utility/structural-equals.ts +72 -0
  176. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  177. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  178. package/dist/src/utility/block-id-to-bytes.js +0 -7
  179. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  180. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -2,12 +2,13 @@ import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, Commi
2
2
  import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
3
3
  import type { PeerId } from "../network/types.js";
4
4
  import { isConflictFailure } from "../network/stale-failure.js";
5
- import type { Collection, CollectionSnapshot } from "../collection/collection.js";
5
+ import type { Collection, CollectionSnapshot, RefreshReport } from "../collection/collection.js";
6
6
  import type { SyncOptions } from "../collection/index.js";
7
+ import { TornActionError } from "../collection/struct.js";
7
8
  import { isTransactionExpired, clampPriority } from "./transaction.js";
8
9
  import { Log } from "../log/log.js";
9
- import { blockIdsForTransforms } from "../transform/helpers.js";
10
- import { computeBlockContentDigests, blockDigestsField } from "../transform/digest.js";
10
+ import { blockIdsForTransforms, copyTransforms } from "../transform/helpers.js";
11
+ import { computeBlockContentDigests, blockDigestsField, baseRevsField } from "../transform/digest.js";
11
12
  import { collectOperations, hashOperations } from "./operations-hash.js";
12
13
  import { CoordinatorConcurrentStampError, CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
13
14
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
@@ -46,6 +47,55 @@ class PendRejectedError extends Error {
46
47
  }
47
48
  }
48
49
 
50
+ /**
51
+ * What one {@link TransactionCoordinator.commit} call keeps across all of its attempts. Created
52
+ * once per commit, filled in by each attempt and by the refresh between attempts, and read when
53
+ * the commit fails, so the failure can say which participants are already saved.
54
+ */
55
+ type CommitCycle = {
56
+ /** Disposers for the in-flight marks each attempt sets on the participants it latches. They
57
+ * must outlive the individual attempt: the inter-attempt refresh is the ONLY reader of the mark,
58
+ * and it deliberately runs after the commit span released its latches (`Latches` is
59
+ * non-reentrant), so a clear tied to the latch would already have run. Each disposer is
60
+ * id-guarded, so re-marking on a later attempt is harmless and a stale disposer cannot wipe a
61
+ * newer mark. */
62
+ inFlightDisposers: (() => void)[];
63
+ /** Every collection any attempt included — one with staged changes when that attempt began. A
64
+ * registered collection no attempt included is not part of this transaction and appears in no
65
+ * report. */
66
+ participants: Set<CollectionId>;
67
+ /** Participants that are durably committed while the commit is still being driven: a refresh
68
+ * between attempts found this transaction's own log entry and finished it (see
69
+ * {@link RefreshReport}), or an attempt committed them and was refused on a sibling that can
70
+ * still be finished (the partial-retry branch of {@link commitOnceLatched}). Once non-empty,
71
+ * the commit can no longer fail cleanly: every later failure is reported through
72
+ * {@link reportSaved} as a partial landing naming these as committed. A successful attempt
73
+ * ends the commit and records nothing here; a partial landing on a hard failure reports its
74
+ * own committed set at once. */
75
+ saved: Set<CollectionId>;
76
+ };
77
+
78
+ /**
79
+ * What a pend this coordinator sends carries for the receiving cluster members to re-check.
80
+ *
81
+ * - `'transaction'` (default): the transaction and the hash of every operation it produced — the
82
+ * session-mode shape. A member with a transaction validator re-executes the statements and
83
+ * compares hashes; the aged retry priority rides inside the transaction.
84
+ * - `'none'`: bare transforms, exactly the shape a single-collection `Collection.sync` pend has.
85
+ * For a commit that has no statements to re-execute — the Quereus adapter's legacy multi-tree
86
+ * commit, whose rows were staged directly into the trees — a validating member would otherwise
87
+ * refuse the pend as a validator fault. The aged priority then rides on the pend itself
88
+ * (`PendRequest.priority`), which is the other carrier a member's race resolution reads, so a
89
+ * repeatedly-losing commit still ages. Members configured `unvalidatablePendPolicy: 'reject'`
90
+ * refuse these pends, as they refuse every unvalidatable pend.
91
+ */
92
+ export type PendValidationMode = 'transaction' | 'none';
93
+
94
+ export interface CoordinatorOptions {
95
+ /** See {@link PendValidationMode}. Default `'transaction'`. */
96
+ pendValidation?: PendValidationMode;
97
+ }
98
+
49
99
  /**
50
100
  * Coordinates multi-collection transactions.
51
101
  *
@@ -95,10 +145,35 @@ export class TransactionCoordinator {
95
145
  preSnapshot: Map<Collection<any>, CollectionSnapshot<any>>;
96
146
  }>();
97
147
 
148
+ /** See {@link PendValidationMode}; fixed for the coordinator's lifetime, so every pend it
149
+ * sends has one shape. */
150
+ private readonly pendValidation: PendValidationMode;
151
+
98
152
  constructor(
99
153
  private readonly transactor: ITransactor,
100
- private readonly collections: Map<CollectionId, Collection<any>>
101
- ) {}
154
+ private readonly collections: Map<CollectionId, Collection<any>>,
155
+ options: CoordinatorOptions = {}
156
+ ) {
157
+ this.pendValidation = options.pendValidation ?? 'transaction';
158
+ }
159
+
160
+ /**
161
+ * The validation pair every pend of this attempt carries, or `undefined` when this coordinator
162
+ * sends unvalidated pends (see {@link PendValidationMode}). Built once per attempt, AFTER the
163
+ * log append: the hash covers ALL operations across ALL collections, which a validator
164
+ * recomputes from its own re-execution and compares. The shared operations-hash module
165
+ * canonicalises (sort + canonical JSON), so the fingerprint is order-independent. Skipped
166
+ * entirely — not computed and discarded — when no pend will carry it.
167
+ */
168
+ private async pendValidationFor(
169
+ transaction: Transaction,
170
+ collectionTransforms: Map<CollectionId, Transforms>
171
+ ): Promise<PendRequest['validation']> {
172
+ if (this.pendValidation === 'none') {
173
+ return undefined;
174
+ }
175
+ return { transaction, operationsHash: await hashOperations(collectOperations(collectionTransforms)) };
176
+ }
102
177
 
103
178
  /**
104
179
  * Apply actions to collections (called by engines during statement execution).
@@ -213,21 +288,43 @@ export class TransactionCoordinator {
213
288
  /**
214
289
  * Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
215
290
  *
216
- * The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
217
- * fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
218
- * durably committed, every tracker restored to its pre-append state). Before each re-attempt it
219
- * re-reads each collection to fresh revisions (so the retry pends against current state rather
220
- * than immediately re-failing stale), then backs off with the same jitter policy as
291
+ * The single-attempt work lives in {@link commitOnce}; {@link commitAttempts} re-drives it when
292
+ * the attempt fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} —
293
+ * nothing durably committed, every tracker restored to its pre-append state). Before each
294
+ * re-attempt it re-reads each collection to fresh revisions (so the retry pends against current
295
+ * state rather than immediately re-failing stale), then backs off with the same jitter policy as
221
296
  * {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
222
297
  * `deadlineMs`, and honours an abort `signal`.
223
298
  *
224
- * A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
225
- * is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
226
- * Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
227
- * propagates without retry only genuine clean stale losses are re-driven.
299
+ * A PARTIAL landing — some collection durably committed while a sibling's commit was refused —
300
+ * is re-driven too when every refusal was a returned one (a member behind on the block's base,
301
+ * a rival that took the revision slot): the committed collections are recorded as saved and the
302
+ * next attempt commits only what is still staged, so the durable half is never re-logged and the
303
+ * refused collection gets the same refresh-and-finish recovery a clean loss gets. A caller must
304
+ * still never re-drive a partial landing itself. A partial landing on a HARD failure (the
305
+ * transport budget spent, a structural rejection) escapes at once as
306
+ * {@link CoordinatorPartialCommitError}, and any other failure (expired transaction,
307
+ * unavailable transactor, unreachable cluster) also propagates without retry.
308
+ *
309
+ * A participant can also be SAVED between attempts: an attempt that reported a loss may still
310
+ * have stored that participant's log tail, and the refresh before the next attempt then finishes
311
+ * and consumes the participant's own entry (see {@link refreshBetweenAttempts}). From that moment
312
+ * the commit can no longer fail cleanly, so EVERY failure that escapes afterwards — a torn sibling,
313
+ * a sibling that keeps losing until the budget runs out, an abort, an expiry, a hard error — is
314
+ * rethrown as {@link CoordinatorPartialCommitError}, counting the refresh-saved participants as
315
+ * committed and carrying the original error as its `reason` (see {@link reportSaved}). A commit in
316
+ * which nothing was saved fails with exactly the error it failed with.
228
317
  *
229
318
  * Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
230
319
  *
320
+ * NOTE: this returns nothing, so a multi-collection commit reports NO durability — unlike
321
+ * `Collection.sync`, which now answers with the {@link WriteDurability} of what it committed. The
322
+ * gap is deliberate and tracked in `tickets/backlog/feat-multi-collection-commit-reports-durability.md`:
323
+ * folding a class across collections that may individually have committed or been dropped (see
324
+ * {@link CoordinatorPartialCommitError}) is its own design problem, not a pass-through. Until that
325
+ * lands, a caller that needs to know whether a write is fully held has to use the single-collection
326
+ * path; do not infer one here from a plain successful return.
327
+ *
231
328
  * @param transaction - The transaction to commit
232
329
  * @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
233
330
  */
@@ -241,6 +338,31 @@ export class TransactionCoordinator {
241
338
  if (open !== undefined) {
242
339
  throw new CoordinatorConcurrentStampError(open, transaction.stamp.id);
243
340
  }
341
+
342
+ const cycle: CommitCycle = {
343
+ inFlightDisposers: [],
344
+ participants: new Set(),
345
+ saved: new Set(),
346
+ };
347
+ try {
348
+ await this.commitAttempts(transaction, cycle, options);
349
+ } catch (err) {
350
+ // The ONE place a failure leaves this commit, so no exit — present or future — can skip
351
+ // reporting a participant that is already saved.
352
+ throw this.reportSaved(transaction, cycle, err);
353
+ } finally {
354
+ // The in-flight marks must outlive each individual attempt (see CommitCycle), so they are
355
+ // cleared here, on every exit: return, stale-loss exhaustion, a partial landing, a hard
356
+ // error, an abort.
357
+ for (const dispose of cycle.inFlightDisposers) {
358
+ dispose();
359
+ }
360
+ }
361
+ }
362
+
363
+ /** The retry loop behind {@link commit}: every attempt, and the refresh between them. Records
364
+ * what it learns in `cycle`; {@link commit} owns turning a failure into the right report. */
365
+ private async commitAttempts(transaction: Transaction, cycle: CommitCycle, options?: SyncOptions): Promise<void> {
244
366
  const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
245
367
  const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
246
368
  const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
@@ -254,74 +376,198 @@ export class TransactionCoordinator {
254
376
  let staleLosses = 0;
255
377
  let lastLoss: CoordinatorStaleLossError | undefined;
256
378
 
257
- // Disposers for the in-flight marks {@link commitOnce} sets on each participant it latches.
258
- // They must outlive the individual attempt: the inter-attempt refresh below is the ONLY
259
- // reader of the mark, and it deliberately runs after the commit span released its latches
260
- // (`Latches` is non-reentrant), so a clear tied to the latch would already have run. Hence
261
- // the finally spans the WHOLE retry loop every exit clears: return, stale-loss exhaustion,
262
- // a partial landing, a hard error, an abort. Each disposer is id-guarded, so re-marking on a
263
- // later attempt is harmless and a stale disposer cannot wipe a newer mark.
264
- const inFlightDisposers: (() => void)[] = [];
265
- try {
266
- for (;;) {
267
- if (signal?.aborted) {
268
- throw makeAbortError(signal);
269
- }
270
- // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
271
- // wall-clock deadline passed (independent of the attempt cap).
272
- if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
273
- throw lastLoss;
274
- }
379
+ for (;;) {
380
+ if (signal?.aborted) {
381
+ throw makeAbortError(signal);
382
+ }
383
+ // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
384
+ // wall-clock deadline passed (independent of the attempt cap).
385
+ if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
386
+ throw lastLoss;
387
+ }
275
388
 
276
- // Age the transaction's advisory priority by the number of losses taken so far, so a
277
- // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
278
- // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
279
- // signature, so bumping it here does not churn identity. Left untouched on the first
280
- // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
281
- if (staleLosses > 0) {
282
- transaction.priority = clampPriority(staleLosses);
283
- }
389
+ // Age the transaction's advisory priority by the number of losses taken so far, so a
390
+ // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
391
+ // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
392
+ // signature, so bumping it here does not churn identity. Left untouched on the first
393
+ // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
394
+ if (staleLosses > 0) {
395
+ transaction.priority = clampPriority(staleLosses);
396
+ }
284
397
 
285
- try {
286
- await this.commitOnce(transaction, inFlightDisposers);
287
- return;
288
- } catch (err) {
289
- // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
290
- // unavailable transactor, etc. all propagate unchanged.
291
- if (!(err instanceof CoordinatorStaleLossError)) {
292
- throw err;
293
- }
294
- lastLoss = err;
295
- staleLosses++;
296
- if (staleLosses >= maxAttempts) {
297
- throw err;
298
- }
398
+ try {
399
+ await this.commitOnce(transaction, cycle);
400
+ return;
401
+ } catch (err) {
402
+ // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
403
+ // unavailable transactor, etc. all propagate unchanged.
404
+ if (!(err instanceof CoordinatorStaleLossError)) {
405
+ throw err;
406
+ }
407
+ lastLoss = err;
408
+ staleLosses++;
409
+ if (staleLosses >= maxAttempts) {
410
+ throw err;
411
+ }
412
+ // Re-read fresh state before re-attempting so the next commit pends against current
413
+ // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
414
+ //
415
+ // A participant whose log tail landed despite the reported loss finds its own entry
416
+ // here and FINISHES that action before consuming it (Collection.completeOwnEntry,
417
+ // from the attempt commitOnceLatched retained). When finishing is refused for a cause
418
+ // that can clear, the refresh — not the commit — is what gets retried: commitOnce
419
+ // would rebuild the participant's log entry with a fresh timestamp and send a second
420
+ // version of a log tail already stored under this transaction's id and revision.
421
+ // Each such round counts against the same budget as a stale loss; a permanent
422
+ // refusal (a rival holds the revision) escapes as the named TornActionError at once.
423
+ // Re-refreshing collections an earlier round already refreshed is a no-op.
424
+ // The round that would spend the last of the budget is told so, which makes each
425
+ // participant SETTLE a half-landed write rather than ask for another round: the
426
+ // error thrown below then says whether the write can still land (`final`). A
427
+ // deadline cannot be foreseen the same way; a write given up on it stays unsettled.
428
+ for (;;) {
299
429
  const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
300
430
  await abortableDelay(delay, signal);
301
- // Re-read fresh state before re-attempting so the next commit pends against current
302
- // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
303
- // NOTE: refreshes EVERY registered collection, not only the participants of this
304
- // transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
305
- // if its header momentarily reads absent while it holds a committed revision, aborting
306
- // this retry. The registered set is small today; if that (or retry latency) ever bites,
307
- // narrow this to the transaction's participating collections.
308
- for (const collection of this.collections.values()) {
309
- await collection.update();
431
+ const refused = await this.refreshBetweenAttempts(cycle, staleLosses + 1 >= maxAttempts);
432
+ if (refused === undefined) {
433
+ break;
434
+ }
435
+ staleLosses++;
436
+ if (staleLosses >= maxAttempts
437
+ || (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs)) {
438
+ throw refused;
310
439
  }
311
440
  }
441
+ if (cycle.saved.size > 0 && this.stagedCollections().length === 0) {
442
+ // The refresh saved what was left: nothing is staged any more, so this transaction
443
+ // is committed. Return now rather than go round again, where an abort, the deadline
444
+ // or an expiry check could still report failure for a transaction that is entirely
445
+ // saved. The refresh's consume and replay already gave each saved participant the
446
+ // success path's local treatment, so only the stamp is left to release.
447
+ // Tested on what is still STAGED, not on "every participant saved": a participant
448
+ // can be saved and still hold actions staged after the attempt began, and those
449
+ // the next attempt must commit.
450
+ this.stampData.delete(transaction.stamp.id);
451
+ return;
452
+ }
312
453
  }
313
- } finally {
314
- for (const dispose of inFlightDisposers) {
315
- dispose();
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Refresh every registered collection between attempts, recording in `cycle.saved` each one
459
+ * whose refresh finished this transaction's own log entry (see {@link Collection.refreshInFlight}).
460
+ *
461
+ * Visits EVERY collection even after one throws. Stopping at the first throw would leave a
462
+ * participant later in the map unfinished whenever an earlier one is torn — its log tail stored
463
+ * and its other blocks never landed, the silent loss `Collection.completeOwnEntry` exists to
464
+ * prevent — and unreported. Each refresh is independent (its own latch, its own instance state),
465
+ * so one failing says nothing about the next.
466
+ *
467
+ * NOTE: refreshes EVERY registered collection, not only the participants of this transaction.
468
+ * Not free: a non-participant's refresh throws CollectionHeaderVanishedError if its header
469
+ * momentarily reads absent while it holds a committed revision, aborting this retry. And because
470
+ * a throw no longer stops the round, an unreachable cluster costs one failed read per registered
471
+ * collection rather than one. The registered set is small today; if that (or retry latency) ever
472
+ * bites, narrow this to the transaction's participants.
473
+ *
474
+ * @param lastChance - this is the round that spends the last of the retry budget, so a
475
+ * participant whose half-landed write is refused again settles it (`Collection.completeOwnEntry`)
476
+ * rather than asking for a round it will not get; the error returned then carries `final`.
477
+ * @returns the first `completion-refused` {@link TornActionError} when that is the only way any
478
+ * refresh failed — the round is worth retrying; `undefined` when every refresh succeeded.
479
+ * @throws the first other error, once every collection has been visited.
480
+ */
481
+ private async refreshBetweenAttempts(cycle: CommitCycle, lastChance: boolean): Promise<TornActionError | undefined> {
482
+ let terminal: { error: unknown } | undefined;
483
+ let refused: TornActionError | undefined;
484
+ for (const [collectionId, collection] of this.collections) {
485
+ const report: RefreshReport = {};
486
+ try {
487
+ await collection.refreshInFlight(report, lastChance);
488
+ } catch (err) {
489
+ if (err instanceof TornActionError && err.reason === 'completion-refused') {
490
+ refused ??= err;
491
+ } else {
492
+ terminal ??= { error: err };
493
+ }
494
+ }
495
+ // Read after the try, not only on success: a refresh can throw AFTER it finished the
496
+ // entry, and that participant is saved either way.
497
+ if (report.ownEntryFinished !== undefined) {
498
+ cycle.saved.add(collectionId);
316
499
  }
317
500
  }
501
+ if (terminal !== undefined) {
502
+ throw terminal.error;
503
+ }
504
+ return refused;
505
+ }
506
+
507
+ /** Every registered collection with something to push — the participants an attempt starting
508
+ * now would commit. The predicate is {@link Collection.hasUnsyncedChanges}, the one
509
+ * `Collection.sync` loops on: staged actions OR uncommitted tracker transforms. Not the
510
+ * transforms alone: an action can be staged and have changed no block — a delete of an index
511
+ * entry this instance's stale view never held (the shape `blind-index-delete-is-logged-but-
512
+ * never-applied` fixed for the single-collection path) — and such a collection MUST still take
513
+ * part. Its stale pend is refused, and the refresh between attempts replays the action against
514
+ * the adopted revision, where it finds the entry and produces the delete the retry commits.
515
+ * Filtering on transforms dropped it from the batch entirely, so the row went and its index
516
+ * entry stayed behind, on every node. */
517
+ private stagedCollections(): { collectionId: CollectionId; collection: Collection<any>; transforms: Transforms }[] {
518
+ return Array.from(this.collections.entries())
519
+ .filter(([, collection]) => collection.hasUnsyncedChanges())
520
+ .map(([collectionId, collection]) => ({
521
+ collectionId,
522
+ collection,
523
+ transforms: collection.tracker.transforms
524
+ }));
525
+ }
526
+
527
+ /**
528
+ * What a failure escaping {@link commit} must be reported as. When nothing is saved, the error
529
+ * itself, untouched. When a refresh between attempts already saved a participant, the commit
530
+ * half-landed whatever the error says — a {@link CoordinatorStaleLossError}'s "nothing durably
531
+ * committed, safe to re-drive" would be false, and a re-drive would apply the saved half twice —
532
+ * so it becomes a {@link CoordinatorPartialCommitError}:
533
+ *
534
+ * - `committedCollections`: every refresh-saved participant, plus the committed set of `err`
535
+ * when it is itself a partial landing (merged, never wrapped twice — its `reason` is kept);
536
+ * - `failedCollections`: every other participant of any attempt. Empty when a failure came after
537
+ * every participant was saved (a refresh that finished its entry and then threw, say);
538
+ * - `reason`: the original error.
539
+ *
540
+ * Local state already matches that report: a saved participant was consumed by its refresh, and
541
+ * an unsaved one kept its staged actions (a failed finish throws before touching anything, and
542
+ * commitOnceLatched restores every participant it did not commit). The stamp is released, as on
543
+ * the existing partial path, because the transaction can no longer be cleanly rolled back.
544
+ */
545
+ private reportSaved(transaction: Transaction, cycle: CommitCycle, err: unknown): unknown {
546
+ if (cycle.saved.size === 0) {
547
+ return err;
548
+ }
549
+ const committed = new Set(cycle.saved);
550
+ let reason = err;
551
+ if (err instanceof CoordinatorPartialCommitError) {
552
+ for (const id of err.committedCollections) committed.add(id);
553
+ reason = err.reason;
554
+ }
555
+ // NOTE: a participant is in exactly one list, so one saved by a refresh that ALSO held actions
556
+ // staged after its attempt began (and a later attempt failed to commit them) is listed only as
557
+ // committed. Unreachable while a session stages and commits on one call path; if staging ever
558
+ // races a commit, report such a participant in both lists.
559
+ const failed = [...cycle.participants].filter(id => !committed.has(id));
560
+ log('commit:partial-after-refresh tx=%s committed=%o failed=%o reason=%s', transaction.id,
561
+ [...committed], failed, reason instanceof Error ? reason.name : String(reason));
562
+ this.stampData.delete(transaction.stamp.id);
563
+ return new CoordinatorPartialCommitError([...committed], failed, reason);
318
564
  }
319
565
 
320
566
  /**
321
567
  * Commit a transaction (single attempt): materialise a log entry from each collection's staged
322
568
  * pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
323
569
  *
324
- * Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
570
+ * Called by {@link commitAttempts} (the backoff+jitter retry loop behind {@link commit}). The
325
571
  * staged mutations already live in each collection's tracker — applied either via
326
572
  * applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
327
573
  * (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
@@ -329,37 +575,36 @@ export class TransactionCoordinator {
329
575
  * and folds the committed transforms back into each collection's read cache.
330
576
  *
331
577
  * On a clean stale loss (nothing durable, every tracker restored) it throws
332
- * {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
578
+ * {@link CoordinatorStaleLossError} so the caller can retry. On a partial landing it throws the
579
+ * same, after recording the committed collections in `cycle.saved`, when every refusal was a
580
+ * returned one (the retry finishes the refused collection); on a hard failure it throws
333
581
  * {@link CoordinatorPartialCommitError} (not retryable).
334
582
  *
335
583
  * @param transaction - The transaction to commit
336
- * @param inFlightDisposers - Collects one disposer per participant marked in flight under this
337
- * transaction's id (see {@link Collection.beginInFlightAction}). REQUIRED, so a future caller
338
- * cannot silently reintroduce the unmarked refresh this parameter exists to prevent: a caller
339
- * that never refreshes between attempts passes a throwaway array and simply ignores it. The
340
- * caller owns clearing them, because the mark has to survive past this attempt see the
341
- * array's declaration in {@link commit}.
584
+ * @param cycle - The state this commit keeps across attempts (see {@link CommitCycle}). This
585
+ * attempt adds its participants to `cycle.participants` and one disposer per participant it marks
586
+ * in flight under this transaction's id (see {@link Collection.beginInFlightAction}) to
587
+ * `cycle.inFlightDisposers`. REQUIRED, so a future caller cannot silently reintroduce the
588
+ * unmarked refresh the disposers exist to prevent, nor lose track of who took part. The caller
589
+ * owns clearing the marks, because they have to survive past this attempt.
342
590
  */
343
- private async commitOnce(transaction: Transaction, inFlightDisposers: (() => void)[]): Promise<void> {
591
+ private async commitOnce(transaction: Transaction, cycle: CommitCycle): Promise<void> {
344
592
  if (isTransactionExpired(transaction.stamp)) {
345
593
  throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
346
594
  }
347
595
 
348
596
  // Collect collections with staged (un-synced) changes.
349
- const collectionData = Array.from(this.collections.entries())
350
- .map(([collectionId, collection]) => ({
351
- collectionId,
352
- collection,
353
- transforms: collection.tracker.transforms
354
- }))
355
- .filter(({ transforms }) =>
356
- Object.keys(transforms.inserts ?? {}).length +
357
- Object.keys(transforms.updates ?? {}).length +
358
- (transforms.deletes?.length ?? 0) > 0
359
- );
597
+ const collectionData = this.stagedCollections();
360
598
 
361
599
  if (collectionData.length === 0) {
362
- return; // Nothing to commit
600
+ // Nothing to commit is a successful commit: release the stamp as the success path does,
601
+ // or a read-only transaction that opened one (the empty pre-stage barrier) wedges every
602
+ // later stamp on this coordinator.
603
+ this.stampData.delete(transaction.stamp.id);
604
+ return;
605
+ }
606
+ for (const { collectionId } of collectionData) {
607
+ cycle.participants.add(collectionId);
363
608
  }
364
609
  // NOTE: this selection reads each tracker BEFORE the latches below are held, so a stage
365
610
  // that lands between the filter and the acquisition is simply not part of this commit.
@@ -376,7 +621,7 @@ export class TransactionCoordinator {
376
621
  // (db-p2p/src/storage/block-latch.ts), so two concurrent commits over overlapping
377
622
  // participant sets cannot deadlock. `Latches` is non-reentrant, so nothing inside the
378
623
  // held span may call a latched Collection method (act/update/sync/updateAndSync) on a
379
- // participant — the retry loop's blanket collection.update() in commit() runs OUTSIDE
624
+ // participant — the retry loop's blanket refresh in commitAttempts runs OUTSIDE
380
625
  // this span, after release.
381
626
  // NOTE: the span covers the pend/commit consensus round trips, so every latched method on
382
627
  // a participant instance (act/update/sync) queues for as long as the transactor takes.
@@ -390,15 +635,16 @@ export class TransactionCoordinator {
390
635
  for (const { collection } of latchOrder) {
391
636
  latchReleases.push(await collection.acquireLatch());
392
637
  // Mark THIS attempt's action id on each participant while its latch is held, so the
393
- // inter-attempt refresh in commit() recognises a log entry this transaction itself
394
- // made durable (a torn commit: header and log tail committed, a later sweep block
395
- // reported the conflict) and consumes it instead of replaying it into a second entry
396
- // under the same id. `transaction.id` is stable across retries, so re-marking on a
638
+ // inter-attempt refresh in commitAttempts recognises a log entry this transaction itself
639
+ // stored (a torn commit: the log tail landed, and either its own answer or a later
640
+ // sweep block's reported failure), finishes that action's remaining blocks, and then
641
+ // consumes the entry instead of replaying it into a second entry under the same id
642
+ // (Collection.completeOwnEntry). `transaction.id` is stable across retries, so re-marking on a
397
643
  // later attempt re-states the same fact. Only participants are marked; a registered
398
644
  // non-participant is left unmarked and its refresh behaves exactly as a reader's.
399
- inFlightDisposers.push(collection.beginInFlightAction(transaction.id));
645
+ cycle.inFlightDisposers.push(collection.beginInFlightAction(transaction.id));
400
646
  }
401
- await this.commitOnceLatched(transaction, collectionData);
647
+ await this.commitOnceLatched(transaction, collectionData, cycle);
402
648
  } finally {
403
649
  for (const release of latchReleases.reverse()) {
404
650
  release();
@@ -412,10 +658,15 @@ export class TransactionCoordinator {
412
658
  * participant's latch — every Collection member it touches (snapshotPending, getPendingActions,
413
659
  * recordCommitted, applyCommittedToCache, restorePending, clearPendingActions, tracker.reset)
414
660
  * is latch-free by contract.
661
+ *
662
+ * `cycle` is written only on a partial landing that is retried forward: the participants this
663
+ * attempt committed go into `cycle.saved`, so the retry loop reports every later failure as the
664
+ * partial landing it is (see {@link reportSaved}).
415
665
  */
416
666
  private async commitOnceLatched(
417
667
  transaction: Transaction,
418
- collectionData: { collectionId: CollectionId; collection: Collection<any> }[]
668
+ collectionData: { collectionId: CollectionId; collection: Collection<any> }[],
669
+ cycle: CommitCycle
419
670
  ): Promise<void> {
420
671
  // Append each collection's staged actions to its log, then collect the
421
672
  // resulting transforms + critical (log-tail) block for consensus.
@@ -437,6 +688,15 @@ export class TransactionCoordinator {
437
688
  // through pend, commit, and the local recordCommitted, so all four name the same number.
438
689
  const pendedRevs = new Map<CollectionId, number>();
439
690
 
691
+ // A participant is never pended over a base that moved under its staged operations
692
+ // (Collection.restageIfBasesMoved). Each re-stages its own queue only. BEFORE the snapshots
693
+ // below, so a failed attempt restores transforms that agree with the tracker's pins; and
694
+ // before the append loop, since a replay resets the tracker and would drop the appended
695
+ // log entry.
696
+ for (const { collection } of collectionData) {
697
+ await collection.restageIfBasesMoved();
698
+ }
699
+
440
700
  // Snapshot EVERY participating collection's staged state (transforms + pending
441
701
  // queue) BEFORE the append loop mutates any tracker. The loop appends log
442
702
  // entries sequentially, so a failure on the Nth collection must also undo the
@@ -472,16 +732,13 @@ export class TransactionCoordinator {
472
732
  pendedRevs.set(collectionId, applyResult.rev!);
473
733
  }
474
734
 
475
- // Compute hash of ALL operations across ALL collections (post-log-append).
476
- // Validators re-execute the transaction and compare their computed hash.
477
- // The shared operations-hash module canonicalises (sort + canonical JSON) so
478
- // this order-independent fingerprint matches what a validator recomputes.
479
- const operationsHash = await hashOperations(collectOperations(collectionTransforms));
735
+ // What each pend carries for members to re-check (post-log-append) — see pendValidationFor.
736
+ const validation = await this.pendValidationFor(transaction, collectionTransforms);
480
737
 
481
738
  // Execute consensus phases (GATHER, PEND, COMMIT)
482
739
  coordResult = await this.coordinateTransaction(
483
740
  transaction,
484
- operationsHash,
741
+ validation,
485
742
  collectionTransforms,
486
743
  criticalBlocks,
487
744
  pendedRevs
@@ -500,6 +757,24 @@ export class TransactionCoordinator {
500
757
 
501
758
  if (!coordResult.success) {
502
759
  const committed = coordResult.committedCollections ?? new Set<CollectionId>();
760
+ // Keep exactly what each participant that did NOT commit sent, BEFORE the restores below
761
+ // swap its tracker out. A refused commit is not proof nothing landed: a participant's log
762
+ // tail is committed first and can be stored while the answer is still a failure, and
763
+ // cancelPhase has just dropped the pending records of every block that did not land. If
764
+ // the next refresh of that participant finds this transaction's own log entry, these are
765
+ // the transforms that finish it (Collection.completeOwnEntry) — without them the refresh
766
+ // can only refuse the write as torn, never save it. Copied, because until the restore
767
+ // this is the participant's LIVE tracker state. No block digests are retained: they are
768
+ // computed per commit from the live tracker (commitCollection), which is gone by the time
769
+ // a refresh re-sends, and an undeclared block falls back to member-side corroboration.
770
+ for (const { collectionId, collection } of collectionData) {
771
+ if (committed.has(collectionId)) continue;
772
+ collection.retainInFlightAttempt(transaction.id, {
773
+ rev: pendedRevs.get(collectionId)!,
774
+ transforms: copyTransforms(collectionTransforms.get(collectionId)!),
775
+ tailId: criticalBlocks.get(collectionId)!,
776
+ });
777
+ }
503
778
  if (committed.size > 0) {
504
779
  // PARTIAL COMMIT: at least one collection durably committed via consensus
505
780
  // while another failed permanently. A uniform pre-append restore would
@@ -526,15 +801,38 @@ export class TransactionCoordinator {
526
801
  collection.restorePending(preCommitSnapshots.get(collectionId)!);
527
802
  }
528
803
  }
529
- // The transaction half-landed, so it is neither cleanly retryable nor
530
- // cleanly abortable: drop its stamp tracking (the success path does the
531
- // same at the end) and surface the structured signal for reconciliation.
804
+ const failed = [...(coordResult.failedCollections ?? new Set<CollectionId>())];
805
+ if (coordResult.staleLoss) {
806
+ // Every failure was a RETURNED refusal: a member behind on the block's base (the
807
+ // durability gate's `commit-not-durable`, from a replica that has not caught up —
808
+ // the writer's own, on a node that has just joined), or a rival that took the
809
+ // revision slot. The siblings above have landed and cannot be rolled back, so
810
+ // FORWARD recovery — finishing this collection — is the only way back to
811
+ // all-or-nothing, and the retry loop already does it: the refresh between attempts
812
+ // finds this transaction's own log entry where the tail landed and re-sends the
813
+ // retained attempt at the same revision (Collection.completeOwnEntry), or the next
814
+ // attempt re-pends afresh when nothing of it landed. The committed siblings are
815
+ // recorded as saved, so every failure that still escapes is reported as the partial
816
+ // landing it is (reportSaved), and the next attempt commits only what is still
817
+ // staged (stagedCollections) — the durable half is never re-logged. The stamp stays
818
+ // open for the retry; reportSaved or the success path releases it.
819
+ // The committed siblings keep their in-flight mark until the cycle ends, so the
820
+ // blanket refresh between attempts WALKS their log (the mark disables the tail
821
+ // shortcut) but cannot find their own entry: recordCommitted above advanced each
822
+ // one's held revision to the entry's own, and Log.getFrom returns only entries
823
+ // ABOVE the held revision. Collection.consumeOwnEntry is therefore unreachable for
824
+ // a sibling committed here, and no attempt was retained for it (the loop above
825
+ // skips committed participants) — the refresh is a reader's, at a cost of one walk.
826
+ for (const collectionId of committed) cycle.saved.add(collectionId);
827
+ log('commit:partial-retry tx=%s committed=%o failed=%o reason=%s', transaction.id, [...committed], failed, coordResult.error);
828
+ throw new CoordinatorStaleLossError(failed, coordResult.error);
829
+ }
830
+ // Half-landed on a HARD failure (the transport budget spent on a thrown fault, a
831
+ // structural rejection): neither cleanly retryable nor cleanly abortable, so drop the
832
+ // stamp tracking (the success path does the same at the end) and surface the
833
+ // structured signal for reconciliation at once.
532
834
  this.stampData.delete(transaction.stamp.id);
533
- throw new CoordinatorPartialCommitError(
534
- [...committed],
535
- [...(coordResult.failedCollections ?? new Set<CollectionId>())],
536
- coordResult.error
537
- );
835
+ throw new CoordinatorPartialCommitError([...committed], failed, coordResult.error);
538
836
  }
539
837
 
540
838
  // EMPTY committed set: PEND failed, or the whole commit failed cleanly with
@@ -855,6 +1153,12 @@ export class TransactionCoordinator {
855
1153
  }
856
1154
  }
857
1155
 
1156
+ // Same pre-pend rule as commitOnceLatched: no participant is pended over a moved base.
1157
+ // Under the latches and before the append loop, which a replay's tracker reset would undo.
1158
+ for (const collectionId of allCollectionIds) {
1159
+ await this.collections.get(collectionId)?.restageIfBasesMoved();
1160
+ }
1161
+
858
1162
  for (const [collectionId, actions] of batches) {
859
1163
  const applyResult = await this.applyActionsToCollection(
860
1164
  { collectionId, actions },
@@ -872,8 +1176,8 @@ export class TransactionCoordinator {
872
1176
  pendedRevs.set(collectionId, applyResult.rev!);
873
1177
  }
874
1178
 
875
- // 3. Compute operations hash for validation (order-independent; see commit()).
876
- const operationsHash = await hashOperations(collectOperations(collectionTransforms));
1179
+ // 3. What each pend carries for members to re-check (see pendValidationFor).
1180
+ const validation = await this.pendValidationFor(transaction, collectionTransforms);
877
1181
 
878
1182
  const applyMs = Date.now() - tApply;
879
1183
 
@@ -881,7 +1185,7 @@ export class TransactionCoordinator {
881
1185
  const tCoord = Date.now();
882
1186
  const coordResult = await this.coordinateTransaction(
883
1187
  transaction,
884
- operationsHash,
1188
+ validation,
885
1189
  collectionTransforms,
886
1190
  criticalBlocks,
887
1191
  pendedRevs
@@ -899,6 +1203,14 @@ export class TransactionCoordinator {
899
1203
  // unsafe all-or-nothing undo handle survives (rollback() would rewind the winner).
900
1204
  // Kept await-free for the same reason the success fold is — restorePending is
901
1205
  // synchronous and latch-free by contract, so it is safe inside this latched span.
1206
+ // NOTE: execute() has no retry loop at all, so a returned refusal after a sibling
1207
+ // committed is reported here at once, where commit() would re-drive the refused
1208
+ // collection forward (the partial-retry branch of commitOnceLatched). No production
1209
+ // caller reaches this today — the plugin's session mode stages through applyActions
1210
+ // and commits through commit() — so the split window is documented, not closed
1211
+ // (docs/transactions.md). If a host ever commits multi-collection transactions
1212
+ // through execute(), route its coordination through commit()'s retry rather than
1213
+ // duplicating that recovery here.
902
1214
  const committed = coordResult.committedCollections ?? new Set<CollectionId>();
903
1215
  if (committed.size > 0) {
904
1216
  for (const collectionId of batches.keys()) {
@@ -1032,9 +1344,10 @@ export class TransactionCoordinator {
1032
1344
  // which collection's log the dependent landed in.
1033
1345
  // NOTE: `allCollectionIds` names the participants of THIS attempt, and a retry's participant
1034
1346
  // set can be SMALLER than the first attempt's. After a torn commit, the participant whose
1035
- // entry landed durably consumes that entry on the inter-attempt refresh
1036
- // (Collection.inFlightActionId), empties its pending queue and resets its tracker, so
1037
- // commitOnce's non-empty-transforms filter drops it from the next attempt. The retry's
1347
+ // log entry landed finishes that action on the inter-attempt refresh — landing the blocks
1348
+ // the refused commit left behind, at the same id and revision and only then consumes the
1349
+ // entry (Collection.completeOwnEntry), emptying its pending queue and resetting its tracker,
1350
+ // so commitOnce's non-empty-transforms filter drops it from the next attempt. The retry's
1038
1351
  // entries therefore list only the REMAINING participants, while the torn participant's
1039
1352
  // already-durable entry lists them all — one transaction id, two different
1040
1353
  // `allCollectionIds` values across its entries. Nothing today keys off that list for
@@ -1064,7 +1377,8 @@ export class TransactionCoordinator {
1064
1377
  * Coordinate a transaction across multiple collections.
1065
1378
  *
1066
1379
  * @param transaction - The transaction to coordinate
1067
- * @param operationsHash - Hash of all operations for validation
1380
+ * @param validation - What every pend carries for members to re-check, or undefined for an
1381
+ * unvalidated pend (see {@link pendValidationFor})
1068
1382
  * @param collectionTransforms - Map of collectionId to its transforms
1069
1383
  * @param criticalBlocks - Map of collectionId to its log tail blockId
1070
1384
  * @param pendedRevs - Per collection, the revision its log entry was stamped with (from
@@ -1073,7 +1387,7 @@ export class TransactionCoordinator {
1073
1387
  */
1074
1388
  private async coordinateTransaction(
1075
1389
  transaction: Transaction,
1076
- operationsHash: string,
1390
+ validation: PendRequest['validation'],
1077
1391
  collectionTransforms: Map<CollectionId, Transforms>,
1078
1392
  criticalBlocks: Map<CollectionId, BlockId>,
1079
1393
  pendedRevs: ReadonlyMap<CollectionId, number>
@@ -1099,7 +1413,7 @@ export class TransactionCoordinator {
1099
1413
  const tPend = Date.now();
1100
1414
  const pendResult = await this.pendPhase(
1101
1415
  transaction,
1102
- operationsHash,
1416
+ validation,
1103
1417
  collectionTransforms,
1104
1418
  pendedRevs,
1105
1419
  superclusterNominees
@@ -1192,8 +1506,10 @@ export class TransactionCoordinator {
1192
1506
  /**
1193
1507
  * PEND phase: Distribute transaction to all affected block clusters.
1194
1508
  *
1195
- * @param transaction - The full transaction for replay/validation
1196
- * @param operationsHash - Hash of all operations for validation
1509
+ * @param transaction - The transaction being pended (its id names the action; its aged
1510
+ * priority rides on an unvalidated pend)
1511
+ * @param validation - What every pend carries for members to re-check, or undefined for an
1512
+ * unvalidated pend (see {@link pendValidationFor})
1197
1513
  * @param collectionTransforms - Map of collectionId to its transforms
1198
1514
  * @param pendedRevs - Per collection, the revision its log entry was stamped with — the pend
1199
1515
  * request repeats it verbatim rather than recomputing from the collection (a recompute after
@@ -1202,7 +1518,7 @@ export class TransactionCoordinator {
1202
1518
  */
1203
1519
  private async pendPhase(
1204
1520
  transaction: Transaction,
1205
- operationsHash: string,
1521
+ validation: PendRequest['validation'],
1206
1522
  collectionTransforms: ReadonlyMap<CollectionId, Transforms>,
1207
1523
  pendedRevs: ReadonlyMap<CollectionId, number>,
1208
1524
  superclusterNominees: ReadonlySet<PeerId> | null
@@ -1221,7 +1537,7 @@ export class TransactionCoordinator {
1221
1537
  // with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
1222
1538
  const outcomes = await Promise.allSettled(
1223
1539
  Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) =>
1224
- this.pendCollection(transaction, operationsHash, collectionId, transforms, pendedRevs.get(collectionId)!, actionId, nominees)
1540
+ this.pendCollection(transaction, validation, collectionId, transforms, pendedRevs.get(collectionId)!, actionId, nominees)
1225
1541
  )
1226
1542
  );
1227
1543
 
@@ -1267,7 +1583,7 @@ export class TransactionCoordinator {
1267
1583
  */
1268
1584
  private async pendCollection(
1269
1585
  transaction: Transaction,
1270
- operationsHash: string,
1586
+ validation: PendRequest['validation'],
1271
1587
  collectionId: CollectionId,
1272
1588
  transforms: Transforms,
1273
1589
  /** The revision the log entry was stamped with (threaded from applyActionsToCollection),
@@ -1282,14 +1598,29 @@ export class TransactionCoordinator {
1282
1598
  throw new Error(`Collection not found: ${collectionId}`);
1283
1599
  }
1284
1600
 
1285
- // Create pend request with the validation payload (transaction + operations hash) —
1286
- // always BOTH, as one pair: this is the only producer of PendRequest.validation.
1601
+ // Create the pend request. This is the only producer of PendRequest.validation, and it
1602
+ // always sends the pair whole (transaction + operations hash) or not at all. The aged
1603
+ // priority has one carrier per shape — inside the transaction when the pend carries it,
1604
+ // on the pend itself when it does not — because a member's race resolution reads the
1605
+ // transaction's priority first and falls back to the pend's (recordPriority in db-p2p's
1606
+ // race-resolution.ts); an unvalidated pend that left both empty would never age. Omitted
1607
+ // at 0, exactly as the single-collection path sends it (TransactorSource.transact), so
1608
+ // the common first-attempt pend is indistinguishable from a Collection.sync pend.
1609
+ const priority = clampPriority(transaction.priority);
1610
+ // The base each update-only block's operations were computed against, read from the same
1611
+ // tracker the commit side digests (commitCollection); it still holds this transaction's staged
1612
+ // transforms and pins. Storage keeps it with the pending record so the record is never applied
1613
+ // to a different version of the block (see PendRequest.baseRevs). Omitted when empty.
1614
+ const baseRevs = collection.tracker.stagedBaseRevs(blockIdsForTransforms(transforms));
1287
1615
  const pendRequest: PendRequest = {
1288
1616
  actionId,
1289
1617
  rev,
1290
1618
  transforms,
1291
1619
  policy: 'r', // Return policy: fail but return pending actions
1292
- validation: { transaction, operationsHash },
1620
+ ...(validation === undefined
1621
+ ? (priority > 0 ? { priority } : {})
1622
+ : { validation }),
1623
+ ...baseRevsField(baseRevs),
1293
1624
  superclusterNominees: nominees
1294
1625
  };
1295
1626
 
@@ -1415,10 +1746,14 @@ export class TransactionCoordinator {
1415
1746
  };
1416
1747
 
1417
1748
  // Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
1418
- // A returned { success:false } is a permanent stale loss (someone committed a newer rev);
1419
- // the identical request can never win, so return immediately without retrying. Either way
1420
- // cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
1421
- // exactly once commit itself no longer self-cancels.
1749
+ // A returned { success:false } is a refusal the IDENTICAL request can never win (a rival
1750
+ // committed a newer rev, or a member behind on the base refused and dropped its pending
1751
+ // record), so return immediately without retrying it here. The refusal is retried at the
1752
+ // coordinator level instead, as a DIFFERENT request: the retry loop refreshes, finishes the
1753
+ // collection's own log entry where the tail landed, or re-pends afresh — and it does so even
1754
+ // when a sibling collection has already committed (the partial-retry branch of
1755
+ // commitOnceLatched). Either way cancelPhase (run by coordinateTransaction on commitPhase
1756
+ // failure) releases the pend exactly once — commit itself no longer self-cancels.
1422
1757
  let lastTransientError: string | undefined;
1423
1758
  for (let attempt = 0; attempt < 3; attempt++) {
1424
1759
  try {
@@ -1430,7 +1765,11 @@ export class TransactionCoordinator {
1430
1765
  // mark it retryable at the coordinator level (after a re-read advances the rev).
1431
1766
  // NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
1432
1767
  // like the pend path does. Once the pend succeeded, a returned commit failure means
1433
- // the revision slot moved. One commit producer DOES set `conflict` now
1768
+ // the revision slot moved, or a member behind on the base refused the durability
1769
+ // gate's `commit-not-durable` (which does set `conflict`) or a solo StorageRepo's
1770
+ // `missing-base-revision` (a bare `reason`, no `conflict`). All of these clear on a
1771
+ // re-drive, so gating on `conflict` here would fail the last of them fast. One commit
1772
+ // producer DOES set `conflict` now —
1434
1773
  // db-p2p's CoordinatorRepo.commit returns lost commit-consensus races and classified
1435
1774
  // stale-commit rejections as `{ success:false, conflict:true }` (returning, not
1436
1775
  // throwing, is what keeps them out of the verbatim retry above) — but every returned