@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.
@@ -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