@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.
package/src/client.ts ADDED
@@ -0,0 +1,1051 @@
1
+ import type {
2
+ ConnectionState,
3
+ SyncClientState,
4
+ SyncStore,
5
+ Transaction,
6
+ TransactionAction,
7
+ } from "@strata-sync/core";
8
+ import {
9
+ generateUUID,
10
+ getOrCreateClientId,
11
+ ModelRegistry,
12
+ } from "@strata-sync/core";
13
+ import { YjsDocumentManager, YjsPresenceManager } from "@strata-sync/yjs";
14
+ import { IdentityMapRegistry } from "./identity-map";
15
+ import { OutboxManager } from "./outbox-manager";
16
+ import { executeQuery } from "./query";
17
+ import { SyncOrchestrator } from "./sync-orchestrator";
18
+ import type {
19
+ ModelFactory,
20
+ ModelFactoryFactory,
21
+ ModelStore,
22
+ QueryOptions,
23
+ QueryResult,
24
+ SyncClient,
25
+ SyncClientEvent,
26
+ SyncClientOptions,
27
+ } from "./types";
28
+
29
+ /**
30
+ * Creates a sync client instance
31
+ */
32
+ export function createSyncClient(options: SyncClientOptions): SyncClient {
33
+ const resolvedOptions: SyncClientOptions = { ...options };
34
+
35
+ const identityMaps = new IdentityMapRegistry(resolvedOptions.reactivity);
36
+ const eventListeners = new Set<(event: SyncClientEvent) => void>();
37
+ const missingModels = new Set<string>();
38
+ const pendingLoads = new Map<string, Promise<unknown | null>>();
39
+ const pendingIndexLoads = new Map<
40
+ string,
41
+ Promise<Record<string, unknown>[]>
42
+ >();
43
+
44
+ interface HistoryOperation {
45
+ action: TransactionAction;
46
+ modelName: string;
47
+ modelId: string;
48
+ payload: Record<string, unknown>;
49
+ original?: Record<string, unknown>;
50
+ }
51
+
52
+ interface HistoryEntry {
53
+ id?: string;
54
+ undo: HistoryOperation;
55
+ redo: HistoryOperation;
56
+ }
57
+
58
+ const undoStack: HistoryEntry[] = [];
59
+ const redoStack: HistoryEntry[] = [];
60
+ let suppressHistory = false;
61
+
62
+ const pushHistory = (entry: HistoryEntry | null): void => {
63
+ if (!entry || suppressHistory) {
64
+ return;
65
+ }
66
+ undoStack.push(entry);
67
+ redoStack.length = 0;
68
+ };
69
+
70
+ const removeHistoryByTxId = (clientTxId: string): void => {
71
+ const removeMatches = (stack: HistoryEntry[]): void => {
72
+ for (let i = stack.length - 1; i >= 0; i--) {
73
+ if (stack[i]?.id === clientTxId) {
74
+ stack.splice(i, 1);
75
+ }
76
+ }
77
+ };
78
+ removeMatches(undoStack);
79
+ removeMatches(redoStack);
80
+ };
81
+ const getModelKey = (modelName: string, id: string): string => {
82
+ return `${modelName}:${id}`;
83
+ };
84
+
85
+ const getModelData = (
86
+ value: Record<string, unknown>
87
+ ): Record<string, unknown> => {
88
+ const candidate = value as { toJSON?: () => Record<string, unknown> };
89
+ if (typeof candidate?.toJSON === "function") {
90
+ return candidate.toJSON();
91
+ }
92
+ return value;
93
+ };
94
+
95
+ const pickOriginal = (
96
+ existing: Record<string, unknown>,
97
+ changes: Record<string, unknown>
98
+ ): Record<string, unknown> => {
99
+ const original: Record<string, unknown> = {};
100
+ for (const key of Object.keys(changes)) {
101
+ original[key] = existing[key];
102
+ }
103
+ return original;
104
+ };
105
+
106
+ const buildHistoryEntry = (
107
+ action: TransactionAction,
108
+ modelName: string,
109
+ modelId: string,
110
+ payload: Record<string, unknown>,
111
+ original?: Record<string, unknown>
112
+ ): HistoryEntry | null => {
113
+ const redo: HistoryOperation = {
114
+ action,
115
+ modelName,
116
+ modelId,
117
+ payload,
118
+ original,
119
+ };
120
+
121
+ let undo: HistoryOperation | null = null;
122
+
123
+ switch (action) {
124
+ case "I":
125
+ undo = {
126
+ action: "D",
127
+ modelName,
128
+ modelId,
129
+ payload: {},
130
+ original: payload,
131
+ };
132
+ break;
133
+ case "D":
134
+ if (original) {
135
+ undo = {
136
+ action: "I",
137
+ modelName,
138
+ modelId,
139
+ payload: original,
140
+ };
141
+ }
142
+ break;
143
+ case "U":
144
+ if (original) {
145
+ undo = {
146
+ action: "U",
147
+ modelName,
148
+ modelId,
149
+ payload: original,
150
+ original: payload,
151
+ };
152
+ }
153
+ break;
154
+ case "A": {
155
+ const archivedAt = payload.archivedAt as string | undefined;
156
+ undo = {
157
+ action: "V",
158
+ modelName,
159
+ modelId,
160
+ payload: {},
161
+ original: { archivedAt },
162
+ };
163
+ break;
164
+ }
165
+ case "V": {
166
+ const archivedAt = original?.archivedAt as string | undefined;
167
+ undo = {
168
+ action: "A",
169
+ modelName,
170
+ modelId,
171
+ payload: { archivedAt: archivedAt ?? new Date().toISOString() },
172
+ original: { archivedAt: undefined },
173
+ };
174
+ break;
175
+ }
176
+ default:
177
+ break;
178
+ }
179
+
180
+ if (!undo) {
181
+ return null;
182
+ }
183
+ return { undo, redo };
184
+ };
185
+
186
+ const emitEvent = (event: SyncClientEvent): void => {
187
+ if (event.type === "modelChange") {
188
+ const key = getModelKey(event.modelName, event.modelId);
189
+ if (event.action === "delete") {
190
+ missingModels.add(key);
191
+ } else {
192
+ missingModels.delete(key);
193
+ }
194
+ }
195
+
196
+ for (const listener of eventListeners) {
197
+ listener(event);
198
+ }
199
+ };
200
+
201
+ const rollbackTransaction = (tx: Transaction): void => {
202
+ const map = identityMaps.getMap<Record<string, unknown>>(tx.modelName);
203
+ const existing = map.get(tx.modelId);
204
+ const original = tx.original;
205
+
206
+ switch (tx.action) {
207
+ case "I": {
208
+ if (existing) {
209
+ map.delete(tx.modelId);
210
+ emitEvent({
211
+ type: "modelChange",
212
+ modelName: tx.modelName,
213
+ modelId: tx.modelId,
214
+ action: "delete",
215
+ });
216
+ }
217
+ break;
218
+ }
219
+ case "D": {
220
+ if (original) {
221
+ map.set(tx.modelId, original as Record<string, unknown>);
222
+ emitEvent({
223
+ type: "modelChange",
224
+ modelName: tx.modelName,
225
+ modelId: tx.modelId,
226
+ action: "insert",
227
+ });
228
+ }
229
+ break;
230
+ }
231
+ case "U": {
232
+ if (!original) {
233
+ break;
234
+ }
235
+ if (existing) {
236
+ map.update(tx.modelId, original as Record<string, unknown>);
237
+ emitEvent({
238
+ type: "modelChange",
239
+ modelName: tx.modelName,
240
+ modelId: tx.modelId,
241
+ action: "update",
242
+ });
243
+ } else {
244
+ map.set(tx.modelId, original as Record<string, unknown>);
245
+ emitEvent({
246
+ type: "modelChange",
247
+ modelName: tx.modelName,
248
+ modelId: tx.modelId,
249
+ action: "insert",
250
+ });
251
+ }
252
+ break;
253
+ }
254
+ case "A":
255
+ case "V": {
256
+ if (existing) {
257
+ const archivedAt =
258
+ (original as Record<string, unknown> | undefined)?.archivedAt ??
259
+ undefined;
260
+ map.update(tx.modelId, { archivedAt } as Record<string, unknown>);
261
+ emitEvent({
262
+ type: "modelChange",
263
+ modelName: tx.modelName,
264
+ modelId: tx.modelId,
265
+ action: "update",
266
+ });
267
+ } else if (original) {
268
+ map.set(tx.modelId, original as Record<string, unknown>);
269
+ emitEvent({
270
+ type: "modelChange",
271
+ modelName: tx.modelName,
272
+ modelId: tx.modelId,
273
+ action: "insert",
274
+ });
275
+ }
276
+ break;
277
+ }
278
+ default:
279
+ break;
280
+ }
281
+ };
282
+
283
+ const applyHistoryOperation = async (
284
+ operation: HistoryOperation
285
+ ): Promise<string | undefined> => {
286
+ const client = getClientRef();
287
+ let txId: string | undefined;
288
+ const capture = (tx: Transaction) => {
289
+ txId = tx.clientTxId;
290
+ };
291
+
292
+ switch (operation.action) {
293
+ case "I":
294
+ await client.create(operation.modelName, operation.payload, {
295
+ onTransactionCreated: capture,
296
+ });
297
+ break;
298
+ case "U":
299
+ await client.update(
300
+ operation.modelName,
301
+ operation.modelId,
302
+ operation.payload,
303
+ {
304
+ original: operation.original,
305
+ onTransactionCreated: capture,
306
+ }
307
+ );
308
+ break;
309
+ case "D":
310
+ await client.delete(operation.modelName, operation.modelId, {
311
+ original: operation.original,
312
+ onTransactionCreated: capture,
313
+ });
314
+ break;
315
+ case "A":
316
+ await client.archive(operation.modelName, operation.modelId, {
317
+ original: operation.original,
318
+ archivedAt: operation.payload.archivedAt as string | undefined,
319
+ onTransactionCreated: capture,
320
+ });
321
+ break;
322
+ case "V":
323
+ await client.unarchive(operation.modelName, operation.modelId, {
324
+ original: operation.original,
325
+ onTransactionCreated: capture,
326
+ });
327
+ break;
328
+ default:
329
+ break;
330
+ }
331
+
332
+ return txId;
333
+ };
334
+
335
+ const orchestrator = new SyncOrchestrator(
336
+ resolvedOptions,
337
+ identityMaps,
338
+ emitEvent
339
+ );
340
+
341
+ let yjsManagers: SyncClient["yjs"] | undefined;
342
+ if (resolvedOptions.yjsTransport) {
343
+ const clientId = getOrCreateClientId(
344
+ `${resolvedOptions.dbName ?? "sync-db"}_client_id`
345
+ );
346
+ const connId = generateUUID();
347
+ const documentManager = new YjsDocumentManager({
348
+ clientId,
349
+ connId,
350
+ });
351
+ const presenceManager = new YjsPresenceManager({
352
+ clientId,
353
+ connId,
354
+ });
355
+
356
+ documentManager.setTransport(resolvedOptions.yjsTransport);
357
+ presenceManager.setTransport(resolvedOptions.yjsTransport);
358
+
359
+ yjsManagers = { documentManager, presenceManager };
360
+ }
361
+
362
+ let outboxManager: OutboxManager | null = null;
363
+
364
+ const buildOutboxOptions = () => ({
365
+ storage: options.storage,
366
+ transport: options.transport,
367
+ clientId: orchestrator.getClientId() || "temp",
368
+ batchMutations: options.batchMutations,
369
+ batchDelay: options.batchDelay,
370
+ onTransactionStateChange: () => {
371
+ emitPendingCount().catch((error) => {
372
+ console.error("Failed to emit pending count", error);
373
+ });
374
+ },
375
+ onTransactionRejected: (tx: Transaction) => {
376
+ rollbackTransaction(tx);
377
+ removeHistoryByTxId(tx.clientTxId);
378
+ },
379
+ });
380
+
381
+ const emitPendingCount = async (): Promise<void> => {
382
+ if (!outboxManager) {
383
+ return;
384
+ }
385
+ const pendingCount = await outboxManager.getPendingCount();
386
+ emitEvent({ type: "outboxChange", pendingCount });
387
+ };
388
+
389
+ const processBatchLoadRow = async (
390
+ row: import("@strata-sync/core").ModelRow
391
+ ): Promise<string | undefined> => {
392
+ const rowPrimaryKey = orchestrator
393
+ .getRegistry()
394
+ .getPrimaryKey(row.modelName);
395
+ const rowId = row.data[rowPrimaryKey] as string;
396
+ if (typeof rowId !== "string") {
397
+ return undefined;
398
+ }
399
+ await options.storage.put(row.modelName, row.data);
400
+ const rowMap = identityMaps.getMap<Record<string, unknown>>(row.modelName);
401
+ const existed = rowMap.has(rowId);
402
+ rowMap.merge(rowId, row.data);
403
+ emitEvent({
404
+ type: "modelChange",
405
+ modelName: row.modelName,
406
+ modelId: rowId,
407
+ action: existed ? "update" : "insert",
408
+ });
409
+ return rowId;
410
+ };
411
+
412
+ const ensureModelInternal = async <T>(
413
+ modelName: string,
414
+ id: string
415
+ ): Promise<T | null> => {
416
+ const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
417
+ const cached = map.get(id);
418
+ if (cached) {
419
+ return cached as T;
420
+ }
421
+
422
+ const stored = await options.storage.get<T>(modelName, id);
423
+ if (stored) {
424
+ map.set(id, stored as T & Record<string, unknown>);
425
+ const key = getModelKey(modelName, id);
426
+ missingModels.delete(key);
427
+ return stored;
428
+ }
429
+
430
+ const model = orchestrator.getRegistry().getModelMetadata(modelName);
431
+ if (!model) {
432
+ return null;
433
+ }
434
+
435
+ const loadStrategy = model.loadStrategy ?? "instant";
436
+ if (loadStrategy === "instant" || loadStrategy === "local") {
437
+ const key = getModelKey(modelName, id);
438
+ missingModels.add(key);
439
+ return null;
440
+ }
441
+
442
+ const key = getModelKey(modelName, id);
443
+ const pending = pendingLoads.get(key);
444
+ if (pending) {
445
+ return pending as Promise<T | null>;
446
+ }
447
+
448
+ const loadPromise = (async () => {
449
+ let found: T | null = null;
450
+ const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
451
+ const stream = options.transport.batchLoad({
452
+ firstSyncId: orchestrator.getFirstSyncId(),
453
+ requests: [
454
+ {
455
+ modelName,
456
+ indexedKey: primaryKey,
457
+ keyValue: id,
458
+ },
459
+ ],
460
+ });
461
+
462
+ for await (const row of stream) {
463
+ const rowId = await processBatchLoadRow(row);
464
+ if (row.modelName === modelName && rowId === id) {
465
+ found = row.data as T;
466
+ }
467
+ }
468
+
469
+ if (found) {
470
+ missingModels.delete(key);
471
+ } else {
472
+ missingModels.add(key);
473
+ }
474
+
475
+ return found;
476
+ })();
477
+
478
+ pendingLoads.set(key, loadPromise);
479
+
480
+ try {
481
+ return await loadPromise;
482
+ } finally {
483
+ pendingLoads.delete(key);
484
+ }
485
+ };
486
+
487
+ const loadByIndexInternal = async <T extends Record<string, unknown>>(
488
+ modelName: string,
489
+ indexedKey: string,
490
+ keyValue: string
491
+ ): Promise<T[]> => {
492
+ const model = orchestrator.getRegistry().getModelMetadata(modelName);
493
+ const isPartial = model?.loadStrategy === "partial";
494
+
495
+ if (!isPartial) {
496
+ return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
497
+ }
498
+
499
+ const hasIndex = await options.storage.hasPartialIndex(
500
+ modelName,
501
+ indexedKey,
502
+ keyValue
503
+ );
504
+
505
+ if (hasIndex) {
506
+ return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
507
+ }
508
+
509
+ const loadKey = `${modelName}:${indexedKey}:${keyValue}`;
510
+ const pending = pendingIndexLoads.get(loadKey);
511
+ if (pending) {
512
+ return pending as Promise<T[]>;
513
+ }
514
+
515
+ const loadPromise = (async () => {
516
+ const stream = options.transport.batchLoad({
517
+ firstSyncId: orchestrator.getFirstSyncId(),
518
+ requests: [
519
+ {
520
+ modelName,
521
+ indexedKey,
522
+ keyValue,
523
+ },
524
+ ],
525
+ });
526
+
527
+ for await (const row of stream) {
528
+ await processBatchLoadRow(row);
529
+ }
530
+
531
+ await options.storage.setPartialIndex(modelName, indexedKey, keyValue);
532
+ return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
533
+ })();
534
+
535
+ pendingIndexLoads.set(loadKey, loadPromise);
536
+
537
+ try {
538
+ return await loadPromise;
539
+ } finally {
540
+ pendingIndexLoads.delete(loadKey);
541
+ }
542
+ };
543
+
544
+ let clientRef: SyncClient | null = null;
545
+
546
+ const getClientRef = (): SyncClient => {
547
+ if (!clientRef) {
548
+ throw new Error("Sync client is not initialized");
549
+ }
550
+ return clientRef;
551
+ };
552
+
553
+ const modelStore: ModelStore & SyncStore = {
554
+ get: ensureModelInternal,
555
+ getByIndex: (modelName, indexName, key) =>
556
+ options.storage.getByIndex(modelName, indexName, key),
557
+ loadByIndex: loadByIndexInternal,
558
+ hasPartialIndex: (modelName, indexName, key) =>
559
+ options.storage.hasPartialIndex(modelName, indexName, key),
560
+ setPartialIndex: (modelName, indexName, key) =>
561
+ options.storage.setPartialIndex(modelName, indexName, key),
562
+ create: (modelName, data) => {
563
+ const client = getClientRef();
564
+ return client.create(modelName, data);
565
+ },
566
+ update: (modelName, id, changes, options) => {
567
+ const client = getClientRef();
568
+ return client.update(modelName, id, changes, options);
569
+ },
570
+ delete: (modelName, id, options) => {
571
+ const client = getClientRef();
572
+ return client.delete(modelName, id, options);
573
+ },
574
+ archive: (modelName, id, options) => {
575
+ const client = getClientRef();
576
+ return client.archive(modelName, id, options);
577
+ },
578
+ unarchive: (modelName, id, options) => {
579
+ const client = getClientRef();
580
+ return client.unarchive(modelName, id, options);
581
+ },
582
+ };
583
+
584
+ const defaultModelFactory: ModelFactoryFactory = ({ store }) => {
585
+ return (modelName: string, data: Record<string, unknown>) => {
586
+ const ctor = ModelRegistry.getModelConstructor(modelName);
587
+ if (!ctor) {
588
+ return data;
589
+ }
590
+ const instance = new ctor() as Record<string, unknown>;
591
+ const candidate = instance as {
592
+ store?: SyncStore;
593
+ _applyUpdate?: (changes: Record<string, unknown>) => void;
594
+ };
595
+ if ("store" in candidate) {
596
+ candidate.store = store;
597
+ }
598
+ if (typeof candidate._applyUpdate === "function") {
599
+ candidate._applyUpdate(data);
600
+ } else {
601
+ Object.assign(instance, data);
602
+ }
603
+ return instance;
604
+ };
605
+ };
606
+
607
+ const resolveModelFactory = (
608
+ factory: ModelFactory | ModelFactoryFactory | undefined
609
+ ): ModelFactory | undefined => {
610
+ if (!factory) {
611
+ return undefined;
612
+ }
613
+ if (typeof factory === "function" && factory.length <= 1) {
614
+ return (factory as ModelFactoryFactory)({ store: modelStore });
615
+ }
616
+ return factory as ModelFactory;
617
+ };
618
+
619
+ const resolvedModelFactory = resolveModelFactory(
620
+ resolvedOptions.modelFactory ?? defaultModelFactory
621
+ );
622
+ identityMaps.setModelFactory(resolvedModelFactory);
623
+
624
+ const client: SyncClient = {
625
+ get state(): SyncClientState {
626
+ return orchestrator.state;
627
+ },
628
+
629
+ get connectionState(): ConnectionState {
630
+ return orchestrator.connectionState;
631
+ },
632
+
633
+ get lastSyncId(): number {
634
+ return orchestrator.getLastSyncId();
635
+ },
636
+
637
+ get lastError(): Error | null {
638
+ return orchestrator.getLastError();
639
+ },
640
+
641
+ get clientId(): string {
642
+ return orchestrator.getClientId();
643
+ },
644
+
645
+ yjs: yjsManagers,
646
+
647
+ async start(): Promise<void> {
648
+ outboxManager = new OutboxManager(buildOutboxOptions());
649
+ orchestrator.setOutboxManager(outboxManager);
650
+ orchestrator.setConflictHandler((tx) => {
651
+ rollbackTransaction(tx);
652
+ removeHistoryByTxId(tx.clientTxId);
653
+ });
654
+ await orchestrator.start();
655
+
656
+ // Recreate with correct client ID after orchestrator resolves it
657
+ outboxManager = new OutboxManager(buildOutboxOptions());
658
+ orchestrator.setOutboxManager(outboxManager);
659
+
660
+ await emitPendingCount();
661
+ },
662
+
663
+ async stop(): Promise<void> {
664
+ await orchestrator.stop();
665
+ },
666
+
667
+ async get<T>(modelName: string, id: string): Promise<T | null> {
668
+ // Try identity map first
669
+ const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
670
+ const cached = map.get(id);
671
+ if (cached) {
672
+ return cached as T;
673
+ }
674
+
675
+ // Fall back to storage
676
+ const stored = await options.storage.get<T>(modelName, id);
677
+ if (stored) {
678
+ // Cache in identity map
679
+ map.set(id, stored as T & Record<string, unknown>);
680
+ }
681
+ return stored;
682
+ },
683
+
684
+ getCached<T>(modelName: string, id: string): T | null {
685
+ const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
686
+ const cached = map.get(id);
687
+ return cached ? (cached as T) : null;
688
+ },
689
+
690
+ ensureModel<T>(modelName: string, id: string): Promise<T | null> {
691
+ return ensureModelInternal(modelName, id);
692
+ },
693
+
694
+ getAll<T>(modelName: string, queryOptions?: QueryOptions<T>): Promise<T[]> {
695
+ const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
696
+ const result = executeQuery(map, queryOptions);
697
+ return Promise.resolve(result.data as T[]);
698
+ },
699
+
700
+ query<T>(
701
+ modelName: string,
702
+ queryOptions?: QueryOptions<T>
703
+ ): Promise<QueryResult<T>> {
704
+ const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
705
+ return Promise.resolve(executeQuery(map, queryOptions) as QueryResult<T>);
706
+ },
707
+
708
+ async create<T extends Record<string, unknown>>(
709
+ modelName: string,
710
+ data: T,
711
+ mutationOptions?: { onTransactionCreated?: (tx: Transaction) => void }
712
+ ): Promise<T> {
713
+ const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
714
+
715
+ // Generate ID if not provided
716
+ const id = (data[primaryKey] as string) || generateUUID();
717
+ const fullData = { ...data, [primaryKey]: id };
718
+
719
+ // Optimistic update
720
+ if (resolvedOptions.optimistic !== false) {
721
+ const map = identityMaps.getMap<T>(modelName);
722
+ map.set(id, fullData);
723
+ missingModels.delete(getModelKey(modelName, id));
724
+ emitEvent({
725
+ type: "modelChange",
726
+ modelName,
727
+ modelId: id,
728
+ action: "insert",
729
+ });
730
+ }
731
+
732
+ let queuedTx: Transaction | undefined;
733
+ if (outboxManager) {
734
+ queuedTx = await outboxManager.insert(modelName, id, fullData);
735
+ mutationOptions?.onTransactionCreated?.(queuedTx);
736
+ }
737
+
738
+ const historyEntry = buildHistoryEntry("I", modelName, id, fullData);
739
+ if (historyEntry) {
740
+ historyEntry.id = queuedTx?.clientTxId;
741
+ pushHistory(historyEntry);
742
+ }
743
+
744
+ return fullData;
745
+ },
746
+
747
+ async update<T extends Record<string, unknown>>(
748
+ modelName: string,
749
+ id: string,
750
+ changes: Partial<T>,
751
+ mutationOptions?: {
752
+ original?: Record<string, unknown>;
753
+ onTransactionCreated?: (tx: Transaction) => void;
754
+ }
755
+ ): Promise<T> {
756
+ const map = identityMaps.getMap<T>(modelName);
757
+ const existing = map.get(id);
758
+
759
+ if (!existing) {
760
+ throw new Error(`Model ${modelName} with id ${id} not found`);
761
+ }
762
+
763
+ const existingData = getModelData(existing);
764
+ const original =
765
+ mutationOptions?.original ??
766
+ pickOriginal(existingData, changes as Record<string, unknown>);
767
+ const updated = { ...existingData, ...changes } as T;
768
+
769
+ // Optimistic update
770
+ if (resolvedOptions.optimistic !== false) {
771
+ map.update(id, changes);
772
+ missingModels.delete(getModelKey(modelName, id));
773
+ emitEvent({
774
+ type: "modelChange",
775
+ modelName,
776
+ modelId: id,
777
+ action: "update",
778
+ });
779
+ }
780
+
781
+ let queuedTx: Transaction | undefined;
782
+ if (outboxManager) {
783
+ queuedTx = await outboxManager.update(
784
+ modelName,
785
+ id,
786
+ changes as Record<string, unknown>,
787
+ original
788
+ );
789
+ mutationOptions?.onTransactionCreated?.(queuedTx);
790
+ }
791
+
792
+ const historyEntry = buildHistoryEntry(
793
+ "U",
794
+ modelName,
795
+ id,
796
+ changes as Record<string, unknown>,
797
+ original
798
+ );
799
+ if (historyEntry) {
800
+ historyEntry.id = queuedTx?.clientTxId;
801
+ pushHistory(historyEntry);
802
+ }
803
+
804
+ return updated;
805
+ },
806
+
807
+ async delete(
808
+ modelName: string,
809
+ id: string,
810
+ mutationOptions?: {
811
+ original?: Record<string, unknown>;
812
+ onTransactionCreated?: (tx: Transaction) => void;
813
+ }
814
+ ): Promise<void> {
815
+ const map = identityMaps.getMap(modelName);
816
+ const existing = map.get(id);
817
+
818
+ if (!existing) {
819
+ throw new Error(`Model ${modelName} with id ${id} not found`);
820
+ }
821
+
822
+ // Optimistic delete
823
+ if (resolvedOptions.optimistic !== false) {
824
+ map.delete(id);
825
+ emitEvent({
826
+ type: "modelChange",
827
+ modelName,
828
+ modelId: id,
829
+ action: "delete",
830
+ });
831
+ }
832
+
833
+ const original = mutationOptions?.original ?? getModelData(existing);
834
+ let queuedTx: Transaction | undefined;
835
+ if (outboxManager) {
836
+ queuedTx = await outboxManager.delete(modelName, id, original);
837
+ mutationOptions?.onTransactionCreated?.(queuedTx);
838
+ }
839
+
840
+ const historyEntry = buildHistoryEntry("D", modelName, id, {}, original);
841
+ if (historyEntry) {
842
+ historyEntry.id = queuedTx?.clientTxId;
843
+ pushHistory(historyEntry);
844
+ }
845
+ },
846
+
847
+ async archive(
848
+ modelName: string,
849
+ id: string,
850
+ mutationOptions?: {
851
+ original?: Record<string, unknown>;
852
+ archivedAt?: string;
853
+ onTransactionCreated?: (tx: Transaction) => void;
854
+ }
855
+ ): Promise<void> {
856
+ const map = identityMaps.getMap(modelName);
857
+ const existing = map.get(id);
858
+
859
+ if (!existing) {
860
+ throw new Error(`Model ${modelName} with id ${id} not found`);
861
+ }
862
+
863
+ const existingData = getModelData(existing);
864
+ const archivedAt =
865
+ mutationOptions?.archivedAt ?? new Date().toISOString();
866
+ const original = mutationOptions?.original ?? {
867
+ archivedAt: existingData.archivedAt,
868
+ };
869
+ const archived = {
870
+ ...existingData,
871
+ archivedAt,
872
+ };
873
+
874
+ // Optimistic archive
875
+ if (resolvedOptions.optimistic !== false) {
876
+ map.update(id, { archivedAt: archived.archivedAt } as Record<
877
+ string,
878
+ unknown
879
+ >);
880
+ emitEvent({
881
+ type: "modelChange",
882
+ modelName,
883
+ modelId: id,
884
+ action: "archive",
885
+ });
886
+ }
887
+
888
+ let queuedTx: Transaction | undefined;
889
+ if (outboxManager) {
890
+ queuedTx = await outboxManager.archive(modelName, id, {
891
+ original,
892
+ archivedAt,
893
+ });
894
+ mutationOptions?.onTransactionCreated?.(queuedTx);
895
+ }
896
+
897
+ const historyEntry = buildHistoryEntry(
898
+ "A",
899
+ modelName,
900
+ id,
901
+ { archivedAt },
902
+ original
903
+ );
904
+ if (historyEntry) {
905
+ historyEntry.id = queuedTx?.clientTxId;
906
+ pushHistory(historyEntry);
907
+ }
908
+ },
909
+
910
+ async unarchive(
911
+ modelName: string,
912
+ id: string,
913
+ mutationOptions?: {
914
+ original?: Record<string, unknown>;
915
+ onTransactionCreated?: (tx: Transaction) => void;
916
+ }
917
+ ): Promise<void> {
918
+ const map = identityMaps.getMap(modelName);
919
+ const existing = map.get(id);
920
+
921
+ if (!existing) {
922
+ throw new Error(`Model ${modelName} with id ${id} not found`);
923
+ }
924
+
925
+ const existingData = getModelData(existing);
926
+ const original = mutationOptions?.original ?? {
927
+ archivedAt: existingData.archivedAt,
928
+ };
929
+
930
+ // Optimistic unarchive
931
+ if (resolvedOptions.optimistic !== false) {
932
+ map.update(id, { archivedAt: undefined } as Record<string, unknown>);
933
+ emitEvent({
934
+ type: "modelChange",
935
+ modelName,
936
+ modelId: id,
937
+ action: "unarchive",
938
+ });
939
+ }
940
+
941
+ let queuedTx: Transaction | undefined;
942
+ if (outboxManager) {
943
+ queuedTx = await outboxManager.unarchive(modelName, id, { original });
944
+ mutationOptions?.onTransactionCreated?.(queuedTx);
945
+ }
946
+
947
+ const historyEntry = buildHistoryEntry("V", modelName, id, {}, original);
948
+ if (historyEntry) {
949
+ historyEntry.id = queuedTx?.clientTxId;
950
+ pushHistory(historyEntry);
951
+ }
952
+ },
953
+
954
+ canUndo(): boolean {
955
+ return undoStack.length > 0;
956
+ },
957
+
958
+ canRedo(): boolean {
959
+ return redoStack.length > 0;
960
+ },
961
+
962
+ async undo(): Promise<void> {
963
+ const entry = undoStack.pop();
964
+ if (!entry) {
965
+ return;
966
+ }
967
+
968
+ suppressHistory = true;
969
+ try {
970
+ const txId = await applyHistoryOperation(entry.undo);
971
+ entry.id = txId;
972
+ redoStack.push(entry);
973
+ } catch (error) {
974
+ undoStack.push(entry);
975
+ throw error;
976
+ } finally {
977
+ suppressHistory = false;
978
+ }
979
+ },
980
+
981
+ async redo(): Promise<void> {
982
+ const entry = redoStack.pop();
983
+ if (!entry) {
984
+ return;
985
+ }
986
+
987
+ suppressHistory = true;
988
+ try {
989
+ const txId = await applyHistoryOperation(entry.redo);
990
+ entry.id = txId;
991
+ undoStack.push(entry);
992
+ } catch (error) {
993
+ redoStack.push(entry);
994
+ throw error;
995
+ } finally {
996
+ suppressHistory = false;
997
+ }
998
+ },
999
+
1000
+ onStateChange(callback: (state: SyncClientState) => void): () => void {
1001
+ return orchestrator.onStateChange(callback);
1002
+ },
1003
+
1004
+ onConnectionStateChange(
1005
+ callback: (state: ConnectionState) => void
1006
+ ): () => void {
1007
+ return orchestrator.onConnectionStateChange(callback);
1008
+ },
1009
+
1010
+ onEvent(callback: (event: SyncClientEvent) => void): () => void {
1011
+ eventListeners.add(callback);
1012
+ return () => {
1013
+ eventListeners.delete(callback);
1014
+ };
1015
+ },
1016
+
1017
+ getPendingCount(): Promise<number> {
1018
+ if (!outboxManager) {
1019
+ return Promise.resolve(0);
1020
+ }
1021
+ return outboxManager.getPendingCount();
1022
+ },
1023
+
1024
+ async syncNow(): Promise<void> {
1025
+ await orchestrator.syncNow();
1026
+ },
1027
+
1028
+ async clearAll(): Promise<void> {
1029
+ identityMaps.clearAll();
1030
+ await options.storage.clear();
1031
+ pendingLoads.clear();
1032
+ missingModels.clear();
1033
+ undoStack.length = 0;
1034
+ redoStack.length = 0;
1035
+ emitEvent({ type: "outboxChange", pendingCount: 0 });
1036
+ },
1037
+
1038
+ getIdentityMap<T>(modelName: string): Map<string, T> {
1039
+ return identityMaps
1040
+ .getMap<T & Record<string, unknown>>(modelName)
1041
+ .getRawMap() as Map<string, T>;
1042
+ },
1043
+
1044
+ isModelMissing(modelName: string, id: string): boolean {
1045
+ return missingModels.has(getModelKey(modelName, id));
1046
+ },
1047
+ };
1048
+
1049
+ clientRef = client;
1050
+ return client;
1051
+ }