@optimystic/db-core 0.5.2 → 0.7.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 (93) hide show
  1. package/dist/src/btree/btree.d.ts +2 -0
  2. package/dist/src/btree/btree.d.ts.map +1 -1
  3. package/dist/src/btree/btree.js +72 -52
  4. package/dist/src/btree/btree.js.map +1 -1
  5. package/dist/src/cluster/structs.d.ts +13 -0
  6. package/dist/src/cluster/structs.d.ts.map +1 -1
  7. package/dist/src/collection/collection.d.ts +10 -0
  8. package/dist/src/collection/collection.d.ts.map +1 -1
  9. package/dist/src/collection/collection.js +34 -0
  10. package/dist/src/collection/collection.js.map +1 -1
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +1 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/log/log.js +1 -1
  16. package/dist/src/log/log.js.map +1 -1
  17. package/dist/src/logger.d.ts +4 -0
  18. package/dist/src/logger.d.ts.map +1 -0
  19. package/dist/src/logger.js +8 -0
  20. package/dist/src/logger.js.map +1 -0
  21. package/dist/src/transaction/coordinator.d.ts +31 -8
  22. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  23. package/dist/src/transaction/coordinator.js +206 -53
  24. package/dist/src/transaction/coordinator.js.map +1 -1
  25. package/dist/src/transaction/index.d.ts +2 -2
  26. package/dist/src/transaction/index.d.ts.map +1 -1
  27. package/dist/src/transaction/index.js +1 -1
  28. package/dist/src/transaction/index.js.map +1 -1
  29. package/dist/src/transaction/session.d.ts +11 -7
  30. package/dist/src/transaction/session.d.ts.map +1 -1
  31. package/dist/src/transaction/session.js +27 -14
  32. package/dist/src/transaction/session.js.map +1 -1
  33. package/dist/src/transaction/transaction.d.ts +9 -3
  34. package/dist/src/transaction/transaction.d.ts.map +1 -1
  35. package/dist/src/transaction/transaction.js +14 -7
  36. package/dist/src/transaction/transaction.js.map +1 -1
  37. package/dist/src/transaction/validator.d.ts +9 -2
  38. package/dist/src/transaction/validator.d.ts.map +1 -1
  39. package/dist/src/transaction/validator.js +26 -6
  40. package/dist/src/transaction/validator.js.map +1 -1
  41. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  42. package/dist/src/transactor/network-transactor.js +84 -9
  43. package/dist/src/transactor/network-transactor.js.map +1 -1
  44. package/dist/src/transactor/transactor-source.d.ts +4 -0
  45. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  46. package/dist/src/transactor/transactor-source.js +25 -9
  47. package/dist/src/transactor/transactor-source.js.map +1 -1
  48. package/dist/src/transform/atomic-proxy.d.ts +26 -0
  49. package/dist/src/transform/atomic-proxy.d.ts.map +1 -0
  50. package/dist/src/transform/atomic-proxy.js +47 -0
  51. package/dist/src/transform/atomic-proxy.js.map +1 -0
  52. package/dist/src/transform/cache-source.d.ts +3 -2
  53. package/dist/src/transform/cache-source.d.ts.map +1 -1
  54. package/dist/src/transform/cache-source.js +15 -3
  55. package/dist/src/transform/cache-source.js.map +1 -1
  56. package/dist/src/transform/index.d.ts +1 -0
  57. package/dist/src/transform/index.d.ts.map +1 -1
  58. package/dist/src/transform/index.js +1 -0
  59. package/dist/src/transform/index.js.map +1 -1
  60. package/dist/src/utility/batch-coordinator.d.ts +2 -0
  61. package/dist/src/utility/batch-coordinator.d.ts.map +1 -1
  62. package/dist/src/utility/batch-coordinator.js +6 -1
  63. package/dist/src/utility/batch-coordinator.js.map +1 -1
  64. package/dist/src/utility/hash-string.d.ts +3 -6
  65. package/dist/src/utility/hash-string.d.ts.map +1 -1
  66. package/dist/src/utility/hash-string.js +8 -11
  67. package/dist/src/utility/hash-string.js.map +1 -1
  68. package/dist/src/utility/lru-map.d.ts +18 -0
  69. package/dist/src/utility/lru-map.d.ts.map +1 -0
  70. package/dist/src/utility/lru-map.js +52 -0
  71. package/dist/src/utility/lru-map.js.map +1 -0
  72. package/package.json +15 -8
  73. package/src/btree/btree.ts +71 -50
  74. package/src/cluster/structs.ts +11 -0
  75. package/src/collection/collection.ts +44 -0
  76. package/src/index.ts +1 -0
  77. package/src/log/log.ts +1 -1
  78. package/src/logger.ts +10 -0
  79. package/src/transaction/coordinator.ts +244 -57
  80. package/src/transaction/index.ts +4 -2
  81. package/src/transaction/session.ts +38 -14
  82. package/src/transaction/transaction.ts +23 -10
  83. package/src/transaction/validator.ts +34 -7
  84. package/src/transactor/network-transactor.ts +94 -13
  85. package/src/transactor/transactor-source.ts +28 -9
  86. package/src/transform/atomic-proxy.ts +49 -0
  87. package/src/transform/cache-source.ts +18 -4
  88. package/src/transform/index.ts +1 -0
  89. package/src/utility/batch-coordinator.ts +9 -1
  90. package/src/utility/hash-string.ts +14 -17
  91. package/src/utility/lru-map.ts +55 -0
  92. package/dist/index.min.js +0 -9
  93. package/dist/index.min.js.map +0 -7
@@ -1,11 +1,14 @@
1
1
  import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId, IBlock, BlockOperations } from "../index.js";
2
- import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions } from "./transaction.js";
2
+ import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
3
3
  import type { PeerId } from "../network/types.js";
4
4
  import type { Collection } from "../collection/collection.js";
5
5
  import { TransactionContext } from "./context.js";
6
6
  import { ActionsEngine } from "./actions-engine.js";
7
- import { createActionsStatements, createTransactionStamp, createTransactionId } from "./transaction.js";
7
+ import { createActionsStatements, createTransactionStamp, createTransactionId, isTransactionExpired } from "./transaction.js";
8
8
  import { Log, blockIdsForTransforms, transformsFromTransform, hashString } from "../index.js";
9
+ import { createLogger } from "../logger.js";
10
+
11
+ const log = createLogger('trx:coordinator');
9
12
 
10
13
  /**
11
14
  * Represents an operation on a block within a collection.
@@ -26,6 +29,14 @@ type Operation =
26
29
  * - Commit transactions by running consensus phases (GATHER, PEND, COMMIT)
27
30
  */
28
31
  export class TransactionCoordinator {
32
+ /** Per-stampId tracking: snapshot before first apply + accumulated actions for replay */
33
+ private stampData = new Map<string, {
34
+ order: number;
35
+ preSnapshot: Map<CollectionId, Transforms>;
36
+ actionBatches: CollectionActions[][];
37
+ }>();
38
+ private nextStampOrder = 0;
39
+
29
40
  constructor(
30
41
  private readonly transactor: ITransactor,
31
42
  private readonly collections: Map<CollectionId, Collection<any>>
@@ -44,15 +55,37 @@ export class TransactionCoordinator {
44
55
  async applyActions(
45
56
  actions: CollectionActions[],
46
57
  stampId: string
58
+ ): Promise<void> {
59
+ // On first call for this stampId, snapshot all collections for potential rollback
60
+ if (!this.stampData.has(stampId)) {
61
+ const snapshot = new Map<CollectionId, Transforms>();
62
+ for (const [id, col] of this.collections) {
63
+ snapshot.set(id, structuredClone(col.tracker.transforms));
64
+ }
65
+ this.stampData.set(stampId, {
66
+ order: this.nextStampOrder++,
67
+ preSnapshot: snapshot,
68
+ actionBatches: []
69
+ });
70
+ }
71
+ this.stampData.get(stampId)!.actionBatches.push(actions);
72
+
73
+ await this.applyActionsRaw(actions, stampId);
74
+ }
75
+
76
+ /**
77
+ * Apply actions without tracking (used internally and for replay during rollback).
78
+ */
79
+ private async applyActionsRaw(
80
+ actions: CollectionActions[],
81
+ stampId: string
47
82
  ): Promise<void> {
48
83
  for (const { collectionId, actions: collectionActions } of actions) {
49
- // Get collection
50
84
  const collection = this.collections.get(collectionId);
51
85
  if (!collection) {
52
86
  throw new Error(`Collection not found: ${collectionId}`);
53
87
  }
54
88
 
55
- // Apply each action (tagged with stampId)
56
89
  for (const action of collectionActions) {
57
90
  const taggedAction = { ...(action as any), transaction: stampId };
58
91
  await collection.act(taggedAction);
@@ -70,6 +103,10 @@ export class TransactionCoordinator {
70
103
  * @param transaction - The transaction to commit
71
104
  */
72
105
  async commit(transaction: Transaction): Promise<void> {
106
+ if (isTransactionExpired(transaction.stamp)) {
107
+ throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
108
+ }
109
+
73
110
  // Collect transforms and determine critical blocks for each affected collection
74
111
  const collectionData = Array.from(this.collections.entries())
75
112
  .map(([collectionId, collection]) => ({
@@ -122,7 +159,7 @@ export class TransactionCoordinator {
122
159
  )
123
160
  ]);
124
161
 
125
- const operationsHash = this.hashOperations(allOperations);
162
+ const operationsHash = await this.hashOperations(allOperations);
126
163
 
127
164
  // Execute consensus phases (GATHER, PEND, COMMIT)
128
165
  const coordResult = await this.coordinateTransaction(
@@ -135,24 +172,75 @@ export class TransactionCoordinator {
135
172
  if (!coordResult.success) {
136
173
  throw new Error(`Transaction commit failed: ${coordResult.error}`);
137
174
  }
175
+
176
+ // Reset trackers and update actionContext after successful commit
177
+ for (const { collection } of collectionData) {
178
+ const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
179
+ collection['source'].actionContext = {
180
+ committed: [
181
+ ...(collection['source'].actionContext?.committed ?? []),
182
+ { actionId: transaction.id, rev: newRev }
183
+ ],
184
+ rev: newRev,
185
+ };
186
+ collection.tracker.reset();
187
+ }
188
+
189
+ // Clean up stamp tracking data
190
+ this.stampData.delete(transaction.stamp.id);
138
191
  }
139
192
 
140
193
  /**
141
- * Rollback a transaction (undo applied actions).
194
+ * Rollback a transaction (undo only the given stampId's applied actions).
142
195
  *
143
- * This is called by TransactionSession.rollback() to undo all actions
144
- * that were applied via applyActions().
196
+ * Restores tracker state to the snapshot taken before the stampId's first
197
+ * applyActions call, then replays any later stamps' actions to preserve
198
+ * other sessions' transforms.
145
199
  *
146
- * @param _stampId - The transaction stamp ID to rollback (currently unused - we clear all trackers)
200
+ * @param stampId - The transaction stamp ID to rollback
147
201
  */
148
- async rollback(_stampId: string): Promise<void> {
149
- // Clear trackers for all collections
150
- // This discards all pending changes that were applied via applyActions()
151
- // TODO: In the future, we may want to track which collections were affected by
152
- // a specific stampId and only reset those trackers
153
- for (const collection of this.collections.values()) {
154
- // Reset the tracker to discard all pending transforms
155
- collection.tracker.reset();
202
+ async rollback(stampId: string): Promise<void> {
203
+ const data = this.stampData.get(stampId);
204
+ if (!data) return;
205
+
206
+ this.stampData.delete(stampId);
207
+
208
+ // Collect all remaining stamps to replay
209
+ const toReplay = [...this.stampData.entries()]
210
+ .sort(([, a], [, b]) => a.order - b.order);
211
+
212
+ // Find the earliest snapshot among the rolled-back stamp and all remaining stamps.
213
+ // This is necessary because interleaved execution means a lower-order stamp
214
+ // may have batches applied after a higher-order stamp's snapshot was taken.
215
+ let earliestSnapshot = data.preSnapshot;
216
+ let earliestOrder = data.order;
217
+ for (const [, d] of toReplay) {
218
+ if (d.order < earliestOrder) {
219
+ earliestSnapshot = d.preSnapshot;
220
+ earliestOrder = d.order;
221
+ }
222
+ }
223
+
224
+ // Restore to the earliest snapshot
225
+ for (const [collectionId, transforms] of earliestSnapshot) {
226
+ const collection = this.collections.get(collectionId);
227
+ if (collection) {
228
+ collection.tracker.reset(structuredClone(transforms));
229
+ }
230
+ }
231
+
232
+ // Replay all remaining stamps' batches in order
233
+ for (const [replayStampId, replayData] of toReplay) {
234
+ // Update the snapshot to reflect current (post-replay) state
235
+ const newSnapshot = new Map<CollectionId, Transforms>();
236
+ for (const [id, col] of this.collections) {
237
+ newSnapshot.set(id, structuredClone(col.tracker.transforms));
238
+ }
239
+ replayData.preSnapshot = newSnapshot;
240
+
241
+ for (const actionBatch of replayData.actionBatches) {
242
+ await this.applyActionsRaw(actionBatch, replayStampId);
243
+ }
156
244
  }
157
245
  }
158
246
 
@@ -190,14 +278,34 @@ export class TransactionCoordinator {
190
278
  }
191
279
  }
192
280
 
281
+ /**
282
+ * Collect read dependencies from all participating collections.
283
+ */
284
+ getReadDependencies(): ReadDependency[] {
285
+ const reads: ReadDependency[] = [];
286
+ for (const collection of this.collections.values()) {
287
+ reads.push(...collection.getReadDependencies());
288
+ }
289
+ return reads;
290
+ }
291
+
292
+ /**
293
+ * Clear read dependencies from all collections.
294
+ */
295
+ clearReadDependencies(): void {
296
+ for (const collection of this.collections.values()) {
297
+ collection.clearReadDependencies();
298
+ }
299
+ }
300
+
193
301
  /**
194
302
  * Compute hash of all operations in a transaction.
195
303
  * This hash is used for validation - validators re-execute the transaction
196
304
  * and compare their computed operations hash with this one.
197
305
  */
198
- private hashOperations(operations: readonly Operation[]): string {
306
+ private async hashOperations(operations: readonly Operation[]): Promise<string> {
199
307
  const operationsData = JSON.stringify(operations);
200
- return `ops:${hashString(operationsData)}`;
308
+ return `ops:${await hashString(operationsData)}`;
201
309
  }
202
310
 
203
311
  /**
@@ -223,7 +331,7 @@ export class TransactionCoordinator {
223
331
  const reads = context.getReads();
224
332
 
225
333
  // Create stamp from context
226
- const stamp = createTransactionStamp(
334
+ const stamp = await createTransactionStamp(
227
335
  'local', // TODO: Get from context or coordinator
228
336
  Date.now(),
229
337
  '', // TODO: Get from engine
@@ -234,7 +342,7 @@ export class TransactionCoordinator {
234
342
  stamp,
235
343
  statements,
236
344
  reads,
237
- id: createTransactionId(stamp.id, statements, reads)
345
+ id: await createTransactionId(stamp.id, statements, reads)
238
346
  };
239
347
 
240
348
  const engine = new ActionsEngine(this);
@@ -254,12 +362,22 @@ export class TransactionCoordinator {
254
362
  * @returns Execution result with actions and results
255
363
  */
256
364
  async execute(transaction: Transaction, engine: ITransactionEngine): Promise<ExecutionResult> {
365
+ const trxId = transaction.id;
366
+ const t0 = Date.now();
367
+
368
+ if (isTransactionExpired(transaction.stamp)) {
369
+ return { success: false, error: `Transaction expired at ${transaction.stamp.expiration}` };
370
+ }
371
+
257
372
  // 1. Validate engine matches transaction
258
373
  // Note: We don't enforce this strictly since the engine is passed in explicitly
259
374
  // The caller is responsible for ensuring the correct engine is used
260
375
 
376
+ const tEngine = Date.now();
261
377
  const result = await engine.execute(transaction);
378
+ const engineMs = Date.now() - tEngine;
262
379
  if (!result.success) {
380
+ log('execute:done trxId=%s engine=%dms success=false total=%dms', trxId, engineMs, Date.now() - t0);
263
381
  return result;
264
382
  }
265
383
 
@@ -268,6 +386,7 @@ export class TransactionCoordinator {
268
386
  }
269
387
 
270
388
  // 2. Apply actions to collections and collect transforms
389
+ const tApply = Date.now();
271
390
  const collectionTransforms = new Map<CollectionId, Transforms>();
272
391
  const criticalBlocks = new Map<CollectionId, BlockId>();
273
392
  const actionResults = new Map<CollectionId, any[]>();
@@ -301,9 +420,12 @@ export class TransactionCoordinator {
301
420
  ({ type: 'delete' as const, collectionId, blockId })
302
421
  )
303
422
  ]);
304
- const operationsHash = this.hashOperations(allOperations);
423
+ const operationsHash = await this.hashOperations(allOperations);
424
+
425
+ const applyMs = Date.now() - tApply;
305
426
 
306
427
  // 4. Coordinate (GATHER if multi-collection)
428
+ const tCoord = Date.now();
307
429
  const coordResult = await this.coordinateTransaction(
308
430
  transaction,
309
431
  operationsHash,
@@ -311,11 +433,34 @@ export class TransactionCoordinator {
311
433
  criticalBlocks
312
434
  );
313
435
 
436
+ const coordMs = Date.now() - tCoord;
314
437
  if (!coordResult.success) {
438
+ log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
315
439
  return coordResult;
316
440
  }
317
441
 
318
- // 4. Return results from actions
442
+ // 5. Update actionContext and reset trackers after successful commit
443
+ for (const collectionActions of result.actions) {
444
+ const collection = this.collections.get(collectionActions.collectionId);
445
+ if (collection) {
446
+ const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
447
+ const actionId = transaction.id;
448
+ collection['source'].actionContext = {
449
+ committed: [
450
+ ...(collection['source'].actionContext?.committed ?? []),
451
+ { actionId, rev: newRev }
452
+ ],
453
+ rev: newRev,
454
+ };
455
+ collection.tracker.reset();
456
+ }
457
+ }
458
+
459
+ // Clean up stamp tracking data
460
+ this.stampData.delete(transaction.stamp.id);
461
+
462
+ // 6. Return results from actions
463
+ log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
319
464
  return {
320
465
  success: true,
321
466
  actions: result.actions,
@@ -399,36 +544,52 @@ export class TransactionCoordinator {
399
544
  collectionTransforms: Map<CollectionId, Transforms>,
400
545
  criticalBlocks: Map<CollectionId, BlockId>
401
546
  ): Promise<{ success: boolean; error?: string }> {
547
+ const trxId = transaction.id;
548
+ const t0 = Date.now();
549
+
402
550
  // 1. GATHER phase: collect critical cluster nominees (skip if single collection)
403
551
  const criticalBlockIds = Array.from(criticalBlocks.values());
552
+ const tGather = Date.now();
404
553
  const superclusterNominees = await this.gatherPhase(criticalBlockIds);
554
+ const gatherMs = Date.now() - tGather;
405
555
 
406
556
  // 2. PEND phase: distribute to all block clusters
557
+ const tPend = Date.now();
407
558
  const pendResult = await this.pendPhase(
408
559
  transaction,
409
560
  operationsHash,
410
561
  collectionTransforms,
411
562
  superclusterNominees
412
563
  );
564
+ const pendMs = Date.now() - tPend;
413
565
  if (!pendResult.success) {
566
+ log('trx:phases trxId=%s gather=%dms pend=%dms (failed) total=%dms', trxId, gatherMs, pendMs, Date.now() - t0);
414
567
  return pendResult;
415
568
  }
416
569
 
417
- // 3. COMMIT phase: commit to all critical blocks
570
+ // 3. COMMIT phase: commit to all critical blocks (with retry for forward recovery)
571
+ const tCommit = Date.now();
418
572
  const commitResult = await this.commitPhase(
419
573
  transaction.id as ActionId,
420
574
  criticalBlockIds,
421
575
  pendResult.pendedBlockIds!
422
576
  );
577
+ const commitMs = Date.now() - tCommit;
423
578
  if (!commitResult.success) {
424
- // Cancel pending actions on failure
425
- await this.cancelPhase(transaction.id as ActionId, collectionTransforms);
426
- return commitResult;
579
+ // Targeted cancel: only cancel collections that are still pending (not already committed)
580
+ await this.cancelPhase(
581
+ transaction.id as ActionId,
582
+ pendResult.pendedBlockIds!,
583
+ commitResult.committedCollections
584
+ );
585
+ log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms (failed) total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
586
+ return { success: false, error: commitResult.error };
427
587
  }
428
588
 
429
589
  // 4. PROPAGATE and CHECKPOINT phases are handled by clusters automatically
430
590
  // (as per user's note: "managed by each cluster, the client doesn't have to worry about them")
431
591
 
592
+ log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
432
593
  return { success: true };
433
594
  }
434
595
 
@@ -518,6 +679,10 @@ export class TransactionCoordinator {
518
679
  // Pend the transaction
519
680
  const pendResult = await this.transactor.pend(pendRequest);
520
681
  if (!pendResult.success) {
682
+ // Cancel any already-pended collections before returning
683
+ for (const [pendedCollectionId, pendedBlockIdList] of pendedBlockIds.entries()) {
684
+ await this.transactor.cancel({ actionId, blockIds: pendedBlockIdList });
685
+ }
521
686
  return {
522
687
  success: false,
523
688
  error: `Pend failed for collection ${collectionId}: ${pendResult.reason}`
@@ -532,18 +697,36 @@ export class TransactionCoordinator {
532
697
  }
533
698
 
534
699
  /**
535
- * COMMIT phase: Commit to all critical blocks.
700
+ * COMMIT phase: Commit to all critical blocks with retry for transient failures.
701
+ *
702
+ * Once all collections are pended (Phase 1 passes), the coordinator has decided
703
+ * to commit. Failed commits are retried (forward recovery) before giving up.
704
+ * Returns which collections committed vs failed so the caller can do targeted cancel.
536
705
  */
537
706
  private async commitPhase(
538
707
  actionId: ActionId,
539
708
  criticalBlockIds: BlockId[],
540
709
  pendedBlockIds: Map<CollectionId, BlockId[]>
541
- ): Promise<{ success: boolean; error?: string }> {
542
- // Commit each collection's transaction
710
+ ): Promise<{
711
+ success: boolean;
712
+ error?: string;
713
+ committedCollections: Set<CollectionId>;
714
+ failedCollections: Set<CollectionId>;
715
+ }> {
716
+ const committedCollections = new Set<CollectionId>();
717
+ const failedCollections = new Set<CollectionId>();
718
+
719
+ // Commit each collection's transaction with retry
543
720
  for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
544
721
  const collection = this.collections.get(collectionId);
545
722
  if (!collection) {
546
- return { success: false, error: `Collection not found: ${collectionId}` };
723
+ failedCollections.add(collectionId);
724
+ return {
725
+ success: false,
726
+ error: `Collection not found: ${collectionId}`,
727
+ committedCollections,
728
+ failedCollections
729
+ };
547
730
  }
548
731
 
549
732
  // Get revision
@@ -555,9 +738,12 @@ export class TransactionCoordinator {
555
738
  );
556
739
 
557
740
  if (!logTailBlockId) {
741
+ failedCollections.add(collectionId);
558
742
  return {
559
743
  success: false,
560
- error: `Log tail block not found for collection ${collectionId}`
744
+ error: `Log tail block not found for collection ${collectionId}`,
745
+ committedCollections,
746
+ failedCollections
561
747
  };
562
748
  }
563
749
 
@@ -569,41 +755,42 @@ export class TransactionCoordinator {
569
755
  rev
570
756
  };
571
757
 
572
- // Commit the transaction
573
- const commitResult = await this.transactor.commit(commitRequest);
574
- if (!commitResult.success) {
575
- return {
576
- success: false,
577
- error: `Commit failed for collection ${collectionId}`
578
- };
758
+ // Retry up to 3 attempts for transient failures
759
+ let committed = false;
760
+ for (let attempt = 0; attempt < 3 && !committed; attempt++) {
761
+ const commitResult = await this.transactor.commit(commitRequest);
762
+ if (commitResult.success) {
763
+ committed = true;
764
+ committedCollections.add(collectionId);
765
+ } else if (attempt === 2) {
766
+ failedCollections.add(collectionId);
767
+ return {
768
+ success: false,
769
+ error: `Commit failed for collection ${collectionId} after 3 attempts`,
770
+ committedCollections,
771
+ failedCollections
772
+ };
773
+ }
579
774
  }
580
775
  }
581
776
 
582
- return { success: true };
777
+ return { success: true, committedCollections, failedCollections };
583
778
  }
584
779
 
585
780
  /**
586
- * CANCEL phase: Cancel pending actions on all affected blocks.
781
+ * CANCEL phase: Cancel pending actions on affected blocks.
782
+ *
783
+ * Uses the authoritative pended block IDs from pendPhase rather than
784
+ * recomputing from transforms. Optionally skips already-committed collections.
587
785
  */
588
786
  private async cancelPhase(
589
787
  actionId: ActionId,
590
- collectionTransforms: Map<CollectionId, Transforms>
788
+ pendedBlockIds: Map<CollectionId, BlockId[]>,
789
+ excludeCollections?: Set<CollectionId>
591
790
  ): Promise<void> {
592
- // Cancel each collection's pending transaction
593
- for (const [collectionId, transforms] of collectionTransforms.entries()) {
594
- const collection = this.collections.get(collectionId);
595
- if (!collection) {
596
- continue; // Skip if collection not found
597
- }
598
-
599
- // Get the block IDs from transforms
600
- const blockIds = blockIdsForTransforms(transforms);
601
-
602
- // Cancel the transaction
603
- await this.transactor.cancel({
604
- actionId,
605
- blockIds
606
- });
791
+ for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
792
+ if (excludeCollections?.has(collectionId)) continue;
793
+ await this.transactor.cancel({ actionId, blockIds });
607
794
  }
608
795
  }
609
796
 
@@ -14,7 +14,9 @@ export type {
14
14
  export {
15
15
  createTransactionStamp,
16
16
  createTransactionId,
17
- createActionsStatements
17
+ createActionsStatements,
18
+ DEFAULT_TRANSACTION_TTL_MS,
19
+ isTransactionExpired
18
20
  } from './transaction.js';
19
21
 
20
22
  export {
@@ -26,4 +28,4 @@ export { TransactionCoordinator } from './coordinator.js';
26
28
  export { TransactionContext } from './context.js';
27
29
  export { TransactionSession } from './session.js';
28
30
  export { TransactionValidator } from './validator.js';
29
- export type { EngineRegistration, ValidationCoordinatorFactory } from './validator.js';
31
+ export type { EngineRegistration, ValidationCoordinatorFactory, BlockStateProvider } from './validator.js';
@@ -1,6 +1,6 @@
1
1
  import type { TransactionCoordinator } from "./coordinator.js";
2
2
  import type { Transaction, ExecutionResult, ITransactionEngine, TransactionStamp, CollectionActions } from "./transaction.js";
3
- import { createTransactionStamp, createTransactionId } from "./transaction.js";
3
+ import { createTransactionStamp, createTransactionId, isTransactionExpired } from "./transaction.js";
4
4
 
5
5
  /**
6
6
  * TransactionSession manages incremental transaction building.
@@ -14,7 +14,7 @@ import { createTransactionStamp, createTransactionId } from "./transaction.js";
14
14
  * - The Transaction is then committed through coordinator.commit() for PEND/COMMIT orchestration
15
15
  *
16
16
  * Usage:
17
- * const session = new TransactionSession(coordinator, engine);
17
+ * const session = await TransactionSession.create(coordinator, engine);
18
18
  * await session.execute('INSERT INTO users (id, name) VALUES (?, ?)', [1, 'Alice']);
19
19
  * await session.execute('SELECT * FROM orders WHERE user_id = ?', [1]);
20
20
  * const result = await session.commit();
@@ -27,19 +27,33 @@ export class TransactionSession {
27
27
  private committed = false;
28
28
  private rolledBack = false;
29
29
 
30
- constructor(
30
+ private constructor(
31
31
  private readonly coordinator: TransactionCoordinator,
32
32
  private readonly engine: ITransactionEngine,
33
- peerId: string = 'local', // TODO: Get from coordinator or config
34
- schemaHash: string = '' // TODO: Get from engine
33
+ stamp: TransactionStamp
35
34
  ) {
36
- // Create stamp at BEGIN (stable throughout transaction)
37
- this.stamp = createTransactionStamp(
35
+ this.stamp = stamp;
36
+ }
37
+
38
+ /**
39
+ * Create a new TransactionSession.
40
+ * Uses async factory because stamp creation requires SHA-256 hashing.
41
+ */
42
+ static async create(
43
+ coordinator: TransactionCoordinator,
44
+ engine: ITransactionEngine,
45
+ peerId: string = 'local',
46
+ schemaHash: string = '',
47
+ ttlMs?: number
48
+ ): Promise<TransactionSession> {
49
+ const stamp = await createTransactionStamp(
38
50
  peerId,
39
51
  Date.now(),
40
52
  schemaHash,
41
- 'unknown' // TODO: Get engine ID from engine
53
+ 'unknown', // TODO: Get engine ID from engine
54
+ ttlMs
42
55
  );
56
+ return new TransactionSession(coordinator, engine, stamp);
43
57
  }
44
58
 
45
59
  /**
@@ -107,28 +121,37 @@ export class TransactionSession {
107
121
  if (this.rolledBack) {
108
122
  return { success: false, error: 'Transaction already rolled back' };
109
123
  }
124
+ if (isTransactionExpired(this.stamp)) {
125
+ return { success: false, error: `Transaction expired at ${this.stamp.expiration}` };
126
+ }
127
+
128
+ // Collect read dependencies from all participating collections
129
+ const reads = this.coordinator.getReadDependencies();
110
130
 
111
131
  // Create the complete transaction
112
132
  const transaction: Transaction = {
113
133
  stamp: this.stamp,
114
134
  statements: this.statements,
115
- reads: [], // TODO: Track reads during statement execution
116
- id: createTransactionId(this.stamp.id, this.statements, [])
135
+ reads,
136
+ id: await createTransactionId(this.stamp.id, this.statements, reads)
117
137
  };
118
138
 
119
139
  // Commit through coordinator (which will orchestrate PEND/COMMIT)
120
140
  await this.coordinator.commit(transaction);
121
141
 
142
+ // Clear read dependencies after successful commit
143
+ this.coordinator.clearReadDependencies();
144
+
122
145
  this.committed = true;
123
146
  return { success: true };
124
147
  }
125
148
 
126
149
  /**
127
- * Rollback the transaction (discard local state).
150
+ * Rollback the transaction (undo this session's applied actions).
128
151
  *
129
- * Note: Actions have already been applied to collections' trackers.
130
- * Rollback just prevents commit and clears session state.
131
- * Collections will discard tracker state when they sync or update.
152
+ * Delegates to coordinator.rollback(stampId) which restores collection
153
+ * trackers to the pre-session snapshot and replays any later sessions'
154
+ * actions to preserve their transforms.
132
155
  */
133
156
  async rollback(): Promise<void> {
134
157
  if (this.committed) {
@@ -140,6 +163,7 @@ export class TransactionSession {
140
163
 
141
164
  // Rollback through coordinator
142
165
  await this.coordinator.rollback(this.stamp.id);
166
+ this.coordinator.clearReadDependencies();
143
167
  this.rolledBack = true;
144
168
  this.statements.length = 0;
145
169
  }