@abloatai/ablo 0.27.0 → 0.28.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.
- package/CHANGELOG.md +29 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +225 -28
- package/dist/Model.d.ts +17 -0
- package/dist/Model.js +32 -1
- package/dist/SyncClient.d.ts +10 -6
- package/dist/SyncClient.js +379 -76
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +12 -0
- package/dist/cli.cjs +6 -2
- package/dist/client/Ablo.d.ts +24 -1
- package/dist/client/Ablo.js +4 -3
- package/dist/client/ApiClient.js +309 -43
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +23 -0
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/commit/contract.d.ts +493 -0
- package/dist/commit/contract.js +187 -0
- package/dist/commit/index.d.ts +6 -0
- package/dist/commit/index.js +5 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/errorCodes.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/interfaces/index.d.ts +2 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +27 -4
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
- package/dist/transactions/TransactionQueue.d.ts +66 -8
- package/dist/transactions/TransactionQueue.js +607 -89
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +15 -0
- package/dist/transactions/commitPayload.js +6 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
- package/dist/transactions/httpCommitEnvelope.js +179 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +236 -0
- package/dist/wire/frames.js +21 -0
- package/dist/wire/index.d.ts +4 -2
- package/dist/wire/index.js +2 -1
- package/docs/api.md +10 -10
- package/docs/coordination.md +2 -2
- package/docs/mcp.md +1 -1
- package/package.json +7 -2
|
@@ -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,
|
|
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 seal the commit outbox 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] Commit outbox 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
|
|
440
|
-
*
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
759
|
-
// re-enqueued
|
|
760
|
-
// in `inFlightByModel
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const preserveWatermark =
|
|
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
|
|
770
|
-
const merged = mergeUpdateData(
|
|
771
|
-
this.pendingMergeByModel.set(modelKey,
|
|
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
|
-
!
|
|
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
|
-
//
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
1226
|
-
...(
|
|
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
|
-
|
|
1233
|
-
|
|
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.
|
|
1261
|
-
this.
|
|
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 commit outbox', {
|
|
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
|