@optimystic/db-core 0.21.0 → 0.24.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 (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. package/dist/src/transaction/context.js.map +0 -1
@@ -1,489 +1,638 @@
1
- import { type ITransactor, type GetBlockResults, type ActionBlocks, type BlockActionStatus, type PendResult, type CommitResult, type PendRequest, type BlockId, type CommitRequest, type BlockGets, type IBlock, type ActionId, type ActionTransforms, type Transform, type Transforms, ensuredMap, Latches, type ClusterNomineesResult } from "../index.js";
2
- import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
3
-
4
- type RevisionNumber = number;
5
-
6
- type BlockState = {
7
- /** The current materialized block at each revision */
8
- materializedBlocks: Map<RevisionNumber, IBlock>;
9
- /** The latest revision number */
10
- latestRev: RevisionNumber;
11
- /** The action that created each revision */
12
- revisionActions: Map<RevisionNumber, ActionId>;
13
- /** Currently pending actions */
14
- pendingActions: Map<ActionId, Transform>;
15
- /** Committed actions */
16
- committedActions: Map<ActionId, Transform>;
17
- }
18
-
19
- // Simple in-memory transactor for testing that maintains materialized blocks for every revision
20
- export class TestTransactor implements ITransactor {
21
- private blocks = new Map<BlockId, BlockState>();
22
- available = true;
23
- private getLocks = new Map<BlockId, Promise<() => void>>(); // Track lock releases
24
-
25
- constructor() {}
26
-
27
- async get(blockGets: BlockGets): Promise<GetBlockResults> {
28
- this.checkAvailable();
29
- const results: GetBlockResults = {};
30
- const uniqueBlockIds = [...new Set(blockGets.blockIds)].sort(); // Ensure consistent lock order if needed, though get is read-only
31
- const releases: (() => void)[] = [];
32
-
33
- try {
34
- // Acquire locks for all requested blocks to ensure consistent read
35
- for (const blockId of uniqueBlockIds) {
36
- const lockId = `TestTransactor.commit:${blockId}`; // Use the same lock as commit
37
- // Wait for any existing lock promise to resolve before acquiring the next
38
- let release = await this.getLocks.get(lockId);
39
- if (release) await Promise.resolve(release); // Ensure previous lock released if overlapping calls happen
40
-
41
- const releasePromise = Latches.acquire(lockId);
42
- this.getLocks.set(blockId, releasePromise.then(r => () => {
43
- r();
44
- this.getLocks.delete(blockId); // Clean up map entry after release
45
- }));
46
- release = await releasePromise;
47
- releases.push(release);
48
- }
49
-
50
- // --- Start of Critical Section (Read) ---
51
- for (const blockId of blockGets.blockIds) {
52
- const blockState = this.blocks.get(blockId);
53
- if (!blockState) {
54
- // Block doesn't exist yet
55
- results[blockId] = {
56
- block: undefined,
57
- state: { latest: undefined, pendings: [] }
58
- };
59
- continue;
60
- }
61
-
62
- // Get the appropriate materialized block based on context
63
- let block: IBlock | undefined;
64
- // The revision `block` was actually materialized at, reported as
65
- // GetBlockResult.materializedRev. Equals `latestRev` on every unpinned path; only a
66
- // revision-pinned read of a block committed further since the pin makes them differ,
67
- // and there the pinned value is what the reader observed (see the field's doc).
68
- let materializedRev: number | undefined;
69
- if (blockGets.context?.actionId !== undefined) {
70
- // If requesting a specific action, apply pending transform if it exists
71
- const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
72
- if (pendingTransform) {
73
- // Read latest committed block as base for pending transform
74
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
75
- block = applyTransformSafe(baseBlock, pendingTransform);
76
- // A pending carries no revision of its own — report the committed base it was
77
- // applied over. Absent when there was no base (a pending-only insert).
78
- if (baseBlock) materializedRev = blockState.latestRev;
79
- } else {
80
- // Action not pending, maybe committed? Or maybe invalid actionId for context.
81
- // For simplicity, return undefined block if specific pending action not found.
82
- // A more complex impl might check committedActions history.
83
- block = undefined;
84
- }
85
- } else if (blockGets.context?.committed) {
86
- // Check context.committed for matching pending actions mirrors coordinator
87
- // behavior: context.committed proves the action succeeded, so pending blocks
88
- // for that action should be served.
89
- for (const { actionId: cId } of blockGets.context.committed) {
90
- const pendingTransform = blockState.pendingActions.get(cId);
91
- if (pendingTransform) {
92
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
93
- block = applyTransformSafe(baseBlock, pendingTransform);
94
- if (baseBlock) materializedRev = blockState.latestRev;
95
- break;
96
- }
97
- }
98
- // Fall through to standard resolution if no pending match
99
- if (block === undefined) {
100
- if (blockGets.context.rev !== undefined) {
101
- const found = latestMaterializedAt(blockState, blockGets.context.rev);
102
- block = structuredClone(found?.block);
103
- materializedRev = found?.rev;
104
- } else {
105
- block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
106
- if (block) materializedRev = blockState.latestRev;
107
- }
108
- }
109
- } else if (blockGets.context?.rev !== undefined) {
110
- // Return the materialized block at the highest revision ≤ requested
111
- const found = latestMaterializedAt(blockState, blockGets.context.rev);
112
- block = structuredClone(found?.block);
113
- materializedRev = found?.rev;
114
- } else {
115
- // Otherwise return latest materialized block
116
- block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
117
- if (block) materializedRev = blockState.latestRev;
118
- }
119
-
120
-
121
- const actionId = blockState.revisionActions.get(blockState.latestRev);
122
- results[blockId] = {
123
- block,
124
- ...(materializedRev !== undefined ? { materializedRev } : {}),
125
- state: {
126
- latest: actionId !== undefined ? {
127
- rev: blockState.latestRev,
128
- actionId
129
- } : undefined,
130
- pendings: Array.from(blockState.pendingActions.keys())
131
- }
132
- };
133
- }
134
- // --- End of Critical Section (Read) ---
135
-
136
- } finally {
137
- // Release locks in reverse order
138
- releases.reverse().forEach(release => release());
139
- }
140
- return results;
141
- }
142
-
143
- async getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]> {
144
- return actionRefs.map(ref => ({
145
- ...ref,
146
- statuses: ref.blockIds.map(blockId => {
147
- const blockState = this.blocks.get(blockId);
148
- if (!blockState) return 'aborted';
149
- return blockState.pendingActions.has(ref.actionId) ? 'pending'
150
- : Array.from(blockState.revisionActions.values()).some(actionId => actionId === ref.actionId) ? 'committed'
151
- : 'aborted';
152
- })
153
- }));
154
- }
155
-
156
- async pend(request: PendRequest): Promise<PendResult> {
157
- this.checkAvailable();
158
- const { actionId, transforms, policy, rev } = request;
159
- const blockIds = blockIdsForTransforms(transforms);
160
- const conflictingPendings: { blockId: BlockId, actionId: ActionId }[] = [];
161
- const missing: ActionTransforms[] = [];
162
-
163
- // Check for conflicts (pending or committed based on rev/insert)
164
- for (const blockId of blockIds) {
165
- const blockState = this.blocks.get(blockId);
166
- const blockTransform = transformForBlockId(transforms, blockId);
167
- if (!blockTransform) continue; // Should not happen
168
-
169
- if (blockState) {
170
- // Check for existing pending actions
171
- if (blockState.pendingActions.size > 0) {
172
- blockState.pendingActions.forEach((_, pendingActionId) => {
173
- conflictingPendings.push({ blockId, actionId: pendingActionId });
174
- });
175
- }
176
-
177
- // Check for conflicting committed revisions (if rev specified or it's an insert)
178
- if (rev !== undefined || blockTransform.insert) {
179
- const checkRev = rev ?? 0; // Check from revision 0 if it's an insert
180
- if (blockState.latestRev >= checkRev) {
181
- // Collect conflicting committed actions
182
- const missingForBlock = new Map<ActionId, { rev: number, transform: Transform }>();
183
- for (let r = checkRev as number; r <= blockState.latestRev; r++) {
184
- const committedActionId = blockState.revisionActions.get(r);
185
- if (committedActionId !== undefined) {
186
- const committedTransform = blockState.committedActions.get(committedActionId);
187
- if (committedTransform) {
188
- missingForBlock.set(committedActionId, { rev: r, transform: committedTransform });
189
- }
190
- }
191
- }
192
-
193
- // Add collected missing transforms for this block to the main missing list
194
- for (const [mActionId, data] of missingForBlock.entries()) {
195
- let existing = missing.find(m => m.actionId === mActionId);
196
- if (!existing) {
197
- existing = { actionId: mActionId, rev: data.rev, transforms: emptyTransforms() };
198
- missing.push(existing);
199
- }
200
- existing.rev = Math.max(existing.rev ?? 0, data.rev);
201
- existing.transforms = concatTransform(existing.transforms, blockId, data.transform);
202
- }
203
- }
204
- }
205
- }
206
- }
207
-
208
- // Handle failure due to committed conflicts first.
209
- // `conflict: true` on the three optimistic-concurrency returns below mirrors what
210
- // StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
211
- if (missing.length > 0) {
212
- return {
213
- success: false,
214
- conflict: true,
215
- missing
216
- };
217
- }
218
-
219
- // Handle failure/retry due to pending conflicts
220
- if (conflictingPendings.length > 0) {
221
- if (policy === 'f') {
222
- return { success: false, conflict: true, pending: conflictingPendings };
223
- } else if (policy === 'r') {
224
- // Simulate fetching pending transforms for 'r' policy
225
- const pendingWithTransforms = conflictingPendings
226
- .map(({ blockId: pBlockId, actionId: pActionId }) => {
227
- const pBlockState = this.blocks.get(pBlockId);
228
- const pTransform = pBlockState?.pendingActions.get(pActionId)
229
- ?? pBlockState?.committedActions.get(pActionId); // Might have been committed since check
230
- if (pTransform) {
231
- return { blockId: pBlockId, actionId: pActionId, transform: pTransform };
232
- }
233
- return null; // Handle case where it disappeared (cancelled?)
234
- })
235
- .filter(p => p !== null) as { blockId: BlockId, actionId: ActionId, transform: Transform }[];
236
-
237
- return {
238
- success: false,
239
- conflict: true,
240
- pending: pendingWithTransforms
241
- };
242
- }
243
- // Policy 'w' allows proceeding despite pending transactions
244
- }
245
-
246
- // No fatal conflicts found, proceed to pend
247
- for (const blockId of blockIds) {
248
- const blockTransform = transformForBlockId(transforms, blockId);
249
- if (blockTransform) {
250
- const blockState = ensuredMap(this.blocks, blockId, () => newBlockState());
251
- blockState.pendingActions.set(actionId, blockTransform);
252
- }
253
- }
254
-
255
- // Return success, include pending list as per StorageRepo behavior
256
- return {
257
- success: true,
258
- pending: conflictingPendings,
259
- blockIds
260
- } as PendResult;
261
- }
262
-
263
- async cancel(actionRef: ActionBlocks): Promise<void> {
264
- this.checkAvailable();
265
- for (const blockId of actionRef.blockIds) {
266
- const blockState = this.blocks.get(blockId);
267
- if (blockState) {
268
- blockState.pendingActions.delete(actionRef.actionId);
269
- }
270
- }
271
- }
272
-
273
- async commit(request: CommitRequest): Promise<CommitResult> {
274
- this.checkAvailable();
275
- const { actionId, rev, blockIds } = request;
276
- const uniqueBlockIds = [...new Set(blockIds)].sort();
277
- const releases: (() => void)[] = [];
278
-
279
- try {
280
- // Simulate acquiring locks sequentially like StorageRepo
281
- for (const id of uniqueBlockIds) {
282
- const lockId = `TestTransactor.commit:${id}`;
283
- const release = await Latches.acquire(lockId);
284
- releases.push(release);
285
- }
286
-
287
- // --- Start of Critical Section (Simulated) ---
288
-
289
- // Check for stale revisions
290
- const staleBlocks = blockIds.filter(blockId => {
291
- const blockState = this.blocks.get(blockId);
292
- return blockState && blockState.latestRev >= rev;
293
- });
294
-
295
- if (staleBlocks.length > 0) {
296
- // Collect missing actions for stale blocks
297
- const missingByAction = new Map<ActionId, Transforms>();
298
- for (const blockId of staleBlocks) {
299
- const blockState = this.blocks.get(blockId)!;
300
- for (let r = rev; r <= blockState.latestRev; r++) {
301
- const committedActionId = blockState.revisionActions.get(r);
302
- if (committedActionId) {
303
- const transform = blockState.committedActions.get(committedActionId);
304
- if (transform) {
305
- const existing = missingByAction.get(committedActionId) ?? emptyTransforms();
306
- missingByAction.set(committedActionId, concatTransform(existing, blockId, transform));
307
- }
308
- }
309
- }
310
- }
311
-
312
- const missing: ActionTransforms[] = Array.from(missingByAction.entries()).map(([actionId, transforms]) => ({
313
- actionId,
314
- rev: Array.from(this.blocks.values())
315
- .flatMap(bs => Array.from(bs.revisionActions.entries()))
316
- .find(([, aId]) => aId === actionId)?.[0] ?? rev,
317
- transforms
318
- }));
319
- return { success: false, missing };
320
- }
321
-
322
- // Verify all blocks have the pending action
323
- for (const blockId of blockIds) {
324
- const blockState = this.blocks.get(blockId);
325
- if (!blockState || !blockState.pendingActions.has(actionId)) {
326
- return {
327
- success: false,
328
- reason: `Action ${actionId} not found or not pending for block ${blockId}`
329
- };
330
- }
331
- }
332
-
333
- // Commit the action for each block
334
- for (const blockId of blockIds) {
335
- const blockState = this.blocks.get(blockId)!;
336
- const transform = blockState.pendingActions.get(actionId)!;
337
-
338
- // Get base block to apply transform to
339
- const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
340
-
341
- let newBlock: IBlock | undefined;
342
- if (!baseBlock) {
343
- if (!transform.insert) {
344
- throw new Error(`Commit Error: Action ${actionId} has no insert for new block ${blockId}`);
345
- }
346
- newBlock = structuredClone(transform.insert);
347
- } else {
348
- newBlock = applyTransformSafe(baseBlock, transform);
349
- if (!newBlock && !transform.delete) {
350
- throw new Error(`Commit Error: Action ${actionId} resulted in undefined block but had no delete flag for block ${blockId}`);
351
- }
352
- }
353
-
354
- if (newBlock) {
355
- blockState.materializedBlocks.set(rev, newBlock);
356
- }
357
-
358
- // Update block state
359
- blockState.latestRev = rev;
360
- blockState.revisionActions.set(rev, actionId);
361
- blockState.committedActions.set(actionId, transform);
362
- blockState.pendingActions.delete(actionId);
363
- }
364
-
365
- // --- End of Critical Section (Simulated) ---
366
-
367
- return { success: true };
368
-
369
- } finally {
370
- // Release locks in reverse order
371
- releases.reverse().forEach(release => release());
372
- }
373
- }
374
-
375
- // Helper methods for testing
376
- reset() {
377
- this.blocks.clear();
378
- }
379
-
380
- getPendingActions(): Map<ActionId, ActionTransforms> {
381
- const allPending = new Map<ActionId, ActionTransforms>();
382
- for (const [blockId, blockState] of this.blocks.entries()) {
383
- for (const [actionId, transform] of blockState.pendingActions) {
384
- const existing = allPending.get(actionId);
385
- if (!existing) {
386
- allPending.set(actionId, { actionId, transforms: transformsFromTransform(transform, blockId) });
387
- } else {
388
- existing.transforms = concatTransform(existing.transforms, blockId, transform);
389
- }
390
- }
391
- }
392
- return allPending;
393
- }
394
-
395
- getCommittedActions(): Map<ActionId, ActionTransforms> {
396
- const allCommitted = new Map<ActionId, ActionTransforms>();
397
- for (const [blockId, blockState] of this.blocks.entries()) {
398
- for (const [rev, actionId] of blockState.revisionActions) {
399
- const transform = blockState.committedActions.get(actionId);
400
- if (transform) {
401
- const existing = allCommitted.get(actionId);
402
- if (!existing) {
403
- allCommitted.set(actionId, {
404
- actionId,
405
- rev,
406
- transforms: transformsFromTransform(transform, blockId)
407
- });
408
- } else {
409
- existing.transforms = concatTransform(existing.transforms, blockId, transform);
410
- }
411
- }
412
- }
413
- }
414
- return allCommitted;
415
- }
416
-
417
- setAvailable(available: boolean) {
418
- this.available = available;
419
- }
420
-
421
- checkAvailable() {
422
- if (!this.available) {
423
- throw new Error('Transactor is not available');
424
- }
425
- }
426
-
427
- /** Optional method for querying cluster nominees (used in GATHER phase for multi-collection transactions) */
428
- queryClusterNominees?: (blockId: BlockId) => Promise<ClusterNomineesResult>;
429
- }
430
-
431
- /**
432
- * Wraps a {@link TestTransactor} and forces its commit phase to fail a bounded (or unbounded)
433
- * number of times before delegating, so tests can exercise the sync retry / backoff / give-up
434
- * path deterministically. pend/get/getStatus/cancel delegate unchanged, so the pend→commit→cancel
435
- * round-trip runs exactly as in production.
436
- */
437
- export class FlakyCommitTransactor implements ITransactor {
438
- /** Number of commit() calls observed so far (across success and forced failure). */
439
- commitAttempts = 0;
440
-
441
- /**
442
- * @param inner delegate transactor
443
- * @param failFirstN number of initial commit() calls to fail; Infinity to always fail
444
- * @param reason the StaleFailure.reason returned on a forced failure
445
- */
446
- constructor(
447
- private readonly inner: TestTransactor,
448
- private readonly failFirstN: number,
449
- private readonly reason = 'forced stale',
450
- ) {}
451
-
452
- get(b: BlockGets): Promise<GetBlockResults> { return this.inner.get(b); }
453
- getStatus(a: ActionBlocks[]): Promise<BlockActionStatus[]> { return this.inner.getStatus(a); }
454
- pend(r: PendRequest): Promise<PendResult> { return this.inner.pend(r); }
455
- cancel(a: ActionBlocks): Promise<void> { return this.inner.cancel(a); }
456
-
457
- async commit(request: CommitRequest): Promise<CommitResult> {
458
- this.commitAttempts++;
459
- if (this.commitAttempts <= this.failFirstN) {
460
- return { success: false, reason: this.reason };
461
- }
462
- return this.inner.commit(request);
463
- }
464
- }
465
-
466
- function newBlockState(): BlockState {
467
- return {
468
- materializedBlocks: new Map(),
469
- latestRev: 0,
470
- revisionActions: new Map(),
471
- pendingActions: new Map(),
472
- committedActions: new Map()
473
- };
474
- }
475
-
476
- /** Returns the materialized block at the highest revision ≤ the given revision, together with
477
- * that revision — the caller reports it as {@link GetBlockResult.materializedRev}. */
478
- function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
479
- for (let rev = maxRev; rev >= 0; rev--) {
480
- const block = blockState.materializedBlocks.get(rev);
481
- if (block) return { block, rev };
482
- }
483
- return undefined;
484
- }
485
-
486
- function applyTransformSafe(block: IBlock | undefined, transform: Transform): IBlock | undefined {
487
- if (!block) return undefined;
488
- return applyTransform(structuredClone(block), transform);
489
- }
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
+ }