@stratasync/client 0.2.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/README.md +76 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +759 -0
- package/dist/client.js.map +1 -0
- package/dist/history-manager.d.ts +45 -0
- package/dist/history-manager.d.ts.map +1 -0
- package/dist/history-manager.js +266 -0
- package/dist/history-manager.js.map +1 -0
- package/dist/identity-map.d.ts +127 -0
- package/dist/identity-map.d.ts.map +1 -0
- package/dist/identity-map.js +295 -0
- package/dist/identity-map.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox-manager.d.ts +122 -0
- package/dist/outbox-manager.d.ts.map +1 -0
- package/dist/outbox-manager.js +373 -0
- package/dist/outbox-manager.js.map +1 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +36 -0
- package/dist/query.js.map +1 -0
- package/dist/sync-orchestrator.d.ts +208 -0
- package/dist/sync-orchestrator.d.ts.map +1 -0
- package/dist/sync-orchestrator.js +1287 -0
- package/dist/sync-orchestrator.js.map +1 -0
- package/dist/types.d.ts +309 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +26 -0
- package/dist/utils.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
import { applyDeltas, createArchivePayload, createUnarchivePatch, getOrCreateClientId, isSyncIdGreaterThan, ModelRegistry, readArchivedAt, rebaseTransactions, ZERO_SYNC_ID, } from "@stratasync/core";
|
|
2
|
+
import { getModelKey } from "./utils.js";
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrates the sync state machine
|
|
5
|
+
*/
|
|
6
|
+
export class SyncOrchestrator {
|
|
7
|
+
storage;
|
|
8
|
+
transport;
|
|
9
|
+
identityMaps;
|
|
10
|
+
outboxManager = null;
|
|
11
|
+
options;
|
|
12
|
+
registry;
|
|
13
|
+
_state = "disconnected";
|
|
14
|
+
_connectionState = "disconnected";
|
|
15
|
+
stateListeners = new Set();
|
|
16
|
+
connectionListeners = new Set();
|
|
17
|
+
schemaHash;
|
|
18
|
+
clientId = "";
|
|
19
|
+
lastSyncId = ZERO_SYNC_ID;
|
|
20
|
+
lastError = null;
|
|
21
|
+
firstSyncId = ZERO_SYNC_ID;
|
|
22
|
+
groups = [];
|
|
23
|
+
deltaSubscription = null;
|
|
24
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
25
|
+
deltaPacketQueue = Promise.resolve();
|
|
26
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
27
|
+
deltaReplayBarrier = Promise.resolve();
|
|
28
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
29
|
+
stateUpdateLock = Promise.resolve();
|
|
30
|
+
running = false;
|
|
31
|
+
runToken = 0;
|
|
32
|
+
emitEvent;
|
|
33
|
+
onTransactionConflict;
|
|
34
|
+
/** Conflict rollbacks deferred until the identity map batch. */
|
|
35
|
+
deferredConflictTxs = [];
|
|
36
|
+
constructor(options, identityMaps, emitEvent) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.storage = options.storage;
|
|
39
|
+
this.transport = options.transport;
|
|
40
|
+
this.identityMaps = identityMaps;
|
|
41
|
+
this.registry = new ModelRegistry(options.schema ?? ModelRegistry.snapshot());
|
|
42
|
+
this.schemaHash = this.registry.getSchemaHash();
|
|
43
|
+
this.groups = options.groups ?? [];
|
|
44
|
+
this.emitEvent = emitEvent;
|
|
45
|
+
// Listen for transport connection changes
|
|
46
|
+
this.transport.onConnectionStateChange((state) => {
|
|
47
|
+
const previousState = this._connectionState;
|
|
48
|
+
this.setConnectionState(state);
|
|
49
|
+
this.handleConnectionChange(previousState, state);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
setOutboxManager(outboxManager) {
|
|
53
|
+
this.outboxManager = outboxManager;
|
|
54
|
+
}
|
|
55
|
+
setConflictHandler(handler) {
|
|
56
|
+
this.onTransactionConflict = handler;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Gets the current sync state
|
|
60
|
+
*/
|
|
61
|
+
get state() {
|
|
62
|
+
return this._state;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Gets the current connection state
|
|
66
|
+
*/
|
|
67
|
+
get connectionState() {
|
|
68
|
+
return this._connectionState;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Gets the client ID
|
|
72
|
+
*/
|
|
73
|
+
getClientId() {
|
|
74
|
+
return this.clientId;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Gets the last sync ID
|
|
78
|
+
*/
|
|
79
|
+
getLastSyncId() {
|
|
80
|
+
return this.lastSyncId;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Gets the first sync ID from the last full bootstrap
|
|
84
|
+
*/
|
|
85
|
+
getFirstSyncId() {
|
|
86
|
+
return this.firstSyncId;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Gets the last error
|
|
90
|
+
*/
|
|
91
|
+
getLastError() {
|
|
92
|
+
return this.lastError;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Starts the sync orchestrator
|
|
96
|
+
*/
|
|
97
|
+
async start() {
|
|
98
|
+
if (this.running) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.running = true;
|
|
102
|
+
this.runToken += 1;
|
|
103
|
+
const activeRunToken = this.runToken;
|
|
104
|
+
this.emitEvent?.({ type: "syncStart" });
|
|
105
|
+
this.setState("connecting");
|
|
106
|
+
try {
|
|
107
|
+
await this.openStorage();
|
|
108
|
+
if (!this.isRunActive(activeRunToken)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const meta = await this.loadMetadata();
|
|
112
|
+
if (!this.isRunActive(activeRunToken)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await this.configureGroups(meta);
|
|
116
|
+
if (!this.isRunActive(activeRunToken)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await this.bootstrapIfNeeded(meta, activeRunToken);
|
|
120
|
+
if (!this.isRunActive(activeRunToken)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await this.applyPendingOutboxTransactions();
|
|
124
|
+
if (!this.isRunActive(activeRunToken)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Local data is ready — mark as syncing so the UI can render
|
|
128
|
+
// cached content immediately without waiting for network ops.
|
|
129
|
+
this.setState("syncing");
|
|
130
|
+
// Network operations run in background, don't block start()
|
|
131
|
+
const subscribeAfterSyncId = this.lastSyncId;
|
|
132
|
+
this.startDeltaSubscription(subscribeAfterSyncId);
|
|
133
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
134
|
+
this.catchUpMissedDeltas(subscribeAfterSyncId, activeRunToken).catch(
|
|
135
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
136
|
+
(error) => {
|
|
137
|
+
if (this.isRunActive(activeRunToken)) {
|
|
138
|
+
this.handleSyncError(error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// oxlint-disable-next-line prefer-await-to-then, prefer-await-to-callbacks -- fire-and-forget error handler
|
|
142
|
+
this.processOutboxTransactions().catch((error) => {
|
|
143
|
+
if (this.isRunActive(activeRunToken)) {
|
|
144
|
+
this.handleSyncError(error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.handleSyncError(error);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async openStorage() {
|
|
154
|
+
await this.storage.open({
|
|
155
|
+
name: this.options.dbName,
|
|
156
|
+
schema: this.options.schema ?? this.registry.snapshot(),
|
|
157
|
+
userId: this.options.userId,
|
|
158
|
+
userVersion: this.options.userVersion,
|
|
159
|
+
version: this.options.version,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async loadMetadata() {
|
|
163
|
+
const meta = await this.storage.getMeta();
|
|
164
|
+
this.clientId =
|
|
165
|
+
meta.clientId ??
|
|
166
|
+
getOrCreateClientId(`${this.options.dbName ?? "sync-db"}_client_id`);
|
|
167
|
+
this.lastSyncId = meta.lastSyncId ?? ZERO_SYNC_ID;
|
|
168
|
+
this.firstSyncId = meta.firstSyncId ?? this.lastSyncId;
|
|
169
|
+
return meta;
|
|
170
|
+
}
|
|
171
|
+
async configureGroups(meta) {
|
|
172
|
+
const storedGroups = meta.subscribedSyncGroups ?? [];
|
|
173
|
+
const configuredGroups = this.options.groups ?? storedGroups;
|
|
174
|
+
this.groups = configuredGroups.length > 0 ? configuredGroups : storedGroups;
|
|
175
|
+
if (SyncOrchestrator.areGroupsEqual(storedGroups, this.groups)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.firstSyncId = this.lastSyncId;
|
|
179
|
+
await this.storage.setMeta({
|
|
180
|
+
firstSyncId: this.firstSyncId,
|
|
181
|
+
subscribedSyncGroups: this.groups,
|
|
182
|
+
updatedAt: Date.now(),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async bootstrapIfNeeded(meta, runToken) {
|
|
186
|
+
const needsBootstrap = await this.shouldBootstrap(meta);
|
|
187
|
+
if (!needsBootstrap) {
|
|
188
|
+
await this.hydrateIdentityMaps(runToken);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await this.runBootstrapStrategy(runToken);
|
|
192
|
+
}
|
|
193
|
+
async shouldBootstrap(meta) {
|
|
194
|
+
const bootstrapModels = this.registry.getBootstrapModelNames();
|
|
195
|
+
const arePersisted = await this.areModelsPersisted(bootstrapModels);
|
|
196
|
+
const storedHash = meta.schemaHash ?? "";
|
|
197
|
+
// Treat an empty/missing hash as a mismatch — a valid bootstrap always
|
|
198
|
+
// writes the hash, so an empty value means prior state is corrupt.
|
|
199
|
+
const hasSchemaMismatch = storedHash.length === 0 || storedHash !== this.schemaHash;
|
|
200
|
+
return (meta.bootstrapComplete === false ||
|
|
201
|
+
hasSchemaMismatch ||
|
|
202
|
+
this.lastSyncId === ZERO_SYNC_ID ||
|
|
203
|
+
!arePersisted);
|
|
204
|
+
}
|
|
205
|
+
async runBootstrapStrategy(runToken) {
|
|
206
|
+
const bootstrapMode = this.options.bootstrapMode ?? "auto";
|
|
207
|
+
if (bootstrapMode === "local") {
|
|
208
|
+
await this.localBootstrap(runToken);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await this.bootstrap(runToken);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const canFallback = bootstrapMode === "auto" && (await this.hasLocalData());
|
|
216
|
+
if (canFallback) {
|
|
217
|
+
await this.localBootstrap(runToken);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async applyPendingOutboxTransactions() {
|
|
224
|
+
const pending = await this.getActiveOutboxTransactions();
|
|
225
|
+
await this.applyPendingTransactionsToIdentityMaps(pending);
|
|
226
|
+
}
|
|
227
|
+
async processOutboxTransactions() {
|
|
228
|
+
if (!this.outboxManager) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await this.outboxManager.completeUpToSyncId(this.lastSyncId);
|
|
232
|
+
await this.outboxManager.processPendingTransactions();
|
|
233
|
+
await this.emitOutboxCount();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Best-effort catch-up for deltas created between bootstrap completion and
|
|
237
|
+
* subscription readiness.
|
|
238
|
+
*/
|
|
239
|
+
async catchUpMissedDeltas(afterSyncId, runToken) {
|
|
240
|
+
try {
|
|
241
|
+
await this.fetchAndApplyDeltaPages(afterSyncId, {
|
|
242
|
+
maxAttempts: 2,
|
|
243
|
+
runToken,
|
|
244
|
+
suppressFetchErrors: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (this.isRunActive(runToken)) {
|
|
249
|
+
this.handleSyncError(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async fetchAndApplyDeltaPages(afterSyncId, options = {}) {
|
|
254
|
+
let nextAfterSyncId = afterSyncId;
|
|
255
|
+
let releaseBarrier = null;
|
|
256
|
+
try {
|
|
257
|
+
while (true) {
|
|
258
|
+
const packet = await this.fetchDeltaPage(nextAfterSyncId, options);
|
|
259
|
+
if (!packet) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (options.runToken !== undefined &&
|
|
263
|
+
!this.isRunActive(options.runToken)) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (packet.hasMore && !releaseBarrier) {
|
|
267
|
+
releaseBarrier = this.acquireDeltaReplayBarrier();
|
|
268
|
+
}
|
|
269
|
+
await this.enqueueDeltaPacket(packet);
|
|
270
|
+
if (!packet.hasMore) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!isSyncIdGreaterThan(packet.lastSyncId, nextAfterSyncId)) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
nextAfterSyncId = packet.lastSyncId;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
releaseBarrier?.();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async fetchDeltaPage(afterSyncId, options) {
|
|
284
|
+
const maxAttempts = options.maxAttempts ?? 1;
|
|
285
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
286
|
+
if (options.runToken !== undefined &&
|
|
287
|
+
!this.isRunActive(options.runToken)) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
return await this.transport.fetchDeltas(afterSyncId, undefined, this.groups);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
const isLastAttempt = attempt >= maxAttempts - 1;
|
|
295
|
+
if (isLastAttempt ||
|
|
296
|
+
(options.runToken !== undefined &&
|
|
297
|
+
!this.isRunActive(options.runToken))) {
|
|
298
|
+
if (options.suppressFetchErrors) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
await SyncOrchestrator.wait(300 * (attempt + 1));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (options.suppressFetchErrors) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
static wait(ms) {
|
|
312
|
+
// oxlint-disable-next-line avoid-new -- wrapping callback API in promise
|
|
313
|
+
return new Promise((resolve) => {
|
|
314
|
+
setTimeout(resolve, ms);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
isRunActive(runToken) {
|
|
318
|
+
return this.running && this.runToken === runToken;
|
|
319
|
+
}
|
|
320
|
+
shouldAbortBootstrap(runToken) {
|
|
321
|
+
if (this.isRunActive(runToken)) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
this.identityMaps.clearAll();
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Stops the sync orchestrator
|
|
329
|
+
*/
|
|
330
|
+
async stop() {
|
|
331
|
+
await this.reset();
|
|
332
|
+
await this.storage.close();
|
|
333
|
+
}
|
|
334
|
+
async reset() {
|
|
335
|
+
this.running = false;
|
|
336
|
+
this.runToken += 1;
|
|
337
|
+
if (this.deltaSubscription) {
|
|
338
|
+
try {
|
|
339
|
+
await this.deltaSubscription.return?.();
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Best-effort close while resetting.
|
|
343
|
+
}
|
|
344
|
+
this.deltaSubscription = null;
|
|
345
|
+
}
|
|
346
|
+
await this.transport.close();
|
|
347
|
+
await this.deltaPacketQueue.catch(() => {
|
|
348
|
+
/* noop */
|
|
349
|
+
});
|
|
350
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
351
|
+
this.deltaPacketQueue = Promise.resolve();
|
|
352
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
353
|
+
this.deltaReplayBarrier = Promise.resolve();
|
|
354
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
355
|
+
this.stateUpdateLock = Promise.resolve();
|
|
356
|
+
this.deferredConflictTxs = [];
|
|
357
|
+
this.clientId = "";
|
|
358
|
+
this.lastSyncId = ZERO_SYNC_ID;
|
|
359
|
+
this.firstSyncId = ZERO_SYNC_ID;
|
|
360
|
+
this.groups = this.options.groups ?? [];
|
|
361
|
+
this.lastError = null;
|
|
362
|
+
this.setConnectionState("disconnected");
|
|
363
|
+
this.setState("disconnected");
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Forces an immediate sync
|
|
367
|
+
*/
|
|
368
|
+
async syncNow() {
|
|
369
|
+
await this.fetchAndApplyDeltaPages(this.lastSyncId);
|
|
370
|
+
// Process pending outbox
|
|
371
|
+
if (this.outboxManager) {
|
|
372
|
+
await this.outboxManager.processPendingTransactions();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Subscribes to state changes
|
|
377
|
+
*/
|
|
378
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
379
|
+
onStateChange(callback) {
|
|
380
|
+
this.stateListeners.add(callback);
|
|
381
|
+
return () => {
|
|
382
|
+
this.stateListeners.delete(callback);
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Subscribes to connection state changes
|
|
387
|
+
*/
|
|
388
|
+
onConnectionStateChange(
|
|
389
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
390
|
+
callback) {
|
|
391
|
+
this.connectionListeners.add(callback);
|
|
392
|
+
return () => {
|
|
393
|
+
this.connectionListeners.delete(callback);
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
async runWithStateLock(operation) {
|
|
397
|
+
const previous = this.stateUpdateLock;
|
|
398
|
+
let releaseCurrent;
|
|
399
|
+
// oxlint-disable-next-line avoid-new -- wrapping callback API in promise
|
|
400
|
+
const current = new Promise((resolve) => {
|
|
401
|
+
releaseCurrent = resolve;
|
|
402
|
+
});
|
|
403
|
+
this.stateUpdateLock = (async () => {
|
|
404
|
+
try {
|
|
405
|
+
await previous;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* noop */
|
|
409
|
+
}
|
|
410
|
+
await current;
|
|
411
|
+
})();
|
|
412
|
+
try {
|
|
413
|
+
await previous;
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
/* noop */
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
return await operation();
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
releaseCurrent?.();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Performs initial bootstrap
|
|
427
|
+
*/
|
|
428
|
+
async bootstrap(runToken) {
|
|
429
|
+
this.setState("bootstrapping");
|
|
430
|
+
// Clear existing model/meta state but keep outbox transactions so
|
|
431
|
+
// unsynced mutations survive a full bootstrap on restart.
|
|
432
|
+
await this.storage.clear({ preserveOutbox: true });
|
|
433
|
+
this.identityMaps.clearAll();
|
|
434
|
+
if (this.shouldAbortBootstrap(runToken)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// Stream bootstrap data
|
|
438
|
+
const iterator = this.transport.bootstrap({
|
|
439
|
+
onlyModels: this.registry.getBootstrapModelNames(),
|
|
440
|
+
schemaHash: this.schemaHash,
|
|
441
|
+
syncGroups: this.groups,
|
|
442
|
+
type: "full",
|
|
443
|
+
});
|
|
444
|
+
const metadata = await this.readBootstrapStream(iterator, runToken);
|
|
445
|
+
if (!metadata) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const databaseVersion = this.applyBootstrapMetadata(metadata);
|
|
449
|
+
const persisted = await this.markBootstrapModelsPersisted(runToken);
|
|
450
|
+
if (!persisted) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
await this.storage.setMeta({
|
|
454
|
+
bootstrapComplete: true,
|
|
455
|
+
databaseVersion,
|
|
456
|
+
firstSyncId: this.firstSyncId,
|
|
457
|
+
lastSyncAt: Date.now(),
|
|
458
|
+
lastSyncId: this.lastSyncId,
|
|
459
|
+
schemaHash: this.schemaHash,
|
|
460
|
+
subscribedSyncGroups: this.groups,
|
|
461
|
+
updatedAt: Date.now(),
|
|
462
|
+
});
|
|
463
|
+
this.emitEvent?.({ lastSyncId: this.lastSyncId, type: "syncComplete" });
|
|
464
|
+
}
|
|
465
|
+
async readBootstrapStream(iterator, runToken) {
|
|
466
|
+
while (true) {
|
|
467
|
+
const { value, done } = await iterator.next();
|
|
468
|
+
if (this.shouldAbortBootstrap(runToken)) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (done) {
|
|
472
|
+
if (!value) {
|
|
473
|
+
throw new Error("Bootstrap completed without metadata");
|
|
474
|
+
}
|
|
475
|
+
return value;
|
|
476
|
+
}
|
|
477
|
+
await this.storeBootstrapRow(value);
|
|
478
|
+
if (this.shouldAbortBootstrap(runToken)) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async storeBootstrapRow(row) {
|
|
484
|
+
const primaryKey = this.registry.getPrimaryKey(row.modelName);
|
|
485
|
+
const id = row.data[primaryKey];
|
|
486
|
+
if (typeof id !== "string") {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
await this.storage.put(row.modelName, row.data);
|
|
490
|
+
const map = this.identityMaps.getMap(row.modelName);
|
|
491
|
+
map.set(id, row.data);
|
|
492
|
+
}
|
|
493
|
+
applyBootstrapMetadata(metadata) {
|
|
494
|
+
if (metadata.lastSyncId === undefined) {
|
|
495
|
+
throw new Error("Bootstrap metadata is missing lastSyncId");
|
|
496
|
+
}
|
|
497
|
+
this.lastSyncId = metadata.lastSyncId;
|
|
498
|
+
this.firstSyncId = metadata.lastSyncId;
|
|
499
|
+
this.groups =
|
|
500
|
+
(metadata.subscribedSyncGroups?.length ?? 0) > 0
|
|
501
|
+
? metadata.subscribedSyncGroups
|
|
502
|
+
: this.groups;
|
|
503
|
+
return metadata.databaseVersion;
|
|
504
|
+
}
|
|
505
|
+
async markBootstrapModelsPersisted(runToken) {
|
|
506
|
+
for (const modelName of this.registry.getBootstrapModelNames()) {
|
|
507
|
+
await this.storage.setModelPersistence(modelName, true);
|
|
508
|
+
if (this.shouldAbortBootstrap(runToken)) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Performs a local-only bootstrap using existing storage data.
|
|
516
|
+
*/
|
|
517
|
+
async localBootstrap(runToken) {
|
|
518
|
+
this.setState("bootstrapping");
|
|
519
|
+
await this.hydrateIdentityMaps(runToken);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Checks whether any hydrated models exist in storage.
|
|
523
|
+
*/
|
|
524
|
+
async hasLocalData() {
|
|
525
|
+
for (const modelName of this.registry.getBootstrapModelNames()) {
|
|
526
|
+
const count = await this.storage.count(modelName);
|
|
527
|
+
if (count > 0) {
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Loads existing data from storage into identity maps
|
|
535
|
+
*/
|
|
536
|
+
async hydrateIdentityMaps(runToken) {
|
|
537
|
+
for (const modelName of this.registry.getBootstrapModelNames()) {
|
|
538
|
+
if (!this.isRunActive(runToken)) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const rows = await this.storage.getAll(modelName);
|
|
542
|
+
if (!this.isRunActive(runToken)) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const map = this.identityMaps.getMap(modelName);
|
|
546
|
+
const primaryKey = this.registry.getPrimaryKey(modelName);
|
|
547
|
+
for (const row of rows) {
|
|
548
|
+
if (!this.isRunActive(runToken)) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const id = row[primaryKey];
|
|
552
|
+
if (typeof id !== "string") {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
map.set(id, row);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async areModelsPersisted(modelNames) {
|
|
560
|
+
for (const modelName of modelNames) {
|
|
561
|
+
const persistence = await this.storage.getModelPersistence(modelName);
|
|
562
|
+
if (!persistence.persisted) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
static areGroupsEqual(a, b) {
|
|
569
|
+
if (a.length !== b.length) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
const setA = new Set(a);
|
|
573
|
+
if (setA.size !== b.length) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
for (const value of b) {
|
|
577
|
+
if (!setA.has(value)) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Starts the delta subscription
|
|
585
|
+
*/
|
|
586
|
+
startDeltaSubscription(afterSyncId = this.lastSyncId) {
|
|
587
|
+
const subscription = this.transport.subscribe({
|
|
588
|
+
afterSyncId,
|
|
589
|
+
groups: this.groups,
|
|
590
|
+
});
|
|
591
|
+
this.deltaSubscription = subscription[Symbol.asyncIterator]();
|
|
592
|
+
// Process deltas in background
|
|
593
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
594
|
+
this.processDeltaStream().catch(() => {
|
|
595
|
+
/* noop */
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
async restartDeltaSubscription(afterSyncId) {
|
|
599
|
+
const current = this.deltaSubscription;
|
|
600
|
+
this.deltaSubscription = null;
|
|
601
|
+
if (current) {
|
|
602
|
+
try {
|
|
603
|
+
await current.return?.();
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Best-effort close of the existing iterator.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
this.startDeltaSubscription(afterSyncId);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Processes the delta stream
|
|
613
|
+
*/
|
|
614
|
+
async processDeltaStream() {
|
|
615
|
+
if (!this.deltaSubscription) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
while (this.running) {
|
|
620
|
+
await this.deltaReplayBarrier;
|
|
621
|
+
const subscription = this.deltaSubscription;
|
|
622
|
+
if (!subscription) {
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
const { value, done } = await subscription.next();
|
|
626
|
+
if (done) {
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
await this.deltaReplayBarrier;
|
|
630
|
+
await this.enqueueDeltaPacket(value);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (this.running) {
|
|
635
|
+
this.handleSyncError(error);
|
|
636
|
+
// Try to reconnect
|
|
637
|
+
setTimeout(() => {
|
|
638
|
+
if (this.running) {
|
|
639
|
+
this.startDeltaSubscription();
|
|
640
|
+
}
|
|
641
|
+
}, 5000);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
enqueueDeltaPacket(packet) {
|
|
646
|
+
const previous = this.deltaPacketQueue;
|
|
647
|
+
const run = (async () => {
|
|
648
|
+
await previous;
|
|
649
|
+
if (!this.running) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await this.runWithStateLock(async () => {
|
|
653
|
+
await this.applyDeltaPacket(packet);
|
|
654
|
+
});
|
|
655
|
+
})();
|
|
656
|
+
// oxlint-disable-next-line prefer-await-to-then -- fire-and-forget pattern
|
|
657
|
+
this.deltaPacketQueue = run.catch(() => {
|
|
658
|
+
/* noop */
|
|
659
|
+
});
|
|
660
|
+
return run;
|
|
661
|
+
}
|
|
662
|
+
handleSyncError(error) {
|
|
663
|
+
this.lastError = error instanceof Error ? error : new Error(String(error));
|
|
664
|
+
this.emitEvent?.({ error: this.lastError, type: "syncError" });
|
|
665
|
+
this.setState("error");
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Applies a delta packet to local state.
|
|
669
|
+
*
|
|
670
|
+
* Identity map mutations are deferred and applied in a single batch at the
|
|
671
|
+
* end so that MobX observers never see an intermediate state where server
|
|
672
|
+
* data has been written but pending local (outbox) changes have not yet been
|
|
673
|
+
* re-applied.
|
|
674
|
+
*/
|
|
675
|
+
async applyDeltaPacket(packet) {
|
|
676
|
+
const latestAppliedSyncId = this.lastSyncId;
|
|
677
|
+
const nextActions = packet.actions.filter((action) => isSyncIdGreaterThan(action.id, latestAppliedSyncId));
|
|
678
|
+
const filteredPacket = {
|
|
679
|
+
...packet,
|
|
680
|
+
actions: nextActions,
|
|
681
|
+
};
|
|
682
|
+
if (filteredPacket.actions.length === 0) {
|
|
683
|
+
await this.handleEmptyPacket(filteredPacket);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
await this.storage.addSyncActions(filteredPacket.actions);
|
|
687
|
+
const activeTransactions = await this.getActiveOutboxTransactions();
|
|
688
|
+
// rebasePendingTransactions may detect conflicts and defer rollbacks
|
|
689
|
+
// into this.deferredConflictTxs (processed inside the batch below).
|
|
690
|
+
await this.rebasePendingTransactions(activeTransactions, filteredPacket.actions);
|
|
691
|
+
await this.handleCoverageActions(filteredPacket.actions);
|
|
692
|
+
await this.handleSyncGroupActions(filteredPacket.actions, filteredPacket.lastSyncId);
|
|
693
|
+
// Write to storage and collect identity map ops (no MobX reactions yet).
|
|
694
|
+
const deferredOps = [];
|
|
695
|
+
await this.collectDeferredDeltaOps(filteredPacket.actions, deferredOps);
|
|
696
|
+
const syncCursorAdvanced = await this.updateSyncMetadata(filteredPacket.lastSyncId);
|
|
697
|
+
await this.finishOutboxProcessing(filteredPacket.actions);
|
|
698
|
+
// Use the instance-local set of clientTxIds for echo suppression.
|
|
699
|
+
// Reading from shared storage (IndexedDB) would include cross-tab
|
|
700
|
+
// transactions, incorrectly suppressing identity map merges for them.
|
|
701
|
+
const localTxIds = this.outboxManager?.getLocalClientTxIds() ?? new Set();
|
|
702
|
+
const ownClientTxIds = SyncOrchestrator.buildOwnClientTxIds(filteredPacket.actions, localTxIds);
|
|
703
|
+
// Apply identity map changes in a single MobX action so observers
|
|
704
|
+
// only see the final state (server data + pending local changes).
|
|
705
|
+
// Deferred conflict rollbacks are processed here too — inside the
|
|
706
|
+
// batch — so their intermediate deletes are never visible.
|
|
707
|
+
const pending = await this.getActiveOutboxTransactions();
|
|
708
|
+
this.identityMaps.batch(() => {
|
|
709
|
+
// Process conflict rollbacks inside the batch. This ensures that
|
|
710
|
+
// the rollback's map.delete() and the subsequent server merge's
|
|
711
|
+
// map.merge() are in the same runInAction — microtask-scheduled
|
|
712
|
+
// refreshSync only fires after both have completed.
|
|
713
|
+
// Also remove rolled-back clientTxIds from ownClientTxIds so the
|
|
714
|
+
// server merge's modelChange event emits properly for the model.
|
|
715
|
+
for (const tx of this.deferredConflictTxs) {
|
|
716
|
+
this.onTransactionConflict?.(tx);
|
|
717
|
+
if (tx.clientTxId) {
|
|
718
|
+
ownClientTxIds.delete(tx.clientTxId);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
this.deferredConflictTxs = [];
|
|
722
|
+
for (const op of deferredOps) {
|
|
723
|
+
const map = this.identityMaps.getMap(op.modelName);
|
|
724
|
+
const isOwnOptimisticEcho = op.type === "merge" &&
|
|
725
|
+
typeof op.clientTxId === "string" &&
|
|
726
|
+
ownClientTxIds.has(op.clientTxId) &&
|
|
727
|
+
map.has(op.id);
|
|
728
|
+
if (isOwnOptimisticEcho) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (op.type === "merge" && op.data) {
|
|
732
|
+
map.merge(op.id, op.data);
|
|
733
|
+
}
|
|
734
|
+
else if (op.type === "delete") {
|
|
735
|
+
map.delete(op.id);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
this.applyPendingTransactionsToIdentityMaps(pending);
|
|
739
|
+
});
|
|
740
|
+
this.emitModelChangeEvents(filteredPacket.actions, ownClientTxIds);
|
|
741
|
+
await this.emitOutboxCount();
|
|
742
|
+
if (syncCursorAdvanced) {
|
|
743
|
+
this.emitEvent?.({ lastSyncId: this.lastSyncId, type: "syncComplete" });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async handleEmptyPacket(packet) {
|
|
747
|
+
const syncCursorAdvanced = await this.updateSyncMetadata(packet.lastSyncId);
|
|
748
|
+
if (this.outboxManager) {
|
|
749
|
+
await this.outboxManager.completeUpToSyncId(this.lastSyncId);
|
|
750
|
+
}
|
|
751
|
+
await this.emitOutboxCount();
|
|
752
|
+
if (syncCursorAdvanced) {
|
|
753
|
+
this.emitEvent?.({ lastSyncId: this.lastSyncId, type: "syncComplete" });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Build set of own-action keys for transactions that this local runtime
|
|
758
|
+
* already applied optimistically and then saw confirmed by the server.
|
|
759
|
+
*
|
|
760
|
+
* Uses the instance-local set of clientTxIds (in-memory, not from shared
|
|
761
|
+
* storage) so that cross-tab transactions sharing the same IndexedDB are
|
|
762
|
+
* not incorrectly treated as own optimistic echoes.
|
|
763
|
+
*/
|
|
764
|
+
static buildOwnClientTxIds(actions, localTxIds) {
|
|
765
|
+
const clientTxIds = new Set();
|
|
766
|
+
for (const action of actions) {
|
|
767
|
+
if (action.clientTxId && localTxIds.has(action.clientTxId)) {
|
|
768
|
+
clientTxIds.add(action.clientTxId);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return clientTxIds;
|
|
772
|
+
}
|
|
773
|
+
async getActiveOutboxTransactions() {
|
|
774
|
+
const outbox = await this.storage.getOutbox();
|
|
775
|
+
return outbox.filter((tx) => tx.state === "queued" ||
|
|
776
|
+
tx.state === "sent" ||
|
|
777
|
+
tx.state === "awaitingSync");
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Creates a delta target that writes to storage immediately but collects
|
|
781
|
+
* identity map operations for deferred application in a single batch.
|
|
782
|
+
*/
|
|
783
|
+
createDeferredDeltaTarget(ops, action) {
|
|
784
|
+
return {
|
|
785
|
+
delete: async (modelName, id) => {
|
|
786
|
+
await this.storage.delete(modelName, id);
|
|
787
|
+
ops.push({
|
|
788
|
+
clientTxId: action.clientTxId,
|
|
789
|
+
id,
|
|
790
|
+
modelName,
|
|
791
|
+
type: "delete",
|
|
792
|
+
});
|
|
793
|
+
},
|
|
794
|
+
get: (modelName, id) => this.storage.get(modelName, id),
|
|
795
|
+
patch: async (modelName, id, changes) => {
|
|
796
|
+
const existing = await this.storage.get(modelName, id);
|
|
797
|
+
const pk = this.registry.getPrimaryKey(modelName);
|
|
798
|
+
const updated = existing
|
|
799
|
+
? { ...existing, ...changes }
|
|
800
|
+
: { ...changes, [pk]: id };
|
|
801
|
+
await this.storage.put(modelName, updated);
|
|
802
|
+
ops.push({
|
|
803
|
+
clientTxId: action.clientTxId,
|
|
804
|
+
data: updated,
|
|
805
|
+
id,
|
|
806
|
+
modelName,
|
|
807
|
+
type: "merge",
|
|
808
|
+
});
|
|
809
|
+
},
|
|
810
|
+
put: async (modelName, id, data) => {
|
|
811
|
+
const pk = this.registry.getPrimaryKey(modelName);
|
|
812
|
+
const row = { ...data, [pk]: id };
|
|
813
|
+
await this.storage.put(modelName, row);
|
|
814
|
+
ops.push({
|
|
815
|
+
clientTxId: action.clientTxId,
|
|
816
|
+
data: row,
|
|
817
|
+
id,
|
|
818
|
+
modelName,
|
|
819
|
+
type: "merge",
|
|
820
|
+
});
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
async collectDeferredDeltaOps(actions, ops) {
|
|
825
|
+
for (const action of actions) {
|
|
826
|
+
const deferredTarget = this.createDeferredDeltaTarget(ops, action);
|
|
827
|
+
await applyDeltas({ actions: [action], lastSyncId: action.id }, deferredTarget, this.registry, { mergeUpdates: true });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async updateSyncMetadata(lastSyncId) {
|
|
831
|
+
if (!isSyncIdGreaterThan(lastSyncId, this.lastSyncId)) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
this.lastSyncId = lastSyncId;
|
|
835
|
+
await this.storage.setMeta({
|
|
836
|
+
lastSyncAt: Date.now(),
|
|
837
|
+
lastSyncId: this.lastSyncId,
|
|
838
|
+
updatedAt: Date.now(),
|
|
839
|
+
});
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
async finishOutboxProcessing(actions) {
|
|
843
|
+
const confirmedTxIds = await this.removeConfirmedTransactions(actions);
|
|
844
|
+
const redundantTxIds = await this.removeRedundantCreateTransactions(actions);
|
|
845
|
+
if (this.outboxManager) {
|
|
846
|
+
await this.outboxManager.completeUpToSyncId(this.lastSyncId);
|
|
847
|
+
}
|
|
848
|
+
for (const id of redundantTxIds) {
|
|
849
|
+
confirmedTxIds.add(id);
|
|
850
|
+
}
|
|
851
|
+
return confirmedTxIds;
|
|
852
|
+
}
|
|
853
|
+
static resolveModelChangeAction(action) {
|
|
854
|
+
switch (action) {
|
|
855
|
+
case "I": {
|
|
856
|
+
return "insert";
|
|
857
|
+
}
|
|
858
|
+
case "U": {
|
|
859
|
+
return "update";
|
|
860
|
+
}
|
|
861
|
+
case "D": {
|
|
862
|
+
return "delete";
|
|
863
|
+
}
|
|
864
|
+
case "A": {
|
|
865
|
+
return "archive";
|
|
866
|
+
}
|
|
867
|
+
case "V": {
|
|
868
|
+
return "unarchive";
|
|
869
|
+
}
|
|
870
|
+
default: {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
emitModelChangeEvents(actions, ownClientTxIds) {
|
|
876
|
+
// Deduplicate by modelName:modelId and skip local optimistic echoes only.
|
|
877
|
+
// Cross-tab updates can share clientId and must still emit modelChange.
|
|
878
|
+
const lastByKey = new Map();
|
|
879
|
+
for (const action of actions) {
|
|
880
|
+
if (action.clientTxId && ownClientTxIds.has(action.clientTxId)) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const key = getModelKey(action.modelName, action.modelId);
|
|
884
|
+
lastByKey.set(key, action);
|
|
885
|
+
}
|
|
886
|
+
for (const action of lastByKey.values()) {
|
|
887
|
+
const eventAction = SyncOrchestrator.resolveModelChangeAction(action.action);
|
|
888
|
+
if (!eventAction) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
this.emitEvent?.({
|
|
892
|
+
action: eventAction,
|
|
893
|
+
modelId: action.modelId,
|
|
894
|
+
modelName: action.modelName,
|
|
895
|
+
type: "modelChange",
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async emitOutboxCount() {
|
|
900
|
+
if (!this.emitEvent) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// oxlint-disable-next-line no-await-expression-member
|
|
904
|
+
const pendingCount = (await this.getActiveOutboxTransactions()).length;
|
|
905
|
+
this.emitEvent({ pendingCount, type: "outboxChange" });
|
|
906
|
+
}
|
|
907
|
+
async rebasePendingTransactions(pending, actions) {
|
|
908
|
+
if (pending.length === 0) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const rebaseOptions = {
|
|
912
|
+
clientId: this.clientId,
|
|
913
|
+
defaultResolution: this.options.rebaseStrategy ?? "server-wins",
|
|
914
|
+
fieldLevelConflicts: this.options.fieldLevelConflicts ?? true,
|
|
915
|
+
};
|
|
916
|
+
const result = rebaseTransactions(pending, actions, rebaseOptions);
|
|
917
|
+
for (const conflict of result.conflicts) {
|
|
918
|
+
await this.handleConflict(conflict);
|
|
919
|
+
}
|
|
920
|
+
await this.updatePendingOriginals(result.pending, actions);
|
|
921
|
+
}
|
|
922
|
+
async handleConflict(conflict) {
|
|
923
|
+
const { localTransaction: tx } = conflict;
|
|
924
|
+
const resolution = conflict.resolution === "manual" ? "server-wins" : conflict.resolution;
|
|
925
|
+
if (resolution === "server-wins") {
|
|
926
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
927
|
+
// Defer identity map rollback until the batch so it runs in the same
|
|
928
|
+
// runInAction as the server merge. Firing it here would delete the
|
|
929
|
+
// item from the identity map and emit modelChange(delete) BEFORE the
|
|
930
|
+
// deferred batch re-adds it, causing a visible flash of empty state.
|
|
931
|
+
this.deferredConflictTxs.push(tx);
|
|
932
|
+
}
|
|
933
|
+
else if ((resolution === "client-wins" || resolution === "merge") &&
|
|
934
|
+
(tx.action === "U" || tx.action === "A" || tx.action === "V")) {
|
|
935
|
+
const updatedOriginal = {
|
|
936
|
+
...tx.original,
|
|
937
|
+
...conflict.serverAction.data,
|
|
938
|
+
};
|
|
939
|
+
tx.original = updatedOriginal;
|
|
940
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
941
|
+
original: updatedOriginal,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
this.emitEvent?.({
|
|
945
|
+
conflictType: conflict.conflictType,
|
|
946
|
+
modelId: tx.modelId,
|
|
947
|
+
modelName: tx.modelName,
|
|
948
|
+
resolution,
|
|
949
|
+
type: "rebaseConflict",
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async updatePendingOriginals(pending, actions) {
|
|
953
|
+
const actionsByKey = SyncOrchestrator.buildActionsByKey(actions);
|
|
954
|
+
for (const tx of pending) {
|
|
955
|
+
if (tx.action !== "U" && tx.action !== "A" && tx.action !== "V") {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const key = getModelKey(tx.modelName, tx.modelId);
|
|
959
|
+
const related = actionsByKey.get(key);
|
|
960
|
+
if (!related) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const updatedOriginal = SyncOrchestrator.getUpdatedOriginal(tx, related);
|
|
964
|
+
if (!updatedOriginal) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
tx.original = updatedOriginal;
|
|
968
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
969
|
+
original: updatedOriginal,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
static buildActionsByKey(actions) {
|
|
974
|
+
const actionsByKey = new Map();
|
|
975
|
+
for (const action of actions) {
|
|
976
|
+
const key = getModelKey(action.modelName, action.modelId);
|
|
977
|
+
const existing = actionsByKey.get(key) ?? [];
|
|
978
|
+
existing.push(action);
|
|
979
|
+
actionsByKey.set(key, existing);
|
|
980
|
+
}
|
|
981
|
+
return actionsByKey;
|
|
982
|
+
}
|
|
983
|
+
static shouldRebaseAction(action) {
|
|
984
|
+
return action === "U" || action === "I" || action === "V" || action === "C";
|
|
985
|
+
}
|
|
986
|
+
static getUpdatedOriginal(tx, related) {
|
|
987
|
+
const original = { ...tx.original };
|
|
988
|
+
let updated = false;
|
|
989
|
+
for (const action of related) {
|
|
990
|
+
if (!SyncOrchestrator.shouldRebaseAction(action.action)) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
for (const field of Object.keys(tx.payload)) {
|
|
994
|
+
if (field in action.data) {
|
|
995
|
+
original[field] = action.data[field];
|
|
996
|
+
updated = true;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return updated ? original : null;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Re-applies pending outbox transactions to identity maps after a server sync.
|
|
1004
|
+
* This intentionally differs from rollbackTransaction (which inverts) and
|
|
1005
|
+
* applyDeltas (which writes to storage) — it re-applies forward to restore
|
|
1006
|
+
* optimistic state on top of newly-synced server data.
|
|
1007
|
+
*/
|
|
1008
|
+
applyPendingTransactionsToIdentityMaps(pending) {
|
|
1009
|
+
if (pending.length === 0) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
for (const tx of pending) {
|
|
1013
|
+
const map = this.identityMaps.getMap(tx.modelName);
|
|
1014
|
+
switch (tx.action) {
|
|
1015
|
+
case "I": {
|
|
1016
|
+
// Only re-create if the model was removed (e.g. conflict rollback).
|
|
1017
|
+
// If it already exists, the optimistic insert is still valid and
|
|
1018
|
+
// re-merging the full create payload would overwrite field changes
|
|
1019
|
+
// from optimistic updates whose outbox writes are still in-flight.
|
|
1020
|
+
if (!map.has(tx.modelId)) {
|
|
1021
|
+
map.merge(tx.modelId, tx.payload);
|
|
1022
|
+
}
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
case "U": {
|
|
1026
|
+
map.merge(tx.modelId, tx.payload);
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
case "D": {
|
|
1030
|
+
map.delete(tx.modelId);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
case "A": {
|
|
1034
|
+
map.merge(tx.modelId, createArchivePayload(readArchivedAt(tx.payload)));
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
case "V": {
|
|
1038
|
+
const existing = map.get(tx.modelId);
|
|
1039
|
+
if (existing) {
|
|
1040
|
+
const updated = {
|
|
1041
|
+
...existing,
|
|
1042
|
+
...createUnarchivePatch(),
|
|
1043
|
+
};
|
|
1044
|
+
map.set(tx.modelId, updated);
|
|
1045
|
+
}
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
default: {
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async removeConfirmedTransactions(actions) {
|
|
1055
|
+
const confirmed = new Set();
|
|
1056
|
+
const clientTxIds = actions
|
|
1057
|
+
.map((action) => action.clientTxId)
|
|
1058
|
+
.filter((value) => typeof value === "string");
|
|
1059
|
+
if (clientTxIds.length === 0) {
|
|
1060
|
+
return confirmed;
|
|
1061
|
+
}
|
|
1062
|
+
const outbox = await this.storage.getOutbox();
|
|
1063
|
+
const known = new Set(outbox.map((tx) => tx.clientTxId));
|
|
1064
|
+
for (const clientTxId of clientTxIds) {
|
|
1065
|
+
if (known.has(clientTxId)) {
|
|
1066
|
+
await this.storage.removeFromOutbox(clientTxId);
|
|
1067
|
+
confirmed.add(clientTxId);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return confirmed;
|
|
1071
|
+
}
|
|
1072
|
+
async removeRedundantCreateTransactions(actions) {
|
|
1073
|
+
const redundant = new Set();
|
|
1074
|
+
const createdIds = actions
|
|
1075
|
+
.filter((action) => action.action === "I")
|
|
1076
|
+
.map((action) => action.modelId);
|
|
1077
|
+
if (createdIds.length === 0) {
|
|
1078
|
+
return redundant;
|
|
1079
|
+
}
|
|
1080
|
+
const createdSet = new Set(createdIds);
|
|
1081
|
+
const outbox = await this.storage.getOutbox();
|
|
1082
|
+
for (const tx of outbox) {
|
|
1083
|
+
if (tx.action === "I" && createdSet.has(tx.modelId)) {
|
|
1084
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
1085
|
+
redundant.add(tx.clientTxId);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return redundant;
|
|
1089
|
+
}
|
|
1090
|
+
async handleCoverageActions(actions) {
|
|
1091
|
+
for (const action of actions) {
|
|
1092
|
+
if (action.action !== "C") {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
const { indexedKey } = action.data;
|
|
1096
|
+
const { keyValue } = action.data;
|
|
1097
|
+
if (typeof indexedKey === "string" && typeof keyValue === "string") {
|
|
1098
|
+
await this.storage.setPartialIndex(action.modelName, indexedKey, keyValue);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async handleSyncGroupActions(actions, nextSyncId) {
|
|
1103
|
+
const groupUpdates = [];
|
|
1104
|
+
for (const action of actions) {
|
|
1105
|
+
if (action.action !== "G" && action.action !== "S") {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const data = action.data;
|
|
1109
|
+
const groups = data.subscribedSyncGroups;
|
|
1110
|
+
if (Array.isArray(groups)) {
|
|
1111
|
+
const filtered = groups.filter((group) => typeof group === "string");
|
|
1112
|
+
if (filtered.length > 0) {
|
|
1113
|
+
groupUpdates.push(filtered);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (groupUpdates.length === 0) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const nextGroups = groupUpdates.at(-1);
|
|
1121
|
+
if (!nextGroups ||
|
|
1122
|
+
SyncOrchestrator.areGroupsEqual(this.groups, nextGroups)) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const currentSet = new Set(this.groups);
|
|
1126
|
+
const nextSet = new Set(nextGroups);
|
|
1127
|
+
const addedGroups = nextGroups.filter((group) => !currentSet.has(group));
|
|
1128
|
+
const removedGroups = this.groups.filter((group) => !nextSet.has(group));
|
|
1129
|
+
if (addedGroups.length > 0) {
|
|
1130
|
+
await this.bootstrapSyncGroups(addedGroups, nextSyncId);
|
|
1131
|
+
}
|
|
1132
|
+
if (removedGroups.length > 0) {
|
|
1133
|
+
await this.removeSyncGroupData(removedGroups);
|
|
1134
|
+
}
|
|
1135
|
+
this.groups = nextGroups;
|
|
1136
|
+
this.firstSyncId = nextSyncId;
|
|
1137
|
+
await this.storage.setMeta({
|
|
1138
|
+
firstSyncId: this.firstSyncId,
|
|
1139
|
+
subscribedSyncGroups: this.groups,
|
|
1140
|
+
updatedAt: Date.now(),
|
|
1141
|
+
});
|
|
1142
|
+
const pending = await this.getActiveOutboxTransactions();
|
|
1143
|
+
this.identityMaps.batch(() => {
|
|
1144
|
+
this.applyPendingTransactionsToIdentityMaps(pending);
|
|
1145
|
+
});
|
|
1146
|
+
if (this.running) {
|
|
1147
|
+
await this.restartDeltaSubscription(nextSyncId);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async bootstrapSyncGroups(groups, firstSyncId) {
|
|
1151
|
+
const iterator = this.transport.bootstrap({
|
|
1152
|
+
firstSyncId,
|
|
1153
|
+
noSyncPackets: true,
|
|
1154
|
+
schemaHash: this.schemaHash,
|
|
1155
|
+
syncGroups: groups,
|
|
1156
|
+
type: "partial",
|
|
1157
|
+
});
|
|
1158
|
+
const hydrated = new Set(this.registry.getBootstrapModelNames());
|
|
1159
|
+
while (true) {
|
|
1160
|
+
const { value, done } = await iterator.next();
|
|
1161
|
+
if (done) {
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
const row = value;
|
|
1165
|
+
const primaryKey = this.registry.getPrimaryKey(row.modelName);
|
|
1166
|
+
const id = row.data[primaryKey];
|
|
1167
|
+
if (typeof id !== "string") {
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
await this.storage.put(row.modelName, row.data);
|
|
1171
|
+
if (hydrated.has(row.modelName)) {
|
|
1172
|
+
const map = this.identityMaps.getMap(row.modelName);
|
|
1173
|
+
map.merge(id, row.data);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
async removeSyncGroupData(groups) {
|
|
1178
|
+
if (groups.length === 0) {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
for (const model of this.registry.getAllModels()) {
|
|
1182
|
+
const modelName = model.name ?? "";
|
|
1183
|
+
const { groupKey } = model;
|
|
1184
|
+
if (!(modelName && groupKey)) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const primaryKey = model.primaryKey ?? "id";
|
|
1188
|
+
const map = this.identityMaps.getMap(modelName);
|
|
1189
|
+
for (const group of groups) {
|
|
1190
|
+
const rows = await this.storage.getByIndex(modelName, groupKey, group);
|
|
1191
|
+
for (const row of rows) {
|
|
1192
|
+
const id = row[primaryKey];
|
|
1193
|
+
if (typeof id !== "string") {
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
await this.storage.delete(modelName, id);
|
|
1197
|
+
map.delete(id);
|
|
1198
|
+
this.emitEvent?.({
|
|
1199
|
+
action: "delete",
|
|
1200
|
+
modelId: id,
|
|
1201
|
+
modelName,
|
|
1202
|
+
type: "modelChange",
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Handles connection state changes
|
|
1210
|
+
*/
|
|
1211
|
+
handleConnectionChange(previousState, state) {
|
|
1212
|
+
if (!this.running ||
|
|
1213
|
+
state !== "connected" ||
|
|
1214
|
+
previousState === "connected" ||
|
|
1215
|
+
this._state === "connecting" ||
|
|
1216
|
+
this._state === "bootstrapping") {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
(async () => {
|
|
1220
|
+
try {
|
|
1221
|
+
await this.syncNow();
|
|
1222
|
+
if (this.running) {
|
|
1223
|
+
this.setState("syncing");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
// Ignore errors, will retry
|
|
1228
|
+
}
|
|
1229
|
+
})();
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Sets the sync state
|
|
1233
|
+
*/
|
|
1234
|
+
setState(state) {
|
|
1235
|
+
if (this._state !== state) {
|
|
1236
|
+
this._state = state;
|
|
1237
|
+
if (state !== "error") {
|
|
1238
|
+
this.lastError = null;
|
|
1239
|
+
}
|
|
1240
|
+
this.emitEvent?.({ state, type: "stateChange" });
|
|
1241
|
+
for (const listener of this.stateListeners) {
|
|
1242
|
+
listener(state);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Sets the connection state
|
|
1248
|
+
*/
|
|
1249
|
+
setConnectionState(state) {
|
|
1250
|
+
if (this._connectionState !== state) {
|
|
1251
|
+
this._connectionState = state;
|
|
1252
|
+
this.emitEvent?.({ state, type: "connectionChange" });
|
|
1253
|
+
for (const listener of this.connectionListeners) {
|
|
1254
|
+
listener(state);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Gets the model registry
|
|
1260
|
+
*/
|
|
1261
|
+
getRegistry() {
|
|
1262
|
+
return this.registry;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Gets the storage adapter
|
|
1266
|
+
*/
|
|
1267
|
+
getStorage() {
|
|
1268
|
+
return this.storage;
|
|
1269
|
+
}
|
|
1270
|
+
acquireDeltaReplayBarrier() {
|
|
1271
|
+
const previousBarrier = this.deltaReplayBarrier;
|
|
1272
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping -- releaseBarrier is reassigned inside Promise constructor
|
|
1273
|
+
let releaseBarrier = () => undefined;
|
|
1274
|
+
// oxlint-disable-next-line avoid-new -- wrapping callback API in promise
|
|
1275
|
+
const currentBarrier = new Promise((resolve) => {
|
|
1276
|
+
releaseBarrier = resolve;
|
|
1277
|
+
});
|
|
1278
|
+
this.deltaReplayBarrier = (async () => {
|
|
1279
|
+
await previousBarrier;
|
|
1280
|
+
await currentBarrier;
|
|
1281
|
+
})();
|
|
1282
|
+
return () => {
|
|
1283
|
+
releaseBarrier();
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
//# sourceMappingURL=sync-orchestrator.js.map
|