@stratasync/client 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,759 @@
1
+ import { captureArchiveState, createArchivePayload, createUnarchivePatch, createUnarchivePayload, generateUUID, getOrCreateClientId, ModelRegistry, readArchivedAt, } from "@stratasync/core";
2
+ import { YjsDocumentManager, YjsPresenceManager } from "@stratasync/yjs";
3
+ import { HistoryManager } from "./history-manager.js";
4
+ import { IdentityMapRegistry } from "./identity-map.js";
5
+ import { OutboxManager } from "./outbox-manager.js";
6
+ import { executeQuery } from "./query.js";
7
+ import { SyncOrchestrator } from "./sync-orchestrator.js";
8
+ import { getModelData, getModelKey, pickOriginal } from "./utils.js";
9
+ /**
10
+ * Default model factory: creates model instances using the constructor
11
+ * registered in ModelRegistry, falling back to plain data objects.
12
+ */
13
+ const defaultModelFactory = ({ store }) => (modelName, data) => {
14
+ const ctor = ModelRegistry.getModelConstructor(modelName);
15
+ if (!ctor) {
16
+ return data;
17
+ }
18
+ const instance = new ctor();
19
+ const candidate = instance;
20
+ if ("store" in candidate) {
21
+ candidate.store = store;
22
+ }
23
+ if (typeof candidate._applyUpdate === "function") {
24
+ candidate._applyUpdate(data);
25
+ }
26
+ else {
27
+ Object.assign(instance, data);
28
+ }
29
+ return instance;
30
+ };
31
+ /**
32
+ * Resolves a ModelFactory or ModelFactoryFactory into a ModelFactory.
33
+ * A ModelFactoryFactory is a function that takes a store context and returns
34
+ * a ModelFactory. We distinguish by arity: factories take 1 arg (context),
35
+ * plain ModelFactory takes 2 (modelName, data).
36
+ */
37
+ const resolveModelFactory = (factory, modelStore) => {
38
+ if (!factory) {
39
+ return undefined;
40
+ }
41
+ if (typeof factory === "function" && factory.length <= 1) {
42
+ return factory({ store: modelStore });
43
+ }
44
+ return factory;
45
+ };
46
+ const buildEffectiveUpdate = (existingData, changes) => {
47
+ const effectiveChanges = {};
48
+ for (const [key, value] of Object.entries(changes)) {
49
+ if (!Object.is(existingData[key], value)) {
50
+ effectiveChanges[key] = value;
51
+ }
52
+ }
53
+ return {
54
+ effectiveChangeRecord: effectiveChanges,
55
+ effectiveChanges,
56
+ };
57
+ };
58
+ /**
59
+ * Creates a sync client instance.
60
+ *
61
+ * Uses a closure to encapsulate mutable state (identity maps, outbox, history).
62
+ * The `clientRef` / `getClientRef` pattern exists because the client object
63
+ * literal needs to reference itself for history replay operations, but it
64
+ * hasn't been assigned yet at definition time.
65
+ */
66
+ export const createSyncClient = (options) => {
67
+ const resolvedOptions = { ...options };
68
+ const identityMaps = new IdentityMapRegistry(resolvedOptions.reactivity, undefined, resolvedOptions.identityMapMaxSize);
69
+ const eventListeners = new Set();
70
+ const missingModels = new Set();
71
+ const pendingLoads = new Map();
72
+ const pendingIndexLoads = new Map();
73
+ const history = new HistoryManager();
74
+ const emitEvent = (event) => {
75
+ if (event.type === "modelChange") {
76
+ const key = getModelKey(event.modelName, event.modelId);
77
+ if (event.action === "delete") {
78
+ missingModels.add(key);
79
+ }
80
+ else {
81
+ missingModels.delete(key);
82
+ }
83
+ }
84
+ for (const listener of eventListeners) {
85
+ listener(event);
86
+ }
87
+ };
88
+ const emitModelChange = (modelName, modelId, action) => {
89
+ emitEvent({
90
+ action,
91
+ modelId,
92
+ modelName,
93
+ type: "modelChange",
94
+ });
95
+ };
96
+ const recordHistoryEntry = (entry, queuedTx) => {
97
+ if (!entry) {
98
+ return;
99
+ }
100
+ history.record(entry, queuedTx?.clientTxId);
101
+ };
102
+ /**
103
+ * Rolls back a transaction by inverting its effect on the identity map.
104
+ * This intentionally differs from `applyDeltas` (which writes to storage)
105
+ * and `applyPendingTransactionsToIdentityMaps` (which re-applies forward).
106
+ */
107
+ const rollbackTransaction = (tx) => {
108
+ const map = identityMaps.getMap(tx.modelName);
109
+ const existing = map.get(tx.modelId);
110
+ const { original } = tx;
111
+ switch (tx.action) {
112
+ case "I": {
113
+ if (existing) {
114
+ map.delete(tx.modelId);
115
+ emitModelChange(tx.modelName, tx.modelId, "delete");
116
+ }
117
+ break;
118
+ }
119
+ case "D": {
120
+ if (original) {
121
+ map.set(tx.modelId, original);
122
+ emitModelChange(tx.modelName, tx.modelId, "insert");
123
+ }
124
+ break;
125
+ }
126
+ case "U": {
127
+ if (!original) {
128
+ break;
129
+ }
130
+ if (existing) {
131
+ map.update(tx.modelId, original);
132
+ emitModelChange(tx.modelName, tx.modelId, "update");
133
+ }
134
+ else {
135
+ map.set(tx.modelId, original);
136
+ emitModelChange(tx.modelName, tx.modelId, "insert");
137
+ }
138
+ break;
139
+ }
140
+ case "A":
141
+ case "V": {
142
+ if (existing) {
143
+ map.update(tx.modelId, captureArchiveState(original));
144
+ emitModelChange(tx.modelName, tx.modelId, "update");
145
+ }
146
+ else if (original) {
147
+ map.set(tx.modelId, original);
148
+ emitModelChange(tx.modelName, tx.modelId, "insert");
149
+ }
150
+ break;
151
+ }
152
+ default: {
153
+ break;
154
+ }
155
+ }
156
+ };
157
+ const applyHistoryOperation = async (operation) => {
158
+ const client = getClientRef();
159
+ let txId;
160
+ const capture = (tx) => {
161
+ txId = tx.clientTxId;
162
+ };
163
+ switch (operation.action) {
164
+ case "I": {
165
+ await client.create(operation.modelName, operation.payload, {
166
+ onTransactionCreated: capture,
167
+ });
168
+ break;
169
+ }
170
+ case "U": {
171
+ await client.update(operation.modelName, operation.modelId, operation.payload, {
172
+ onTransactionCreated: capture,
173
+ original: operation.original,
174
+ });
175
+ break;
176
+ }
177
+ case "D": {
178
+ await client.delete(operation.modelName, operation.modelId, {
179
+ onTransactionCreated: capture,
180
+ original: operation.original,
181
+ });
182
+ break;
183
+ }
184
+ case "A": {
185
+ await client.archive(operation.modelName, operation.modelId, {
186
+ archivedAt: readArchivedAt(operation.payload),
187
+ onTransactionCreated: capture,
188
+ original: operation.original,
189
+ });
190
+ break;
191
+ }
192
+ case "V": {
193
+ await client.unarchive(operation.modelName, operation.modelId, {
194
+ onTransactionCreated: capture,
195
+ original: operation.original,
196
+ });
197
+ break;
198
+ }
199
+ default: {
200
+ break;
201
+ }
202
+ }
203
+ return txId;
204
+ };
205
+ const orchestrator = new SyncOrchestrator(resolvedOptions, identityMaps, emitEvent);
206
+ const getStorageOpenOptions = () => ({
207
+ name: resolvedOptions.dbName,
208
+ schema: resolvedOptions.schema ?? orchestrator.getRegistry().snapshot(),
209
+ userId: resolvedOptions.userId,
210
+ userVersion: resolvedOptions.userVersion,
211
+ version: resolvedOptions.version,
212
+ });
213
+ let yjsManagers;
214
+ if (resolvedOptions.yjsTransport) {
215
+ const clientId = getOrCreateClientId(`${resolvedOptions.dbName ?? "sync-db"}_client_id`);
216
+ const connId = generateUUID();
217
+ const documentManager = new YjsDocumentManager({
218
+ clientId,
219
+ connId,
220
+ });
221
+ const presenceManager = new YjsPresenceManager({
222
+ clientId,
223
+ connId,
224
+ });
225
+ // Presence must replay before document sync handshake on reconnect so
226
+ // the server sees the connection as viewing before yjs_sync_step1.
227
+ presenceManager.setTransport(resolvedOptions.yjsTransport);
228
+ documentManager.setTransport(resolvedOptions.yjsTransport);
229
+ yjsManagers = { documentManager, presenceManager };
230
+ }
231
+ let outboxManager = null;
232
+ let startPromise = null;
233
+ let hasStarted = false;
234
+ let lifecycleVersion = 0;
235
+ const buildOutboxOptions = () => ({
236
+ batchDelay: options.batchDelay,
237
+ batchMutations: options.batchMutations,
238
+ clientId: orchestrator.getClientId() || "temp",
239
+ onTransactionRejected: rollbackTransaction,
240
+ onTransactionStateChange: async () => {
241
+ try {
242
+ await emitPendingCount();
243
+ }
244
+ catch (error) {
245
+ emitEvent({
246
+ error: error instanceof Error
247
+ ? error
248
+ : new Error("Failed to emit pending count"),
249
+ type: "syncError",
250
+ });
251
+ }
252
+ },
253
+ storage: options.storage,
254
+ transport: options.transport,
255
+ });
256
+ const createOutboxManager = () => {
257
+ const nextOutboxManager = new OutboxManager(buildOutboxOptions());
258
+ outboxManager = nextOutboxManager;
259
+ orchestrator.setOutboxManager(nextOutboxManager);
260
+ return nextOutboxManager;
261
+ };
262
+ const replaceOutboxManager = () => {
263
+ outboxManager?.dispose();
264
+ return createOutboxManager();
265
+ };
266
+ const clearOutboxManager = () => {
267
+ outboxManager?.dispose();
268
+ outboxManager = null;
269
+ };
270
+ const getPendingCountInternal = async (options) => {
271
+ if (!outboxManager) {
272
+ return 0;
273
+ }
274
+ if (options?.awaitStart !== false && startPromise) {
275
+ try {
276
+ await startPromise;
277
+ }
278
+ catch {
279
+ return 0;
280
+ }
281
+ if (!outboxManager) {
282
+ return 0;
283
+ }
284
+ }
285
+ return outboxManager.getPendingCount();
286
+ };
287
+ const emitPendingCount = async (options) => {
288
+ if (!outboxManager) {
289
+ return;
290
+ }
291
+ const pendingCount = await getPendingCountInternal(options);
292
+ emitEvent({ pendingCount, type: "outboxChange" });
293
+ };
294
+ const clearYjsState = () => {
295
+ try {
296
+ const documentManager = yjsManagers?.documentManager;
297
+ documentManager?.destroyAll();
298
+ documentManager?.clearPersistedDocuments?.();
299
+ }
300
+ catch {
301
+ // Best-effort cleanup — don't abort clearAll if Yjs teardown fails
302
+ }
303
+ };
304
+ const runWithStateLock = (operation) => orchestrator.runWithStateLock(operation);
305
+ const createBatchLoadStream = (requests) => options.transport.batchLoad({
306
+ firstSyncId: orchestrator.getFirstSyncId(),
307
+ requests,
308
+ });
309
+ const queueUpdateTransaction = async (modelName, id, effectiveChangeRecord, original, mutationOptions) => {
310
+ if (!outboxManager) {
311
+ return undefined;
312
+ }
313
+ const queuedTx = await outboxManager.update(modelName, id, effectiveChangeRecord, original);
314
+ mutationOptions?.onTransactionCreated?.(queuedTx);
315
+ return queuedTx;
316
+ };
317
+ const processBatchLoadRow = async (row) => await runWithStateLock(async () => {
318
+ const rowPrimaryKey = orchestrator
319
+ .getRegistry()
320
+ .getPrimaryKey(row.modelName);
321
+ const rowId = row.data[rowPrimaryKey];
322
+ if (typeof rowId !== "string") {
323
+ return;
324
+ }
325
+ await options.storage.put(row.modelName, row.data);
326
+ const rowMap = identityMaps.getMap(row.modelName);
327
+ const existed = rowMap.has(rowId);
328
+ rowMap.merge(rowId, row.data);
329
+ emitModelChange(row.modelName, rowId, existed ? "update" : "insert");
330
+ return rowId;
331
+ });
332
+ const ensureModelInternal = async (modelName, id) => {
333
+ const map = identityMaps.getMap(modelName);
334
+ const cached = map.get(id);
335
+ if (cached) {
336
+ return cached;
337
+ }
338
+ const stored = await options.storage.get(modelName, id);
339
+ if (stored) {
340
+ map.set(id, stored);
341
+ const key = getModelKey(modelName, id);
342
+ missingModels.delete(key);
343
+ return stored;
344
+ }
345
+ const model = orchestrator.getRegistry().getModelMetadata(modelName);
346
+ if (!model) {
347
+ return null;
348
+ }
349
+ const loadStrategy = model.loadStrategy ?? "instant";
350
+ if (loadStrategy === "instant" || loadStrategy === "local") {
351
+ const key = getModelKey(modelName, id);
352
+ missingModels.add(key);
353
+ return null;
354
+ }
355
+ const key = getModelKey(modelName, id);
356
+ const pending = pendingLoads.get(key);
357
+ if (pending) {
358
+ return pending;
359
+ }
360
+ const loadPromise = (async () => {
361
+ let found = null;
362
+ const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
363
+ const stream = createBatchLoadStream([
364
+ {
365
+ indexedKey: primaryKey,
366
+ keyValue: id,
367
+ modelName,
368
+ },
369
+ ]);
370
+ for await (const row of stream) {
371
+ const rowId = await processBatchLoadRow(row);
372
+ if (row.modelName === modelName && rowId === id) {
373
+ found = row.data;
374
+ }
375
+ }
376
+ if (found) {
377
+ missingModels.delete(key);
378
+ }
379
+ else {
380
+ missingModels.add(key);
381
+ }
382
+ return found;
383
+ })();
384
+ pendingLoads.set(key, loadPromise);
385
+ try {
386
+ return await loadPromise;
387
+ }
388
+ finally {
389
+ pendingLoads.delete(key);
390
+ }
391
+ };
392
+ const loadByIndexInternal = async (modelName, indexedKey, keyValue) => {
393
+ const model = orchestrator.getRegistry().getModelMetadata(modelName);
394
+ const isPartial = model?.loadStrategy === "partial";
395
+ if (!isPartial) {
396
+ return options.storage.getByIndex(modelName, indexedKey, keyValue);
397
+ }
398
+ const hasIndex = await options.storage.hasPartialIndex(modelName, indexedKey, keyValue);
399
+ if (hasIndex) {
400
+ return options.storage.getByIndex(modelName, indexedKey, keyValue);
401
+ }
402
+ const loadKey = `${modelName}:${indexedKey}:${keyValue}`;
403
+ const pending = pendingIndexLoads.get(loadKey);
404
+ if (pending) {
405
+ return pending;
406
+ }
407
+ const loadPromise = (async () => {
408
+ const stream = createBatchLoadStream([
409
+ {
410
+ indexedKey,
411
+ keyValue,
412
+ modelName,
413
+ },
414
+ ]);
415
+ for await (const row of stream) {
416
+ await processBatchLoadRow(row);
417
+ }
418
+ await options.storage.setPartialIndex(modelName, indexedKey, keyValue);
419
+ return options.storage.getByIndex(modelName, indexedKey, keyValue);
420
+ })();
421
+ pendingIndexLoads.set(loadKey, loadPromise);
422
+ try {
423
+ return await loadPromise;
424
+ }
425
+ finally {
426
+ pendingIndexLoads.delete(loadKey);
427
+ }
428
+ };
429
+ // clientRef / getClientRef: The client object literal references itself
430
+ // (via getClientRef) for history replay operations, but it hasn't been
431
+ // assigned yet at definition time. clientRef is set after the object is built.
432
+ let clientRef = null;
433
+ const getClientRef = () => {
434
+ if (!clientRef) {
435
+ throw new Error("Sync client is not initialized");
436
+ }
437
+ return clientRef;
438
+ };
439
+ const modelStore = {
440
+ archive: (modelName, id, archiveOpts) => {
441
+ const client = getClientRef();
442
+ return client.archive(modelName, id, archiveOpts);
443
+ },
444
+ create: (modelName, data) => {
445
+ const client = getClientRef();
446
+ return client.create(modelName, data);
447
+ },
448
+ delete: (modelName, id, deleteOpts) => {
449
+ const client = getClientRef();
450
+ return client.delete(modelName, id, deleteOpts);
451
+ },
452
+ get: ensureModelInternal,
453
+ getByIndex: (modelName, indexName, key) => options.storage.getByIndex(modelName, indexName, key),
454
+ hasPartialIndex: (modelName, indexName, key) => options.storage.hasPartialIndex(modelName, indexName, key),
455
+ loadByIndex: loadByIndexInternal,
456
+ setPartialIndex: (modelName, indexName, key) => options.storage.setPartialIndex(modelName, indexName, key),
457
+ unarchive: (modelName, id, unarchiveOpts) => {
458
+ const client = getClientRef();
459
+ return client.unarchive(modelName, id, unarchiveOpts);
460
+ },
461
+ update: (modelName, id, changes, updateOpts) => {
462
+ const client = getClientRef();
463
+ return client.update(modelName, id, changes, updateOpts);
464
+ },
465
+ };
466
+ const resolvedModelFactory = resolveModelFactory(resolvedOptions.modelFactory ?? defaultModelFactory, modelStore);
467
+ identityMaps.setModelFactory(resolvedModelFactory);
468
+ const client = {
469
+ async archive(modelName, id, mutationOptions) {
470
+ await runWithStateLock(async () => {
471
+ const map = identityMaps.getMap(modelName);
472
+ const existing = map.get(id);
473
+ if (!existing) {
474
+ throw new Error(`Model ${modelName} with id ${id} not found`);
475
+ }
476
+ const existingData = getModelData(existing);
477
+ const archived = createArchivePayload(mutationOptions?.archivedAt);
478
+ const original = mutationOptions?.original ?? captureArchiveState(existingData);
479
+ if (resolvedOptions.optimistic !== false) {
480
+ map.update(id, archived);
481
+ emitModelChange(modelName, id, "archive");
482
+ }
483
+ let queuedTx;
484
+ if (outboxManager) {
485
+ queuedTx = await outboxManager.archive(modelName, id, {
486
+ archivedAt: archived.archivedAt ?? undefined,
487
+ original,
488
+ });
489
+ mutationOptions?.onTransactionCreated?.(queuedTx);
490
+ }
491
+ recordHistoryEntry(history.buildEntry("A", modelName, id, archived, original), queuedTx);
492
+ });
493
+ },
494
+ canRedo() {
495
+ return history.canRedo();
496
+ },
497
+ canUndo() {
498
+ return history.canUndo();
499
+ },
500
+ async clearAll() {
501
+ const pendingStart = startPromise;
502
+ lifecycleVersion += 1;
503
+ startPromise = null;
504
+ hasStarted = false;
505
+ await outboxManager?.clear();
506
+ clearOutboxManager();
507
+ clearYjsState();
508
+ await orchestrator.reset();
509
+ identityMaps.clearAll();
510
+ await options.storage.close();
511
+ await options.storage.open(getStorageOpenOptions());
512
+ try {
513
+ await options.storage.clear();
514
+ }
515
+ finally {
516
+ await options.storage.close();
517
+ }
518
+ pendingLoads.clear();
519
+ pendingIndexLoads.clear();
520
+ missingModels.clear();
521
+ history.clear();
522
+ await pendingStart?.catch(() => {
523
+ /* noop */
524
+ });
525
+ emitEvent({ pendingCount: 0, type: "outboxChange" });
526
+ },
527
+ get clientId() {
528
+ return orchestrator.getClientId();
529
+ },
530
+ get connectionState() {
531
+ return orchestrator.connectionState;
532
+ },
533
+ async create(modelName, data, mutationOptions) {
534
+ return await runWithStateLock(async () => {
535
+ const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
536
+ // Generate ID if not provided
537
+ const id = data[primaryKey] || generateUUID();
538
+ const fullData = { ...data, [primaryKey]: id };
539
+ if (resolvedOptions.optimistic !== false) {
540
+ const map = identityMaps.getMap(modelName);
541
+ map.set(id, fullData);
542
+ missingModels.delete(getModelKey(modelName, id));
543
+ emitModelChange(modelName, id, "insert");
544
+ }
545
+ let queuedTx;
546
+ if (outboxManager) {
547
+ queuedTx = await outboxManager.insert(modelName, id, fullData);
548
+ mutationOptions?.onTransactionCreated?.(queuedTx);
549
+ }
550
+ recordHistoryEntry(history.buildEntry("I", modelName, id, fullData), queuedTx);
551
+ return fullData;
552
+ });
553
+ },
554
+ async delete(modelName, id, mutationOptions) {
555
+ await runWithStateLock(async () => {
556
+ const map = identityMaps.getMap(modelName);
557
+ const existing = map.get(id);
558
+ if (!existing) {
559
+ throw new Error(`Model ${modelName} with id ${id} not found`);
560
+ }
561
+ if (resolvedOptions.optimistic !== false) {
562
+ map.delete(id);
563
+ emitModelChange(modelName, id, "delete");
564
+ }
565
+ const original = mutationOptions?.original ?? getModelData(existing);
566
+ let queuedTx;
567
+ if (outboxManager) {
568
+ queuedTx = await outboxManager.delete(modelName, id, original);
569
+ mutationOptions?.onTransactionCreated?.(queuedTx);
570
+ }
571
+ recordHistoryEntry(history.buildEntry("D", modelName, id, {}, original), queuedTx);
572
+ });
573
+ },
574
+ ensureModel(modelName, id) {
575
+ return ensureModelInternal(modelName, id);
576
+ },
577
+ async get(modelName, id) {
578
+ // Try identity map first
579
+ const map = identityMaps.getMap(modelName);
580
+ const cached = map.get(id);
581
+ if (cached) {
582
+ return cached;
583
+ }
584
+ // Fall back to storage
585
+ const stored = await options.storage.get(modelName, id);
586
+ if (stored) {
587
+ // Cache in identity map
588
+ map.set(id, stored);
589
+ }
590
+ return stored;
591
+ },
592
+ getAll(modelName, queryOptions) {
593
+ const map = identityMaps.getMap(modelName);
594
+ const result = executeQuery(map, queryOptions);
595
+ return Promise.resolve(result.data);
596
+ },
597
+ getCached(modelName, id) {
598
+ const map = identityMaps.getMap(modelName);
599
+ const cached = map.get(id);
600
+ return cached ? cached : null;
601
+ },
602
+ getIdentityMap(modelName) {
603
+ return identityMaps
604
+ .getMap(modelName)
605
+ .getRawMap();
606
+ },
607
+ getPendingCount() {
608
+ return getPendingCountInternal();
609
+ },
610
+ isModelMissing(modelName, id) {
611
+ return missingModels.has(getModelKey(modelName, id));
612
+ },
613
+ get lastError() {
614
+ return orchestrator.getLastError();
615
+ },
616
+ get lastSyncId() {
617
+ return orchestrator.getLastSyncId();
618
+ },
619
+ onConnectionStateChange(
620
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
621
+ callback) {
622
+ return orchestrator.onConnectionStateChange(callback);
623
+ },
624
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
625
+ onEvent(callback) {
626
+ eventListeners.add(callback);
627
+ return () => {
628
+ eventListeners.delete(callback);
629
+ };
630
+ },
631
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
632
+ onStateChange(callback) {
633
+ return orchestrator.onStateChange(callback);
634
+ },
635
+ query(modelName, queryOptions) {
636
+ const map = identityMaps.getMap(modelName);
637
+ return Promise.resolve(executeQuery(map, queryOptions));
638
+ },
639
+ async redo() {
640
+ await history.redo(applyHistoryOperation);
641
+ },
642
+ async runAsUndoGroup(operation) {
643
+ return await history.runAsGroup(operation);
644
+ },
645
+ start() {
646
+ if (startPromise) {
647
+ return startPromise;
648
+ }
649
+ if (hasStarted) {
650
+ return Promise.resolve();
651
+ }
652
+ const startVersion = lifecycleVersion;
653
+ let currentStartPromise = Promise.resolve();
654
+ currentStartPromise = (async () => {
655
+ replaceOutboxManager();
656
+ orchestrator.setConflictHandler(rollbackTransaction);
657
+ try {
658
+ await orchestrator.start();
659
+ if (startVersion !== lifecycleVersion) {
660
+ return;
661
+ }
662
+ // Recreate with correct client ID after orchestrator resolves it.
663
+ replaceOutboxManager();
664
+ hasStarted = true;
665
+ await emitPendingCount({ awaitStart: false });
666
+ }
667
+ catch (error) {
668
+ if (startVersion === lifecycleVersion) {
669
+ clearOutboxManager();
670
+ hasStarted = false;
671
+ }
672
+ throw error;
673
+ }
674
+ finally {
675
+ if (startPromise === currentStartPromise) {
676
+ startPromise = null;
677
+ }
678
+ }
679
+ })();
680
+ startPromise = currentStartPromise;
681
+ return currentStartPromise;
682
+ },
683
+ get state() {
684
+ return orchestrator.state;
685
+ },
686
+ async stop() {
687
+ const pendingStart = startPromise;
688
+ lifecycleVersion += 1;
689
+ startPromise = null;
690
+ hasStarted = false;
691
+ clearOutboxManager();
692
+ await orchestrator.stop();
693
+ await pendingStart?.catch(() => {
694
+ /* noop */
695
+ });
696
+ },
697
+ async syncNow() {
698
+ await orchestrator.syncNow();
699
+ },
700
+ async unarchive(modelName, id, mutationOptions) {
701
+ await runWithStateLock(async () => {
702
+ const map = identityMaps.getMap(modelName);
703
+ const existing = map.get(id);
704
+ if (!existing) {
705
+ throw new Error(`Model ${modelName} with id ${id} not found`);
706
+ }
707
+ const existingData = getModelData(existing);
708
+ const original = mutationOptions?.original ?? captureArchiveState(existingData);
709
+ const unarchivePatch = createUnarchivePatch();
710
+ if (resolvedOptions.optimistic !== false) {
711
+ map.update(id, unarchivePatch);
712
+ emitModelChange(modelName, id, "unarchive");
713
+ }
714
+ let queuedTx;
715
+ if (outboxManager) {
716
+ queuedTx = await outboxManager.unarchive(modelName, id, { original });
717
+ mutationOptions?.onTransactionCreated?.(queuedTx);
718
+ }
719
+ recordHistoryEntry(history.buildEntry("V", modelName, id, createUnarchivePayload(), original), queuedTx);
720
+ });
721
+ },
722
+ async undo() {
723
+ await history.undo(applyHistoryOperation);
724
+ },
725
+ async update(modelName, id, changes, mutationOptions) {
726
+ // Apply the optimistic update synchronously (outside the state lock)
727
+ // so MobX observers see it immediately and the identity map is updated
728
+ // before any concurrent delta processing can run. The outbox queue and
729
+ // history recording still use the lock to serialize with deltas.
730
+ const map = identityMaps.getMap(modelName);
731
+ const existing = map.get(id);
732
+ if (!existing) {
733
+ throw new Error(`Model ${modelName} with id ${id} not found`);
734
+ }
735
+ const existingData = getModelData(existing);
736
+ const { effectiveChanges, effectiveChangeRecord } = buildEffectiveUpdate(existingData, changes);
737
+ if (Object.keys(effectiveChangeRecord).length === 0) {
738
+ return existingData;
739
+ }
740
+ const originalSource = mutationOptions?.original ?? existingData;
741
+ const original = pickOriginal(originalSource, effectiveChangeRecord);
742
+ const updated = { ...existingData, ...effectiveChanges };
743
+ if (resolvedOptions.optimistic !== false) {
744
+ map.update(id, effectiveChanges);
745
+ missingModels.delete(getModelKey(modelName, id));
746
+ emitModelChange(modelName, id, "update");
747
+ }
748
+ await runWithStateLock(async () => {
749
+ const queuedTx = await queueUpdateTransaction(modelName, id, effectiveChangeRecord, original, mutationOptions);
750
+ recordHistoryEntry(history.buildEntry("U", modelName, id, effectiveChangeRecord, original), queuedTx);
751
+ });
752
+ return updated;
753
+ },
754
+ yjs: yjsManagers,
755
+ };
756
+ clientRef = client;
757
+ return client;
758
+ };
759
+ //# sourceMappingURL=client.js.map