@abloatai/ablo 0.27.0 → 0.29.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 (126) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +228 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +390 -75
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +29 -13
  12. package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
  13. package/dist/auth/schemas.d.ts +6 -0
  14. package/dist/auth/schemas.js +7 -0
  15. package/dist/cli.cjs +821 -402
  16. package/dist/client/Ablo.d.ts +53 -24
  17. package/dist/client/Ablo.js +12 -199
  18. package/dist/client/claimHeartbeatLoop.d.ts +1 -1
  19. package/dist/client/claimHeartbeatLoop.js +1 -1
  20. package/dist/client/createInternalComponents.d.ts +4 -0
  21. package/dist/client/createInternalComponents.js +3 -1
  22. package/dist/client/durableWrites.d.ts +21 -0
  23. package/dist/client/durableWrites.js +46 -0
  24. package/dist/client/httpClient.d.ts +21 -45
  25. package/dist/client/httpClient.js +104 -26
  26. package/dist/client/httpTransport.d.ts +8 -0
  27. package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
  28. package/dist/client/modelRegistration.js +11 -0
  29. package/dist/client/options.d.ts +54 -7
  30. package/dist/client/options.js +3 -2
  31. package/dist/client/resourceTypes.d.ts +9 -85
  32. package/dist/client/resourceTypes.js +1 -1
  33. package/dist/client/sessionMint.d.ts +5 -6
  34. package/dist/client/sessionMint.js +4 -5
  35. package/dist/client/validateAbloOptions.js +3 -3
  36. package/dist/client/wsMutationExecutor.d.ts +3 -2
  37. package/dist/client/wsMutationExecutor.js +3 -2
  38. package/dist/coordination/schema.d.ts +30 -0
  39. package/dist/coordination/schema.js +14 -0
  40. package/dist/core/StoreManager.d.ts +2 -0
  41. package/dist/core/StoreManager.js +12 -0
  42. package/dist/core/index.d.ts +1 -1
  43. package/dist/errorCodes.js +6 -2
  44. package/dist/errors.d.ts +4 -40
  45. package/dist/errors.js +5 -5
  46. package/dist/index.d.ts +20 -8
  47. package/dist/index.js +9 -5
  48. package/dist/interfaces/index.d.ts +5 -2
  49. package/dist/mutators/UndoManager.d.ts +2 -0
  50. package/dist/mutators/UndoManager.js +32 -0
  51. package/dist/mutators/defineMutators.d.ts +18 -0
  52. package/dist/mutators/defineMutators.js +4 -8
  53. package/dist/react/index.d.ts +1 -1
  54. package/dist/react/useAblo.d.ts +6 -4
  55. package/dist/react/useAblo.js +25 -3
  56. package/dist/schema/index.d.ts +2 -2
  57. package/dist/schema/index.js +1 -1
  58. package/dist/schema/schema.d.ts +31 -1
  59. package/dist/schema/select.d.ts +15 -0
  60. package/dist/schema/select.js +27 -3
  61. package/dist/source/connector.d.ts +3 -3
  62. package/dist/source/factory.d.ts +4 -6
  63. package/dist/source/factory.js +4 -7
  64. package/dist/source/index.d.ts +4 -4
  65. package/dist/source/index.js +2 -2
  66. package/dist/source/signing.d.ts +0 -3
  67. package/dist/source/types.d.ts +0 -26
  68. package/dist/stores/ObjectStore.d.ts +14 -1
  69. package/dist/stores/ObjectStore.js +33 -10
  70. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  71. package/dist/surface.d.ts +1 -1
  72. package/dist/surface.js +3 -0
  73. package/dist/sync/BootstrapFetcher.d.ts +10 -0
  74. package/dist/sync/BootstrapFetcher.js +27 -4
  75. package/dist/sync/SyncWebSocket.d.ts +2 -1
  76. package/dist/sync/createClaimStream.d.ts +11 -2
  77. package/dist/sync/createClaimStream.js +4 -3
  78. package/dist/sync/persistedPrefix.d.ts +12 -0
  79. package/dist/sync/persistedPrefix.js +22 -0
  80. package/dist/testing/index.d.ts +2 -0
  81. package/dist/testing/index.js +1 -0
  82. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  83. package/dist/testing/mocks/FakeDatabase.js +10 -0
  84. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  85. package/dist/transactions/TransactionQueue.d.ts +66 -8
  86. package/dist/transactions/TransactionQueue.js +607 -89
  87. package/dist/transactions/commitEnvelope.d.ts +132 -0
  88. package/dist/transactions/commitEnvelope.js +139 -0
  89. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  90. package/dist/transactions/commitOutboxStore.js +26 -0
  91. package/dist/transactions/commitPayload.d.ts +15 -0
  92. package/dist/transactions/commitPayload.js +6 -0
  93. package/dist/transactions/durableWriteStore.d.ts +123 -0
  94. package/dist/transactions/durableWriteStore.js +30 -0
  95. package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
  96. package/dist/transactions/httpCommitEnvelope.js +181 -0
  97. package/dist/transactions/idempotencyKey.d.ts +10 -0
  98. package/dist/transactions/idempotencyKey.js +9 -0
  99. package/dist/transactions/replayValidation.d.ts +83 -0
  100. package/dist/transactions/replayValidation.js +46 -1
  101. package/dist/types/global.d.ts +10 -29
  102. package/dist/types/global.js +4 -7
  103. package/dist/types/streams.d.ts +2 -28
  104. package/dist/wire/bootstrapReason.d.ts +9 -0
  105. package/dist/wire/bootstrapReason.js +8 -0
  106. package/dist/wire/frames.d.ts +6 -21
  107. package/dist/wire/frames.js +4 -4
  108. package/dist/wire/index.d.ts +6 -3
  109. package/dist/wire/index.js +3 -2
  110. package/dist/wire/protocolVersion.d.ts +30 -17
  111. package/dist/wire/protocolVersion.js +34 -18
  112. package/docs/agents.md +8 -3
  113. package/docs/api.md +16 -14
  114. package/docs/client-behavior.md +6 -3
  115. package/docs/coordination.md +4 -5
  116. package/docs/data-sources.md +5 -8
  117. package/docs/guarantees.md +21 -0
  118. package/docs/integration-guide.md +1 -0
  119. package/docs/mcp.md +1 -1
  120. package/docs/migration.md +21 -1
  121. package/docs/quickstart.md +11 -0
  122. package/docs/react.md +0 -46
  123. package/examples/README.md +6 -5
  124. package/llms.txt +15 -15
  125. package/package.json +3 -3
  126. package/dist/client/ApiClient.d.ts +0 -177
@@ -12,17 +12,24 @@
12
12
  * - A dependency-injected executor, so several queues can coexist.
13
13
  */
14
14
  import { EventEmitter } from 'events';
15
+ import { v4 as uuid } from 'uuid';
15
16
  import { Model } from '../Model.js';
16
17
  import { getContext } from '../context.js';
17
- import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
18
+ import { AbloError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, errorCodeSpec, } from '../errors.js';
18
19
  import { SyncPosition } from '../sync/syncPosition.js';
19
- import { projectCommitPayload, computePriorityScore, normalizeModelKey, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
20
+ import { projectCommitPayload, computePriorityScore, normalizeModelKey,
21
+ // Includes stale guards as well as request identity/audit barriers.
22
+ hasCommitCoalescingBarrier, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
20
23
  import { TransactionStore } from './TransactionStore.js';
21
24
  import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
22
25
  import { DeltaConfirmationTracker } from './deltaConfirmation.js';
23
- import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './replayValidation.js';
26
+ import { deserializePersistedTransaction, isNonReplayablePersistedRow, pendingMutationRecordId, } from './replayValidation.js';
27
+ import { createCommitEnvelopeMember, createDurableCommitEnvelope, commitEnvelopeRecordId, durableCommitEnvelopeSchema, } from './commitEnvelope.js';
28
+ import { stableStringify } from '../utils/json.js';
24
29
  import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
25
30
  export class TransactionQueue extends EventEmitter {
31
+ // Keep one hour of clock/network margin inside the server's 24-hour ledger.
32
+ static DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
26
33
  store = new TransactionStore();
27
34
  // Signature of the last permanent-error we logged at `warn`. A `create`
28
35
  // whose id already exists (`unique_violation`) is a permanent rejection
@@ -63,6 +70,39 @@ export class TransactionQueue extends EventEmitter {
63
70
  commitLane = [];
64
71
  commitStore = new Map();
65
72
  commitProcessing = false;
73
+ lastCommitSequence = 0;
74
+ durableReplayBlock = null;
75
+ /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
76
+ commitOutbox = null;
77
+ commitOutboxScope = null;
78
+ nextCommitSequence() {
79
+ const wallSequence = Date.now() * 1_000;
80
+ this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
81
+ return this.lastCommitSequence;
82
+ }
83
+ emitCommitLifecycle(event, payload) {
84
+ try {
85
+ this.emit(event, payload);
86
+ }
87
+ catch (error) {
88
+ getContext().observability.captureTransactionFailure({
89
+ context: `commit-lifecycle-listener:${event}`,
90
+ error: error instanceof Error ? error : String(error),
91
+ });
92
+ }
93
+ }
94
+ assertDurableReplayOpen() {
95
+ if (this.durableReplayBlock)
96
+ throw this.durableReplayBlock;
97
+ }
98
+ assertEnvelopeInsideReplayWindow(envelope) {
99
+ this.assertDurableReplayOpen();
100
+ if (Date.now() - envelope.sealedAt >=
101
+ TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
102
+ this.durableReplayBlock = new AbloIdempotencyError('A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.', { code: 'idempotency_conflict' });
103
+ throw this.durableReplayBlock;
104
+ }
105
+ }
66
106
  computePriorityScore(type, modelName) {
67
107
  return computePriorityScore(type, modelName);
68
108
  }
@@ -97,7 +137,7 @@ export class TransactionQueue extends EventEmitter {
97
137
  findCreateBarrierForDelete(modelName, modelId) {
98
138
  return findCreateBarrierForDelete(this.store, modelName, modelId);
99
139
  }
100
- completeLocalDelete(model, context, writeOptions) {
140
+ completeLocalDelete(model, context, writeOptions, sourceMutationIds = []) {
101
141
  const actualModelName = model.getModelName();
102
142
  const modelKey = normalizeModelKey(actualModelName);
103
143
  const transaction = {
@@ -114,6 +154,9 @@ export class TransactionQueue extends EventEmitter {
114
154
  attempts: 0,
115
155
  priority: 'high',
116
156
  writeOptions,
157
+ ...(sourceMutationIds.length > 0
158
+ ? { sourceMutationIds: [...new Set(sourceMutationIds)] }
159
+ : {}),
117
160
  localOnly: true,
118
161
  };
119
162
  this.attachConfirmation(transaction);
@@ -193,6 +236,207 @@ export class TransactionQueue extends EventEmitter {
193
236
  }
194
237
  // Batch management
195
238
  batchIndex = 0;
239
+ /** Mints the request identity once; retry paths only read the stored value. */
240
+ generateCommitIdempotencyKey() {
241
+ return `commit_${uuid()}`;
242
+ }
243
+ /**
244
+ * Binds an ordered transaction batch to one wire-level idempotency key.
245
+ * Existing envelopes are validated and restored to their original order;
246
+ * they are never extended with newly queued work.
247
+ */
248
+ ensureCommitEnvelope(batch) {
249
+ const firstTransaction = batch[0];
250
+ if (!firstTransaction) {
251
+ throw new Error('Cannot create an idempotency envelope for an empty commit');
252
+ }
253
+ const existingKeys = new Set(batch
254
+ .map((tx) => tx.commitEnvelope?.idempotencyKey)
255
+ .filter((key) => key !== undefined));
256
+ if (existingKeys.size > 1) {
257
+ throw new Error('Cannot combine transactions from different commit envelopes');
258
+ }
259
+ const existingKey = existingKeys.values().next().value;
260
+ if (existingKey) {
261
+ const expectedCount = firstTransaction.commitEnvelope?.operationCount;
262
+ const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
263
+ const isCompleteEnvelope = expectedCount === batch.length &&
264
+ indexes.size === batch.length &&
265
+ batch.every((tx) => tx.commitEnvelope?.idempotencyKey === existingKey &&
266
+ tx.commitEnvelope.operationCount === expectedCount &&
267
+ tx.commitEnvelope.operationIndex < expectedCount);
268
+ if (!isCompleteEnvelope) {
269
+ throw new Error('Cannot replay a partial or malformed commit envelope');
270
+ }
271
+ const persistedSealTimes = new Set(batch
272
+ .map((transaction) => transaction.commitEnvelope?.sealedAt)
273
+ .filter((sealedAt) => sealedAt !== undefined));
274
+ if (persistedSealTimes.size > 1) {
275
+ throw new Error('Cannot replay a commit envelope with inconsistent seal times');
276
+ }
277
+ const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
278
+ for (const transaction of batch) {
279
+ if (transaction.commitEnvelope)
280
+ transaction.commitEnvelope.sealedAt = sealedAt;
281
+ }
282
+ batch.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
283
+ (b.commitEnvelope?.operationIndex ?? 0));
284
+ return existingKey;
285
+ }
286
+ // An explicit public key owns its request. Transactions carrying one are
287
+ // selected as solo batches by takeNextExecutionBatch().
288
+ const explicitKey = batch.length === 1
289
+ ? firstTransaction.writeOptions?.idempotencyKey
290
+ : undefined;
291
+ const idempotencyKey = typeof explicitKey === 'string' && explicitKey.length > 0
292
+ ? explicitKey
293
+ : this.generateCommitIdempotencyKey();
294
+ const sealedAt = Date.now();
295
+ const sequence = this.nextCommitSequence();
296
+ batch.forEach((tx, operationIndex) => {
297
+ tx.commitEnvelope = createCommitEnvelopeMember({
298
+ idempotencyKey,
299
+ operationIndex,
300
+ operationCount: batch.length,
301
+ sealedAt,
302
+ sequence,
303
+ });
304
+ });
305
+ return idempotencyKey;
306
+ }
307
+ /** Bind the strict local outbox used before any mutation reaches the wire. */
308
+ setCommitOutbox(outbox) {
309
+ this.commitOutbox = outbox;
310
+ }
311
+ setCommitOutboxScope(scope) {
312
+ this.commitOutboxScope = scope;
313
+ }
314
+ sourceMutationIdsFor(batch) {
315
+ return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
316
+ }
317
+ /**
318
+ * Atomically replaces staged mutation journal rows with one exact request.
319
+ * The returned operations are the JSON-normalized values that must be sent;
320
+ * callers never send a separately reconstructed payload after sealing.
321
+ */
322
+ async sealDurableCommit(input) {
323
+ const sourceMutationIds = [...new Set(input.sourceMutationIds ?? [])];
324
+ const envelope = createDurableCommitEnvelope({
325
+ idempotencyKey: input.idempotencyKey,
326
+ origin: input.origin,
327
+ operations: [...input.operations],
328
+ sourceMutationIds,
329
+ commitOptions: {
330
+ ...(input.commitOptions?.causedByTaskId !== undefined
331
+ ? { causedByTaskId: input.commitOptions.causedByTaskId }
332
+ : {}),
333
+ ...(input.commitOptions?.reads !== undefined
334
+ ? {
335
+ reads: input.commitOptions.reads === null
336
+ ? null
337
+ : [...input.commitOptions.reads],
338
+ }
339
+ : {}),
340
+ },
341
+ ...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
342
+ createdAt: input.createdAt,
343
+ sealedAt: input.sealedAt,
344
+ sequence: input.sequence ?? input.sealedAt * 1_000,
345
+ });
346
+ if (this.config.enablePersistence && this.commitOutbox) {
347
+ try {
348
+ await this.commitOutbox.seal(envelope, sourceMutationIds.map(pendingMutationRecordId));
349
+ }
350
+ catch (cause) {
351
+ if (cause instanceof AbloError)
352
+ throw cause;
353
+ throw new AbloConnectionError('Could not persist the durable write before dispatch', {
354
+ code: 'db_not_opened',
355
+ cause,
356
+ });
357
+ }
358
+ this.emitCommitLifecycle('commit:envelope_persisted', {
359
+ idempotencyKey: envelope.idempotencyKey,
360
+ sourceMutationIds: envelope.sourceMutationIds,
361
+ });
362
+ }
363
+ return envelope;
364
+ }
365
+ /** Best-effort cleanup after a definitive result; replay is safe if it fails. */
366
+ async removeDurableCommit(idempotencyKey) {
367
+ if (!this.config.enablePersistence || !this.commitOutbox)
368
+ return;
369
+ try {
370
+ await this.commitOutbox.remove(commitEnvelopeRecordId(idempotencyKey));
371
+ }
372
+ catch (error) {
373
+ getContext().logger.debug('[TransactionQueue] Durable-write cleanup deferred', {
374
+ idempotencyKey,
375
+ error: error instanceof Error ? error.message : String(error),
376
+ });
377
+ }
378
+ }
379
+ /**
380
+ * Takes either one complete retry envelope or a fresh batch. A retry waits
381
+ * until every original member has re-entered the queue, preventing an
382
+ * ambiguous A+B commit from being replayed later as A and B separately.
383
+ */
384
+ takeNextExecutionBatch() {
385
+ const retryGroups = new Map();
386
+ for (const tx of this.executionQueue) {
387
+ const envelope = tx.commitEnvelope;
388
+ if (!envelope)
389
+ continue;
390
+ const group = retryGroups.get(envelope.idempotencyKey) ??
391
+ new Map();
392
+ group.set(tx.id, tx);
393
+ retryGroups.set(envelope.idempotencyKey, group);
394
+ }
395
+ for (const [idempotencyKey, byId] of retryGroups) {
396
+ const members = [...byId.values()];
397
+ const expectedCount = members[0]?.commitEnvelope?.operationCount;
398
+ if (expectedCount === undefined || members.length !== expectedCount)
399
+ continue;
400
+ this.executionQueue = this.executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
401
+ members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
402
+ (b.commitEnvelope?.operationIndex ?? 0));
403
+ return members;
404
+ }
405
+ const fresh = this.executionQueue.filter((tx) => !tx.commitEnvelope);
406
+ const firstFresh = fresh[0];
407
+ if (!firstFresh)
408
+ return [];
409
+ // A caller-supplied key describes exactly one public mutation call. Keep
410
+ // that transaction out of an SDK-created aggregate batch so the key maps
411
+ // to the request its caller intended.
412
+ const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
413
+ const selected = explicitIndex === 0
414
+ ? [firstFresh]
415
+ : fresh.slice(0, Math.min(this.config.maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
416
+ const selectedIds = new Set(selected.map((tx) => tx.id));
417
+ this.executionQueue = this.executionQueue.filter((tx) => !selectedIds.has(tx.id));
418
+ return selected;
419
+ }
420
+ /**
421
+ * Selects one reconnect batch without changing the request identity of work
422
+ * that was already attempted. Explicit caller keys remain one-call batches;
423
+ * an existing envelope is replayed only with all of its original members.
424
+ */
425
+ takeOfflineFlushBatch(pending) {
426
+ const first = pending[0];
427
+ if (!first)
428
+ return [];
429
+ const envelope = first.commitEnvelope;
430
+ if (envelope) {
431
+ return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
432
+ }
433
+ if (typeof first.writeOptions?.idempotencyKey === 'string') {
434
+ return [first];
435
+ }
436
+ const boundary = pending.findIndex((tx) => tx.commitEnvelope !== undefined ||
437
+ typeof tx.writeOptions?.idempotencyKey === 'string');
438
+ return pending.slice(0, Math.min(this.config.maxBatchSize, boundary > 0 ? boundary : pending.length));
439
+ }
196
440
  /**
197
441
  * Resolvers for per-transaction `confirmation` promises. Populated in
198
442
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -436,10 +680,11 @@ export class TransactionQueue extends EventEmitter {
436
680
  }
437
681
  /**
438
682
  * Flushes every pending transaction in one commit, the fast path taken on
439
- * reconnect. If the batch fails, each transaction falls back to normal,
440
- * one-by-one processing.
683
+ * reconnect. If transport fails, the transactions retain this exact commit
684
+ * envelope when they fall back to normal queue processing.
441
685
  */
442
686
  async flushOfflineQueue() {
687
+ this.assertDurableReplayOpen();
443
688
  // Kick the commit lane too: atomic envelopes from `commits.create()` may
444
689
  // have been left at the head of the lane while the connection was down.
445
690
  // Fire-and-forget; processCommitLane serializes itself.
@@ -448,47 +693,74 @@ export class TransactionQueue extends EventEmitter {
448
693
  const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
449
694
  if (pending.length === 0)
450
695
  return;
451
- // Build operations list
452
- const operations = pending.map((tx) => {
453
- this.ensureDerivedFields(tx);
454
- return applyWriteOptions({
455
- type: TX_TYPE_TO_MUTATION_OP[tx.type],
456
- model: tx.modelKey,
457
- id: tx.modelId,
458
- input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
459
- }, tx);
460
- });
461
- try {
462
- const res = await this.mutationExecutor.commit(operations);
463
- // Mark all as completed
464
- for (const tx of pending) {
465
- this.store.updateStatus(tx.id, 'completed');
466
- this.emit('transaction:completed', tx);
467
- this.emit(`transaction:completed:${tx.id}`, tx);
468
- this.optimisticUpdates.delete(tx.id);
469
- }
470
- // Simple perf note
471
- getContext().logger.debug('txn:commit', 0, {
472
- count: pending.length,
473
- lastSyncId: res?.lastSyncId,
474
- });
475
- }
476
- catch (err) {
477
- // If batch fails, fall back to normal processing
478
- // Only log if we're online (if we're offline, this is expected)
479
- const isOffline = !getContext().onlineStatus.isOnline();
480
- const isNetworkError = err instanceof Error &&
481
- (err.message.includes('Failed to fetch') ||
482
- err.message.includes('Network request failed') ||
483
- err.message.includes('NetworkError'));
484
- if (!isOffline || !isNetworkError) {
485
- getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
486
- error: err instanceof Error ? err.message : String(err),
696
+ const pendingIds = new Set(pending.map((tx) => tx.id));
697
+ // These rows may already be waiting behind the normal batch timer. The
698
+ // reconnect fast path takes ownership of them for this attempt so the same
699
+ // transaction cannot dispatch concurrently through both paths.
700
+ this.executionQueue = this.executionQueue.filter((tx) => !pendingIds.has(tx.id));
701
+ const remaining = [...pending];
702
+ while (remaining.length > 0) {
703
+ const batch = this.takeOfflineFlushBatch(remaining);
704
+ if (batch.length === 0)
705
+ break;
706
+ const batchIds = new Set(batch.map((tx) => tx.id));
707
+ const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
708
+ try {
709
+ const idempotencyKey = this.ensureCommitEnvelope(batch);
710
+ const projectedOperations = batch.map((tx) => {
711
+ this.ensureDerivedFields(tx);
712
+ return applyWriteOptions({
713
+ type: TX_TYPE_TO_MUTATION_OP[tx.type],
714
+ model: tx.modelKey,
715
+ id: tx.modelId,
716
+ input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
717
+ transactionId: tx.id,
718
+ }, tx);
487
719
  });
720
+ const durableEnvelope = await this.sealDurableCommit({
721
+ idempotencyKey,
722
+ origin: 'model_batch',
723
+ operations: projectedOperations,
724
+ sourceMutationIds: this.sourceMutationIdsFor(batch),
725
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
726
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
727
+ sequence: batch[0]?.commitEnvelope?.sequence,
728
+ });
729
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
730
+ const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
731
+ idempotencyKey,
732
+ });
733
+ await this.removeDurableCommit(idempotencyKey);
734
+ // Mark this request envelope as completed before moving to the next.
735
+ for (const tx of batch) {
736
+ this.store.updateStatus(tx.id, 'completed');
737
+ this.emit('transaction:completed', tx);
738
+ this.emit(`transaction:completed:${tx.id}`, tx);
739
+ this.optimisticUpdates.delete(tx.id);
740
+ }
741
+ getContext().logger.debug('txn:commit', 0, {
742
+ count: batch.length,
743
+ lastSyncId: result.lastSyncId,
744
+ });
745
+ remaining.splice(0, remaining.length, ...nextRemaining);
488
746
  }
489
- // Enqueue pending ones to executionQueue
490
- for (const tx of pending) {
491
- this.enqueue(tx);
747
+ catch (err) {
748
+ // If one request fails, hand it and every later request back to the
749
+ // normal lane. Their envelopes stay attached for safe retry.
750
+ const isOffline = !getContext().onlineStatus.isOnline();
751
+ const isNetworkError = err instanceof Error &&
752
+ (err.message.includes('Failed to fetch') ||
753
+ err.message.includes('Network request failed') ||
754
+ err.message.includes('NetworkError'));
755
+ if (!isOffline || !isNetworkError) {
756
+ getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
757
+ error: err instanceof Error ? err.message : String(err),
758
+ });
759
+ }
760
+ for (const tx of [...batch, ...nextRemaining]) {
761
+ this.enqueue(tx);
762
+ }
763
+ return;
492
764
  }
493
765
  }
494
766
  }
@@ -497,7 +769,8 @@ export class TransactionQueue extends EventEmitter {
497
769
  * batched commit. Returns the {@link Transaction}, whose `confirmation`
498
770
  * promise settles once the server confirms the write.
499
771
  */
500
- async create(model, context, writeOptions) {
772
+ async create(model, context, writeOptions, sourceMutationId) {
773
+ this.assertDurableReplayOpen();
501
774
  const actualModelName = model.getModelName();
502
775
  const transaction = {
503
776
  id: this.generateId(),
@@ -516,6 +789,7 @@ export class TransactionQueue extends EventEmitter {
516
789
  attempts: 0,
517
790
  priority: 'normal',
518
791
  writeOptions,
792
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
519
793
  };
520
794
  this.attachConfirmation(transaction);
521
795
  this.store.add(transaction);
@@ -535,7 +809,8 @@ export class TransactionQueue extends EventEmitter {
535
809
  * operation.
536
810
  * @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
537
811
  */
538
- async update(model, context, precomputedChanges, writeOptions) {
812
+ async update(model, context, precomputedChanges, writeOptions, sourceMutationId) {
813
+ this.assertDurableReplayOpen();
539
814
  const actualModelName = model.getModelName();
540
815
  // Use pre-computed changes if provided, otherwise extract from model
541
816
  const updateInput = precomputedChanges
@@ -567,6 +842,7 @@ export class TransactionQueue extends EventEmitter {
567
842
  attempts: 0,
568
843
  priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
569
844
  writeOptions,
845
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
570
846
  };
571
847
  this.attachConfirmation(transaction);
572
848
  this.store.add(transaction);
@@ -587,7 +863,8 @@ export class TransactionQueue extends EventEmitter {
587
863
  * followed by a delete; if the create is already in flight, the delete waits
588
864
  * until it settles so the server never sees a delete before the create.
589
865
  */
590
- async delete(model, context, writeOptions) {
866
+ async delete(model, context, writeOptions, sourceMutationId) {
867
+ this.assertDurableReplayOpen();
591
868
  // Use getModelName() rather than constructor.name, which is unreliable once
592
869
  // class names are minified.
593
870
  const actualModelName = model.getModelName();
@@ -610,6 +887,7 @@ export class TransactionQueue extends EventEmitter {
610
887
  attempts: 0,
611
888
  priority: 'high',
612
889
  writeOptions,
890
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
613
891
  localOnly: true,
614
892
  // Activity deletes complete synchronously (audit-record skip path).
615
893
  // Pre-resolved so consumers can still `await tx.confirmation` uniformly.
@@ -628,7 +906,10 @@ export class TransactionQueue extends EventEmitter {
628
906
  const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
629
907
  if (unsentCreate) {
630
908
  await this.cancelUnsentCreateForDelete(unsentCreate);
631
- return this.completeLocalDelete(model, context, writeOptions);
909
+ return this.completeLocalDelete(model, context, writeOptions, [
910
+ ...(unsentCreate.sourceMutationIds ?? []),
911
+ ...(sourceMutationId ? [sourceMutationId] : []),
912
+ ]);
632
913
  }
633
914
  const transaction = {
634
915
  id: this.generateId(),
@@ -644,13 +925,22 @@ export class TransactionQueue extends EventEmitter {
644
925
  attempts: 0,
645
926
  priority: 'high', // Deletes are high priority
646
927
  writeOptions,
928
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
647
929
  };
648
930
  this.attachConfirmation(transaction);
649
931
  this.store.add(transaction);
650
932
  // Cancel any pending/in-flight updates for this model to prevent "no rows" errors
651
933
  // when the delete executes before the update (race condition fix)
652
- this.cancelTransactionsForModel(model.id, 'update');
934
+ const canceledUpdates = this.cancelTransactionsForModel(model.id, 'update');
653
935
  const entityKey = this.entityKey(actualModelName, model.id);
936
+ const pendingMerge = this.pendingMergeByModel.get(entityKey);
937
+ transaction.sourceMutationIds = [
938
+ ...new Set([
939
+ ...(transaction.sourceMutationIds ?? []),
940
+ ...canceledUpdates.flatMap((candidate) => candidate.sourceMutationIds ?? []),
941
+ ...(pendingMerge?.sourceMutationIds ?? []),
942
+ ]),
943
+ ];
654
944
  this.pendingMergeByModel.delete(entityKey);
655
945
  this.inFlightByModel.delete(entityKey);
656
946
  // Apply optimistic delete
@@ -672,20 +962,23 @@ export class TransactionQueue extends EventEmitter {
672
962
  /**
673
963
  * Uploads a single attachment, delegating to the mutation executor.
674
964
  */
675
- async uploadAttachment(_file, options, _context) {
965
+ async uploadAttachment(_file, options, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
966
+ ) {
676
967
  return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
677
968
  }
678
969
  /**
679
970
  * Uploads several attachments in one call, delegating to the mutation executor.
680
971
  */
681
- async batchUploadAttachments(_files, items, _context) {
972
+ async batchUploadAttachments(_files, items, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
973
+ ) {
682
974
  return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
683
975
  }
684
976
  /**
685
977
  * Records an archive and applies it optimistically, then stages it for the
686
978
  * next batched commit.
687
979
  */
688
- async archive(model, context, writeOptions) {
980
+ async archive(model, context, writeOptions, sourceMutationId) {
981
+ this.assertDurableReplayOpen();
689
982
  // Use getModelName() rather than constructor.name, which is unreliable once
690
983
  // class names are minified.
691
984
  const actualModelName = model.getModelName();
@@ -705,6 +998,7 @@ export class TransactionQueue extends EventEmitter {
705
998
  attempts: 0,
706
999
  priority: 'normal',
707
1000
  writeOptions,
1001
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
708
1002
  };
709
1003
  this.attachConfirmation(transaction);
710
1004
  this.store.add(transaction);
@@ -718,6 +1012,7 @@ export class TransactionQueue extends EventEmitter {
718
1012
  * next batched commit.
719
1013
  */
720
1014
  async unarchive(model, context) {
1015
+ this.assertDurableReplayOpen();
721
1016
  // Use getModelName() rather than constructor.name, which is unreliable once
722
1017
  // class names are minified.
723
1018
  const actualModelName = model.getModelName();
@@ -755,20 +1050,28 @@ export class TransactionQueue extends EventEmitter {
755
1050
  // event-loop tick, so only two cases remain here: merging into an in-flight
756
1051
  // update, and merging into a pending same-entity update.
757
1052
  //
758
- // Retries (attempts > 0) must not take either merge path. A retry is
759
- // re-enqueued from `handleFailure` while its own model key is still marked
760
- // in `inFlightByModel`, so treating it as a concurrent edit would move its
761
- // data into `pendingMergeByModel`, remove the transaction, and then mint a
762
- // fresh follow-up with the attempt counter reset to zero. The counter would
763
- // never accumulate, `maxRetries` would never trip, and the write would
764
- // resend in an endless loop.
765
- if (transaction.type === 'update' && transaction.attempts === 0) {
766
- const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
1053
+ // Retries (attempts > 0 or an already assigned commit envelope) must not
1054
+ // take either merge path. A retry is re-enqueued while its own model key may
1055
+ // still be marked in `inFlightByModel`; treating it as a concurrent edit
1056
+ // would change the already-attempted request, discard its stable envelope,
1057
+ // and either defeat maxRetries or duplicate an ambiguously committed write.
1058
+ if (transaction.type === 'update' &&
1059
+ transaction.attempts === 0 &&
1060
+ !transaction.commitEnvelope) {
1061
+ const preserveWatermark = hasCommitCoalescingBarrier(transaction.writeOptions);
767
1062
  // If there is an in-flight update for this model, merge into post-flight buffer
768
1063
  if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
769
- const prev = this.pendingMergeByModel.get(modelKey) || {};
770
- const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
771
- this.pendingMergeByModel.set(modelKey, merged);
1064
+ const previous = this.pendingMergeByModel.get(modelKey);
1065
+ const merged = mergeUpdateData(previous?.data ?? {}, transaction.data || {}, transaction.modelName);
1066
+ this.pendingMergeByModel.set(modelKey, {
1067
+ data: merged,
1068
+ sourceMutationIds: [
1069
+ ...new Set([
1070
+ ...(previous?.sourceMutationIds ?? []),
1071
+ ...(transaction.sourceMutationIds ?? []),
1072
+ ]),
1073
+ ],
1074
+ });
772
1075
  this.store.remove(transaction.id);
773
1076
  return;
774
1077
  }
@@ -777,9 +1080,15 @@ export class TransactionQueue extends EventEmitter {
777
1080
  t.type === 'update' &&
778
1081
  t.modelId === transaction.modelId &&
779
1082
  t.modelName === transaction.modelName &&
780
- !hasStaleWriteOptions(t.writeOptions));
1083
+ !hasCommitCoalescingBarrier(t.writeOptions));
781
1084
  if (!preserveWatermark && pendingInQueue) {
782
1085
  pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
1086
+ pendingInQueue.sourceMutationIds = [
1087
+ ...new Set([
1088
+ ...(pendingInQueue.sourceMutationIds ?? []),
1089
+ ...(transaction.sourceMutationIds ?? []),
1090
+ ]),
1091
+ ];
783
1092
  this.store.remove(transaction.id);
784
1093
  return;
785
1094
  }
@@ -832,6 +1141,8 @@ export class TransactionQueue extends EventEmitter {
832
1141
  * greatly reduces batch latency.
833
1142
  */
834
1143
  async processBatch() {
1144
+ if (this.durableReplayBlock)
1145
+ return;
835
1146
  const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
836
1147
  if (this.isProcessing || this.executionQueue.length === 0) {
837
1148
  return;
@@ -856,8 +1167,12 @@ export class TransactionQueue extends EventEmitter {
856
1167
  }
857
1168
  return a.priorityScore - b.priorityScore;
858
1169
  });
859
- // Get batch (now guaranteed to have parent entities before children)
860
- batch = this.executionQueue.splice(0, this.config.maxBatchSize);
1170
+ // Take a fresh batch or one complete retry envelope. Retry envelopes
1171
+ // retain both their original membership and operation order.
1172
+ batch = this.takeNextExecutionBatch();
1173
+ if (batch.length === 0)
1174
+ return;
1175
+ const commitIdempotencyKey = this.ensureCommitEnvelope(batch);
861
1176
  // Track executing count for backpressure
862
1177
  this.executingCount += batch.length;
863
1178
  // Mark all as executing
@@ -887,17 +1202,30 @@ export class TransactionQueue extends EventEmitter {
887
1202
  }
888
1203
  // Execute the unified commit for every operation in one round trip.
889
1204
  if (batchOps.length > 0) {
890
- const operations = batchOps.map(({ op }) => op);
1205
+ let dispatchStarted = false;
891
1206
  try {
1207
+ const durableEnvelope = await this.sealDurableCommit({
1208
+ idempotencyKey: commitIdempotencyKey,
1209
+ origin: 'model_batch',
1210
+ operations: batchOps.map(({ op }) => op),
1211
+ sourceMutationIds: this.sourceMutationIdsFor(batch),
1212
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
1213
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
1214
+ sequence: batch[0]?.commitEnvelope?.sequence,
1215
+ });
1216
+ const operations = durableEnvelope.operations;
892
1217
  // Capture lastSyncId from the server response for threshold-based
893
1218
  // confirmation.
894
1219
  //
895
- // Idempotency: the default HTTP executor derives a stable
896
- // idempotency key from the operations array itself (a sorted
897
- // SHA-256), so retrying the same batch reaches the server's
898
- // `mutation_log` replay path without threading a key through the
899
- // microtask boundary here.
900
- const result = await this.mutationExecutor.commit(operations);
1220
+ // The queue owns request identity. A lost acknowledgement may be
1221
+ // retried after a backoff or reconnect, so every retry must send
1222
+ // the exact key assigned before the first transport attempt.
1223
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
1224
+ dispatchStarted = true;
1225
+ const result = await this.mutationExecutor.commit(operations, {
1226
+ idempotencyKey: commitIdempotencyKey,
1227
+ });
1228
+ await this.removeDurableCommit(commitIdempotencyKey);
901
1229
  const lastSyncId = result?.lastSyncId ?? 0;
902
1230
  this.noteAck(lastSyncId);
903
1231
  // The server returned stale-context notifications for operations
@@ -998,6 +1326,9 @@ export class TransactionQueue extends EventEmitter {
998
1326
  }
999
1327
  catch (error) {
1000
1328
  const errorMessage = error.message || '';
1329
+ if (dispatchStarted && this.isDefinitiveRejection(error)) {
1330
+ await this.removeDurableCommit(commitIdempotencyKey);
1331
+ }
1001
1332
  // Surface the raw server rejection for the whole batch so a
1002
1333
  // cascaded failure — for example a foreign-key violation that
1003
1334
  // rolls back a multi-operation transaction — is attributable to a
@@ -1043,6 +1374,9 @@ export class TransactionQueue extends EventEmitter {
1043
1374
  // success — the intended end state already holds — and the
1044
1375
  // transaction is treated as completed.
1045
1376
  if (errorMessage.includes('no rows in result set')) {
1377
+ if (dispatchStarted) {
1378
+ await this.removeDurableCommit(commitIdempotencyKey);
1379
+ }
1046
1380
  getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
1047
1381
  batchSize: batchOps.length,
1048
1382
  });
@@ -1077,7 +1411,7 @@ export class TransactionQueue extends EventEmitter {
1077
1411
  if (tx.type === 'update') {
1078
1412
  this.inFlightByModel.delete(key);
1079
1413
  const pending = this.pendingMergeByModel.get(key);
1080
- if (pending && Object.keys(pending).length > 0) {
1414
+ if (pending && Object.keys(pending.data).length > 0) {
1081
1415
  // Create a single merged follow-up transaction
1082
1416
  const followUp = {
1083
1417
  id: this.generateId(),
@@ -1085,7 +1419,7 @@ export class TransactionQueue extends EventEmitter {
1085
1419
  modelName: tx.modelName,
1086
1420
  modelId: tx.modelId,
1087
1421
  modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
1088
- data: pending,
1422
+ data: pending.data,
1089
1423
  previousData: undefined,
1090
1424
  context: tx.context,
1091
1425
  status: 'pending',
@@ -1093,6 +1427,7 @@ export class TransactionQueue extends EventEmitter {
1093
1427
  attempts: 0,
1094
1428
  priority: 'normal',
1095
1429
  priorityScore: this.computePriorityScore('update', tx.modelName),
1430
+ sourceMutationIds: pending.sourceMutationIds,
1096
1431
  };
1097
1432
  this.pendingMergeByModel.delete(key);
1098
1433
  this.store.add(followUp);
@@ -1106,7 +1441,7 @@ export class TransactionQueue extends EventEmitter {
1106
1441
  // Decrement executing count for backpressure tracking
1107
1442
  this.executingCount -= batch.length;
1108
1443
  // Process next batch if needed
1109
- if (this.executionQueue.length > 0) {
1444
+ if (this.executionQueue.length > 0 && batch.length > 0) {
1110
1445
  this.scheduleProcessing(true);
1111
1446
  }
1112
1447
  const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -1175,9 +1510,32 @@ export class TransactionQueue extends EventEmitter {
1175
1510
  * already in flight is ignored: the server's `mutation_log` de-duplicates
1176
1511
  * across sessions, and this guard covers a double-enqueue within one session.
1177
1512
  */
1178
- enqueueCommit(clientTxId, operations, options = {}) {
1179
- if (this.commitStore.has(clientTxId))
1513
+ async enqueueCommit(clientTxId, operations, options = {}) {
1514
+ this.assertDurableReplayOpen();
1515
+ const existing = this.commitStore.get(clientTxId);
1516
+ if (existing) {
1517
+ await existing.sealPromise;
1518
+ const existingIntent = stableStringify({
1519
+ operations: existing.operations,
1520
+ causedByTaskId: existing.causedByTaskId ?? null,
1521
+ reads: existing.reads ?? null,
1522
+ });
1523
+ const incomingIntent = stableStringify({
1524
+ operations,
1525
+ causedByTaskId: options.causedByTaskId ?? null,
1526
+ reads: options.reads ?? null,
1527
+ });
1528
+ if (existingIntent !== incomingIntent) {
1529
+ throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
1530
+ }
1531
+ if (existing.status === 'pending')
1532
+ void this.processCommitLane();
1180
1533
  return;
1534
+ }
1535
+ this.emitCommitLifecycle('commit:staging', {
1536
+ clientTxId,
1537
+ operations,
1538
+ });
1181
1539
  const tx = {
1182
1540
  id: clientTxId,
1183
1541
  kind: 'commit',
@@ -1187,14 +1545,45 @@ export class TransactionQueue extends EventEmitter {
1187
1545
  status: 'pending',
1188
1546
  createdAt: Date.now(),
1189
1547
  attempts: 0,
1548
+ sealedAt: Date.now(),
1549
+ sequence: this.nextCommitSequence(),
1190
1550
  };
1191
1551
  this.commitStore.set(clientTxId, tx);
1552
+ tx.sealPromise = this.sealDurableCommit({
1553
+ idempotencyKey: tx.id,
1554
+ origin: 'atomic_commit',
1555
+ operations: tx.operations,
1556
+ commitOptions: {
1557
+ causedByTaskId: tx.causedByTaskId ?? null,
1558
+ ...(tx.reads ? { reads: tx.reads } : {}),
1559
+ },
1560
+ createdAt: tx.createdAt,
1561
+ sealedAt: tx.sealedAt,
1562
+ sequence: tx.sequence,
1563
+ }).then((envelope) => {
1564
+ tx.durableEnvelope = envelope;
1565
+ tx.operations = envelope.operations.map((operation) => ({ ...operation }));
1566
+ });
1567
+ try {
1568
+ await tx.sealPromise;
1569
+ }
1570
+ catch (error) {
1571
+ this.commitStore.delete(clientTxId);
1572
+ this.emitCommitLifecycle('commit:seal_failed', { clientTxId });
1573
+ throw error;
1574
+ }
1575
+ finally {
1576
+ tx.sealPromise = undefined;
1577
+ }
1192
1578
  this.commitLane.push(tx);
1193
1579
  // Emit the envelope on its own event so the undo stream can record
1194
1580
  // commit-lane writes as well. This deliberately avoids `transaction:created`,
1195
1581
  // which also feeds the optimistic-echo tracker: commit-lane operations apply
1196
1582
  // nothing optimistically and so have no echo to suppress.
1197
- this.emit('commit:created', { clientTxId, operations: tx.operations });
1583
+ this.emitCommitLifecycle('commit:created', {
1584
+ clientTxId,
1585
+ operations: tx.operations,
1586
+ });
1198
1587
  void this.processCommitLane();
1199
1588
  }
1200
1589
  /**
@@ -1205,7 +1594,7 @@ export class TransactionQueue extends EventEmitter {
1205
1594
  * `transaction:failed:<id>` and drops the envelope.
1206
1595
  */
1207
1596
  async processCommitLane() {
1208
- if (this.commitProcessing)
1597
+ if (this.commitProcessing || this.durableReplayBlock)
1209
1598
  return;
1210
1599
  this.commitProcessing = true;
1211
1600
  try {
@@ -1219,21 +1608,48 @@ export class TransactionQueue extends EventEmitter {
1219
1608
  }
1220
1609
  tx.status = 'executing';
1221
1610
  tx.attempts += 1;
1611
+ let dispatchStarted = false;
1222
1612
  try {
1223
- const result = await this.mutationExecutor.commit(tx.operations, {
1613
+ const durableEnvelope = tx.durableEnvelope ??
1614
+ (await this.sealDurableCommit({
1615
+ idempotencyKey: tx.id,
1616
+ origin: 'atomic_commit',
1617
+ operations: tx.operations,
1618
+ sourceMutationIds: tx.sourceMutationIds,
1619
+ commitOptions: {
1620
+ causedByTaskId: tx.causedByTaskId ?? null,
1621
+ ...(tx.reads ? { reads: tx.reads } : {}),
1622
+ },
1623
+ createdAt: tx.createdAt,
1624
+ sealedAt: tx.sealedAt,
1625
+ sequence: tx.sequence,
1626
+ }));
1627
+ tx.durableEnvelope = durableEnvelope;
1628
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
1629
+ dispatchStarted = true;
1630
+ const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
1224
1631
  idempotencyKey: tx.id,
1225
- causedByTaskId: tx.causedByTaskId ?? undefined,
1226
- ...(tx.reads ? { reads: tx.reads } : {}),
1632
+ causedByTaskId: durableEnvelope.commitOptions.causedByTaskId ?? undefined,
1633
+ ...(durableEnvelope.commitOptions.reads
1634
+ ? { reads: durableEnvelope.commitOptions.reads }
1635
+ : {}),
1227
1636
  });
1637
+ await this.removeDurableCommit(tx.id);
1228
1638
  tx.lastSyncId = result?.lastSyncId ?? 0;
1229
1639
  this.noteAck(tx.lastSyncId);
1230
1640
  tx.status = 'completed';
1231
1641
  this.commitLane.shift();
1232
- this.emit('transaction:completed', tx);
1233
- this.emit(`transaction:completed:${tx.id}`, tx);
1642
+ // Guarded: a throwing observer here would land in the catch below,
1643
+ // whose permanent branch shifts the lane a second time and rejects a
1644
+ // commit the server has already durably applied.
1645
+ this.emitCommitLifecycle('transaction:completed', tx);
1646
+ this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
1234
1647
  }
1235
1648
  catch (err) {
1236
1649
  const error = err instanceof Error ? err : new Error(String(err));
1650
+ if (dispatchStarted && this.isDefinitiveRejection(error)) {
1651
+ await this.removeDurableCommit(tx.id);
1652
+ }
1237
1653
  if (!this.isPermanentError(error)) {
1238
1654
  // Transient: leave it at the head and retry on the next kick
1239
1655
  // (reconnect or the next enqueueCommit) rather than tight-looping
@@ -1257,8 +1673,8 @@ export class TransactionQueue extends EventEmitter {
1257
1673
  attempts: tx.attempts,
1258
1674
  message: error.message,
1259
1675
  });
1260
- this.emit('transaction:failed', { transaction: tx, error, permanent: true });
1261
- this.emit(`transaction:failed:${tx.id}`, { error });
1676
+ this.emitCommitLifecycle('transaction:failed', { transaction: tx, error, permanent: true });
1677
+ this.emitCommitLifecycle(`transaction:failed:${tx.id}`, { error });
1262
1678
  }
1263
1679
  }
1264
1680
  }
@@ -1383,6 +1799,16 @@ export class TransactionQueue extends EventEmitter {
1383
1799
  // This is the safe default - better to fail fast than retry forever
1384
1800
  return true;
1385
1801
  }
1802
+ /** True only when the server definitively rejected before applying. */
1803
+ isDefinitiveRejection(error) {
1804
+ if (error instanceof AbloError && error.code) {
1805
+ const spec = errorCodeSpec(error.code);
1806
+ if (spec)
1807
+ return !spec.retryable;
1808
+ }
1809
+ const status = extractStatusCode(error);
1810
+ return status !== undefined && status >= 400 && status < 500 && status !== 429;
1811
+ }
1386
1812
  /**
1387
1813
  * Handles a failed transaction: retries transient failures with backoff and
1388
1814
  * rolls back permanent ones, settling the transaction's confirmation promise
@@ -1579,6 +2005,98 @@ export class TransactionQueue extends EventEmitter {
1579
2005
  });
1580
2006
  }
1581
2007
  }
2008
+ /**
2009
+ * Restore exact sealed requests after the local database is open. Sealed
2010
+ * envelopes replay through the atomic commit lane and are never re-projected
2011
+ * through model/schema state from the new process.
2012
+ */
2013
+ async restoreDurableCommits() {
2014
+ if (!this.config.enablePersistence)
2015
+ return new Set();
2016
+ const sourceMutationIds = new Set();
2017
+ try {
2018
+ if (!this.commitOutbox)
2019
+ return sourceMutationIds;
2020
+ const rows = await this.commitOutbox.list();
2021
+ const envelopes = [];
2022
+ for (const row of rows) {
2023
+ if (typeof row !== 'object' ||
2024
+ row === null ||
2025
+ row.type !== 'commit_envelope')
2026
+ continue;
2027
+ const parsed = durableCommitEnvelopeSchema.safeParse(row);
2028
+ if (parsed.success) {
2029
+ envelopes.push(parsed.data);
2030
+ }
2031
+ else {
2032
+ getContext().logger.warn('A saved local write is unreadable and was held for review.');
2033
+ getContext().observability.captureTransactionFailure({
2034
+ context: 'restore-commit-envelope',
2035
+ error: parsed.error,
2036
+ });
2037
+ throw new AbloValidationError('A saved commit envelope is unreadable; replay stopped before newer writes were sent.', { code: 'write_options_invalid', cause: parsed.error });
2038
+ }
2039
+ }
2040
+ envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
2041
+ (b.sequence ?? b.sealedAt * 1_000) ||
2042
+ a.id.localeCompare(b.id));
2043
+ for (const envelope of envelopes) {
2044
+ for (const mutationId of envelope.sourceMutationIds) {
2045
+ sourceMutationIds.add(mutationId);
2046
+ }
2047
+ if (Date.now() - envelope.sealedAt >=
2048
+ TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
2049
+ getContext().logger.warn('A saved local write is too old to retry safely and was held for review.');
2050
+ getContext().observability.captureTransactionFailure({
2051
+ context: 'quarantine-expired-commit-envelope',
2052
+ error: `Envelope ${envelope.idempotencyKey} is too old to replay safely`,
2053
+ });
2054
+ throw new AbloIdempotencyError('A saved commit is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
2055
+ }
2056
+ if (this.commitOutboxScope &&
2057
+ (!envelope.scope || // eslint-disable-line @typescript-eslint/prefer-optional-chain -- missing scope must quarantine
2058
+ envelope.scope.organizationId !== this.commitOutboxScope.organizationId ||
2059
+ envelope.scope.participantId !== this.commitOutboxScope.participantId ||
2060
+ envelope.scope.namespace !== this.commitOutboxScope.namespace)) {
2061
+ getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
2062
+ continue;
2063
+ }
2064
+ if (this.commitStore.has(envelope.idempotencyKey))
2065
+ continue;
2066
+ const transaction = {
2067
+ id: envelope.idempotencyKey,
2068
+ kind: 'commit',
2069
+ operations: envelope.operations.map((operation) => ({ ...operation })),
2070
+ causedByTaskId: envelope.commitOptions.causedByTaskId ?? null,
2071
+ ...(envelope.commitOptions.reads
2072
+ ? { reads: [...envelope.commitOptions.reads] }
2073
+ : {}),
2074
+ status: 'pending',
2075
+ createdAt: envelope.createdAt,
2076
+ sealedAt: envelope.sealedAt,
2077
+ sequence: envelope.sequence ?? envelope.sealedAt * 1_000,
2078
+ attempts: 0,
2079
+ sourceMutationIds: [...envelope.sourceMutationIds],
2080
+ durableEnvelope: envelope,
2081
+ };
2082
+ this.commitStore.set(transaction.id, transaction);
2083
+ this.commitLane.push(transaction);
2084
+ }
2085
+ if (this.commitLane.length > 0)
2086
+ void this.processCommitLane();
2087
+ }
2088
+ catch (error) {
2089
+ getContext().logger.debug('[TransactionQueue] Failed to restore durable writes', {
2090
+ error: error instanceof Error ? error.message : String(error),
2091
+ });
2092
+ getContext().observability.captureTransactionFailure({
2093
+ context: 'restore-commit-envelopes',
2094
+ error: error instanceof Error ? error : String(error),
2095
+ });
2096
+ throw error;
2097
+ }
2098
+ return sourceMutationIds;
2099
+ }
1582
2100
  /**
1583
2101
  * Validates and rehydrates one persisted row. Rows written to the same store
1584
2102
  * by other subsystems are skipped, and rows that fail the persisted