@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,920 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type BatchLoadOptions,
4
+ type BootstrapMetadata,
5
+ type BootstrapOptions,
6
+ type ConnectionState,
7
+ type DeltaPacket,
8
+ type DeltaSubscription,
9
+ type ModelRegistrySnapshot,
10
+ type ModelRow,
11
+ type MutateResult,
12
+ noopReactivityAdapter,
13
+ type SchemaDefinition,
14
+ type SubscribeOptions,
15
+ type SyncAction,
16
+ type Transaction,
17
+ type TransactionBatch,
18
+ } from "../../sync-core/src/index";
19
+ import { createSyncClient } from "../src/index";
20
+ import type {
21
+ ModelPersistenceMeta,
22
+ StorageAdapter,
23
+ StorageMeta,
24
+ SyncClientEvent,
25
+ TransportAdapter,
26
+ } from "../src/types";
27
+
28
+ class InMemoryStorage implements StorageAdapter {
29
+ private readonly data = new Map<
30
+ string,
31
+ Map<string, Record<string, unknown>>
32
+ >();
33
+ private meta: StorageMeta = { lastSyncId: 0 };
34
+ private readonly modelPersistence = new Map<string, boolean>();
35
+ private readonly outbox: Transaction[] = [];
36
+ private readonly partialIndexes = new Set<string>();
37
+ private readonly syncActions: SyncAction[] = [];
38
+
39
+ open(_options: {
40
+ name?: string;
41
+ userId?: string;
42
+ version?: number;
43
+ userVersion?: number;
44
+ schema?: SchemaDefinition | ModelRegistrySnapshot;
45
+ }): Promise<void> {
46
+ return Promise.resolve();
47
+ }
48
+ close(): Promise<void> {
49
+ return Promise.resolve();
50
+ }
51
+ private getModelStore(
52
+ modelName: string
53
+ ): Map<string, Record<string, unknown>> {
54
+ const existing = this.data.get(modelName);
55
+ if (existing) {
56
+ return existing;
57
+ }
58
+ const created = new Map<string, Record<string, unknown>>();
59
+ this.data.set(modelName, created);
60
+ return created;
61
+ }
62
+ get<T>(modelName: string, id: string): Promise<T | null> {
63
+ return Promise.resolve(
64
+ (this.data.get(modelName)?.get(id) as T | undefined) ?? null
65
+ );
66
+ }
67
+ getAll<T>(modelName: string): Promise<T[]> {
68
+ const store = this.data.get(modelName);
69
+ return Promise.resolve(store ? (Array.from(store.values()) as T[]) : []);
70
+ }
71
+ put<T extends Record<string, unknown>>(
72
+ modelName: string,
73
+ row: T
74
+ ): Promise<void> {
75
+ const id = row.id;
76
+ if (typeof id !== "string") {
77
+ throw new Error(`Missing id for ${modelName}`);
78
+ }
79
+ this.getModelStore(modelName).set(id, { ...row });
80
+ return Promise.resolve();
81
+ }
82
+ delete(modelName: string, id: string): Promise<void> {
83
+ this.data.get(modelName)?.delete(id);
84
+ return Promise.resolve();
85
+ }
86
+ getByIndex<T>(
87
+ modelName: string,
88
+ indexName: string,
89
+ key: string
90
+ ): Promise<T[]> {
91
+ const store = this.data.get(modelName);
92
+ if (!store) {
93
+ return Promise.resolve([]);
94
+ }
95
+ const results: T[] = [];
96
+ for (const row of store.values()) {
97
+ if (row[indexName] === key) {
98
+ results.push(row as T);
99
+ }
100
+ }
101
+ return Promise.resolve(results);
102
+ }
103
+ async writeBatch(
104
+ ops: Array<{
105
+ type: "put" | "delete";
106
+ modelName: string;
107
+ id?: string;
108
+ data?: Record<string, unknown>;
109
+ }>
110
+ ): Promise<void> {
111
+ for (const op of ops) {
112
+ if (op.type === "put" && op.data) {
113
+ await this.put(op.modelName, op.data);
114
+ } else if (op.type === "delete" && op.id) {
115
+ await this.delete(op.modelName, op.id);
116
+ }
117
+ }
118
+ }
119
+ getMeta(): Promise<StorageMeta> {
120
+ return Promise.resolve({ ...this.meta });
121
+ }
122
+ setMeta(meta: Partial<StorageMeta>): Promise<void> {
123
+ this.meta = { ...this.meta, ...meta };
124
+ return Promise.resolve();
125
+ }
126
+ getModelPersistence(modelName: string): Promise<ModelPersistenceMeta> {
127
+ return Promise.resolve({
128
+ modelName,
129
+ persisted: this.modelPersistence.get(modelName) ?? false,
130
+ });
131
+ }
132
+ setModelPersistence(modelName: string, persisted: boolean): Promise<void> {
133
+ this.modelPersistence.set(modelName, persisted);
134
+ return Promise.resolve();
135
+ }
136
+ getOutbox(): Promise<Transaction[]> {
137
+ return Promise.resolve([...this.outbox]);
138
+ }
139
+ addToOutbox(tx: Transaction): Promise<void> {
140
+ this.outbox.push(tx);
141
+ return Promise.resolve();
142
+ }
143
+ removeFromOutbox(clientTxId: string): Promise<void> {
144
+ const idx = this.outbox.findIndex((tx) => tx.clientTxId === clientTxId);
145
+ if (idx >= 0) {
146
+ this.outbox.splice(idx, 1);
147
+ }
148
+ return Promise.resolve();
149
+ }
150
+ updateOutboxTransaction(
151
+ clientTxId: string,
152
+ updates: Partial<Transaction>
153
+ ): Promise<void> {
154
+ const tx = this.outbox.find((entry) => entry.clientTxId === clientTxId);
155
+ if (tx) {
156
+ Object.assign(tx, updates);
157
+ }
158
+ return Promise.resolve();
159
+ }
160
+ hasPartialIndex(
161
+ modelName: string,
162
+ indexedKey: string,
163
+ keyValue: string
164
+ ): Promise<boolean> {
165
+ return Promise.resolve(
166
+ this.partialIndexes.has(`${modelName}:${indexedKey}:${keyValue}`)
167
+ );
168
+ }
169
+ setPartialIndex(
170
+ modelName: string,
171
+ indexedKey: string,
172
+ keyValue: string
173
+ ): Promise<void> {
174
+ this.partialIndexes.add(`${modelName}:${indexedKey}:${keyValue}`);
175
+ return Promise.resolve();
176
+ }
177
+ addSyncActions(actions: SyncAction[]): Promise<void> {
178
+ this.syncActions.push(...actions);
179
+ return Promise.resolve();
180
+ }
181
+ getSyncActions(afterSyncId?: number, limit?: number): Promise<SyncAction[]> {
182
+ const filtered = afterSyncId
183
+ ? this.syncActions.filter((a) => a.id > afterSyncId)
184
+ : [...this.syncActions];
185
+ return Promise.resolve(
186
+ typeof limit === "number" ? filtered.slice(0, limit) : filtered
187
+ );
188
+ }
189
+ clearSyncActions(): Promise<void> {
190
+ this.syncActions.length = 0;
191
+ return Promise.resolve();
192
+ }
193
+ clear(): Promise<void> {
194
+ this.data.clear();
195
+ this.modelPersistence.clear();
196
+ this.outbox.length = 0;
197
+ this.partialIndexes.clear();
198
+ this.syncActions.length = 0;
199
+ this.meta = { lastSyncId: 0 };
200
+ return Promise.resolve();
201
+ }
202
+ count(modelName: string): Promise<number> {
203
+ return Promise.resolve(this.data.get(modelName)?.size ?? 0);
204
+ }
205
+ }
206
+
207
+ class AsyncQueue<T> implements AsyncIterable<T> {
208
+ private readonly items: T[] = [];
209
+ private readonly resolvers: Array<(result: IteratorResult<T>) => void> = [];
210
+ private closed = false;
211
+
212
+ push(item: T): void {
213
+ if (this.closed) {
214
+ return;
215
+ }
216
+ const resolver = this.resolvers.shift();
217
+ if (resolver) {
218
+ resolver({ value: item, done: false });
219
+ return;
220
+ }
221
+ this.items.push(item);
222
+ }
223
+ close(): void {
224
+ this.closed = true;
225
+ for (const r of this.resolvers.splice(0)) {
226
+ r({ value: undefined as T, done: true });
227
+ }
228
+ }
229
+ [Symbol.asyncIterator](): AsyncIterator<T> {
230
+ return {
231
+ next: (): Promise<IteratorResult<T>> => {
232
+ const item = this.items.shift();
233
+ if (item !== undefined) {
234
+ return Promise.resolve({ value: item, done: false });
235
+ }
236
+ if (this.closed) {
237
+ return Promise.resolve({ value: undefined as T, done: true });
238
+ }
239
+ return new Promise((resolve) => {
240
+ this.resolvers.push(resolve);
241
+ });
242
+ },
243
+ return: (): Promise<IteratorResult<T>> => {
244
+ this.close();
245
+ return Promise.resolve({ value: undefined as T, done: true });
246
+ },
247
+ };
248
+ }
249
+ }
250
+
251
+ class TestTransport implements TransportAdapter {
252
+ private readonly deltaQueue = new AsyncQueue<DeltaPacket>();
253
+ private readonly fullRows: ModelRow[];
254
+ private readonly fullMetadata: BootstrapMetadata;
255
+ private readonly connectionListeners = new Set<
256
+ (state: ConnectionState) => void
257
+ >();
258
+ private readonly connectionState: ConnectionState = "connected";
259
+ private nextSyncId: number;
260
+
261
+ constructor(options: {
262
+ fullRows: ModelRow[];
263
+ fullMetadata: BootstrapMetadata;
264
+ startingSyncId?: number;
265
+ }) {
266
+ this.fullRows = options.fullRows;
267
+ this.fullMetadata = options.fullMetadata;
268
+ this.nextSyncId = options.startingSyncId ?? 100;
269
+ }
270
+ bootstrap(
271
+ _options: BootstrapOptions
272
+ ): AsyncGenerator<ModelRow, BootstrapMetadata, unknown> {
273
+ const rows = this.fullRows;
274
+ const metadata = this.fullMetadata;
275
+ // biome-ignore lint/suspicious/useAwait: async generator required for return type
276
+ return (async function* () {
277
+ for (const row of rows) {
278
+ yield row;
279
+ }
280
+ return metadata;
281
+ })();
282
+ }
283
+ batchLoad(
284
+ _options: BatchLoadOptions
285
+ ): AsyncGenerator<ModelRow, void, unknown> {
286
+ return (async function* () {
287
+ // no batch data
288
+ })();
289
+ }
290
+ mutate(batch: TransactionBatch): Promise<MutateResult> {
291
+ const results = batch.transactions.map((tx) => {
292
+ this.nextSyncId += 1;
293
+ return {
294
+ clientTxId: tx.clientTxId,
295
+ success: true,
296
+ syncId: this.nextSyncId,
297
+ };
298
+ });
299
+ return Promise.resolve({
300
+ success: true,
301
+ lastSyncId: this.nextSyncId,
302
+ results,
303
+ });
304
+ }
305
+ subscribe(_options: SubscribeOptions): DeltaSubscription {
306
+ return {
307
+ [Symbol.asyncIterator]: () => this.deltaQueue[Symbol.asyncIterator](),
308
+ unsubscribe: () => this.deltaQueue.close(),
309
+ };
310
+ }
311
+ emitDelta(packet: DeltaPacket): void {
312
+ this.deltaQueue.push(packet);
313
+ }
314
+ fetchDeltas(after: number): Promise<DeltaPacket> {
315
+ return Promise.resolve({ lastSyncId: after, actions: [] });
316
+ }
317
+ getConnectionState(): ConnectionState {
318
+ return this.connectionState;
319
+ }
320
+ onConnectionStateChange(
321
+ callback: (state: ConnectionState) => void
322
+ ): () => void {
323
+ this.connectionListeners.add(callback);
324
+ callback(this.connectionState);
325
+ return () => {
326
+ this.connectionListeners.delete(callback);
327
+ };
328
+ }
329
+ close(): Promise<void> {
330
+ this.deltaQueue.close();
331
+ return Promise.resolve();
332
+ }
333
+ }
334
+
335
+ const schema: SchemaDefinition = {
336
+ models: {
337
+ Issue: {
338
+ loadStrategy: "instant",
339
+ groupKey: "teamId",
340
+ fields: { id: {}, title: {}, priority: {}, teamId: {} },
341
+ },
342
+ Team: {
343
+ loadStrategy: "instant",
344
+ fields: { id: {}, name: {} },
345
+ },
346
+ },
347
+ };
348
+
349
+ const seedRows: ModelRow[] = [
350
+ {
351
+ modelName: "Issue",
352
+ data: { id: "issue-1", title: "Seed", priority: 1, teamId: "team-1" },
353
+ },
354
+ { modelName: "Team", data: { id: "team-1", name: "Core" } },
355
+ ];
356
+
357
+ const waitForSync = async (
358
+ client: ReturnType<typeof createSyncClient>,
359
+ expectedSyncId: number
360
+ ): Promise<void> => {
361
+ await new Promise<void>((resolve, reject) => {
362
+ const timeout = setTimeout(
363
+ () => reject(new Error("Timed out waiting for sync")),
364
+ 2000
365
+ );
366
+ const unsub = client.onEvent((event) => {
367
+ if (
368
+ event.type === "syncComplete" &&
369
+ event.lastSyncId === expectedSyncId
370
+ ) {
371
+ clearTimeout(timeout);
372
+ unsub();
373
+ resolve();
374
+ }
375
+ });
376
+ });
377
+ };
378
+
379
+ const collectEvents = (
380
+ client: ReturnType<typeof createSyncClient>
381
+ ): { events: SyncClientEvent[]; unsub: () => void } => {
382
+ const events: SyncClientEvent[] = [];
383
+ const unsub = client.onEvent((e) => events.push(e));
384
+ return { events, unsub };
385
+ };
386
+
387
+ describe("rebase integration", () => {
388
+ it("update-update conflict with overlapping fields resolves server-wins", async () => {
389
+ const storage = new InMemoryStorage();
390
+ const transport = new TestTransport({
391
+ fullRows: seedRows,
392
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
393
+ startingSyncId: 50,
394
+ });
395
+
396
+ const client = createSyncClient({
397
+ storage,
398
+ transport,
399
+ reactivity: noopReactivityAdapter,
400
+ schema,
401
+ batchMutations: false,
402
+ });
403
+
404
+ try {
405
+ await client.start();
406
+
407
+ // Create a pending update for title
408
+ await client.update("Issue", "issue-1", { title: "Client Title" });
409
+ const outboxBefore = await storage.getOutbox();
410
+ expect(outboxBefore.length).toBeGreaterThanOrEqual(1);
411
+
412
+ const { events, unsub } = collectEvents(client);
413
+
414
+ // Server sends update for the same field (title) from another client
415
+ const delta: DeltaPacket = {
416
+ lastSyncId: 20,
417
+ actions: [
418
+ {
419
+ id: 20,
420
+ action: "U",
421
+ modelName: "Issue",
422
+ modelId: "issue-1",
423
+ data: { id: "issue-1", title: "Server Title", priority: 1 },
424
+ },
425
+ ],
426
+ };
427
+
428
+ const syncWaiter = waitForSync(client, 20);
429
+ transport.emitDelta(delta);
430
+ await syncWaiter;
431
+
432
+ // Conflict should have been detected and the local tx removed
433
+ const outboxAfter = await storage.getOutbox();
434
+ const remainingUpdateTxs = outboxAfter.filter(
435
+ (tx) => tx.action === "U" && tx.modelName === "Issue"
436
+ );
437
+ expect(remainingUpdateTxs).toHaveLength(0);
438
+
439
+ // A rebaseConflict event should have been emitted
440
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
441
+ expect(conflictEvents).toHaveLength(1);
442
+ expect(conflictEvents[0]).toMatchObject({
443
+ type: "rebaseConflict",
444
+ modelName: "Issue",
445
+ modelId: "issue-1",
446
+ conflictType: "update-update",
447
+ resolution: "server-wins",
448
+ });
449
+
450
+ unsub();
451
+ } finally {
452
+ await client.stop();
453
+ }
454
+ });
455
+
456
+ it("update-update with non-overlapping fields does not conflict (field-level merge)", async () => {
457
+ const storage = new InMemoryStorage();
458
+ const transport = new TestTransport({
459
+ fullRows: seedRows,
460
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
461
+ startingSyncId: 50,
462
+ });
463
+
464
+ const client = createSyncClient({
465
+ storage,
466
+ transport,
467
+ reactivity: noopReactivityAdapter,
468
+ schema,
469
+ batchMutations: false,
470
+ fieldLevelConflicts: true,
471
+ });
472
+
473
+ try {
474
+ await client.start();
475
+
476
+ // Local update changes title
477
+ await client.update("Issue", "issue-1", { title: "My Title" });
478
+
479
+ const { events, unsub } = collectEvents(client);
480
+
481
+ // Server update changes priority (different field)
482
+ const delta: DeltaPacket = {
483
+ lastSyncId: 20,
484
+ actions: [
485
+ {
486
+ id: 20,
487
+ action: "U",
488
+ modelName: "Issue",
489
+ modelId: "issue-1",
490
+ data: { id: "issue-1", priority: 5 },
491
+ },
492
+ ],
493
+ };
494
+
495
+ const syncWaiter = waitForSync(client, 20);
496
+ transport.emitDelta(delta);
497
+ await syncWaiter;
498
+
499
+ // No conflict should be emitted
500
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
501
+ expect(conflictEvents).toHaveLength(0);
502
+
503
+ // Pending update should still be in the outbox
504
+ const outbox = await storage.getOutbox();
505
+ const titleUpdates = outbox.filter(
506
+ (tx) => tx.action === "U" && tx.modelName === "Issue"
507
+ );
508
+ expect(titleUpdates).toHaveLength(1);
509
+
510
+ // The pending tx should remain with its title payload intact
511
+ expect(titleUpdates[0]?.payload).toMatchObject({ title: "My Title" });
512
+
513
+ unsub();
514
+ } finally {
515
+ await client.stop();
516
+ }
517
+ });
518
+
519
+ it("update-delete conflict resolves server-wins (delete wins)", async () => {
520
+ const storage = new InMemoryStorage();
521
+ const transport = new TestTransport({
522
+ fullRows: seedRows,
523
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
524
+ startingSyncId: 50,
525
+ });
526
+
527
+ const client = createSyncClient({
528
+ storage,
529
+ transport,
530
+ reactivity: noopReactivityAdapter,
531
+ schema,
532
+ batchMutations: false,
533
+ });
534
+
535
+ try {
536
+ await client.start();
537
+
538
+ // Local update
539
+ await client.update("Issue", "issue-1", { title: "Updated" });
540
+
541
+ const { events, unsub } = collectEvents(client);
542
+
543
+ // Server deletes the same entity
544
+ const delta: DeltaPacket = {
545
+ lastSyncId: 20,
546
+ actions: [
547
+ {
548
+ id: 20,
549
+ action: "D",
550
+ modelName: "Issue",
551
+ modelId: "issue-1",
552
+ data: {},
553
+ },
554
+ ],
555
+ };
556
+
557
+ const syncWaiter = waitForSync(client, 20);
558
+ transport.emitDelta(delta);
559
+ await syncWaiter;
560
+
561
+ // The update tx should be removed from outbox
562
+ const outbox = await storage.getOutbox();
563
+ const issueTxs = outbox.filter((tx) => tx.modelName === "Issue");
564
+ expect(issueTxs).toHaveLength(0);
565
+
566
+ // Conflict event emitted
567
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
568
+ expect(conflictEvents).toHaveLength(1);
569
+ expect(conflictEvents[0]).toMatchObject({
570
+ conflictType: "update-delete",
571
+ resolution: "server-wins",
572
+ });
573
+
574
+ unsub();
575
+ } finally {
576
+ await client.stop();
577
+ }
578
+ });
579
+
580
+ it("delete-update conflict resolves server-wins (update wins)", async () => {
581
+ const storage = new InMemoryStorage();
582
+ const transport = new TestTransport({
583
+ fullRows: seedRows,
584
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
585
+ startingSyncId: 50,
586
+ });
587
+
588
+ const client = createSyncClient({
589
+ storage,
590
+ transport,
591
+ reactivity: noopReactivityAdapter,
592
+ schema,
593
+ batchMutations: false,
594
+ });
595
+
596
+ try {
597
+ await client.start();
598
+
599
+ // Local delete
600
+ await client.delete("Issue", "issue-1", {
601
+ original: {
602
+ id: "issue-1",
603
+ title: "Seed",
604
+ priority: 1,
605
+ teamId: "team-1",
606
+ },
607
+ });
608
+
609
+ const { events, unsub } = collectEvents(client);
610
+
611
+ // Server updates the same entity
612
+ const delta: DeltaPacket = {
613
+ lastSyncId: 20,
614
+ actions: [
615
+ {
616
+ id: 20,
617
+ action: "U",
618
+ modelName: "Issue",
619
+ modelId: "issue-1",
620
+ data: {
621
+ id: "issue-1",
622
+ title: "Server Update",
623
+ priority: 1,
624
+ teamId: "team-1",
625
+ },
626
+ },
627
+ ],
628
+ };
629
+
630
+ const syncWaiter = waitForSync(client, 20);
631
+ transport.emitDelta(delta);
632
+ await syncWaiter;
633
+
634
+ // The delete tx should be removed (server-wins)
635
+ const outbox = await storage.getOutbox();
636
+ const deleteTxs = outbox.filter(
637
+ (tx) => tx.action === "D" && tx.modelName === "Issue"
638
+ );
639
+ expect(deleteTxs).toHaveLength(0);
640
+
641
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
642
+ expect(conflictEvents).toHaveLength(1);
643
+ expect(conflictEvents[0]).toMatchObject({
644
+ conflictType: "delete-update",
645
+ resolution: "server-wins",
646
+ });
647
+
648
+ unsub();
649
+ } finally {
650
+ await client.stop();
651
+ }
652
+ });
653
+
654
+ it("insert-insert conflict resolves server-wins", async () => {
655
+ const storage = new InMemoryStorage();
656
+ const transport = new TestTransport({
657
+ fullRows: seedRows,
658
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
659
+ startingSyncId: 50,
660
+ });
661
+
662
+ const client = createSyncClient({
663
+ storage,
664
+ transport,
665
+ reactivity: noopReactivityAdapter,
666
+ schema,
667
+ batchMutations: false,
668
+ });
669
+
670
+ try {
671
+ await client.start();
672
+
673
+ // Local insert with specific ID
674
+ await client.create("Issue", {
675
+ id: "issue-dup",
676
+ title: "Client Version",
677
+ teamId: "team-1",
678
+ });
679
+
680
+ const { events, unsub } = collectEvents(client);
681
+
682
+ // Server inserts same ID
683
+ const delta: DeltaPacket = {
684
+ lastSyncId: 20,
685
+ actions: [
686
+ {
687
+ id: 20,
688
+ action: "I",
689
+ modelName: "Issue",
690
+ modelId: "issue-dup",
691
+ data: {
692
+ id: "issue-dup",
693
+ title: "Server Version",
694
+ teamId: "team-1",
695
+ },
696
+ },
697
+ ],
698
+ };
699
+
700
+ const syncWaiter = waitForSync(client, 20);
701
+ transport.emitDelta(delta);
702
+ await syncWaiter;
703
+
704
+ // Insert tx should be removed
705
+ const outbox = await storage.getOutbox();
706
+ const insertTxs = outbox.filter(
707
+ (tx) => tx.action === "I" && tx.modelId === "issue-dup"
708
+ );
709
+ expect(insertTxs).toHaveLength(0);
710
+
711
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
712
+ expect(conflictEvents).toHaveLength(1);
713
+ expect(conflictEvents[0]).toMatchObject({
714
+ conflictType: "insert-insert",
715
+ });
716
+
717
+ unsub();
718
+ } finally {
719
+ await client.stop();
720
+ }
721
+ });
722
+
723
+ it("no conflict when delta targets different entities", async () => {
724
+ const storage = new InMemoryStorage();
725
+ const transport = new TestTransport({
726
+ fullRows: seedRows,
727
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
728
+ startingSyncId: 50,
729
+ });
730
+
731
+ const client = createSyncClient({
732
+ storage,
733
+ transport,
734
+ reactivity: noopReactivityAdapter,
735
+ schema,
736
+ batchMutations: false,
737
+ });
738
+
739
+ try {
740
+ await client.start();
741
+
742
+ // Local update on issue-1
743
+ await client.update("Issue", "issue-1", { title: "Local" });
744
+ const outboxBefore = await storage.getOutbox();
745
+ const pendingCount = outboxBefore.filter(
746
+ (tx) => tx.action === "U" && tx.modelName === "Issue"
747
+ ).length;
748
+
749
+ const { events, unsub } = collectEvents(client);
750
+
751
+ // Server updates a totally different entity
752
+ const delta: DeltaPacket = {
753
+ lastSyncId: 20,
754
+ actions: [
755
+ {
756
+ id: 20,
757
+ action: "U",
758
+ modelName: "Team",
759
+ modelId: "team-1",
760
+ data: { id: "team-1", name: "Renamed" },
761
+ },
762
+ ],
763
+ };
764
+
765
+ const syncWaiter = waitForSync(client, 20);
766
+ transport.emitDelta(delta);
767
+ await syncWaiter;
768
+
769
+ // No conflict
770
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
771
+ expect(conflictEvents).toHaveLength(0);
772
+
773
+ // Pending update still in outbox
774
+ const outboxAfter = await storage.getOutbox();
775
+ const remaining = outboxAfter.filter(
776
+ (tx) => tx.action === "U" && tx.modelName === "Issue"
777
+ );
778
+ expect(remaining).toHaveLength(pendingCount);
779
+
780
+ unsub();
781
+ } finally {
782
+ await client.stop();
783
+ }
784
+ });
785
+
786
+ it("archive-delete conflict is detected via normalization", async () => {
787
+ const storage = new InMemoryStorage();
788
+ const transport = new TestTransport({
789
+ fullRows: seedRows,
790
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
791
+ startingSyncId: 50,
792
+ });
793
+
794
+ const client = createSyncClient({
795
+ storage,
796
+ transport,
797
+ reactivity: noopReactivityAdapter,
798
+ schema,
799
+ batchMutations: false,
800
+ });
801
+
802
+ try {
803
+ await client.start();
804
+
805
+ // Local archive
806
+ await client.archive("Issue", "issue-1", {
807
+ original: {
808
+ id: "issue-1",
809
+ title: "Seed",
810
+ priority: 1,
811
+ teamId: "team-1",
812
+ },
813
+ });
814
+
815
+ const { events, unsub } = collectEvents(client);
816
+
817
+ // Server deletes the same entity
818
+ const delta: DeltaPacket = {
819
+ lastSyncId: 20,
820
+ actions: [
821
+ {
822
+ id: 20,
823
+ action: "D",
824
+ modelName: "Issue",
825
+ modelId: "issue-1",
826
+ data: {},
827
+ },
828
+ ],
829
+ };
830
+
831
+ const syncWaiter = waitForSync(client, 20);
832
+ transport.emitDelta(delta);
833
+ await syncWaiter;
834
+
835
+ // Archive tx removed (server-wins)
836
+ const outbox = await storage.getOutbox();
837
+ const archiveTxs = outbox.filter(
838
+ (tx) => tx.action === "A" && tx.modelName === "Issue"
839
+ );
840
+ expect(archiveTxs).toHaveLength(0);
841
+
842
+ // Conflict detected as update-delete (archive normalized to update)
843
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
844
+ expect(conflictEvents).toHaveLength(1);
845
+ expect(conflictEvents[0]).toMatchObject({
846
+ conflictType: "update-delete",
847
+ resolution: "server-wins",
848
+ });
849
+
850
+ unsub();
851
+ } finally {
852
+ await client.stop();
853
+ }
854
+ });
855
+
856
+ it("confirmed own transaction is not treated as conflict", async () => {
857
+ const storage = new InMemoryStorage();
858
+ const transport = new TestTransport({
859
+ fullRows: seedRows,
860
+ fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
861
+ startingSyncId: 50,
862
+ });
863
+
864
+ const client = createSyncClient({
865
+ storage,
866
+ transport,
867
+ reactivity: noopReactivityAdapter,
868
+ schema,
869
+ batchMutations: false,
870
+ });
871
+
872
+ try {
873
+ await client.start();
874
+
875
+ let createdTx: Transaction | undefined;
876
+ await client.update(
877
+ "Issue",
878
+ "issue-1",
879
+ { title: "My Update" },
880
+ {
881
+ onTransactionCreated: (tx) => {
882
+ createdTx = tx;
883
+ },
884
+ }
885
+ );
886
+
887
+ expect(createdTx).toBeDefined();
888
+
889
+ const { events, unsub } = collectEvents(client);
890
+
891
+ // Server echoes back our own transaction
892
+ const delta: DeltaPacket = {
893
+ lastSyncId: 20,
894
+ actions: [
895
+ {
896
+ id: 20,
897
+ action: "U",
898
+ modelName: "Issue",
899
+ modelId: "issue-1",
900
+ data: { id: "issue-1", title: "My Update" },
901
+ clientTxId: createdTx?.clientTxId,
902
+ clientId: client.clientId,
903
+ },
904
+ ],
905
+ };
906
+
907
+ const syncWaiter = waitForSync(client, 20);
908
+ transport.emitDelta(delta);
909
+ await syncWaiter;
910
+
911
+ // No conflict events — this is a confirmation, not a conflict
912
+ const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
913
+ expect(conflictEvents).toHaveLength(0);
914
+
915
+ unsub();
916
+ } finally {
917
+ await client.stop();
918
+ }
919
+ });
920
+ });