@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
package/dist/SyncClient.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* over the network.
|
|
9
9
|
*/
|
|
10
10
|
import { runInAction } from 'mobx';
|
|
11
|
+
import { v4 as uuid } from 'uuid';
|
|
11
12
|
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
12
13
|
import { Model } from './Model.js';
|
|
13
14
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
@@ -17,9 +18,10 @@ import { AbloAuthenticationError, AbloError, AbloValidationError } from './error
|
|
|
17
18
|
import { EventEmitter } from 'events';
|
|
18
19
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
19
20
|
import { TransactionQueue } from './transactions/TransactionQueue.js';
|
|
20
|
-
import { persistedMutationSchema } from './transactions/replayValidation.js';
|
|
21
|
+
import { legacyPendingMutationRecordSchema, PENDING_MUTATION_REPLAY_WINDOW_MS, pendingMutationRecordId, pendingMutationRecordSchema, persistedMutationSchema, } from './transactions/replayValidation.js';
|
|
21
22
|
import { UnconfirmedWrites, } from './transactions/UnconfirmedWrites.js';
|
|
22
23
|
import { SyncPosition } from './sync/syncPosition.js';
|
|
24
|
+
import { DatabaseCommitOutboxStore, } from './transactions/commitOutboxStore.js';
|
|
23
25
|
/**
|
|
24
26
|
* Reports whether an incoming snapshot record is strictly newer than the
|
|
25
27
|
* model already in the pool. The comparison uses the server-stamped
|
|
@@ -67,6 +69,10 @@ export class SyncClient extends EventEmitter {
|
|
|
67
69
|
database;
|
|
68
70
|
get mutationExecutor() { return getContext().mutationExecutor; }
|
|
69
71
|
networkMonitor;
|
|
72
|
+
/**
|
|
73
|
+
* @internal — test seam, stripped from the published declarations by
|
|
74
|
+
* `stripInternal`. Unit suites deliver queue lifecycle events directly.
|
|
75
|
+
*/
|
|
70
76
|
transactionQueue;
|
|
71
77
|
observers = new Set();
|
|
72
78
|
// Authentication context
|
|
@@ -74,6 +80,10 @@ export class SyncClient extends EventEmitter {
|
|
|
74
80
|
organizationId = null;
|
|
75
81
|
// Pending mutations queue
|
|
76
82
|
pendingMutations = [];
|
|
83
|
+
stagedMutationIds = new Set();
|
|
84
|
+
pendingJournalBatch = [];
|
|
85
|
+
journalFlushScheduled = false;
|
|
86
|
+
commitOutboxNamespace;
|
|
77
87
|
/**
|
|
78
88
|
* Tracks the ids of transactions the client has applied optimistically but
|
|
79
89
|
* the server has not yet confirmed. When a delta arrives, the receive path
|
|
@@ -99,10 +109,11 @@ export class SyncClient extends EventEmitter {
|
|
|
99
109
|
* responses, and snapshots and claims read `readFloor`.
|
|
100
110
|
*/
|
|
101
111
|
position = new SyncPosition();
|
|
102
|
-
constructor(objectPool, database) {
|
|
112
|
+
constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
|
|
103
113
|
super();
|
|
104
114
|
this.objectPool = objectPool;
|
|
105
115
|
this.database = database;
|
|
116
|
+
this.commitOutboxNamespace = commitOutboxNamespace;
|
|
106
117
|
this.networkMonitor = new NetworkMonitor();
|
|
107
118
|
// Initialize TransactionQueue with proper configuration
|
|
108
119
|
this.transactionQueue = new TransactionQueue({
|
|
@@ -117,6 +128,53 @@ export class SyncClient extends EventEmitter {
|
|
|
117
128
|
strategy: 'last-write-wins',
|
|
118
129
|
},
|
|
119
130
|
});
|
|
131
|
+
this.transactionQueue.setCommitOutbox(commitOutbox);
|
|
132
|
+
this.transactionQueue.on('commit:envelope_persisted', (event) => {
|
|
133
|
+
if (event.sourceMutationIds.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
const consumed = new Set(event.sourceMutationIds);
|
|
136
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !consumed.has(mutation.mutationId));
|
|
137
|
+
for (const mutationId of consumed)
|
|
138
|
+
this.stagedMutationIds.delete(mutationId);
|
|
139
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
140
|
+
this.scheduleSync();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
this.transactionQueue.on('transaction:completed', (transaction) => {
|
|
144
|
+
const completed = new Set(transaction.sourceMutationIds ?? []);
|
|
145
|
+
if (completed.size > 0) {
|
|
146
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !completed.has(mutation.mutationId));
|
|
147
|
+
}
|
|
148
|
+
for (const mutationId of transaction.sourceMutationIds ?? []) {
|
|
149
|
+
this.stagedMutationIds.delete(mutationId);
|
|
150
|
+
void this.database
|
|
151
|
+
.removeTransaction(pendingMutationRecordId(mutationId))
|
|
152
|
+
.catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
155
|
+
this.scheduleSync();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
this.transactionQueue.on('transaction:failed', ({ transaction }) => {
|
|
159
|
+
const failed = transaction.sourceMutationIds ?? [];
|
|
160
|
+
if (failed.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
const failedSet = new Set(failed);
|
|
163
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !failedSet.has(mutation.mutationId));
|
|
164
|
+
for (const mutationId of failed) {
|
|
165
|
+
this.stagedMutationIds.delete(mutationId);
|
|
166
|
+
// The queue has already rolled the model back, so replaying the
|
|
167
|
+
// journal row on the next boot would resurrect a rejected write.
|
|
168
|
+
void this.database
|
|
169
|
+
.removeTransaction(pendingMutationRecordId(mutationId))
|
|
170
|
+
.catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
// Without this drain, terminally failed ids stayed claimed forever and
|
|
173
|
+
// the size guard in processPendingMutations stalled every later write.
|
|
174
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
175
|
+
this.scheduleSync();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
120
178
|
// Provide connection state to TransactionQueue - prevents rollbacks during disconnection
|
|
121
179
|
this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
|
|
122
180
|
// Restore object-pool state when a transaction is rolled back. If the
|
|
@@ -388,8 +446,23 @@ export class SyncClient extends EventEmitter {
|
|
|
388
446
|
this.userId = userId;
|
|
389
447
|
this.organizationId = organizationId;
|
|
390
448
|
getContext().observability.setContext(userId, organizationId);
|
|
391
|
-
|
|
392
|
-
|
|
449
|
+
this.transactionQueue.setCommitOutboxScope({
|
|
450
|
+
organizationId,
|
|
451
|
+
participantId: userId,
|
|
452
|
+
namespace: this.commitOutboxNamespace,
|
|
453
|
+
});
|
|
454
|
+
// Calls made during startup are allowed to queue before identity arrives,
|
|
455
|
+
// but they cannot be serialized with a trustworthy scope until now. Flush
|
|
456
|
+
// those already-created journal promises before any write can be staged.
|
|
457
|
+
if (this.pendingJournalBatch.length > 0) {
|
|
458
|
+
this.scheduleJournalFlush();
|
|
459
|
+
await Promise.all(this.pendingMutations.map((mutation) => mutation.journaled));
|
|
460
|
+
}
|
|
461
|
+
// Restore exact, already-sealed requests first. The returned source ids
|
|
462
|
+
// suppress any legacy queue entry left behind by an older non-atomic
|
|
463
|
+
// handoff.
|
|
464
|
+
const sealedMutationIds = await this.transactionQueue.restoreDurableCommits();
|
|
465
|
+
await this.restoreMutationQueue(sealedMutationIds);
|
|
393
466
|
// Read the initial network status from the injected OnlineStatusProvider.
|
|
394
467
|
// In the browser this reflects the host's connectivity signal; in Node it
|
|
395
468
|
// reports online by default. NetworkMonitor drives the ongoing
|
|
@@ -403,6 +476,8 @@ export class SyncClient extends EventEmitter {
|
|
|
403
476
|
this.setConnectionState('disconnected');
|
|
404
477
|
this.emit('sync:offline');
|
|
405
478
|
}
|
|
479
|
+
if (this.pendingMutations.length > 0)
|
|
480
|
+
this.scheduleSync();
|
|
406
481
|
}
|
|
407
482
|
/**
|
|
408
483
|
* The organization this client writes under (set by `initialize`).
|
|
@@ -846,6 +921,7 @@ export class SyncClient extends EventEmitter {
|
|
|
846
921
|
*/
|
|
847
922
|
clearPendingMutationsForModel(modelId) {
|
|
848
923
|
const beforeCount = this.pendingMutations.length;
|
|
924
|
+
const removed = this.pendingMutations.filter((mutation) => mutation.model.id === modelId);
|
|
849
925
|
this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
|
|
850
926
|
const afterCount = this.pendingMutations.length;
|
|
851
927
|
if (beforeCount !== afterCount) {
|
|
@@ -854,8 +930,18 @@ export class SyncClient extends EventEmitter {
|
|
|
854
930
|
clearedCount: beforeCount - afterCount,
|
|
855
931
|
remainingCount: afterCount,
|
|
856
932
|
});
|
|
857
|
-
|
|
858
|
-
|
|
933
|
+
for (const mutation of removed) {
|
|
934
|
+
// Once staged, TransactionQueue owns cancellation and transfers this
|
|
935
|
+
// source id into the superseding delete envelope. Deleting the journal
|
|
936
|
+
// row here would make that valid atomic promotion look like a
|
|
937
|
+
// multi-tab loser. Truly unstaged work can be canceled locally.
|
|
938
|
+
if (this.stagedMutationIds.has(mutation.mutationId))
|
|
939
|
+
continue;
|
|
940
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
941
|
+
void mutation.journaled
|
|
942
|
+
.then(() => this.database.removeTransaction(pendingMutationRecordId(mutation.mutationId)))
|
|
943
|
+
.catch(() => undefined);
|
|
944
|
+
}
|
|
859
945
|
}
|
|
860
946
|
}
|
|
861
947
|
/**
|
|
@@ -966,9 +1052,89 @@ export class SyncClient extends EventEmitter {
|
|
|
966
1052
|
* avoid re-reading a model after pool operations that might clear them.
|
|
967
1053
|
*/
|
|
968
1054
|
queueMutation(mutation) {
|
|
969
|
-
|
|
1055
|
+
const mutationId = `mutation_${uuid()}`;
|
|
1056
|
+
const modelData = mutation.model.toJSON
|
|
1057
|
+
? mutation.model.toJSON()
|
|
1058
|
+
: { ...mutation.model };
|
|
1059
|
+
let resolveJournal;
|
|
1060
|
+
let rejectJournal;
|
|
1061
|
+
const journaled = new Promise((resolve, reject) => {
|
|
1062
|
+
resolveJournal = resolve;
|
|
1063
|
+
rejectJournal = reject;
|
|
1064
|
+
});
|
|
1065
|
+
let resolveStaged;
|
|
1066
|
+
let rejectStaged;
|
|
1067
|
+
const staged = new Promise((resolve, reject) => {
|
|
1068
|
+
resolveStaged = resolve;
|
|
1069
|
+
rejectStaged = reject;
|
|
1070
|
+
});
|
|
1071
|
+
const pending = {
|
|
1072
|
+
...mutation,
|
|
1073
|
+
mutationId,
|
|
1074
|
+
modelData,
|
|
1075
|
+
journaled,
|
|
1076
|
+
resolveJournal,
|
|
1077
|
+
rejectJournal,
|
|
1078
|
+
staged,
|
|
1079
|
+
resolveStaged,
|
|
1080
|
+
rejectStaged,
|
|
1081
|
+
};
|
|
1082
|
+
this.pendingJournalBatch.push(pending);
|
|
1083
|
+
this.scheduleJournalFlush();
|
|
1084
|
+
// Offline drains may not await this until much later. Observe rejection
|
|
1085
|
+
// immediately to avoid an unhandled-promise report while retaining the
|
|
1086
|
+
// original rejecting promise for fail-closed dispatch.
|
|
1087
|
+
void pending.journaled.catch(() => undefined);
|
|
1088
|
+
void pending.staged.catch(() => undefined);
|
|
1089
|
+
this.pendingMutations.push(pending);
|
|
970
1090
|
this.scheduleSync();
|
|
971
1091
|
}
|
|
1092
|
+
scheduleJournalFlush() {
|
|
1093
|
+
if (this.journalFlushScheduled)
|
|
1094
|
+
return;
|
|
1095
|
+
this.journalFlushScheduled = true;
|
|
1096
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
1097
|
+
? queueMicrotask
|
|
1098
|
+
: (callback) => { void Promise.resolve().then(callback); };
|
|
1099
|
+
schedule(() => {
|
|
1100
|
+
this.journalFlushScheduled = false;
|
|
1101
|
+
const batch = this.pendingJournalBatch;
|
|
1102
|
+
this.pendingJournalBatch = [];
|
|
1103
|
+
void this.flushPendingMutationJournal(batch);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
async flushPendingMutationJournal(batch) {
|
|
1107
|
+
if (batch.length === 0)
|
|
1108
|
+
return;
|
|
1109
|
+
if (!this.userId || !this.organizationId) {
|
|
1110
|
+
// Startup writes remain behind their unresolved journal promise. Identity
|
|
1111
|
+
// initialization re-kicks this batch once its durable scope is known.
|
|
1112
|
+
this.pendingJournalBatch = [...batch, ...this.pendingJournalBatch];
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
try {
|
|
1116
|
+
const records = batch.map((mutation) => this.pendingMutationRecord(mutation));
|
|
1117
|
+
const database = this.database;
|
|
1118
|
+
if (database.saveTransactions) {
|
|
1119
|
+
await database.saveTransactions(records);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
await Promise.all(records.map((record) => database.saveTransaction(record)));
|
|
1123
|
+
}
|
|
1124
|
+
for (const mutation of batch)
|
|
1125
|
+
mutation.resolveJournal?.();
|
|
1126
|
+
}
|
|
1127
|
+
catch (error) {
|
|
1128
|
+
for (const mutation of batch)
|
|
1129
|
+
mutation.rejectJournal?.(error);
|
|
1130
|
+
}
|
|
1131
|
+
finally {
|
|
1132
|
+
for (const mutation of batch) {
|
|
1133
|
+
mutation.resolveJournal = undefined;
|
|
1134
|
+
mutation.rejectJournal = undefined;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
972
1138
|
syncScheduled = false;
|
|
973
1139
|
scheduleSync() {
|
|
974
1140
|
if (this.syncScheduled)
|
|
@@ -979,7 +1145,6 @@ export class SyncClient extends EventEmitter {
|
|
|
979
1145
|
: (cb) => Promise.resolve().then(cb);
|
|
980
1146
|
schedule(() => {
|
|
981
1147
|
this.syncScheduled = false;
|
|
982
|
-
void this.persistMutationQueue();
|
|
983
1148
|
if (getContext().onlineStatus.isOnline()) {
|
|
984
1149
|
this.processPendingMutations().catch((err) => {
|
|
985
1150
|
getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -987,34 +1152,37 @@ export class SyncClient extends EventEmitter {
|
|
|
987
1152
|
}
|
|
988
1153
|
});
|
|
989
1154
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
async persistMutationQueue() {
|
|
994
|
-
if (!this.database || !this.userId)
|
|
995
|
-
return;
|
|
996
|
-
try {
|
|
997
|
-
const serializedMutations = this.pendingMutations.map((m) => ({
|
|
998
|
-
type: m.type,
|
|
999
|
-
modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
|
|
1000
|
-
modelName: m.model.getModelName(),
|
|
1001
|
-
timestamp: m.timestamp.toISOString(),
|
|
1002
|
-
writeOptions: m.writeOptions,
|
|
1003
|
-
}));
|
|
1004
|
-
await this.database.saveTransaction({
|
|
1005
|
-
id: 'mutation-queue',
|
|
1006
|
-
type: 'queue',
|
|
1007
|
-
mutations: serializedMutations,
|
|
1008
|
-
timestamp: Date.now(),
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
catch (error) {
|
|
1012
|
-
// Best-effort persistence — the in-memory queue still processes; only
|
|
1013
|
-
// a tab close before reconnect loses these. Forensic → debug.
|
|
1014
|
-
getContext().logger.debug('[SyncClient] Failed to persist offline mutation queue', {
|
|
1015
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1016
|
-
});
|
|
1155
|
+
pendingMutationRecord(mutation) {
|
|
1156
|
+
if (!this.userId || !this.organizationId) {
|
|
1157
|
+
throw new AbloValidationError('Cannot persist a mutation before participant scope is initialized', { code: 'write_options_invalid' });
|
|
1017
1158
|
}
|
|
1159
|
+
return pendingMutationRecordSchema.parse({
|
|
1160
|
+
id: pendingMutationRecordId(mutation.mutationId),
|
|
1161
|
+
type: 'pending_mutation',
|
|
1162
|
+
storageVersion: 2,
|
|
1163
|
+
mutation: {
|
|
1164
|
+
mutationId: mutation.mutationId,
|
|
1165
|
+
type: mutation.type,
|
|
1166
|
+
modelData: mutation.modelData,
|
|
1167
|
+
modelName: mutation.model.getModelName(),
|
|
1168
|
+
timestamp: mutation.timestamp.toISOString(),
|
|
1169
|
+
...(mutation.capturedChanges !== undefined
|
|
1170
|
+
? { capturedChanges: mutation.capturedChanges }
|
|
1171
|
+
: {}),
|
|
1172
|
+
...(mutation.writeOptions !== undefined
|
|
1173
|
+
? { writeOptions: mutation.writeOptions }
|
|
1174
|
+
: {}),
|
|
1175
|
+
},
|
|
1176
|
+
scope: {
|
|
1177
|
+
organizationId: this.organizationId,
|
|
1178
|
+
participantId: this.userId,
|
|
1179
|
+
namespace: this.commitOutboxNamespace,
|
|
1180
|
+
},
|
|
1181
|
+
timestamp: mutation.timestamp.getTime(),
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
async persistPendingMutation(mutation) {
|
|
1185
|
+
await this.database.saveTransaction(this.pendingMutationRecord(mutation));
|
|
1018
1186
|
}
|
|
1019
1187
|
/**
|
|
1020
1188
|
* Restore the mutation queue from IndexedDB.
|
|
@@ -1025,32 +1193,95 @@ export class SyncClient extends EventEmitter {
|
|
|
1025
1193
|
* never swallowed silently, because the survival of offline writes must be
|
|
1026
1194
|
* observable.
|
|
1027
1195
|
*/
|
|
1028
|
-
async restoreMutationQueue() {
|
|
1196
|
+
async restoreMutationQueue(sealedMutationIds = new Set()) {
|
|
1029
1197
|
if (!this.database || !this.userId)
|
|
1030
1198
|
return;
|
|
1031
1199
|
try {
|
|
1032
1200
|
const stored = await this.database.getPersistedTransactions();
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1201
|
+
const restoredMutationIds = new Set();
|
|
1202
|
+
let heldForReview = 0;
|
|
1203
|
+
const restore = async (mutation, migrateLegacy, legacyMutationId) => {
|
|
1204
|
+
const parsed = persistedMutationSchema.safeParse(mutation);
|
|
1205
|
+
if (!parsed.success) {
|
|
1206
|
+
getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
|
|
1207
|
+
issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
|
|
1208
|
+
});
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
// The window is anchored to when the write was made, because a
|
|
1212
|
+
// record re-sealed on restore would otherwise reset its own expiry
|
|
1213
|
+
// clock. An unparseable timestamp is held rather than replayed.
|
|
1214
|
+
const writtenAt = Date.parse(parsed.data.timestamp);
|
|
1215
|
+
const age = Date.now() - writtenAt;
|
|
1216
|
+
if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
|
|
1217
|
+
heldForReview += 1;
|
|
1218
|
+
getContext().logger.warn('A saved local write is older than the server idempotency window and was held for review.');
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
|
|
1222
|
+
if (sealedMutationIds.has(mutationId) ||
|
|
1223
|
+
restoredMutationIds.has(mutationId))
|
|
1224
|
+
return;
|
|
1225
|
+
const model = this.objectPool.createFromData(parsed.data.modelData);
|
|
1226
|
+
if (model) {
|
|
1227
|
+
const pending = {
|
|
1228
|
+
mutationId,
|
|
1229
|
+
type: parsed.data.type,
|
|
1230
|
+
model,
|
|
1231
|
+
modelData: parsed.data.modelData,
|
|
1232
|
+
timestamp: new Date(parsed.data.timestamp),
|
|
1233
|
+
...(parsed.data.capturedChanges !== undefined
|
|
1234
|
+
? { capturedChanges: parsed.data.capturedChanges }
|
|
1235
|
+
: {}),
|
|
1236
|
+
...(parsed.data.writeOptions !== undefined
|
|
1237
|
+
? { writeOptions: parsed.data.writeOptions }
|
|
1238
|
+
: {}),
|
|
1239
|
+
journaled: Promise.resolve(),
|
|
1240
|
+
// Restored mutations have no live `wait: 'confirmed'` caller, so
|
|
1241
|
+
// their staging needs no waiter handshake.
|
|
1242
|
+
staged: Promise.resolve(),
|
|
1243
|
+
};
|
|
1244
|
+
if (migrateLegacy) {
|
|
1245
|
+
pending.journaled = this.persistPendingMutation(pending);
|
|
1246
|
+
await pending.journaled;
|
|
1042
1247
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1248
|
+
this.pendingMutations.push(pending);
|
|
1249
|
+
restoredMutationIds.add(mutationId);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
for (const row of stored) {
|
|
1253
|
+
if (row.type !== 'pending_mutation')
|
|
1254
|
+
continue;
|
|
1255
|
+
const parsed = pendingMutationRecordSchema.safeParse(row);
|
|
1256
|
+
if (!parsed.success) {
|
|
1257
|
+
const legacy = legacyPendingMutationRecordSchema.safeParse(row);
|
|
1258
|
+
if (legacy.success) {
|
|
1259
|
+
await restore(legacy.data.mutation, true);
|
|
1260
|
+
continue;
|
|
1053
1261
|
}
|
|
1262
|
+
getContext().logger.debug('[SyncClient] Dropping malformed pending mutation record', {
|
|
1263
|
+
rowId: row.id,
|
|
1264
|
+
});
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (parsed.data.scope.organizationId !== this.organizationId ||
|
|
1268
|
+
parsed.data.scope.participantId !== this.userId ||
|
|
1269
|
+
parsed.data.scope.namespace !== this.commitOutboxNamespace) {
|
|
1270
|
+
getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
await restore(parsed.data.mutation, false);
|
|
1274
|
+
}
|
|
1275
|
+
const legacyQueue = stored.find((row) => row.id === 'mutation-queue');
|
|
1276
|
+
if (legacyQueue?.mutations) {
|
|
1277
|
+
const heldBefore = heldForReview;
|
|
1278
|
+
for (const [index, mutation] of legacyQueue.mutations.entries()) {
|
|
1279
|
+
await restore(mutation, true, `legacy_mutation_${index}`);
|
|
1280
|
+
}
|
|
1281
|
+
// Deleting the legacy row would discard any entry held for review, so
|
|
1282
|
+
// it is only removed once every entry has migrated.
|
|
1283
|
+
if (heldForReview === heldBefore) {
|
|
1284
|
+
await this.database.removeTransaction('mutation-queue');
|
|
1054
1285
|
}
|
|
1055
1286
|
}
|
|
1056
1287
|
}
|
|
@@ -1102,17 +1333,45 @@ export class SyncClient extends EventEmitter {
|
|
|
1102
1333
|
return; // Skip if offline
|
|
1103
1334
|
if (this.isDisposed)
|
|
1104
1335
|
return; // Skip if disposed
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
// the
|
|
1336
|
+
if (this.stagedMutationIds.size > 0)
|
|
1337
|
+
return;
|
|
1338
|
+
const mutations = this.pendingMutations.filter((mutation) => !this.stagedMutationIds.has(mutation.mutationId)).slice(0, 500);
|
|
1339
|
+
if (mutations.length === 0)
|
|
1340
|
+
return;
|
|
1341
|
+
// Claim the batch BEFORE awaiting the journal. This method runs
|
|
1342
|
+
// concurrently — the scheduleSync microtask and a direct syncNow() caller
|
|
1343
|
+
// land in the same tick — and both would otherwise capture this same
|
|
1344
|
+
// batch, suspend on the identical `journaled` promises, and stage every
|
|
1345
|
+
// mutation twice (two transactions on the wire for one write). Claiming
|
|
1346
|
+
// synchronously makes the second caller hit the guard above and return.
|
|
1111
1347
|
for (const mutation of mutations) {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1348
|
+
this.stagedMutationIds.add(mutation.mutationId);
|
|
1349
|
+
}
|
|
1350
|
+
// A journal rejection is permanent for that mutation (fail-closed: it can
|
|
1351
|
+
// never dispatch without its durable record), so drop it rather than
|
|
1352
|
+
// leaving it queued to poison every later pass. Healthy batch members
|
|
1353
|
+
// still stage.
|
|
1354
|
+
const journalResults = await Promise.allSettled(mutations.map((mutation) => mutation.journaled));
|
|
1355
|
+
const journaledMutations = [];
|
|
1356
|
+
journalResults.forEach((result, index) => {
|
|
1357
|
+
const mutation = mutations[index];
|
|
1358
|
+
if (result.status === 'fulfilled') {
|
|
1359
|
+
journaledMutations.push(mutation);
|
|
1360
|
+
return;
|
|
1115
1361
|
}
|
|
1362
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1363
|
+
this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
|
|
1364
|
+
mutation.rejectStaged?.(result.reason);
|
|
1365
|
+
getContext().observability.captureTransactionFailure({
|
|
1366
|
+
context: 'persist-pending-mutation',
|
|
1367
|
+
error: result.reason instanceof Error
|
|
1368
|
+
? result.reason
|
|
1369
|
+
: new Error(String(result.reason)),
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
// Stage every mutation synchronously within the same event-loop tick;
|
|
1373
|
+
// the transaction queue's microtask batches and sends them together.
|
|
1374
|
+
for (const mutation of journaledMutations) {
|
|
1116
1375
|
// Stage synchronously - TransactionQueue handles batching, retry, and errors
|
|
1117
1376
|
this.stageMutation(mutation);
|
|
1118
1377
|
}
|
|
@@ -1123,8 +1382,12 @@ export class SyncClient extends EventEmitter {
|
|
|
1123
1382
|
* @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
|
|
1124
1383
|
*/
|
|
1125
1384
|
stageMutation(mutation) {
|
|
1126
|
-
if (!this.userId || !this.organizationId)
|
|
1385
|
+
if (!this.userId || !this.organizationId) {
|
|
1386
|
+
// Nothing will stage this call; settle the waiter with the legacy
|
|
1387
|
+
// "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
|
|
1388
|
+
mutation.resolveStaged?.();
|
|
1127
1389
|
return;
|
|
1390
|
+
}
|
|
1128
1391
|
const ctx = { userId: this.userId, organizationId: this.organizationId };
|
|
1129
1392
|
// Settlement is delivered via transaction.confirmation, not this promise —
|
|
1130
1393
|
// it only rejects when staging itself throws (change extraction, optimistic
|
|
@@ -1137,16 +1400,17 @@ export class SyncClient extends EventEmitter {
|
|
|
1137
1400
|
modelId: mutation.model.id,
|
|
1138
1401
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
1139
1402
|
});
|
|
1403
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1140
1404
|
};
|
|
1141
|
-
|
|
1142
|
-
this.transactionQueue
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1405
|
+
const staging = mutation.type === 'update'
|
|
1406
|
+
? this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
|
|
1407
|
+
: this.transactionQueue[mutation.type].bind(this.transactionQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
|
|
1408
|
+
staging
|
|
1409
|
+
.then(() => mutation.resolveStaged?.())
|
|
1410
|
+
.catch((error) => {
|
|
1411
|
+
captureStagingFailure(error);
|
|
1412
|
+
mutation.rejectStaged?.(error);
|
|
1413
|
+
});
|
|
1150
1414
|
}
|
|
1151
1415
|
/**
|
|
1152
1416
|
* Resolve a conflict between the local model and incoming server data,
|
|
@@ -1364,6 +1628,16 @@ export class SyncClient extends EventEmitter {
|
|
|
1364
1628
|
*/
|
|
1365
1629
|
markConnected() {
|
|
1366
1630
|
this.setConnectionState('connected');
|
|
1631
|
+
// Browser online state may have marked the client connected before the
|
|
1632
|
+
// WebSocket itself was ready. Always kick both durable lanes on the real
|
|
1633
|
+
// socket event, even when the high-level state did not change.
|
|
1634
|
+
void this.transactionQueue.flushOfflineQueue().catch((error) => {
|
|
1635
|
+
getContext().observability.captureTransactionFailure({
|
|
1636
|
+
context: 'restore-commit-outbox',
|
|
1637
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
void this.processPendingMutations();
|
|
1367
1641
|
}
|
|
1368
1642
|
/**
|
|
1369
1643
|
* Dispose and cleanup
|
|
@@ -1416,7 +1690,14 @@ export class SyncClient extends EventEmitter {
|
|
|
1416
1690
|
* Force sync now - process pending mutations
|
|
1417
1691
|
*/
|
|
1418
1692
|
async syncNow() {
|
|
1693
|
+
// Snapshot before draining: a concurrent drain may already have claimed
|
|
1694
|
+
// this caller's write, in which case processPendingMutations returns
|
|
1695
|
+
// without staging anything. `wait: 'confirmed'` resolves on finding no
|
|
1696
|
+
// in-flight work, so it must not run until every write queued before this
|
|
1697
|
+
// call has a real transaction in the queue or was definitively dropped.
|
|
1698
|
+
const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
|
|
1419
1699
|
await this.processPendingMutations();
|
|
1700
|
+
await Promise.allSettled(queuedBeforeCall);
|
|
1420
1701
|
}
|
|
1421
1702
|
/**
|
|
1422
1703
|
* Get sync statistics. Return type is inferred from the literal so
|
|
@@ -1479,6 +1760,18 @@ export class SyncClient extends EventEmitter {
|
|
|
1479
1760
|
*/
|
|
1480
1761
|
onLocalTransaction(listener) {
|
|
1481
1762
|
this.transactionQueue.on('transaction:created', listener);
|
|
1763
|
+
const snapshotsByCommit = new Map();
|
|
1764
|
+
const onCommitStaging = (payload) => {
|
|
1765
|
+
snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
|
|
1766
|
+
if (operation.type === 'CREATE')
|
|
1767
|
+
return undefined;
|
|
1768
|
+
const resident = this.objectPool.get(operation.id);
|
|
1769
|
+
return resident?.toJSON();
|
|
1770
|
+
}));
|
|
1771
|
+
};
|
|
1772
|
+
const onCommitSealFailed = (payload) => {
|
|
1773
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1774
|
+
};
|
|
1482
1775
|
// Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
|
|
1483
1776
|
// their own `commit:created` event: they have no optimistic pool apply,
|
|
1484
1777
|
// so they must not feed the echo tracker's `transaction:created` path.
|
|
@@ -1486,6 +1779,8 @@ export class SyncClient extends EventEmitter {
|
|
|
1486
1779
|
// (the queue is pool-free) and hand the synthesized transaction to the
|
|
1487
1780
|
// same listener, so undo observes every write door — one stream.
|
|
1488
1781
|
const onCommitCreated = (payload) => {
|
|
1782
|
+
const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
|
|
1783
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1489
1784
|
const TYPE_BY_WIRE = {
|
|
1490
1785
|
CREATE: 'create',
|
|
1491
1786
|
UPDATE: 'update',
|
|
@@ -1497,8 +1792,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1497
1792
|
const type = TYPE_BY_WIRE[op.type];
|
|
1498
1793
|
if (!type || !op.id)
|
|
1499
1794
|
return;
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1795
|
+
const snapshot = type === 'create'
|
|
1796
|
+
? undefined
|
|
1797
|
+
: stagedSnapshots
|
|
1798
|
+
? stagedSnapshots[index]
|
|
1799
|
+
: this.objectPool.get(op.id)?.toJSON();
|
|
1502
1800
|
// A DELETE of a row the local graph never saw is not invertible —
|
|
1503
1801
|
// recording it would make undo "restore" an empty husk. Skip it.
|
|
1504
1802
|
if (type === 'delete' && !snapshot)
|
|
@@ -1529,10 +1827,15 @@ export class SyncClient extends EventEmitter {
|
|
|
1529
1827
|
});
|
|
1530
1828
|
});
|
|
1531
1829
|
};
|
|
1830
|
+
this.transactionQueue.on('commit:staging', onCommitStaging);
|
|
1831
|
+
this.transactionQueue.on('commit:seal_failed', onCommitSealFailed);
|
|
1532
1832
|
this.transactionQueue.on('commit:created', onCommitCreated);
|
|
1533
1833
|
return () => {
|
|
1534
1834
|
this.transactionQueue.off('transaction:created', listener);
|
|
1835
|
+
this.transactionQueue.off('commit:staging', onCommitStaging);
|
|
1836
|
+
this.transactionQueue.off('commit:seal_failed', onCommitSealFailed);
|
|
1535
1837
|
this.transactionQueue.off('commit:created', onCommitCreated);
|
|
1838
|
+
snapshotsByCommit.clear();
|
|
1536
1839
|
};
|
|
1537
1840
|
}
|
|
1538
1841
|
/**
|
|
@@ -17,6 +17,7 @@ export declare class InMemoryObjectStore implements ObjectStoreContract {
|
|
|
17
17
|
private data;
|
|
18
18
|
private indexes;
|
|
19
19
|
constructor(modelName: string, storeName: string, indexNames?: string[]);
|
|
20
|
+
add(record: Record<string, unknown>): Promise<void>;
|
|
20
21
|
put(record: Record<string, unknown>): Promise<void>;
|
|
21
22
|
get(id: string): Promise<Record<string, unknown> | undefined>;
|
|
22
23
|
getAll(): Promise<Record<string, unknown>[]>;
|
|
@@ -22,6 +22,18 @@ export class InMemoryObjectStore {
|
|
|
22
22
|
this.indexes.set(name, new Map());
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
async add(record) {
|
|
26
|
+
const id = record.id;
|
|
27
|
+
if (!id)
|
|
28
|
+
throw new TypeError('In-memory record must carry an id');
|
|
29
|
+
if (this.data.has(id)) {
|
|
30
|
+
const error = new Error(`Record already exists: ${id}`);
|
|
31
|
+
error.name = 'ConstraintError';
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
this.data.set(id, { ...record });
|
|
35
|
+
this.addToIndexes(id, record);
|
|
36
|
+
}
|
|
25
37
|
async put(record) {
|
|
26
38
|
const id = record.id;
|
|
27
39
|
if (!id)
|