@strata-sync/client 0.1.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,1041 @@
1
+ import type {
2
+ ConnectionState,
3
+ DeltaPacket,
4
+ RebaseOptions,
5
+ SyncAction,
6
+ SyncClientState,
7
+ Transaction,
8
+ } from "@strata-sync/core";
9
+ import {
10
+ applyDeltas,
11
+ getOrCreateClientId,
12
+ ModelRegistry,
13
+ rebaseTransactions,
14
+ } from "@strata-sync/core";
15
+ import type { IdentityMapRegistry } from "./identity-map";
16
+ import type { OutboxManager } from "./outbox-manager";
17
+ import type {
18
+ StorageAdapter,
19
+ StorageMeta,
20
+ SyncClientEvent,
21
+ SyncClientOptions,
22
+ TransportAdapter,
23
+ } from "./types";
24
+
25
+ /**
26
+ * Orchestrates the sync state machine
27
+ */
28
+ export class SyncOrchestrator {
29
+ private readonly storage: StorageAdapter;
30
+ private readonly transport: TransportAdapter;
31
+ private readonly identityMaps: IdentityMapRegistry;
32
+ private outboxManager: OutboxManager | null = null;
33
+ private readonly options: SyncClientOptions;
34
+ private readonly registry: ModelRegistry;
35
+
36
+ private _state: SyncClientState = "disconnected";
37
+ private _connectionState: ConnectionState = "disconnected";
38
+ private readonly stateListeners: Set<(state: SyncClientState) => void> =
39
+ new Set();
40
+ private readonly connectionListeners: Set<(state: ConnectionState) => void> =
41
+ new Set();
42
+
43
+ private readonly schemaHash: string;
44
+ private clientId = "";
45
+ private lastSyncId = 0;
46
+ private lastError: Error | null = null;
47
+ private firstSyncId = 0;
48
+ private groups: string[] = [];
49
+
50
+ private deltaSubscription: AsyncIterator<DeltaPacket> | null = null;
51
+ private running = false;
52
+ private readonly emitEvent?: (event: SyncClientEvent) => void;
53
+ private onTransactionConflict?: (tx: Transaction) => void;
54
+
55
+ constructor(
56
+ options: SyncClientOptions,
57
+ identityMaps: IdentityMapRegistry,
58
+ emitEvent?: (event: SyncClientEvent) => void
59
+ ) {
60
+ this.options = options;
61
+ this.storage = options.storage;
62
+ this.transport = options.transport;
63
+ this.identityMaps = identityMaps;
64
+ this.registry = new ModelRegistry(
65
+ options.schema ?? ModelRegistry.snapshot()
66
+ );
67
+ this.schemaHash = this.registry.getSchemaHash();
68
+ this.groups = options.groups ?? [];
69
+ this.emitEvent = emitEvent;
70
+
71
+ // Listen for transport connection changes
72
+ this.transport.onConnectionStateChange((state) => {
73
+ this.setConnectionState(state);
74
+ this.handleConnectionChange(state);
75
+ });
76
+ }
77
+
78
+ setOutboxManager(outboxManager: OutboxManager): void {
79
+ this.outboxManager = outboxManager;
80
+ }
81
+
82
+ setConflictHandler(handler: (tx: Transaction) => void): void {
83
+ this.onTransactionConflict = handler;
84
+ }
85
+
86
+ /**
87
+ * Gets the current sync state
88
+ */
89
+ get state(): SyncClientState {
90
+ return this._state;
91
+ }
92
+
93
+ /**
94
+ * Gets the current connection state
95
+ */
96
+ get connectionState(): ConnectionState {
97
+ return this._connectionState;
98
+ }
99
+
100
+ /**
101
+ * Gets the client ID
102
+ */
103
+ getClientId(): string {
104
+ return this.clientId;
105
+ }
106
+
107
+ /**
108
+ * Gets the last sync ID
109
+ */
110
+ getLastSyncId(): number {
111
+ return this.lastSyncId;
112
+ }
113
+
114
+ /**
115
+ * Gets the first sync ID from the last full bootstrap
116
+ */
117
+ getFirstSyncId(): number {
118
+ return this.firstSyncId;
119
+ }
120
+
121
+ /**
122
+ * Gets the last error
123
+ */
124
+ getLastError(): Error | null {
125
+ return this.lastError;
126
+ }
127
+
128
+ /**
129
+ * Starts the sync orchestrator
130
+ */
131
+ async start(): Promise<void> {
132
+ if (this.running) {
133
+ return;
134
+ }
135
+ this.running = true;
136
+
137
+ this.emitEvent?.({ type: "syncStart" });
138
+ this.setState("connecting");
139
+
140
+ try {
141
+ await this.openStorage();
142
+ const meta = await this.loadMetadata();
143
+ await this.configureGroups(meta);
144
+ await this.bootstrapIfNeeded(meta);
145
+ await this.applyPendingOutboxTransactions();
146
+ this.startDeltaSubscription();
147
+ await this.processOutboxTransactions();
148
+ this.setState("syncing");
149
+ } catch (error) {
150
+ this.lastError =
151
+ error instanceof Error ? error : new Error(String(error));
152
+ this.emitEvent?.({ type: "syncError", error: this.lastError });
153
+ this.setState("error");
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ private async openStorage(): Promise<void> {
159
+ await this.storage.open({
160
+ name: this.options.dbName,
161
+ userId: this.options.userId,
162
+ version: this.options.version,
163
+ userVersion: this.options.userVersion,
164
+ schema: this.options.schema ?? this.registry.snapshot(),
165
+ });
166
+ }
167
+
168
+ private async loadMetadata(): Promise<StorageMeta> {
169
+ const meta = await this.storage.getMeta();
170
+ this.clientId =
171
+ meta.clientId ??
172
+ getOrCreateClientId(`${this.options.dbName ?? "sync-db"}_client_id`);
173
+ this.lastSyncId = meta.lastSyncId ?? 0;
174
+ this.firstSyncId = meta.firstSyncId ?? this.lastSyncId;
175
+ return meta;
176
+ }
177
+
178
+ private async configureGroups(meta: StorageMeta): Promise<void> {
179
+ const storedGroups = meta.subscribedSyncGroups ?? [];
180
+ const configuredGroups = this.options.groups ?? storedGroups;
181
+ this.groups = configuredGroups.length > 0 ? configuredGroups : storedGroups;
182
+
183
+ if (this.areGroupsEqual(storedGroups, this.groups)) {
184
+ return;
185
+ }
186
+
187
+ this.firstSyncId = this.lastSyncId;
188
+ await this.storage.setMeta({
189
+ subscribedSyncGroups: this.groups,
190
+ firstSyncId: this.firstSyncId,
191
+ updatedAt: Date.now(),
192
+ });
193
+ }
194
+
195
+ private async bootstrapIfNeeded(meta: StorageMeta): Promise<void> {
196
+ const needsBootstrap = await this.shouldBootstrap(meta);
197
+ if (!needsBootstrap) {
198
+ await this.hydrateIdentityMaps();
199
+ return;
200
+ }
201
+
202
+ await this.runBootstrapStrategy();
203
+ }
204
+
205
+ private async shouldBootstrap(meta: StorageMeta): Promise<boolean> {
206
+ const bootstrapModels = this.registry.getBootstrapModelNames();
207
+ const arePersisted = await this.areModelsPersisted(bootstrapModels);
208
+ const hasSchemaMismatch = meta.schemaHash
209
+ ? meta.schemaHash !== this.schemaHash
210
+ : false;
211
+
212
+ return (
213
+ meta.bootstrapComplete === false ||
214
+ hasSchemaMismatch ||
215
+ this.lastSyncId === 0 ||
216
+ !arePersisted
217
+ );
218
+ }
219
+
220
+ private async runBootstrapStrategy(): Promise<void> {
221
+ const bootstrapMode = this.options.bootstrapMode ?? "auto";
222
+ if (bootstrapMode === "local") {
223
+ await this.localBootstrap();
224
+ return;
225
+ }
226
+
227
+ try {
228
+ await this.bootstrap();
229
+ } catch (error) {
230
+ const canFallback =
231
+ bootstrapMode === "auto" && (await this.hasLocalData());
232
+ if (canFallback) {
233
+ await this.localBootstrap();
234
+ return;
235
+ }
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ private async applyPendingOutboxTransactions(): Promise<void> {
241
+ const pending = await this.getActiveOutboxTransactions();
242
+ await this.applyPendingTransactionsToIdentityMaps(pending);
243
+ }
244
+
245
+ private async processOutboxTransactions(): Promise<void> {
246
+ if (!this.outboxManager) {
247
+ return;
248
+ }
249
+ await this.outboxManager.processPendingTransactions();
250
+ }
251
+
252
+ /**
253
+ * Stops the sync orchestrator
254
+ */
255
+ async stop(): Promise<void> {
256
+ this.running = false;
257
+
258
+ if (this.deltaSubscription) {
259
+ await this.deltaSubscription.return?.();
260
+ this.deltaSubscription = null;
261
+ }
262
+
263
+ await this.transport.close();
264
+ await this.storage.close();
265
+
266
+ this.setState("disconnected");
267
+ }
268
+
269
+ /**
270
+ * Forces an immediate sync
271
+ */
272
+ async syncNow(): Promise<void> {
273
+ // Fetch any missed deltas
274
+ const packet = await this.transport.fetchDeltas(this.lastSyncId);
275
+ await this.applyDeltaPacket(packet);
276
+
277
+ // Process pending outbox
278
+ if (this.outboxManager) {
279
+ await this.outboxManager.processPendingTransactions();
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Subscribes to state changes
285
+ */
286
+ onStateChange(callback: (state: SyncClientState) => void): () => void {
287
+ this.stateListeners.add(callback);
288
+ return () => {
289
+ this.stateListeners.delete(callback);
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Subscribes to connection state changes
295
+ */
296
+ onConnectionStateChange(
297
+ callback: (state: ConnectionState) => void
298
+ ): () => void {
299
+ this.connectionListeners.add(callback);
300
+ return () => {
301
+ this.connectionListeners.delete(callback);
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Performs initial bootstrap
307
+ */
308
+ private async bootstrap(): Promise<void> {
309
+ this.setState("bootstrapping");
310
+
311
+ // Clear existing data
312
+ await this.storage.clear();
313
+ this.identityMaps.clearAll();
314
+
315
+ // Stream bootstrap data
316
+ const iterator = this.transport.bootstrap({
317
+ type: "full",
318
+ schemaHash: this.schemaHash,
319
+ onlyModels: this.registry.getBootstrapModelNames(),
320
+ syncGroups: this.groups,
321
+ });
322
+
323
+ let databaseVersion: number | undefined;
324
+
325
+ while (true) {
326
+ const { value, done } = await iterator.next();
327
+ if (done) {
328
+ if (!value) {
329
+ throw new Error("Bootstrap completed without metadata");
330
+ }
331
+ const metadata = value;
332
+ if (metadata.lastSyncId === undefined) {
333
+ throw new Error("Bootstrap metadata is missing lastSyncId");
334
+ }
335
+ this.lastSyncId = metadata.lastSyncId;
336
+ this.firstSyncId = metadata.lastSyncId;
337
+ this.groups =
338
+ (metadata.subscribedSyncGroups?.length ?? 0) > 0
339
+ ? metadata.subscribedSyncGroups
340
+ : this.groups;
341
+ databaseVersion = metadata.databaseVersion;
342
+ break;
343
+ }
344
+
345
+ const row = value;
346
+ const primaryKey = this.registry.getPrimaryKey(row.modelName);
347
+ const id = row.data[primaryKey] as string;
348
+ if (typeof id !== "string") {
349
+ continue;
350
+ }
351
+ // Store in IndexedDB
352
+ await this.storage.put(row.modelName, row.data);
353
+
354
+ // Add to identity map
355
+ const map = this.identityMaps.getMap(row.modelName);
356
+ map.set(id, row.data);
357
+ }
358
+
359
+ // Update metadata + persistence flags
360
+ const bootstrapModels = this.registry.getBootstrapModelNames();
361
+
362
+ for (const modelName of bootstrapModels) {
363
+ await this.storage.setModelPersistence(modelName, true);
364
+ }
365
+
366
+ await this.storage.setMeta({
367
+ lastSyncId: this.lastSyncId,
368
+ firstSyncId: this.firstSyncId,
369
+ subscribedSyncGroups: this.groups,
370
+ bootstrapComplete: true,
371
+ lastSyncAt: Date.now(),
372
+ schemaHash: this.schemaHash,
373
+ databaseVersion,
374
+ updatedAt: Date.now(),
375
+ });
376
+ this.emitEvent?.({ type: "syncComplete", lastSyncId: this.lastSyncId });
377
+ }
378
+
379
+ /**
380
+ * Performs a local-only bootstrap using existing storage data.
381
+ */
382
+ private async localBootstrap(): Promise<void> {
383
+ this.setState("bootstrapping");
384
+ await this.hydrateIdentityMaps();
385
+ }
386
+
387
+ /**
388
+ * Checks whether any hydrated models exist in storage.
389
+ */
390
+ private async hasLocalData(): Promise<boolean> {
391
+ for (const modelName of this.registry.getHydratedModelNames()) {
392
+ const count = await this.storage.count(modelName);
393
+ if (count > 0) {
394
+ return true;
395
+ }
396
+ }
397
+ return false;
398
+ }
399
+
400
+ /**
401
+ * Loads existing data from storage into identity maps
402
+ */
403
+ private async hydrateIdentityMaps(): Promise<void> {
404
+ for (const modelName of this.registry.getHydratedModelNames()) {
405
+ const rows =
406
+ await this.storage.getAll<Record<string, unknown>>(modelName);
407
+ const map = this.identityMaps.getMap(modelName);
408
+ const primaryKey = this.registry.getPrimaryKey(modelName);
409
+
410
+ for (const row of rows) {
411
+ const id = row[primaryKey] as string;
412
+ if (typeof id !== "string") {
413
+ continue;
414
+ }
415
+ map.set(id, row);
416
+ }
417
+ }
418
+ }
419
+
420
+ private async areModelsPersisted(modelNames: string[]): Promise<boolean> {
421
+ for (const modelName of modelNames) {
422
+ const persistence = await this.storage.getModelPersistence(modelName);
423
+ if (!persistence.persisted) {
424
+ return false;
425
+ }
426
+ }
427
+ return true;
428
+ }
429
+
430
+ private areGroupsEqual(a: string[], b: string[]): boolean {
431
+ if (a.length !== b.length) {
432
+ return false;
433
+ }
434
+ const setA = new Set(a);
435
+ if (setA.size !== b.length) {
436
+ return false;
437
+ }
438
+ for (const value of b) {
439
+ if (!setA.has(value)) {
440
+ return false;
441
+ }
442
+ }
443
+ return true;
444
+ }
445
+
446
+ /**
447
+ * Starts the delta subscription
448
+ */
449
+ private startDeltaSubscription(): void {
450
+ const subscription = this.transport.subscribe({
451
+ afterSyncId: this.lastSyncId,
452
+ groups: this.groups,
453
+ });
454
+
455
+ this.deltaSubscription = subscription[Symbol.asyncIterator]();
456
+
457
+ // Process deltas in background
458
+ this.processDeltaStream().catch(() => undefined);
459
+ }
460
+
461
+ /**
462
+ * Processes the delta stream
463
+ */
464
+ private async processDeltaStream(): Promise<void> {
465
+ if (!this.deltaSubscription) {
466
+ return;
467
+ }
468
+
469
+ try {
470
+ while (this.running) {
471
+ const { value, done } = await this.deltaSubscription.next();
472
+ if (done) {
473
+ break;
474
+ }
475
+
476
+ await this.applyDeltaPacket(value);
477
+ }
478
+ } catch (error) {
479
+ if (this.running) {
480
+ this.lastError =
481
+ error instanceof Error ? error : new Error(String(error));
482
+ this.emitEvent?.({ type: "syncError", error: this.lastError });
483
+ // Try to reconnect
484
+ this.setState("error");
485
+ setTimeout(() => {
486
+ if (this.running) {
487
+ this.startDeltaSubscription();
488
+ }
489
+ }, 5000);
490
+ }
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Applies a delta packet to local state
496
+ */
497
+ private async applyDeltaPacket(packet: DeltaPacket): Promise<void> {
498
+ if (packet.actions.length === 0) {
499
+ await this.handleEmptyPacket(packet);
500
+ return;
501
+ }
502
+
503
+ await this.storage.addSyncActions(packet.actions);
504
+
505
+ const activeTransactions = await this.getActiveOutboxTransactions();
506
+ await this.rebasePendingTransactions(activeTransactions, packet.actions);
507
+ await this.handleCoverageActions(packet.actions);
508
+ await this.handleSyncGroupActions(packet.actions, packet.lastSyncId);
509
+
510
+ await this.applyPacketToStorage(packet);
511
+ await this.updateSyncMetadata(packet.lastSyncId);
512
+ await this.finishOutboxProcessing(packet.actions);
513
+ await this.applyPendingOutboxToIdentityMaps();
514
+ this.emitModelChangeEvents(packet.actions);
515
+ await this.emitOutboxCount();
516
+ }
517
+
518
+ private async handleEmptyPacket(packet: DeltaPacket): Promise<void> {
519
+ if (packet.lastSyncId > this.lastSyncId) {
520
+ await this.updateSyncMetadata(packet.lastSyncId);
521
+ }
522
+ }
523
+
524
+ private async getActiveOutboxTransactions(): Promise<Transaction[]> {
525
+ const outbox = await this.storage.getOutbox();
526
+ return outbox.filter(
527
+ (tx) =>
528
+ tx.state === "queued" ||
529
+ tx.state === "sent" ||
530
+ tx.state === "awaitingSync"
531
+ );
532
+ }
533
+
534
+ private createDeltaTarget() {
535
+ return {
536
+ put: async (
537
+ modelName: string,
538
+ id: string,
539
+ data: Record<string, unknown>
540
+ ) => {
541
+ await this.storage.put(modelName, data);
542
+ const map = this.identityMaps.getMap(modelName);
543
+ map.merge(id, data);
544
+ },
545
+ delete: async (modelName: string, id: string) => {
546
+ await this.storage.delete(modelName, id);
547
+ const map = this.identityMaps.getMap(modelName);
548
+ map.delete(id);
549
+ },
550
+ patch: async (
551
+ modelName: string,
552
+ id: string,
553
+ changes: Record<string, unknown>
554
+ ) => {
555
+ const existing = await this.storage.get<Record<string, unknown>>(
556
+ modelName,
557
+ id
558
+ );
559
+ if (!existing) {
560
+ return;
561
+ }
562
+ const updated = { ...existing, ...changes };
563
+ await this.storage.put(modelName, updated);
564
+ const map = this.identityMaps.getMap(modelName);
565
+ map.merge(id, updated);
566
+ },
567
+ get: (modelName: string, id: string) => {
568
+ return this.storage.get<Record<string, unknown>>(modelName, id);
569
+ },
570
+ };
571
+ }
572
+
573
+ private async applyPacketToStorage(packet: DeltaPacket): Promise<void> {
574
+ const deltaTarget = this.createDeltaTarget();
575
+ await applyDeltas(packet, deltaTarget, this.registry);
576
+ }
577
+
578
+ private async updateSyncMetadata(lastSyncId: number): Promise<void> {
579
+ this.lastSyncId = lastSyncId;
580
+ await this.storage.setMeta({
581
+ lastSyncId: this.lastSyncId,
582
+ lastSyncAt: Date.now(),
583
+ updatedAt: Date.now(),
584
+ });
585
+ this.emitEvent?.({ type: "syncComplete", lastSyncId: this.lastSyncId });
586
+ }
587
+
588
+ private async finishOutboxProcessing(actions: SyncAction[]): Promise<void> {
589
+ await this.removeConfirmedTransactions(actions);
590
+ await this.removeRedundantCreateTransactions(actions);
591
+ if (this.outboxManager) {
592
+ await this.outboxManager.completeUpToSyncId(this.lastSyncId);
593
+ }
594
+ }
595
+
596
+ private async applyPendingOutboxToIdentityMaps(): Promise<void> {
597
+ const pending = await this.getActiveOutboxTransactions();
598
+ this.applyPendingTransactionsToIdentityMaps(pending);
599
+ }
600
+
601
+ private resolveModelChangeAction(
602
+ action: SyncAction["action"]
603
+ ): "insert" | "update" | "delete" | "archive" | "unarchive" | null {
604
+ switch (action) {
605
+ case "I":
606
+ return "insert";
607
+ case "U":
608
+ return "update";
609
+ case "D":
610
+ return "delete";
611
+ case "A":
612
+ return "archive";
613
+ case "V":
614
+ return "unarchive";
615
+ default:
616
+ return null;
617
+ }
618
+ }
619
+
620
+ private emitModelChangeEvents(actions: SyncAction[]): void {
621
+ for (const action of actions) {
622
+ const eventAction = this.resolveModelChangeAction(action.action);
623
+ if (!eventAction) {
624
+ continue;
625
+ }
626
+ this.emitEvent?.({
627
+ type: "modelChange",
628
+ modelName: action.modelName,
629
+ modelId: action.modelId,
630
+ action: eventAction,
631
+ });
632
+ }
633
+ }
634
+
635
+ private async emitOutboxCount(): Promise<void> {
636
+ if (!this.emitEvent) {
637
+ return;
638
+ }
639
+ const pendingCount = (await this.getActiveOutboxTransactions()).length;
640
+ this.emitEvent({ type: "outboxChange", pendingCount });
641
+ }
642
+
643
+ private async rebasePendingTransactions(
644
+ pending: Transaction[],
645
+ actions: SyncAction[]
646
+ ): Promise<void> {
647
+ if (pending.length === 0) {
648
+ return;
649
+ }
650
+
651
+ const rebaseOptions: RebaseOptions = {
652
+ clientId: this.clientId,
653
+ defaultResolution: this.options.rebaseStrategy ?? "server-wins",
654
+ fieldLevelConflicts: this.options.fieldLevelConflicts ?? true,
655
+ };
656
+
657
+ const result = rebaseTransactions(pending, actions, rebaseOptions);
658
+
659
+ for (const conflict of result.conflicts) {
660
+ await this.handleConflict(conflict);
661
+ }
662
+
663
+ await this.updatePendingOriginals(result.pending, actions);
664
+ }
665
+
666
+ private async handleConflict(
667
+ conflict: import("@strata-sync/core").RebaseConflict
668
+ ): Promise<void> {
669
+ const { localTransaction: tx } = conflict;
670
+ const resolution =
671
+ conflict.resolution === "manual" ? "server-wins" : conflict.resolution;
672
+
673
+ if (resolution === "server-wins") {
674
+ await this.storage.removeFromOutbox(tx.clientTxId);
675
+ this.onTransactionConflict?.(tx);
676
+ } else if (
677
+ (resolution === "client-wins" || resolution === "merge") &&
678
+ (tx.action === "U" || tx.action === "A" || tx.action === "V")
679
+ ) {
680
+ const updatedOriginal = {
681
+ ...(tx.original ?? {}),
682
+ ...conflict.serverAction.data,
683
+ };
684
+ tx.original = updatedOriginal;
685
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
686
+ original: updatedOriginal,
687
+ });
688
+ }
689
+
690
+ this.emitEvent?.({
691
+ type: "rebaseConflict",
692
+ modelName: tx.modelName,
693
+ modelId: tx.modelId,
694
+ conflictType: conflict.conflictType,
695
+ resolution,
696
+ });
697
+ }
698
+
699
+ private async updatePendingOriginals(
700
+ pending: Transaction[],
701
+ actions: SyncAction[]
702
+ ): Promise<void> {
703
+ const actionsByKey = this.buildActionsByKey(actions);
704
+
705
+ for (const tx of pending) {
706
+ if (tx.action !== "U" && tx.action !== "A" && tx.action !== "V") {
707
+ continue;
708
+ }
709
+ const key = `${tx.modelName}:${tx.modelId}`;
710
+ const related = actionsByKey.get(key);
711
+ if (!related) {
712
+ continue;
713
+ }
714
+ const updatedOriginal = this.getUpdatedOriginal(tx, related);
715
+ if (!updatedOriginal) {
716
+ continue;
717
+ }
718
+ tx.original = updatedOriginal;
719
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
720
+ original: updatedOriginal,
721
+ });
722
+ }
723
+ }
724
+
725
+ private buildActionsByKey(actions: SyncAction[]): Map<string, SyncAction[]> {
726
+ const actionsByKey = new Map<string, SyncAction[]>();
727
+ for (const action of actions) {
728
+ const key = `${action.modelName}:${action.modelId}`;
729
+ const existing = actionsByKey.get(key) ?? [];
730
+ existing.push(action);
731
+ actionsByKey.set(key, existing);
732
+ }
733
+ return actionsByKey;
734
+ }
735
+
736
+ private shouldRebaseAction(action: SyncAction["action"]): boolean {
737
+ return action === "U" || action === "I" || action === "V" || action === "C";
738
+ }
739
+
740
+ private getUpdatedOriginal(
741
+ tx: Transaction,
742
+ related: SyncAction[]
743
+ ): Record<string, unknown> | null {
744
+ const original = { ...(tx.original ?? {}) };
745
+ let updated = false;
746
+
747
+ for (const action of related) {
748
+ if (!this.shouldRebaseAction(action.action)) {
749
+ continue;
750
+ }
751
+ for (const field of Object.keys(tx.payload)) {
752
+ if (field in action.data) {
753
+ original[field] = action.data[field];
754
+ updated = true;
755
+ }
756
+ }
757
+ }
758
+
759
+ return updated ? original : null;
760
+ }
761
+
762
+ private applyPendingTransactionsToIdentityMaps(pending: Transaction[]): void {
763
+ if (pending.length === 0) {
764
+ return;
765
+ }
766
+
767
+ for (const tx of pending) {
768
+ const map = this.identityMaps.getMap<Record<string, unknown>>(
769
+ tx.modelName
770
+ );
771
+
772
+ switch (tx.action) {
773
+ case "I":
774
+ case "U":
775
+ map.merge(tx.modelId, tx.payload);
776
+ break;
777
+ case "D":
778
+ map.delete(tx.modelId);
779
+ break;
780
+ case "A": {
781
+ const archivedAt =
782
+ (tx.payload as Record<string, unknown>).archivedAt ??
783
+ new Date().toISOString();
784
+ map.merge(tx.modelId, { archivedAt });
785
+ break;
786
+ }
787
+ case "V": {
788
+ const existing = map.get(tx.modelId);
789
+ if (existing) {
790
+ const updated = { ...existing };
791
+ (updated as Record<string, unknown>).archivedAt = undefined;
792
+ map.set(tx.modelId, updated);
793
+ }
794
+ break;
795
+ }
796
+ default:
797
+ break;
798
+ }
799
+ }
800
+ }
801
+
802
+ private async removeConfirmedTransactions(
803
+ actions: SyncAction[]
804
+ ): Promise<void> {
805
+ const clientTxIds = actions
806
+ .map((action) => action.clientTxId)
807
+ .filter((value): value is string => typeof value === "string");
808
+
809
+ if (clientTxIds.length === 0) {
810
+ return;
811
+ }
812
+
813
+ const outbox = await this.storage.getOutbox();
814
+ const known = new Set(outbox.map((tx) => tx.clientTxId));
815
+ for (const clientTxId of clientTxIds) {
816
+ if (known.has(clientTxId)) {
817
+ await this.storage.removeFromOutbox(clientTxId);
818
+ }
819
+ }
820
+ }
821
+
822
+ private async removeRedundantCreateTransactions(
823
+ actions: SyncAction[]
824
+ ): Promise<void> {
825
+ const createdIds = actions
826
+ .filter((action) => action.action === "I")
827
+ .map((action) => action.modelId);
828
+
829
+ if (createdIds.length === 0) {
830
+ return;
831
+ }
832
+
833
+ const createdSet = new Set(createdIds);
834
+ const outbox = await this.storage.getOutbox();
835
+
836
+ for (const tx of outbox) {
837
+ if (tx.action === "I" && createdSet.has(tx.modelId)) {
838
+ await this.storage.removeFromOutbox(tx.clientTxId);
839
+ }
840
+ }
841
+ }
842
+
843
+ private async handleCoverageActions(actions: SyncAction[]): Promise<void> {
844
+ for (const action of actions) {
845
+ if (action.action !== "C") {
846
+ continue;
847
+ }
848
+ const indexedKey = (action.data as Record<string, unknown>).indexedKey;
849
+ const keyValue = (action.data as Record<string, unknown>).keyValue;
850
+ if (typeof indexedKey === "string" && typeof keyValue === "string") {
851
+ await this.storage.setPartialIndex(
852
+ action.modelName,
853
+ indexedKey,
854
+ keyValue
855
+ );
856
+ }
857
+ }
858
+ }
859
+
860
+ private async handleSyncGroupActions(
861
+ actions: SyncAction[],
862
+ nextSyncId: number
863
+ ): Promise<void> {
864
+ const groupUpdates: string[][] = [];
865
+ for (const action of actions) {
866
+ if (action.action !== "G" && action.action !== "S") {
867
+ continue;
868
+ }
869
+ const data = action.data as Record<string, unknown>;
870
+ const groups = data.subscribedSyncGroups;
871
+ if (Array.isArray(groups)) {
872
+ const filtered = groups.filter(
873
+ (group): group is string => typeof group === "string"
874
+ );
875
+ if (filtered.length > 0) {
876
+ groupUpdates.push(filtered);
877
+ }
878
+ }
879
+ }
880
+
881
+ if (groupUpdates.length === 0) {
882
+ return;
883
+ }
884
+
885
+ const nextGroups = groupUpdates.at(-1);
886
+ if (!nextGroups || this.areGroupsEqual(this.groups, nextGroups)) {
887
+ return;
888
+ }
889
+
890
+ const currentSet = new Set(this.groups);
891
+ const nextSet = new Set(nextGroups);
892
+ const addedGroups = nextGroups.filter((group) => !currentSet.has(group));
893
+ const removedGroups = this.groups.filter((group) => !nextSet.has(group));
894
+
895
+ if (addedGroups.length > 0) {
896
+ await this.bootstrapSyncGroups(addedGroups, nextSyncId);
897
+ }
898
+
899
+ if (removedGroups.length > 0) {
900
+ await this.removeSyncGroupData(removedGroups);
901
+ }
902
+
903
+ this.groups = nextGroups;
904
+ this.firstSyncId = nextSyncId;
905
+ await this.storage.setMeta({
906
+ subscribedSyncGroups: this.groups,
907
+ firstSyncId: this.firstSyncId,
908
+ updatedAt: Date.now(),
909
+ });
910
+ }
911
+
912
+ private async bootstrapSyncGroups(
913
+ groups: string[],
914
+ firstSyncId: number
915
+ ): Promise<void> {
916
+ const iterator = this.transport.bootstrap({
917
+ type: "partial",
918
+ schemaHash: this.schemaHash,
919
+ firstSyncId,
920
+ syncGroups: groups,
921
+ noSyncPackets: true,
922
+ });
923
+
924
+ const hydrated = new Set(this.registry.getHydratedModelNames());
925
+
926
+ while (true) {
927
+ const { value, done } = await iterator.next();
928
+ if (done) {
929
+ break;
930
+ }
931
+
932
+ const row = value;
933
+ const primaryKey = this.registry.getPrimaryKey(row.modelName);
934
+ const id = row.data[primaryKey] as string;
935
+ if (typeof id !== "string") {
936
+ continue;
937
+ }
938
+ await this.storage.put(row.modelName, row.data);
939
+
940
+ if (hydrated.has(row.modelName)) {
941
+ const map = this.identityMaps.getMap(row.modelName);
942
+ map.merge(id, row.data);
943
+ }
944
+ }
945
+ }
946
+
947
+ private async removeSyncGroupData(groups: string[]): Promise<void> {
948
+ if (groups.length === 0) {
949
+ return;
950
+ }
951
+
952
+ for (const model of this.registry.getAllModels()) {
953
+ const modelName = model.name ?? "";
954
+ const groupKey = model.groupKey;
955
+ if (!(modelName && groupKey)) {
956
+ continue;
957
+ }
958
+
959
+ const primaryKey = model.primaryKey ?? "id";
960
+ const map = this.identityMaps.getMap<Record<string, unknown>>(modelName);
961
+
962
+ for (const group of groups) {
963
+ const rows = await this.storage.getByIndex<Record<string, unknown>>(
964
+ modelName,
965
+ groupKey,
966
+ group
967
+ );
968
+
969
+ for (const row of rows) {
970
+ const id = row[primaryKey] as string;
971
+ if (typeof id !== "string") {
972
+ continue;
973
+ }
974
+ await this.storage.delete(modelName, id);
975
+ map.delete(id);
976
+ this.emitEvent?.({
977
+ type: "modelChange",
978
+ modelName,
979
+ modelId: id,
980
+ action: "delete",
981
+ });
982
+ }
983
+ }
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Handles connection state changes
989
+ */
990
+ private handleConnectionChange(state: ConnectionState): void {
991
+ if (state === "connected" && this._state === "error") {
992
+ // Reconnected - catch up on missed deltas
993
+ this.syncNow().catch(() => {
994
+ // Ignore errors, will retry
995
+ });
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Sets the sync state
1001
+ */
1002
+ private setState(state: SyncClientState): void {
1003
+ if (this._state !== state) {
1004
+ this._state = state;
1005
+ if (state !== "error") {
1006
+ this.lastError = null;
1007
+ }
1008
+ this.emitEvent?.({ type: "stateChange", state });
1009
+ for (const listener of this.stateListeners) {
1010
+ listener(state);
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Sets the connection state
1017
+ */
1018
+ private setConnectionState(state: ConnectionState): void {
1019
+ if (this._connectionState !== state) {
1020
+ this._connectionState = state;
1021
+ this.emitEvent?.({ type: "connectionChange", state });
1022
+ for (const listener of this.connectionListeners) {
1023
+ listener(state);
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Gets the model registry
1030
+ */
1031
+ getRegistry(): ModelRegistry {
1032
+ return this.registry;
1033
+ }
1034
+
1035
+ /**
1036
+ * Gets the storage adapter
1037
+ */
1038
+ getStorage(): StorageAdapter {
1039
+ return this.storage;
1040
+ }
1041
+ }