@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,5 +1,6 @@
1
- import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionRev, ActionTransforms, StaleFailure, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
1
+ import type { ITransactor, GetBlockResults, ActionBlocks, ActionLineage, BlockLineage, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionRev, ActionTransforms, StaleFailure, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
2
2
  import { highestStaleAt, isOwnRevision } from "../network/stale-failure.js";
3
+ import { localDurability } from "../network/durability.js";
3
4
  import { ensuredMap } from "../utility/ensured.js";
4
5
  import { Latches } from "../utility/latches.js";
5
6
  import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
@@ -164,6 +165,26 @@ export class TestTransactor implements ITransactor {
164
165
  }));
165
166
  }
166
167
 
168
+ /** One store, no replicas: every revision was applied here onto the one before it, so the
169
+ * revision index answers for lineage outright — mirroring `BlockStorage.lineageOf`, including
170
+ * the one way a later revision does NOT build on its predecessor (an insert replaces the block
171
+ * wholesale). The "cohort" is this store alone, which is what the durability says. */
172
+ async getLineage(ref: ActionBlocks & { rev: number }): Promise<ActionLineage> {
173
+ this.checkAvailable();
174
+ const blocks = ref.blockIds.map((blockId): BlockLineage => {
175
+ const blockState = this.blocks.get(blockId);
176
+ if (!blockState || blockState.latestRev < ref.rev) return 'behind';
177
+ if (blockState.revisionActions.get(ref.rev) !== ref.actionId) return 'excludes';
178
+ const replacedSince = Array.from(blockState.revisionActions.entries()).some(([rev, actionId]) =>
179
+ rev > ref.rev && blockState.committedActions.get(actionId)?.insert !== undefined);
180
+ return replacedSince ? 'unknown' : 'contains';
181
+ });
182
+ return {
183
+ blocks,
184
+ ...(blocks.length > 0 && blocks.every(lineage => lineage === 'contains') ? { durability: localDurability() } : {})
185
+ };
186
+ }
187
+
167
188
  async pend(request: PendRequest): Promise<PendResult> {
168
189
  this.checkAvailable();
169
190
  const { actionId, transforms, policy, rev } = request;
@@ -174,6 +195,12 @@ export class TestTransactor implements ITransactor {
174
195
  // storage is already at or past the requested revision, held by someone other than this same
175
196
  // action. Reported as `staleAt` so a caller reading the shared harness sees the real shape.
176
197
  const staleCandidates: StaleFailure['staleAt'][] = [];
198
+ // Blocks this SAME action already committed at exactly the requested revision — the durable
199
+ // half of a torn action, met again by its own retry. Mirrors StorageRepo.pend's `satisfied`
200
+ // set: such a block is neither a committed conflict nor a pending one, and no pending record
201
+ // is written for it (commit's own-revision arm below never promotes one, so it would never
202
+ // clear). It still rides in the returned `blockIds`, as in the real repo, so `cancel` covers it.
203
+ const satisfied = new Set<BlockId>();
177
204
 
178
205
  // Check for conflicts (pending or committed based on rev/insert)
179
206
  for (const blockId of blockIds) {
@@ -181,6 +208,11 @@ export class TestTransactor implements ITransactor {
181
208
  const blockTransform = transformForBlockId(transforms, blockId);
182
209
  if (!blockTransform) continue; // Should not happen
183
210
 
211
+ if (blockState && isOwnRevision(latestActionRev(blockState), rev, actionId)) {
212
+ satisfied.add(blockId);
213
+ continue;
214
+ }
215
+
184
216
  if (blockState) {
185
217
  // Check for existing pending actions
186
218
  if (blockState.pendingActions.size > 0) {
@@ -196,8 +228,9 @@ export class TestTransactor implements ITransactor {
196
228
  // Mirrors StorageRepo.pend exactly: only a real revision race yields a
197
229
  // meaningful `staleAt`. A rev-less pend reaches here as an insert collision
198
230
  // (`checkRev` degraded to 0), where the block's revision answers a question
199
- // nobody asked; and our own durable half of a torn action is not a rival's win.
200
- if (rev !== undefined && !isOwnRevision(latestActionRev(blockState), rev, actionId)) {
231
+ // nobody asked. (Our own durable half of a torn action never reaches here
232
+ // it was set aside as `satisfied` above.)
233
+ if (rev !== undefined) {
201
234
  staleCandidates.push({ blockId, rev: blockState.latestRev });
202
235
  }
203
236
  // Collect conflicting committed actions
@@ -269,6 +302,7 @@ export class TestTransactor implements ITransactor {
269
302
 
270
303
  // No fatal conflicts found, proceed to pend
271
304
  for (const blockId of blockIds) {
305
+ if (satisfied.has(blockId)) continue;
272
306
  const blockTransform = transformForBlockId(transforms, blockId);
273
307
  if (blockTransform) {
274
308
  const blockState = ensuredMap(this.blocks, blockId, () => newBlockState());
@@ -276,11 +310,13 @@ export class TestTransactor implements ITransactor {
276
310
  }
277
311
  }
278
312
 
279
- // Return success, include pending list as per StorageRepo behavior
313
+ // Return success, include pending list as per StorageRepo behavior. The in-memory double is one
314
+ // node with no cohort, so its honest class is `local` — exactly what a bare StorageRepo answers.
280
315
  return {
281
316
  success: true,
282
317
  pending: conflictingPendings,
283
- blockIds
318
+ blockIds,
319
+ durability: localDurability()
284
320
  } as PendResult;
285
321
  }
286
322
 
@@ -310,10 +346,19 @@ export class TestTransactor implements ITransactor {
310
346
 
311
347
  // --- Start of Critical Section (Simulated) ---
312
348
 
349
+ // Blocks this same action already committed at exactly this revision: an idempotent no-op,
350
+ // mirroring StorageRepo.commit's `alreadyDone` partition. They are neither stale nor in need
351
+ // of a pending record (pend wrote none for them — see `satisfied` there), which is what lets
352
+ // a torn action's retry roll its remaining blocks forward.
353
+ const alreadyDone = new Set(blockIds.filter(blockId => {
354
+ const blockState = this.blocks.get(blockId);
355
+ return blockState !== undefined && isOwnRevision(latestActionRev(blockState), rev, actionId);
356
+ }));
357
+
313
358
  // Check for stale revisions
314
359
  const staleBlocks = blockIds.filter(blockId => {
315
360
  const blockState = this.blocks.get(blockId);
316
- return blockState && blockState.latestRev >= rev;
361
+ return blockState && blockState.latestRev >= rev && !alreadyDone.has(blockId);
317
362
  });
318
363
 
319
364
  if (staleBlocks.length > 0) {
@@ -341,19 +386,17 @@ export class TestTransactor implements ITransactor {
341
386
  transforms
342
387
  }));
343
388
  // Same rule as StorageRepo.commit's missedCommits branch: report the highest confirmed
344
- // revision a stale block is already at, skipping one held by this very action (the
345
- // durable half of a torn action, which its own retry must not be refused by).
346
- const staleAt = highestStaleAt(staleBlocks.map(blockId => {
347
- const blockState = this.blocks.get(blockId)!;
348
- return isOwnRevision(latestActionRev(blockState), rev, actionId)
349
- ? undefined
350
- : { blockId, rev: blockState.latestRev };
351
- }));
389
+ // revision a stale block is already at. A block held by this very action never reaches
390
+ // here it was partitioned out as `alreadyDone` above.
391
+ const staleAt = highestStaleAt(staleBlocks.map(blockId =>
392
+ ({ blockId, rev: this.blocks.get(blockId)!.latestRev })));
352
393
  return { success: false, missing, ...(staleAt ? { staleAt } : {}) };
353
394
  }
354
395
 
355
- // Verify all blocks have the pending action
356
- for (const blockId of blockIds) {
396
+ const toCommit = blockIds.filter(blockId => !alreadyDone.has(blockId));
397
+
398
+ // Verify all blocks that still need committing have the pending action
399
+ for (const blockId of toCommit) {
357
400
  const blockState = this.blocks.get(blockId);
358
401
  if (!blockState || !blockState.pendingActions.has(actionId)) {
359
402
  return {
@@ -364,7 +407,7 @@ export class TestTransactor implements ITransactor {
364
407
  }
365
408
 
366
409
  // Commit the action for each block
367
- for (const blockId of blockIds) {
410
+ for (const blockId of toCommit) {
368
411
  const blockState = this.blocks.get(blockId)!;
369
412
  const transform = blockState.pendingActions.get(actionId)!;
370
413
 
@@ -397,7 +440,7 @@ export class TestTransactor implements ITransactor {
397
440
 
398
441
  // --- End of Critical Section (Simulated) ---
399
442
 
400
- return { success: true };
443
+ return { success: true, durability: localDurability() };
401
444
 
402
445
  } finally {
403
446
  // Release locks in reverse order
@@ -480,6 +523,7 @@ export abstract class DelegatingTransactor implements ITransactor {
480
523
  pend(r: PendRequest): Promise<PendResult> { return this.inner.pend(r); }
481
524
  cancel(a: ActionBlocks): Promise<void> { return this.inner.cancel(a); }
482
525
  commit(r: CommitRequest): Promise<CommitResult> { return this.inner.commit(r); }
526
+ getLineage(ref: ActionBlocks & { rev: number }): Promise<ActionLineage> { return this.inner.getLineage(ref); }
483
527
 
484
528
  get queryClusterNominees(): ((blockId: BlockId) => Promise<ClusterNomineesResult>) | undefined {
485
529
  return this.inner.queryClusterNominees?.bind(this.inner);
@@ -519,16 +563,29 @@ export class FlakyCommitTransactor extends DelegatingTransactor {
519
563
  }
520
564
 
521
565
  /**
522
- * Commits durably on the inner {@link TestTransactor}, then reports a stale failure anyway —
523
- * the exact observable shape of `NetworkTransactor.commit`'s TORN ACTION: the collection's log
524
- * tail is committed BEFORE the sweep of the remaining blocks, so a later sweep block coming back
525
- * as a confirmed conflict returns a stale failure over an action whose log entry is already
526
- * durable.
566
+ * Commits the WHOLE action durably on the inner {@link TestTransactor}, then reports a stale
567
+ * failure anyway a write that fully landed but whose writer was told it failed.
568
+ *
569
+ * This is NOT the shape `NetworkTransactor.commit` produces when it refuses a write whose log tail
570
+ * landed: there the tail is committed BEFORE the sweep of the remaining blocks, so the blocks after
571
+ * the tail did NOT land. That shape is {@link TailLandsButReportsStale}, and it is the one that
572
+ * matters in production. This double makes "my own log entry is visible" and "my action is fully
573
+ * durable" the same thing, so on its own it cannot tell a writer that finishes its half-landed
574
+ * action from one that merely assumes it is finished. Keep it for the case it does model — every
575
+ * block already holds the action's revision, so the writer's completion pass has nothing left to
576
+ * land and must not duplicate or move the entry.
527
577
  *
528
578
  * Safe against the inner transactor's bookkeeping because `TransactorSource.transact` cancels
529
579
  * the pend on the reported failure and {@link TestTransactor.cancel} only deletes PENDING records
530
580
  * — the real commit already promoted them, so the cancel is a no-op.
531
581
  *
582
+ * `afterLanding`, when given, runs a competing writer ONCE, after the first masked commit has
583
+ * landed and before its failure is reported: the rival reads the landed write and commits the next
584
+ * revision ON TOP of it, so by the time the writer looks again every block it wrote has moved past
585
+ * its revision while still containing it. Safe to await from inside `commit` here (unlike
586
+ * {@link TailLandsButReportsStale}'s rival, which has to wait for the cancel): the whole action
587
+ * landed, so it left no pending record for the rival's pend to collide with.
588
+ *
532
589
  * Used by both write paths' own-entry regression suites (collection-own-action-replay.spec.ts and
533
590
  * coordinator-own-action-replay.spec.ts); it lives here so the two cannot drift apart.
534
591
  */
@@ -537,10 +594,12 @@ export class CommitLandsButReportsStale extends DelegatingTransactor {
537
594
  private injections: number;
538
595
  /** Commits that actually landed on the inner transactor (masked or not). */
539
596
  landedCommits = 0;
597
+ private rivalDue: boolean;
540
598
 
541
- constructor(inner: TestTransactor, injections = 1) {
599
+ constructor(inner: TestTransactor, injections = 1, private readonly afterLanding?: RivalWrite) {
542
600
  super(inner);
543
601
  this.injections = injections;
602
+ this.rivalDue = afterLanding !== undefined;
544
603
  }
545
604
 
546
605
  override async commit(request: CommitRequest): Promise<CommitResult> {
@@ -548,6 +607,10 @@ export class CommitLandsButReportsStale extends DelegatingTransactor {
548
607
  if (result.success) {
549
608
  this.landedCommits++;
550
609
  if (this.injections-- > 0) {
610
+ if (this.rivalDue) {
611
+ this.rivalDue = false;
612
+ await this.afterLanding!(this.inner);
613
+ }
551
614
  return { success: false, conflict: true, reason: 'stale commit: injected torn-action conflict' };
552
615
  }
553
616
  }
@@ -555,6 +618,64 @@ export class CommitLandsButReportsStale extends DelegatingTransactor {
555
618
  }
556
619
  }
557
620
 
621
+ /**
622
+ * Lands ONLY the action's log tail on the inner {@link TestTransactor}, then reports a retryable
623
+ * failure — the shape `NetworkTransactor.commit` actually produces for a half-landed write. It
624
+ * commits the tail first and sweeps the remaining blocks only if the tail answered success, and a
625
+ * failed tail answer does not mean the tail is absent: the coordinator's durability gate answers
626
+ * `commit-not-durable` whenever fewer than a majority of the cohort hold the revision, even though
627
+ * some members stored it. So the writer is told "failed" over a log entry that is durable, while
628
+ * none of the entry's other blocks were ever committed — and the writer's own cancel then drops
629
+ * their pending records.
630
+ *
631
+ * A commit carrying nothing but the tail is delegated untouched (there is nothing to abandon), and
632
+ * does not consume an injection.
633
+ *
634
+ * `afterCancel`, when given, runs a competing writer ONCE, right after the writer's cancel of the
635
+ * torn attempt has been delegated. It is on `cancel` and not inside `commit` for the reason
636
+ * {@link CompetingWriterTransactor} documents: until that cancel lands, the torn action's data
637
+ * blocks still carry its pending records, a rival pending with policy `'r'` collides with them, and
638
+ * awaiting that rival from inside the writer's own commit is a livelock.
639
+ */
640
+ export class TailLandsButReportsStale extends DelegatingTransactor {
641
+ /** Remaining number of commits to tear. */
642
+ private injections: number;
643
+ /** Commits torn so far: the tail landed, every other block of the action was abandoned. */
644
+ tears = 0;
645
+ /** Commits delegated whole that succeeded on the inner transactor. */
646
+ landedCommits = 0;
647
+ private rivalDue = false;
648
+
649
+ constructor(inner: TestTransactor, injections = 1, private readonly afterCancel?: RivalWrite) {
650
+ super(inner);
651
+ this.injections = injections;
652
+ }
653
+
654
+ override async commit(request: CommitRequest): Promise<CommitResult> {
655
+ if (this.injections > 0 && request.blockIds.some(blockId => blockId !== request.tailId)) {
656
+ const tail = await this.inner.commit({ ...request, blockIds: [request.tailId] });
657
+ // A tail that itself lost is an ordinary loss — nothing is torn, so report it verbatim
658
+ // and keep the injection for a commit that can actually tear.
659
+ if (!tail.success) return tail;
660
+ this.injections--;
661
+ this.tears++;
662
+ this.rivalDue = this.afterCancel !== undefined;
663
+ return { success: false, conflict: true, reason: 'commit-not-durable: injected tail-only landing' };
664
+ }
665
+ const result = await this.inner.commit(request);
666
+ if (result.success) this.landedCommits++;
667
+ return result;
668
+ }
669
+
670
+ override async cancel(actionRef: ActionBlocks): Promise<void> {
671
+ await this.inner.cancel(actionRef);
672
+ if (this.rivalDue) {
673
+ this.rivalDue = false;
674
+ await this.afterCancel!(this.inner);
675
+ }
676
+ }
677
+ }
678
+
558
679
  /** A competing writer: a real write driven against the UNWRAPPED transactor, so its own
559
680
  * pend/commit calls are invisible to {@link CompetingWriterTransactor}'s counters and cannot
560
681
  * re-trigger the interception. See {@link commitRivalTreeWrite} for the usual implementation. */
@@ -793,6 +914,13 @@ function latestMaterializedAt(blockState: BlockState, maxRev: number): { block:
793
914
  /**
794
915
  * `applyTransform` over cloned inputs, so a returned block never aliases stored state.
795
916
  *
917
+ * The `insert` clone here is not redundant with `transformForBlockId`'s: callers pass `Transform`
918
+ * values pulled straight out of `blockState.pendingActions` (a `Map<ActionId, Transform>` written
919
+ * once by `pend()`), which can be `get()`'d more than once and read again at commit time before
920
+ * being superseded. `applyTransform` mutates `transform.insert` in place when `updates` ride
921
+ * along, so cloning immediately before each call is what keeps that stored entry pristine across
922
+ * repeated reads.
923
+ *
796
924
  * An absent base is NOT a short circuit: an insert needs no base — `applyTransform` adopts it as
797
925
  * the block. Bailing out on `!block` made this double silently drop a pending-only insert read
798
926
  * through the pending overlay, the one shape `StorageRepo.get` serves with content but no