@optimystic/db-core 0.24.2 → 0.25.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 (103) hide show
  1. package/dist/src/blocks/helpers.d.ts +5 -0
  2. package/dist/src/blocks/helpers.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.js +12 -0
  4. package/dist/src/blocks/helpers.js.map +1 -1
  5. package/dist/src/cluster/membership.d.ts +7 -0
  6. package/dist/src/cluster/membership.d.ts.map +1 -1
  7. package/dist/src/cluster/membership.js +12 -8
  8. package/dist/src/cluster/membership.js.map +1 -1
  9. package/dist/src/cluster/structs.d.ts +27 -1
  10. package/dist/src/cluster/structs.d.ts.map +1 -1
  11. package/dist/src/cluster/structs.js.map +1 -1
  12. package/dist/src/collection/action.d.ts +14 -0
  13. package/dist/src/collection/action.d.ts.map +1 -1
  14. package/dist/src/collection/action.js +16 -1
  15. package/dist/src/collection/action.js.map +1 -1
  16. package/dist/src/collection/collection.d.ts +273 -4
  17. package/dist/src/collection/collection.d.ts.map +1 -1
  18. package/dist/src/collection/collection.js +427 -30
  19. package/dist/src/collection/collection.js.map +1 -1
  20. package/dist/src/collections/tree/tree.d.ts +16 -1
  21. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  22. package/dist/src/collections/tree/tree.js +19 -0
  23. package/dist/src/collections/tree/tree.js.map +1 -1
  24. package/dist/src/index.d.ts +1 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/network/i-repo.d.ts +11 -1
  29. package/dist/src/network/i-repo.d.ts.map +1 -1
  30. package/dist/src/network/stale-failure.d.ts +21 -0
  31. package/dist/src/network/stale-failure.d.ts.map +1 -1
  32. package/dist/src/network/stale-failure.js +22 -0
  33. package/dist/src/network/stale-failure.js.map +1 -1
  34. package/dist/src/network/struct.d.ts +66 -21
  35. package/dist/src/network/struct.d.ts.map +1 -1
  36. package/dist/src/network/struct.js.map +1 -1
  37. package/dist/src/testing/test-transactor.d.ts +22 -0
  38. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  39. package/dist/src/testing/test-transactor.js +44 -5
  40. package/dist/src/testing/test-transactor.js.map +1 -1
  41. package/dist/src/transaction/coordinator.d.ts +20 -0
  42. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  43. package/dist/src/transaction/coordinator.js +256 -113
  44. package/dist/src/transaction/coordinator.js.map +1 -1
  45. package/dist/src/transaction/operations-hash.d.ts +1 -1
  46. package/dist/src/transaction/operations-hash.js +1 -1
  47. package/dist/src/transaction/transaction.d.ts +4 -2
  48. package/dist/src/transaction/transaction.d.ts.map +1 -1
  49. package/dist/src/transactor/network-transactor.d.ts +21 -1
  50. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  51. package/dist/src/transactor/network-transactor.js +122 -29
  52. package/dist/src/transactor/network-transactor.js.map +1 -1
  53. package/dist/src/transactor/transactor-source.d.ts +11 -5
  54. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  55. package/dist/src/transactor/transactor-source.js +16 -8
  56. package/dist/src/transactor/transactor-source.js.map +1 -1
  57. package/dist/src/transform/cache-source.d.ts +13 -0
  58. package/dist/src/transform/cache-source.d.ts.map +1 -1
  59. package/dist/src/transform/cache-source.js +18 -0
  60. package/dist/src/transform/cache-source.js.map +1 -1
  61. package/dist/src/transform/digest.d.ts +18 -0
  62. package/dist/src/transform/digest.d.ts.map +1 -0
  63. package/dist/src/transform/digest.js +65 -0
  64. package/dist/src/transform/digest.js.map +1 -0
  65. package/dist/src/transform/index.d.ts +1 -0
  66. package/dist/src/transform/index.d.ts.map +1 -1
  67. package/dist/src/transform/index.js +1 -0
  68. package/dist/src/transform/index.js.map +1 -1
  69. package/dist/src/transform/tracker.d.ts +16 -0
  70. package/dist/src/transform/tracker.d.ts.map +1 -1
  71. package/dist/src/transform/tracker.js +40 -1
  72. package/dist/src/transform/tracker.js.map +1 -1
  73. package/dist/src/utility/canonical-json.d.ts +11 -0
  74. package/dist/src/utility/canonical-json.d.ts.map +1 -0
  75. package/dist/src/utility/canonical-json.js +15 -0
  76. package/dist/src/utility/canonical-json.js.map +1 -0
  77. package/dist/src/utility/lru-map.d.ts +2 -0
  78. package/dist/src/utility/lru-map.d.ts.map +1 -1
  79. package/dist/src/utility/lru-map.js +4 -0
  80. package/dist/src/utility/lru-map.js.map +1 -1
  81. package/package.json +2 -1
  82. package/src/blocks/helpers.ts +26 -13
  83. package/src/cluster/membership.ts +87 -85
  84. package/src/cluster/structs.ts +28 -1
  85. package/src/collection/action.ts +17 -0
  86. package/src/collection/collection.ts +1129 -688
  87. package/src/collections/tree/tree.ts +341 -320
  88. package/src/index.ts +24 -23
  89. package/src/network/i-repo.ts +59 -46
  90. package/src/network/stale-failure.ts +67 -43
  91. package/src/network/struct.ts +332 -270
  92. package/src/testing/test-transactor.ts +680 -638
  93. package/src/transaction/coordinator.ts +1266 -1110
  94. package/src/transaction/operations-hash.ts +1 -1
  95. package/src/transaction/transaction.ts +4 -2
  96. package/src/transactor/network-transactor.ts +123 -32
  97. package/src/transactor/transactor-source.ts +17 -9
  98. package/src/transform/cache-source.ts +20 -0
  99. package/src/transform/digest.ts +72 -0
  100. package/src/transform/index.ts +1 -0
  101. package/src/transform/tracker.ts +42 -1
  102. package/src/utility/canonical-json.ts +16 -0
  103. package/src/utility/lru-map.ts +5 -0
@@ -1,638 +1,680 @@
1
- import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
2
- import { ensuredMap } from "../utility/ensured.js";
3
- import { Latches } from "../utility/latches.js";
4
- import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
5
- import { Tree } from "../collections/tree/tree.js";
6
- import type { TreeReplaceAction } from "../collections/tree/struct.js";
7
-
8
- type RevisionNumber = number;
9
-
10
- type BlockState = {
11
- /** The current materialized block at each revision */
12
- materializedBlocks: Map<RevisionNumber, IBlock>;
13
- /** The latest revision number */
14
- latestRev: RevisionNumber;
15
- /** The action that created each revision */
16
- revisionActions: Map<RevisionNumber, ActionId>;
17
- /** Currently pending actions */
18
- pendingActions: Map<ActionId, Transform>;
19
- /** Committed actions */
20
- committedActions: Map<ActionId, Transform>;
21
- }
22
-
23
- // Simple in-memory transactor for testing that maintains materialized blocks for every revision
24
- export class TestTransactor implements ITransactor {
25
- private blocks = new Map<BlockId, BlockState>();
26
- available = true;
27
- private getLocks = new Map<BlockId, Promise<() => void>>(); // Track lock releases
28
-
29
- constructor() {}
30
-
31
- async get(blockGets: BlockGets): Promise<GetBlockResults> {
32
- this.checkAvailable();
33
- const results: GetBlockResults = {};
34
- const uniqueBlockIds = [...new Set(blockGets.blockIds)].sort(); // Ensure consistent lock order if needed, though get is read-only
35
- const releases: (() => void)[] = [];
36
-
37
- try {
38
- // Acquire locks for all requested blocks to ensure consistent read
39
- for (const blockId of uniqueBlockIds) {
40
- const lockId = `TestTransactor.commit:${blockId}`; // Use the same lock as commit
41
- // Wait for any existing lock promise to resolve before acquiring the next
42
- let release = await this.getLocks.get(lockId);
43
- if (release) await Promise.resolve(release); // Ensure previous lock released if overlapping calls happen
44
-
45
- const releasePromise = Latches.acquire(lockId);
46
- this.getLocks.set(blockId, releasePromise.then(r => () => {
47
- r();
48
- this.getLocks.delete(blockId); // Clean up map entry after release
49
- }));
50
- release = await releasePromise;
51
- releases.push(release);
52
- }
53
-
54
- // --- Start of Critical Section (Read) ---
55
- for (const blockId of blockGets.blockIds) {
56
- const blockState = this.blocks.get(blockId);
57
- if (!blockState) {
58
- // Block doesn't exist yet
59
- results[blockId] = {
60
- block: undefined,
61
- state: { latest: undefined, pendings: [] }
62
- };
63
- continue;
64
- }
65
-
66
- // Get the appropriate materialized block based on context
67
- let block: IBlock | undefined;
68
- // The revision `block` was actually materialized at, reported as
69
- // GetBlockResult.materializedRev. Equals `latestRev` on every unpinned path; only a
70
- // revision-pinned read of a block committed further since the pin makes them differ,
71
- // and there the pinned value is what the reader observed (see the field's doc).
72
- let materializedRev: number | undefined;
73
- if (blockGets.context?.actionId !== undefined) {
74
- // If requesting a specific action, apply pending transform if it exists
75
- const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
76
- if (pendingTransform) {
77
- // Read latest committed block as base for pending transform
78
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
79
- block = applyTransformSafe(baseBlock, pendingTransform);
80
- // A pending carries no revision of its own — report the committed base it was
81
- // applied over. Absent when there was no base (a pending-only insert).
82
- if (baseBlock) materializedRev = blockState.latestRev;
83
- } else {
84
- // Action not pending, maybe committed? Or maybe invalid actionId for context.
85
- // For simplicity, return undefined block if specific pending action not found.
86
- // A more complex impl might check committedActions history.
87
- block = undefined;
88
- }
89
- } else if (blockGets.context?.committed) {
90
- // Check context.committed for matching pending actions — mirrors coordinator
91
- // behavior: context.committed proves the action succeeded, so pending blocks
92
- // for that action should be served.
93
- for (const { actionId: cId } of blockGets.context.committed) {
94
- const pendingTransform = blockState.pendingActions.get(cId);
95
- if (pendingTransform) {
96
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
97
- block = applyTransformSafe(baseBlock, pendingTransform);
98
- if (baseBlock) materializedRev = blockState.latestRev;
99
- break;
100
- }
101
- }
102
- // Fall through to standard resolution if no pending match
103
- if (block === undefined) {
104
- if (blockGets.context.rev !== undefined) {
105
- const found = latestMaterializedAt(blockState, blockGets.context.rev);
106
- block = structuredClone(found?.block);
107
- materializedRev = found?.rev;
108
- } else {
109
- block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
110
- if (block) materializedRev = blockState.latestRev;
111
- }
112
- }
113
- } else if (blockGets.context?.rev !== undefined) {
114
- // Return the materialized block at the highest revision ≤ requested
115
- const found = latestMaterializedAt(blockState, blockGets.context.rev);
116
- block = structuredClone(found?.block);
117
- materializedRev = found?.rev;
118
- } else {
119
- // Otherwise return latest materialized block
120
- block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
121
- if (block) materializedRev = blockState.latestRev;
122
- }
123
-
124
-
125
- const actionId = blockState.revisionActions.get(blockState.latestRev);
126
- results[blockId] = {
127
- block,
128
- ...(materializedRev !== undefined ? { materializedRev } : {}),
129
- state: {
130
- latest: actionId !== undefined ? {
131
- rev: blockState.latestRev,
132
- actionId
133
- } : undefined,
134
- pendings: Array.from(blockState.pendingActions.keys())
135
- }
136
- };
137
- }
138
- // --- End of Critical Section (Read) ---
139
-
140
- } finally {
141
- // Release locks in reverse order
142
- releases.reverse().forEach(release => release());
143
- }
144
- return results;
145
- }
146
-
147
- async getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]> {
148
- return actionRefs.map(ref => ({
149
- ...ref,
150
- statuses: ref.blockIds.map(blockId => {
151
- const blockState = this.blocks.get(blockId);
152
- if (!blockState) return 'aborted';
153
- return blockState.pendingActions.has(ref.actionId) ? 'pending'
154
- : Array.from(blockState.revisionActions.values()).some(actionId => actionId === ref.actionId) ? 'committed'
155
- : 'aborted';
156
- })
157
- }));
158
- }
159
-
160
- async pend(request: PendRequest): Promise<PendResult> {
161
- this.checkAvailable();
162
- const { actionId, transforms, policy, rev } = request;
163
- const blockIds = blockIdsForTransforms(transforms);
164
- const conflictingPendings: { blockId: BlockId, actionId: ActionId }[] = [];
165
- const missing: ActionTransforms[] = [];
166
-
167
- // Check for conflicts (pending or committed based on rev/insert)
168
- for (const blockId of blockIds) {
169
- const blockState = this.blocks.get(blockId);
170
- const blockTransform = transformForBlockId(transforms, blockId);
171
- if (!blockTransform) continue; // Should not happen
172
-
173
- if (blockState) {
174
- // Check for existing pending actions
175
- if (blockState.pendingActions.size > 0) {
176
- blockState.pendingActions.forEach((_, pendingActionId) => {
177
- conflictingPendings.push({ blockId, actionId: pendingActionId });
178
- });
179
- }
180
-
181
- // Check for conflicting committed revisions (if rev specified or it's an insert)
182
- if (rev !== undefined || blockTransform.insert) {
183
- const checkRev = rev ?? 0; // Check from revision 0 if it's an insert
184
- if (blockState.latestRev >= checkRev) {
185
- // Collect conflicting committed actions
186
- const missingForBlock = new Map<ActionId, { rev: number, transform: Transform }>();
187
- for (let r = checkRev as number; r <= blockState.latestRev; r++) {
188
- const committedActionId = blockState.revisionActions.get(r);
189
- if (committedActionId !== undefined) {
190
- const committedTransform = blockState.committedActions.get(committedActionId);
191
- if (committedTransform) {
192
- missingForBlock.set(committedActionId, { rev: r, transform: committedTransform });
193
- }
194
- }
195
- }
196
-
197
- // Add collected missing transforms for this block to the main missing list
198
- for (const [mActionId, data] of missingForBlock.entries()) {
199
- let existing = missing.find(m => m.actionId === mActionId);
200
- if (!existing) {
201
- existing = { actionId: mActionId, rev: data.rev, transforms: emptyTransforms() };
202
- missing.push(existing);
203
- }
204
- existing.rev = Math.max(existing.rev ?? 0, data.rev);
205
- existing.transforms = concatTransform(existing.transforms, blockId, data.transform);
206
- }
207
- }
208
- }
209
- }
210
- }
211
-
212
- // Handle failure due to committed conflicts first.
213
- // `conflict: true` on the three optimistic-concurrency returns below mirrors what
214
- // StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
215
- if (missing.length > 0) {
216
- return {
217
- success: false,
218
- conflict: true,
219
- missing
220
- };
221
- }
222
-
223
- // Handle failure/retry due to pending conflicts
224
- if (conflictingPendings.length > 0) {
225
- if (policy === 'f') {
226
- return { success: false, conflict: true, pending: conflictingPendings };
227
- } else if (policy === 'r') {
228
- // Simulate fetching pending transforms for 'r' policy
229
- const pendingWithTransforms = conflictingPendings
230
- .map(({ blockId: pBlockId, actionId: pActionId }) => {
231
- const pBlockState = this.blocks.get(pBlockId);
232
- const pTransform = pBlockState?.pendingActions.get(pActionId)
233
- ?? pBlockState?.committedActions.get(pActionId); // Might have been committed since check
234
- if (pTransform) {
235
- return { blockId: pBlockId, actionId: pActionId, transform: pTransform };
236
- }
237
- return null; // Handle case where it disappeared (cancelled?)
238
- })
239
- .filter(p => p !== null) as { blockId: BlockId, actionId: ActionId, transform: Transform }[];
240
-
241
- return {
242
- success: false,
243
- conflict: true,
244
- pending: pendingWithTransforms
245
- };
246
- }
247
- // Policy 'w' allows proceeding despite pending transactions
248
- }
249
-
250
- // No fatal conflicts found, proceed to pend
251
- for (const blockId of blockIds) {
252
- const blockTransform = transformForBlockId(transforms, blockId);
253
- if (blockTransform) {
254
- const blockState = ensuredMap(this.blocks, blockId, () => newBlockState());
255
- blockState.pendingActions.set(actionId, blockTransform);
256
- }
257
- }
258
-
259
- // Return success, include pending list as per StorageRepo behavior
260
- return {
261
- success: true,
262
- pending: conflictingPendings,
263
- blockIds
264
- } as PendResult;
265
- }
266
-
267
- async cancel(actionRef: ActionBlocks): Promise<void> {
268
- this.checkAvailable();
269
- for (const blockId of actionRef.blockIds) {
270
- const blockState = this.blocks.get(blockId);
271
- if (blockState) {
272
- blockState.pendingActions.delete(actionRef.actionId);
273
- }
274
- }
275
- }
276
-
277
- async commit(request: CommitRequest): Promise<CommitResult> {
278
- this.checkAvailable();
279
- const { actionId, rev, blockIds } = request;
280
- const uniqueBlockIds = [...new Set(blockIds)].sort();
281
- const releases: (() => void)[] = [];
282
-
283
- try {
284
- // Simulate acquiring locks sequentially like StorageRepo
285
- for (const id of uniqueBlockIds) {
286
- const lockId = `TestTransactor.commit:${id}`;
287
- const release = await Latches.acquire(lockId);
288
- releases.push(release);
289
- }
290
-
291
- // --- Start of Critical Section (Simulated) ---
292
-
293
- // Check for stale revisions
294
- const staleBlocks = blockIds.filter(blockId => {
295
- const blockState = this.blocks.get(blockId);
296
- return blockState && blockState.latestRev >= rev;
297
- });
298
-
299
- if (staleBlocks.length > 0) {
300
- // Collect missing actions for stale blocks
301
- const missingByAction = new Map<ActionId, Transforms>();
302
- for (const blockId of staleBlocks) {
303
- const blockState = this.blocks.get(blockId)!;
304
- for (let r = rev; r <= blockState.latestRev; r++) {
305
- const committedActionId = blockState.revisionActions.get(r);
306
- if (committedActionId) {
307
- const transform = blockState.committedActions.get(committedActionId);
308
- if (transform) {
309
- const existing = missingByAction.get(committedActionId) ?? emptyTransforms();
310
- missingByAction.set(committedActionId, concatTransform(existing, blockId, transform));
311
- }
312
- }
313
- }
314
- }
315
-
316
- const missing: ActionTransforms[] = Array.from(missingByAction.entries()).map(([actionId, transforms]) => ({
317
- actionId,
318
- rev: Array.from(this.blocks.values())
319
- .flatMap(bs => Array.from(bs.revisionActions.entries()))
320
- .find(([, aId]) => aId === actionId)?.[0] ?? rev,
321
- transforms
322
- }));
323
- return { success: false, missing };
324
- }
325
-
326
- // Verify all blocks have the pending action
327
- for (const blockId of blockIds) {
328
- const blockState = this.blocks.get(blockId);
329
- if (!blockState || !blockState.pendingActions.has(actionId)) {
330
- return {
331
- success: false,
332
- reason: `Action ${actionId} not found or not pending for block ${blockId}`
333
- };
334
- }
335
- }
336
-
337
- // Commit the action for each block
338
- for (const blockId of blockIds) {
339
- const blockState = this.blocks.get(blockId)!;
340
- const transform = blockState.pendingActions.get(actionId)!;
341
-
342
- // Get base block to apply transform to
343
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
344
-
345
- let newBlock: IBlock | undefined;
346
- if (!baseBlock) {
347
- if (!transform.insert) {
348
- throw new Error(`Commit Error: Action ${actionId} has no insert for new block ${blockId}`);
349
- }
350
- newBlock = structuredClone(transform.insert);
351
- } else {
352
- newBlock = applyTransformSafe(baseBlock, transform);
353
- if (!newBlock && !transform.delete) {
354
- throw new Error(`Commit Error: Action ${actionId} resulted in undefined block but had no delete flag for block ${blockId}`);
355
- }
356
- }
357
-
358
- if (newBlock) {
359
- blockState.materializedBlocks.set(rev, newBlock);
360
- }
361
-
362
- // Update block state
363
- blockState.latestRev = rev;
364
- blockState.revisionActions.set(rev, actionId);
365
- blockState.committedActions.set(actionId, transform);
366
- blockState.pendingActions.delete(actionId);
367
- }
368
-
369
- // --- End of Critical Section (Simulated) ---
370
-
371
- return { success: true };
372
-
373
- } finally {
374
- // Release locks in reverse order
375
- releases.reverse().forEach(release => release());
376
- }
377
- }
378
-
379
- // Helper methods for testing
380
- reset() {
381
- this.blocks.clear();
382
- }
383
-
384
- getPendingActions(): Map<ActionId, ActionTransforms> {
385
- const allPending = new Map<ActionId, ActionTransforms>();
386
- for (const [blockId, blockState] of this.blocks.entries()) {
387
- for (const [actionId, transform] of blockState.pendingActions) {
388
- const existing = allPending.get(actionId);
389
- if (!existing) {
390
- allPending.set(actionId, { actionId, transforms: transformsFromTransform(transform, blockId) });
391
- } else {
392
- existing.transforms = concatTransform(existing.transforms, blockId, transform);
393
- }
394
- }
395
- }
396
- return allPending;
397
- }
398
-
399
- getCommittedActions(): Map<ActionId, ActionTransforms> {
400
- const allCommitted = new Map<ActionId, ActionTransforms>();
401
- for (const [blockId, blockState] of this.blocks.entries()) {
402
- for (const [rev, actionId] of blockState.revisionActions) {
403
- const transform = blockState.committedActions.get(actionId);
404
- if (transform) {
405
- const existing = allCommitted.get(actionId);
406
- if (!existing) {
407
- allCommitted.set(actionId, {
408
- actionId,
409
- rev,
410
- transforms: transformsFromTransform(transform, blockId)
411
- });
412
- } else {
413
- existing.transforms = concatTransform(existing.transforms, blockId, transform);
414
- }
415
- }
416
- }
417
- }
418
- return allCommitted;
419
- }
420
-
421
- setAvailable(available: boolean) {
422
- this.available = available;
423
- }
424
-
425
- checkAvailable() {
426
- if (!this.available) {
427
- throw new Error('Transactor is not available');
428
- }
429
- }
430
-
431
- /** Optional method for querying cluster nominees (used in GATHER phase for multi-collection transactions) */
432
- queryClusterNominees?: (blockId: BlockId) => Promise<ClusterNomineesResult>;
433
- }
434
-
435
- /**
436
- * Base for the intercepting wrappers below: forwards every {@link ITransactor} member to `inner`,
437
- * so a wrapper overrides only the one call it intercepts and cannot silently drop the rest.
438
- *
439
- * `queryClusterNominees` is forwarded through a GETTER rather than a method, because the coordinator
440
- * treats its ABSENCE as "no supercluster" (see `gatherPhase`). A wrapper that always defined it
441
- * would push every wrapped multi-collection test onto the GATHER path even when the inner
442
- * transactor never opted in; a wrapper that omits it (as these did before) does the opposite —
443
- * a test that sets `inner.queryClusterNominees` and then wraps would skip GATHER and pass
444
- * vacuously. The getter reproduces the inner transactor's own answer either way.
445
- */
446
- export abstract class DelegatingTransactor implements ITransactor {
447
- protected constructor(protected readonly inner: TestTransactor) {}
448
-
449
- get(b: BlockGets): Promise<GetBlockResults> { return this.inner.get(b); }
450
- getStatus(a: ActionBlocks[]): Promise<BlockActionStatus[]> { return this.inner.getStatus(a); }
451
- pend(r: PendRequest): Promise<PendResult> { return this.inner.pend(r); }
452
- cancel(a: ActionBlocks): Promise<void> { return this.inner.cancel(a); }
453
- commit(r: CommitRequest): Promise<CommitResult> { return this.inner.commit(r); }
454
-
455
- get queryClusterNominees(): ((blockId: BlockId) => Promise<ClusterNomineesResult>) | undefined {
456
- return this.inner.queryClusterNominees?.bind(this.inner);
457
- }
458
- }
459
-
460
- /**
461
- * Wraps a {@link TestTransactor} and forces its commit phase to fail a bounded (or unbounded)
462
- * number of times before delegating, so tests can exercise the sync retry / backoff / give-up
463
- * path deterministically. Everything else delegates unchanged, so the pend→commit→cancel
464
- * round-trip runs exactly as in production.
465
- */
466
- export class FlakyCommitTransactor extends DelegatingTransactor {
467
- /** Number of commit() calls observed so far (across success and forced failure). */
468
- commitAttempts = 0;
469
-
470
- /**
471
- * @param inner delegate transactor
472
- * @param failFirstN number of initial commit() calls to fail; Infinity to always fail
473
- * @param reason the StaleFailure.reason returned on a forced failure
474
- */
475
- constructor(
476
- inner: TestTransactor,
477
- private readonly failFirstN: number,
478
- private readonly reason = 'forced stale',
479
- ) {
480
- super(inner);
481
- }
482
-
483
- override async commit(request: CommitRequest): Promise<CommitResult> {
484
- this.commitAttempts++;
485
- if (this.commitAttempts <= this.failFirstN) {
486
- return { success: false, reason: this.reason };
487
- }
488
- return this.inner.commit(request);
489
- }
490
- }
491
-
492
- /** A competing writer: a real write driven against the UNWRAPPED transactor, so its own
493
- * pend/commit calls are invisible to {@link CompetingWriterTransactor}'s counters and cannot
494
- * re-trigger the interception. See {@link commitRivalTreeWrite} for the usual implementation. */
495
- export type RivalWrite = (inner: ITransactor) => Promise<void>;
496
-
497
- export type CompetingWriterOptions = {
498
- /** Fires on the first pend whose request satisfies this predicate. `callIndex` is 1-based over
499
- * ALL pend calls seen (not only matching ones). Default: fire on the first pend call. */
500
- when?: (request: PendRequest, callIndex: number) => boolean;
501
- };
502
-
503
- /**
504
- * Wraps a {@link TestTransactor} and, exactly once, runs a real competing writer to completion
505
- * BEFORE delegating the intercepted pend. The rival durably commits (real log entry, real
506
- * revision bump), so the delegated pend then fails as a GENUINE optimistic-concurrency loss —
507
- * nothing is forced or faked, unlike {@link FlakyCommitTransactor}, which returns a stale failure
508
- * without ever advancing a block's revision.
509
- *
510
- * That distinction is the whole point: only a rival that actually landed can prove the loser
511
- * OBSERVED a newer revision, re-applied its work on top of it, and did not lose an update.
512
- *
513
- * Two deliberate design constraints:
514
- *
515
- * 1. **The trigger is on PEND, and there is no commit trigger.** The rival is a real
516
- * Collection/Tree, so its write pends with policy `'r'` (see `TransactorSource.transact`), and
517
- * {@link TestTransactor.pend} rejects a pend whose blocks already carry a *pending* action.
518
- * Firing before the loser's pend delegates means the loser has pended nothing yet, so the
519
- * rival pends and commits cleanly and the loser's delegated pend then fails with `missing` — a
520
- * real conflict. Firing at COMMIT time instead would leave the loser already pending on the
521
- * shared log-tail block, so the rival's own pend would collide with it and spin through its
522
- * sync retry budget (~10 attempts, ~21s) while the loser sits awaiting the rival inside its
523
- * own commit: a livelock dressed up as a slow test. Do not add a commit trigger.
524
- *
525
- * 2. **The rival runs before delegation, never inside {@link TestTransactor}'s critical section.**
526
- * `TestTransactor.get`/`commit` hold per-block latches (`TestTransactor.commit:${blockId}`), and
527
- * those latch keys are process-global a rival invoked from inside one of them would
528
- * self-deadlock on its own reads. Intercepting here in the wrapper, before
529
- * `await this.inner.pend(...)`, is outside every such section.
530
- */
531
- export class CompetingWriterTransactor extends DelegatingTransactor {
532
- /** Pend calls observed (including the intercepted one). */
533
- pendCalls = 0;
534
- /** Commit calls observed. */
535
- commitCalls = 0;
536
- /** The 1-based pend call index the rival fired on; undefined if it never fired. */
537
- firedAtCall?: number;
538
- /** How many times the rival ran. Latched to at most 1 — a second firing would collide with the
539
- * loser's now-pending blocks and the transaction under test could never win. */
540
- rivalRuns = 0;
541
-
542
- constructor(
543
- inner: TestTransactor,
544
- private readonly rival: RivalWrite,
545
- private readonly options?: CompetingWriterOptions,
546
- ) {
547
- super(inner);
548
- }
549
-
550
- override async pend(request: PendRequest): Promise<PendResult> {
551
- // Increment synchronously, before any await, so call indexes reflect the caller's fan-out
552
- // order rather than scheduling order.
553
- const callIndex = ++this.pendCalls;
554
- const matches = this.options?.when
555
- ? this.options.when(request, callIndex)
556
- : callIndex === 1;
557
- if (this.rivalRuns === 0 && matches) {
558
- this.rivalRuns++;
559
- this.firedAtCall = callIndex;
560
- // A throwing rival escapes as a rejected pend, which pendPhase flattens to a bare message
561
- // string ("hard failure") with the stack discarded so name the source in the message, or
562
- // a broken rival reads as an unexplained coordinator pend failure.
563
- try {
564
- await this.rival(this.inner);
565
- } catch (e) {
566
- throw new Error(`competing writer failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
567
- }
568
- }
569
- return this.inner.pend(request);
570
- }
571
-
572
- override async commit(request: CommitRequest): Promise<CommitResult> {
573
- this.commitCalls++;
574
- return this.inner.commit(request);
575
- }
576
- }
577
-
578
- /**
579
- * Durably commit a conflicting change to a tree collection: opens a SECOND {@link Tree} over the
580
- * same transactor + collection id and replaces `entries` through it, producing a real log entry
581
- * and a real revision bump — the durable competitor a {@link CompetingWriterTransactor} needs.
582
- *
583
- * `Tree.replace` is act + `updateAndSync`, i.e. a full commit through the single-collection sync
584
- * path, so the rival's write is indistinguishable from any other client's.
585
- *
586
- * Pass the UNWRAPPED transactor (that is what {@link RivalWrite} receives).
587
- *
588
- * NOTE: opens the rival tree at the DEFAULT node capacity (64), because fan-out is not persisted
589
- * in the collection header (see Tree.createOrOpen's `nodeCapacity` note). Fine while every rival
590
- * race is between default-fan-out trees; if a test ever needs to race a small-capacity tree, add a
591
- * capacity parameter here and pass the same value both sides, or the two writers will split nodes
592
- * at different fan-outs.
593
- */
594
- export async function commitRivalTreeWrite<TKey, TEntry>(
595
- inner: ITransactor,
596
- collectionId: CollectionId,
597
- keyFromEntry: (entry: TEntry) => TKey,
598
- entries: TreeReplaceAction<TKey, TEntry>,
599
- ): Promise<void> {
600
- const tree = await Tree.createOrOpen<TKey, TEntry>(inner, collectionId, keyFromEntry);
601
- await tree.replace(entries);
602
- }
603
-
604
- function newBlockState(): BlockState {
605
- return {
606
- materializedBlocks: new Map(),
607
- latestRev: 0,
608
- revisionActions: new Map(),
609
- pendingActions: new Map(),
610
- committedActions: new Map()
611
- };
612
- }
613
-
614
- /** Returns the materialized block at the highest revision ≤ the given revision, together with
615
- * that revision — the caller reports it as {@link GetBlockResult.materializedRev}. */
616
- function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
617
- for (let rev = maxRev; rev >= 0; rev--) {
618
- const block = blockState.materializedBlocks.get(rev);
619
- if (block) return { block, rev };
620
- }
621
- return undefined;
622
- }
623
-
624
- /**
625
- * `applyTransform` over cloned inputs, so a returned block never aliases stored state.
626
- *
627
- * An absent base is NOT a short circuit: an insert needs no base — `applyTransform` adopts it as
628
- * the block. Bailing out on `!block` made this double silently drop a pending-only insert read
629
- * through the pending overlay, the one shape `StorageRepo.get` serves with content but no
630
- * `materializedRev` (see docs/internals.md § the `unavailable`/`materializedRev` bullets). A
631
- * pending UPDATE over an absent base still resolves to undefined, matching the real repo.
632
- */
633
- function applyTransformSafe(block: IBlock | undefined, transform: Transform): IBlock | undefined {
634
- return applyTransform(
635
- block ? structuredClone(block) : undefined,
636
- transform.insert ? { ...transform, insert: structuredClone(transform.insert) } : transform
637
- );
638
- }
1
+ import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
2
+ import { ensuredMap } from "../utility/ensured.js";
3
+ import { Latches } from "../utility/latches.js";
4
+ import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
5
+ import { Tree } from "../collections/tree/tree.js";
6
+ import type { TreeReplaceAction } from "../collections/tree/struct.js";
7
+
8
+ type RevisionNumber = number;
9
+
10
+ type BlockState = {
11
+ /** The current materialized block at each revision */
12
+ materializedBlocks: Map<RevisionNumber, IBlock>;
13
+ /** The latest revision number */
14
+ latestRev: RevisionNumber;
15
+ /** The action that created each revision */
16
+ revisionActions: Map<RevisionNumber, ActionId>;
17
+ /** Currently pending actions */
18
+ pendingActions: Map<ActionId, Transform>;
19
+ /** Committed actions */
20
+ committedActions: Map<ActionId, Transform>;
21
+ }
22
+
23
+ // Simple in-memory transactor for testing that maintains materialized blocks for every revision
24
+ export class TestTransactor implements ITransactor {
25
+ private blocks = new Map<BlockId, BlockState>();
26
+ available = true;
27
+ private getLocks = new Map<BlockId, Promise<() => void>>(); // Track lock releases
28
+
29
+ constructor() {}
30
+
31
+ async get(blockGets: BlockGets): Promise<GetBlockResults> {
32
+ this.checkAvailable();
33
+ const results: GetBlockResults = {};
34
+ const uniqueBlockIds = [...new Set(blockGets.blockIds)].sort(); // Ensure consistent lock order if needed, though get is read-only
35
+ const releases: (() => void)[] = [];
36
+
37
+ try {
38
+ // Acquire locks for all requested blocks to ensure consistent read
39
+ for (const blockId of uniqueBlockIds) {
40
+ const lockId = `TestTransactor.commit:${blockId}`; // Use the same lock as commit
41
+ // Wait for any existing lock promise to resolve before acquiring the next
42
+ let release = await this.getLocks.get(lockId);
43
+ if (release) await Promise.resolve(release); // Ensure previous lock released if overlapping calls happen
44
+
45
+ const releasePromise = Latches.acquire(lockId);
46
+ this.getLocks.set(blockId, releasePromise.then(r => () => {
47
+ r();
48
+ this.getLocks.delete(blockId); // Clean up map entry after release
49
+ }));
50
+ release = await releasePromise;
51
+ releases.push(release);
52
+ }
53
+
54
+ // --- Start of Critical Section (Read) ---
55
+ for (const blockId of blockGets.blockIds) {
56
+ const blockState = this.blocks.get(blockId);
57
+ if (!blockState) {
58
+ // Block doesn't exist yet
59
+ results[blockId] = {
60
+ block: undefined,
61
+ state: { latest: undefined, pendings: [] }
62
+ };
63
+ continue;
64
+ }
65
+
66
+ // Get the appropriate materialized block based on context
67
+ let block: IBlock | undefined;
68
+ // The revision `block` was actually materialized at, reported (with its action id) as
69
+ // GetBlockResult.materialized. Equals `latestRev` on every unpinned path; only a
70
+ // revision-pinned read of a block committed further since the pin makes them differ,
71
+ // and there the pinned value is what the reader observed (see the field's doc).
72
+ let materializedRev: number | undefined;
73
+ if (blockGets.context?.actionId !== undefined) {
74
+ // If requesting a specific action, apply pending transform if it exists
75
+ const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
76
+ if (pendingTransform) {
77
+ // Read latest committed block as base for pending transform
78
+ const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
79
+ block = applyTransformSafe(baseBlock, pendingTransform);
80
+ // A pending carries no revision of its own — report the committed base it was
81
+ // applied over. Absent when there was no base (a pending-only insert).
82
+ if (baseBlock) materializedRev = blockState.latestRev;
83
+ } else {
84
+ // Action not pending, maybe committed? Or maybe invalid actionId for context.
85
+ // For simplicity, return undefined block if specific pending action not found.
86
+ // A more complex impl might check committedActions history.
87
+ block = undefined;
88
+ }
89
+ } else if (blockGets.context?.committed) {
90
+ // Check context.committed for matching pending actions — mirrors coordinator
91
+ // behavior: context.committed proves the action succeeded, so pending blocks
92
+ // for that action should be served.
93
+ for (const { actionId: cId } of blockGets.context.committed) {
94
+ const pendingTransform = blockState.pendingActions.get(cId);
95
+ if (pendingTransform) {
96
+ const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
97
+ block = applyTransformSafe(baseBlock, pendingTransform);
98
+ if (baseBlock) materializedRev = blockState.latestRev;
99
+ break;
100
+ }
101
+ }
102
+ // Fall through to standard resolution if no pending match
103
+ if (block === undefined) {
104
+ if (blockGets.context.rev !== undefined) {
105
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
106
+ block = structuredClone(found?.block);
107
+ materializedRev = found?.rev;
108
+ } else {
109
+ block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
110
+ if (block) materializedRev = blockState.latestRev;
111
+ }
112
+ }
113
+ } else if (blockGets.context?.rev !== undefined) {
114
+ // Return the materialized block at the highest revision ≤ requested
115
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
116
+ block = structuredClone(found?.block);
117
+ materializedRev = found?.rev;
118
+ } else {
119
+ // Otherwise return latest materialized block
120
+ block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
121
+ if (block) materializedRev = blockState.latestRev;
122
+ }
123
+
124
+
125
+ const actionId = blockState.revisionActions.get(blockState.latestRev);
126
+ // The materialized revision travels WITH its action id (one field, so the pair can
127
+ // never disagree) — the same `(rev, actionId)` the commit recorded for it.
128
+ const materializedActionId = materializedRev !== undefined
129
+ ? blockState.revisionActions.get(materializedRev) : undefined;
130
+ results[blockId] = {
131
+ block,
132
+ ...(materializedRev !== undefined && materializedActionId !== undefined
133
+ ? { materialized: { rev: materializedRev, actionId: materializedActionId } } : {}),
134
+ state: {
135
+ latest: actionId !== undefined ? {
136
+ rev: blockState.latestRev,
137
+ actionId
138
+ } : undefined,
139
+ pendings: Array.from(blockState.pendingActions.keys())
140
+ }
141
+ };
142
+ }
143
+ // --- End of Critical Section (Read) ---
144
+
145
+ } finally {
146
+ // Release locks in reverse order
147
+ releases.reverse().forEach(release => release());
148
+ }
149
+ return results;
150
+ }
151
+
152
+ async getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]> {
153
+ return actionRefs.map(ref => ({
154
+ ...ref,
155
+ statuses: ref.blockIds.map(blockId => {
156
+ const blockState = this.blocks.get(blockId);
157
+ if (!blockState) return 'aborted';
158
+ return blockState.pendingActions.has(ref.actionId) ? 'pending'
159
+ : Array.from(blockState.revisionActions.values()).some(actionId => actionId === ref.actionId) ? 'committed'
160
+ : 'aborted';
161
+ })
162
+ }));
163
+ }
164
+
165
+ async pend(request: PendRequest): Promise<PendResult> {
166
+ this.checkAvailable();
167
+ const { actionId, transforms, policy, rev } = request;
168
+ const blockIds = blockIdsForTransforms(transforms);
169
+ const conflictingPendings: { blockId: BlockId, actionId: ActionId }[] = [];
170
+ const missing: ActionTransforms[] = [];
171
+
172
+ // Check for conflicts (pending or committed based on rev/insert)
173
+ for (const blockId of blockIds) {
174
+ const blockState = this.blocks.get(blockId);
175
+ const blockTransform = transformForBlockId(transforms, blockId);
176
+ if (!blockTransform) continue; // Should not happen
177
+
178
+ if (blockState) {
179
+ // Check for existing pending actions
180
+ if (blockState.pendingActions.size > 0) {
181
+ blockState.pendingActions.forEach((_, pendingActionId) => {
182
+ conflictingPendings.push({ blockId, actionId: pendingActionId });
183
+ });
184
+ }
185
+
186
+ // Check for conflicting committed revisions (if rev specified or it's an insert)
187
+ if (rev !== undefined || blockTransform.insert) {
188
+ const checkRev = rev ?? 0; // Check from revision 0 if it's an insert
189
+ if (blockState.latestRev >= checkRev) {
190
+ // Collect conflicting committed actions
191
+ const missingForBlock = new Map<ActionId, { rev: number, transform: Transform }>();
192
+ for (let r = checkRev as number; r <= blockState.latestRev; r++) {
193
+ const committedActionId = blockState.revisionActions.get(r);
194
+ if (committedActionId !== undefined) {
195
+ const committedTransform = blockState.committedActions.get(committedActionId);
196
+ if (committedTransform) {
197
+ missingForBlock.set(committedActionId, { rev: r, transform: committedTransform });
198
+ }
199
+ }
200
+ }
201
+
202
+ // Add collected missing transforms for this block to the main missing list
203
+ for (const [mActionId, data] of missingForBlock.entries()) {
204
+ let existing = missing.find(m => m.actionId === mActionId);
205
+ if (!existing) {
206
+ existing = { actionId: mActionId, rev: data.rev, transforms: emptyTransforms() };
207
+ missing.push(existing);
208
+ }
209
+ existing.rev = Math.max(existing.rev ?? 0, data.rev);
210
+ existing.transforms = concatTransform(existing.transforms, blockId, data.transform);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Handle failure due to committed conflicts first.
218
+ // `conflict: true` on the three optimistic-concurrency returns below mirrors what
219
+ // StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
220
+ if (missing.length > 0) {
221
+ return {
222
+ success: false,
223
+ conflict: true,
224
+ missing
225
+ };
226
+ }
227
+
228
+ // Handle failure/retry due to pending conflicts
229
+ if (conflictingPendings.length > 0) {
230
+ if (policy === 'f') {
231
+ return { success: false, conflict: true, pending: conflictingPendings };
232
+ } else if (policy === 'r') {
233
+ // Simulate fetching pending transforms for 'r' policy
234
+ const pendingWithTransforms = conflictingPendings
235
+ .map(({ blockId: pBlockId, actionId: pActionId }) => {
236
+ const pBlockState = this.blocks.get(pBlockId);
237
+ const pTransform = pBlockState?.pendingActions.get(pActionId)
238
+ ?? pBlockState?.committedActions.get(pActionId); // Might have been committed since check
239
+ if (pTransform) {
240
+ return { blockId: pBlockId, actionId: pActionId, transform: pTransform };
241
+ }
242
+ return null; // Handle case where it disappeared (cancelled?)
243
+ })
244
+ .filter(p => p !== null) as { blockId: BlockId, actionId: ActionId, transform: Transform }[];
245
+
246
+ return {
247
+ success: false,
248
+ conflict: true,
249
+ pending: pendingWithTransforms
250
+ };
251
+ }
252
+ // Policy 'w' allows proceeding despite pending transactions
253
+ }
254
+
255
+ // No fatal conflicts found, proceed to pend
256
+ for (const blockId of blockIds) {
257
+ const blockTransform = transformForBlockId(transforms, blockId);
258
+ if (blockTransform) {
259
+ const blockState = ensuredMap(this.blocks, blockId, () => newBlockState());
260
+ blockState.pendingActions.set(actionId, blockTransform);
261
+ }
262
+ }
263
+
264
+ // Return success, include pending list as per StorageRepo behavior
265
+ return {
266
+ success: true,
267
+ pending: conflictingPendings,
268
+ blockIds
269
+ } as PendResult;
270
+ }
271
+
272
+ async cancel(actionRef: ActionBlocks): Promise<void> {
273
+ this.checkAvailable();
274
+ for (const blockId of actionRef.blockIds) {
275
+ const blockState = this.blocks.get(blockId);
276
+ if (blockState) {
277
+ blockState.pendingActions.delete(actionRef.actionId);
278
+ }
279
+ }
280
+ }
281
+
282
+ async commit(request: CommitRequest): Promise<CommitResult> {
283
+ this.checkAvailable();
284
+ const { actionId, rev, blockIds } = request;
285
+ const uniqueBlockIds = [...new Set(blockIds)].sort();
286
+ const releases: (() => void)[] = [];
287
+
288
+ try {
289
+ // Simulate acquiring locks sequentially like StorageRepo
290
+ for (const id of uniqueBlockIds) {
291
+ const lockId = `TestTransactor.commit:${id}`;
292
+ const release = await Latches.acquire(lockId);
293
+ releases.push(release);
294
+ }
295
+
296
+ // --- Start of Critical Section (Simulated) ---
297
+
298
+ // Check for stale revisions
299
+ const staleBlocks = blockIds.filter(blockId => {
300
+ const blockState = this.blocks.get(blockId);
301
+ return blockState && blockState.latestRev >= rev;
302
+ });
303
+
304
+ if (staleBlocks.length > 0) {
305
+ // Collect missing actions for stale blocks
306
+ const missingByAction = new Map<ActionId, Transforms>();
307
+ for (const blockId of staleBlocks) {
308
+ const blockState = this.blocks.get(blockId)!;
309
+ for (let r = rev; r <= blockState.latestRev; r++) {
310
+ const committedActionId = blockState.revisionActions.get(r);
311
+ if (committedActionId) {
312
+ const transform = blockState.committedActions.get(committedActionId);
313
+ if (transform) {
314
+ const existing = missingByAction.get(committedActionId) ?? emptyTransforms();
315
+ missingByAction.set(committedActionId, concatTransform(existing, blockId, transform));
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ const missing: ActionTransforms[] = Array.from(missingByAction.entries()).map(([actionId, transforms]) => ({
322
+ actionId,
323
+ rev: Array.from(this.blocks.values())
324
+ .flatMap(bs => Array.from(bs.revisionActions.entries()))
325
+ .find(([, aId]) => aId === actionId)?.[0] ?? rev,
326
+ transforms
327
+ }));
328
+ return { success: false, missing };
329
+ }
330
+
331
+ // Verify all blocks have the pending action
332
+ for (const blockId of blockIds) {
333
+ const blockState = this.blocks.get(blockId);
334
+ if (!blockState || !blockState.pendingActions.has(actionId)) {
335
+ return {
336
+ success: false,
337
+ reason: `Action ${actionId} not found or not pending for block ${blockId}`
338
+ };
339
+ }
340
+ }
341
+
342
+ // Commit the action for each block
343
+ for (const blockId of blockIds) {
344
+ const blockState = this.blocks.get(blockId)!;
345
+ const transform = blockState.pendingActions.get(actionId)!;
346
+
347
+ // Get base block to apply transform to
348
+ const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
349
+
350
+ let newBlock: IBlock | undefined;
351
+ if (!baseBlock) {
352
+ if (!transform.insert) {
353
+ throw new Error(`Commit Error: Action ${actionId} has no insert for new block ${blockId}`);
354
+ }
355
+ newBlock = structuredClone(transform.insert);
356
+ } else {
357
+ newBlock = applyTransformSafe(baseBlock, transform);
358
+ if (!newBlock && !transform.delete) {
359
+ throw new Error(`Commit Error: Action ${actionId} resulted in undefined block but had no delete flag for block ${blockId}`);
360
+ }
361
+ }
362
+
363
+ if (newBlock) {
364
+ blockState.materializedBlocks.set(rev, newBlock);
365
+ }
366
+
367
+ // Update block state
368
+ blockState.latestRev = rev;
369
+ blockState.revisionActions.set(rev, actionId);
370
+ blockState.committedActions.set(actionId, transform);
371
+ blockState.pendingActions.delete(actionId);
372
+ }
373
+
374
+ // --- End of Critical Section (Simulated) ---
375
+
376
+ return { success: true };
377
+
378
+ } finally {
379
+ // Release locks in reverse order
380
+ releases.reverse().forEach(release => release());
381
+ }
382
+ }
383
+
384
+ // Helper methods for testing
385
+ reset() {
386
+ this.blocks.clear();
387
+ }
388
+
389
+ getPendingActions(): Map<ActionId, ActionTransforms> {
390
+ const allPending = new Map<ActionId, ActionTransforms>();
391
+ for (const [blockId, blockState] of this.blocks.entries()) {
392
+ for (const [actionId, transform] of blockState.pendingActions) {
393
+ const existing = allPending.get(actionId);
394
+ if (!existing) {
395
+ allPending.set(actionId, { actionId, transforms: transformsFromTransform(transform, blockId) });
396
+ } else {
397
+ existing.transforms = concatTransform(existing.transforms, blockId, transform);
398
+ }
399
+ }
400
+ }
401
+ return allPending;
402
+ }
403
+
404
+ getCommittedActions(): Map<ActionId, ActionTransforms> {
405
+ const allCommitted = new Map<ActionId, ActionTransforms>();
406
+ for (const [blockId, blockState] of this.blocks.entries()) {
407
+ for (const [rev, actionId] of blockState.revisionActions) {
408
+ const transform = blockState.committedActions.get(actionId);
409
+ if (transform) {
410
+ const existing = allCommitted.get(actionId);
411
+ if (!existing) {
412
+ allCommitted.set(actionId, {
413
+ actionId,
414
+ rev,
415
+ transforms: transformsFromTransform(transform, blockId)
416
+ });
417
+ } else {
418
+ existing.transforms = concatTransform(existing.transforms, blockId, transform);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ return allCommitted;
424
+ }
425
+
426
+ setAvailable(available: boolean) {
427
+ this.available = available;
428
+ }
429
+
430
+ checkAvailable() {
431
+ if (!this.available) {
432
+ throw new Error('Transactor is not available');
433
+ }
434
+ }
435
+
436
+ /** Optional method for querying cluster nominees (used in GATHER phase for multi-collection transactions) */
437
+ queryClusterNominees?: (blockId: BlockId) => Promise<ClusterNomineesResult>;
438
+ }
439
+
440
+ /**
441
+ * Base for the intercepting wrappers below: forwards every {@link ITransactor} member to `inner`,
442
+ * so a wrapper overrides only the one call it intercepts and cannot silently drop the rest.
443
+ *
444
+ * `queryClusterNominees` is forwarded through a GETTER rather than a method, because the coordinator
445
+ * treats its ABSENCE as "no supercluster" (see `gatherPhase`). A wrapper that always defined it
446
+ * would push every wrapped multi-collection test onto the GATHER path even when the inner
447
+ * transactor never opted in; a wrapper that omits it (as these did before) does the opposite —
448
+ * a test that sets `inner.queryClusterNominees` and then wraps would skip GATHER and pass
449
+ * vacuously. The getter reproduces the inner transactor's own answer either way.
450
+ */
451
+ export abstract class DelegatingTransactor implements ITransactor {
452
+ protected constructor(protected readonly inner: TestTransactor) {}
453
+
454
+ get(b: BlockGets): Promise<GetBlockResults> { return this.inner.get(b); }
455
+ getStatus(a: ActionBlocks[]): Promise<BlockActionStatus[]> { return this.inner.getStatus(a); }
456
+ pend(r: PendRequest): Promise<PendResult> { return this.inner.pend(r); }
457
+ cancel(a: ActionBlocks): Promise<void> { return this.inner.cancel(a); }
458
+ commit(r: CommitRequest): Promise<CommitResult> { return this.inner.commit(r); }
459
+
460
+ get queryClusterNominees(): ((blockId: BlockId) => Promise<ClusterNomineesResult>) | undefined {
461
+ return this.inner.queryClusterNominees?.bind(this.inner);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Wraps a {@link TestTransactor} and forces its commit phase to fail a bounded (or unbounded)
467
+ * number of times before delegating, so tests can exercise the sync retry / backoff / give-up
468
+ * path deterministically. Everything else delegates unchanged, so the pend→commit→cancel
469
+ * round-trip runs exactly as in production.
470
+ */
471
+ export class FlakyCommitTransactor extends DelegatingTransactor {
472
+ /** Number of commit() calls observed so far (across success and forced failure). */
473
+ commitAttempts = 0;
474
+
475
+ /**
476
+ * @param inner delegate transactor
477
+ * @param failFirstN number of initial commit() calls to fail; Infinity to always fail
478
+ * @param reason the StaleFailure.reason returned on a forced failure
479
+ */
480
+ constructor(
481
+ inner: TestTransactor,
482
+ private readonly failFirstN: number,
483
+ private readonly reason = 'forced stale',
484
+ ) {
485
+ super(inner);
486
+ }
487
+
488
+ override async commit(request: CommitRequest): Promise<CommitResult> {
489
+ this.commitAttempts++;
490
+ if (this.commitAttempts <= this.failFirstN) {
491
+ return { success: false, reason: this.reason };
492
+ }
493
+ return this.inner.commit(request);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Commits durably on the inner {@link TestTransactor}, then reports a stale failure anyway
499
+ * the exact observable shape of `NetworkTransactor.commit`'s TORN ACTION: the collection header
500
+ * and log tail are committed BEFORE the sweep of the remaining blocks, so a later sweep block
501
+ * coming back as a confirmed conflict returns a stale failure over an action whose log entry is
502
+ * already durable.
503
+ *
504
+ * Safe against the inner transactor's bookkeeping because `TransactorSource.transact` cancels
505
+ * the pend on the reported failure and {@link TestTransactor.cancel} only deletes PENDING records
506
+ * the real commit already promoted them, so the cancel is a no-op.
507
+ *
508
+ * Used by both write paths' own-entry regression suites (collection-own-action-replay.spec.ts and
509
+ * coordinator-own-action-replay.spec.ts); it lives here so the two cannot drift apart.
510
+ */
511
+ export class CommitLandsButReportsStale extends DelegatingTransactor {
512
+ /** Remaining number of successful commits to mask as stale failures. */
513
+ private injections: number;
514
+ /** Commits that actually landed on the inner transactor (masked or not). */
515
+ landedCommits = 0;
516
+
517
+ constructor(inner: TestTransactor, injections = 1) {
518
+ super(inner);
519
+ this.injections = injections;
520
+ }
521
+
522
+ override async commit(request: CommitRequest): Promise<CommitResult> {
523
+ const result = await this.inner.commit(request);
524
+ if (result.success) {
525
+ this.landedCommits++;
526
+ if (this.injections-- > 0) {
527
+ return { success: false, conflict: true, reason: 'stale commit: injected torn-action conflict' };
528
+ }
529
+ }
530
+ return result;
531
+ }
532
+ }
533
+
534
+ /** A competing writer: a real write driven against the UNWRAPPED transactor, so its own
535
+ * pend/commit calls are invisible to {@link CompetingWriterTransactor}'s counters and cannot
536
+ * re-trigger the interception. See {@link commitRivalTreeWrite} for the usual implementation. */
537
+ export type RivalWrite = (inner: ITransactor) => Promise<void>;
538
+
539
+ export type CompetingWriterOptions = {
540
+ /** Fires on the first pend whose request satisfies this predicate. `callIndex` is 1-based over
541
+ * ALL pend calls seen (not only matching ones). Default: fire on the first pend call. */
542
+ when?: (request: PendRequest, callIndex: number) => boolean;
543
+ };
544
+
545
+ /**
546
+ * Wraps a {@link TestTransactor} and, exactly once, runs a real competing writer to completion
547
+ * BEFORE delegating the intercepted pend. The rival durably commits (real log entry, real
548
+ * revision bump), so the delegated pend then fails as a GENUINE optimistic-concurrency loss —
549
+ * nothing is forced or faked, unlike {@link FlakyCommitTransactor}, which returns a stale failure
550
+ * without ever advancing a block's revision.
551
+ *
552
+ * That distinction is the whole point: only a rival that actually landed can prove the loser
553
+ * OBSERVED a newer revision, re-applied its work on top of it, and did not lose an update.
554
+ *
555
+ * Two deliberate design constraints:
556
+ *
557
+ * 1. **The trigger is on PEND, and there is no commit trigger.** The rival is a real
558
+ * Collection/Tree, so its write pends with policy `'r'` (see `TransactorSource.transact`), and
559
+ * {@link TestTransactor.pend} rejects a pend whose blocks already carry a *pending* action.
560
+ * Firing before the loser's pend delegates means the loser has pended nothing yet, so the
561
+ * rival pends and commits cleanly and the loser's delegated pend then fails with `missing` a
562
+ * real conflict. Firing at COMMIT time instead would leave the loser already pending on the
563
+ * shared log-tail block, so the rival's own pend would collide with it and spin through its
564
+ * sync retry budget (~10 attempts, ~21s) while the loser sits awaiting the rival inside its
565
+ * own commit: a livelock dressed up as a slow test. Do not add a commit trigger.
566
+ *
567
+ * 2. **The rival runs before delegation, never inside {@link TestTransactor}'s critical section.**
568
+ * `TestTransactor.get`/`commit` hold per-block latches (`TestTransactor.commit:${blockId}`), and
569
+ * those latch keys are process-global — a rival invoked from inside one of them would
570
+ * self-deadlock on its own reads. Intercepting here in the wrapper, before
571
+ * `await this.inner.pend(...)`, is outside every such section.
572
+ */
573
+ export class CompetingWriterTransactor extends DelegatingTransactor {
574
+ /** Pend calls observed (including the intercepted one). */
575
+ pendCalls = 0;
576
+ /** Commit calls observed. */
577
+ commitCalls = 0;
578
+ /** The 1-based pend call index the rival fired on; undefined if it never fired. */
579
+ firedAtCall?: number;
580
+ /** How many times the rival ran. Latched to at most 1 a second firing would collide with the
581
+ * loser's now-pending blocks and the transaction under test could never win. */
582
+ rivalRuns = 0;
583
+
584
+ constructor(
585
+ inner: TestTransactor,
586
+ private readonly rival: RivalWrite,
587
+ private readonly options?: CompetingWriterOptions,
588
+ ) {
589
+ super(inner);
590
+ }
591
+
592
+ override async pend(request: PendRequest): Promise<PendResult> {
593
+ // Increment synchronously, before any await, so call indexes reflect the caller's fan-out
594
+ // order rather than scheduling order.
595
+ const callIndex = ++this.pendCalls;
596
+ const matches = this.options?.when
597
+ ? this.options.when(request, callIndex)
598
+ : callIndex === 1;
599
+ if (this.rivalRuns === 0 && matches) {
600
+ this.rivalRuns++;
601
+ this.firedAtCall = callIndex;
602
+ // A throwing rival escapes as a rejected pend, which pendPhase flattens to a bare message
603
+ // string ("hard failure") with the stack discarded — so name the source in the message, or
604
+ // a broken rival reads as an unexplained coordinator pend failure.
605
+ try {
606
+ await this.rival(this.inner);
607
+ } catch (e) {
608
+ throw new Error(`competing writer failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
609
+ }
610
+ }
611
+ return this.inner.pend(request);
612
+ }
613
+
614
+ override async commit(request: CommitRequest): Promise<CommitResult> {
615
+ this.commitCalls++;
616
+ return this.inner.commit(request);
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Durably commit a conflicting change to a tree collection: opens a SECOND {@link Tree} over the
622
+ * same transactor + collection id and replaces `entries` through it, producing a real log entry
623
+ * and a real revision bump — the durable competitor a {@link CompetingWriterTransactor} needs.
624
+ *
625
+ * `Tree.replace` is act + `updateAndSync`, i.e. a full commit through the single-collection sync
626
+ * path, so the rival's write is indistinguishable from any other client's.
627
+ *
628
+ * Pass the UNWRAPPED transactor (that is what {@link RivalWrite} receives).
629
+ *
630
+ * NOTE: opens the rival tree at the DEFAULT node capacity (64), because fan-out is not persisted
631
+ * in the collection header (see Tree.createOrOpen's `nodeCapacity` note). Fine while every rival
632
+ * race is between default-fan-out trees; if a test ever needs to race a small-capacity tree, add a
633
+ * capacity parameter here and pass the same value both sides, or the two writers will split nodes
634
+ * at different fan-outs.
635
+ */
636
+ export async function commitRivalTreeWrite<TKey, TEntry>(
637
+ inner: ITransactor,
638
+ collectionId: CollectionId,
639
+ keyFromEntry: (entry: TEntry) => TKey,
640
+ entries: TreeReplaceAction<TKey, TEntry>,
641
+ ): Promise<void> {
642
+ const tree = await Tree.createOrOpen<TKey, TEntry>(inner, collectionId, keyFromEntry);
643
+ await tree.replace(entries);
644
+ }
645
+
646
+ function newBlockState(): BlockState {
647
+ return {
648
+ materializedBlocks: new Map(),
649
+ latestRev: 0,
650
+ revisionActions: new Map(),
651
+ pendingActions: new Map(),
652
+ committedActions: new Map()
653
+ };
654
+ }
655
+
656
+ /** Returns the materialized block at the highest revision ≤ the given revision, together with
657
+ * that revision — the caller reports it as {@link GetBlockResult.materialized}. */
658
+ function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
659
+ for (let rev = maxRev; rev >= 0; rev--) {
660
+ const block = blockState.materializedBlocks.get(rev);
661
+ if (block) return { block, rev };
662
+ }
663
+ return undefined;
664
+ }
665
+
666
+ /**
667
+ * `applyTransform` over cloned inputs, so a returned block never aliases stored state.
668
+ *
669
+ * An absent base is NOT a short circuit: an insert needs no base — `applyTransform` adopts it as
670
+ * the block. Bailing out on `!block` made this double silently drop a pending-only insert read
671
+ * through the pending overlay, the one shape `StorageRepo.get` serves with content but no
672
+ * `materialized` (see docs/internals.md § the `unavailable`/`materialized` bullets). A
673
+ * pending UPDATE over an absent base still resolves to undefined, matching the real repo.
674
+ */
675
+ function applyTransformSafe(block: IBlock | undefined, transform: Transform): IBlock | undefined {
676
+ return applyTransform(
677
+ block ? structuredClone(block) : undefined,
678
+ transform.insert ? { ...transform, insert: structuredClone(transform.insert) } : transform
679
+ );
680
+ }