@syncular/client-react 0.0.1

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,439 @@
1
+ /**
2
+ * Integration tests for conflict resolution
3
+ *
4
+ * Tests the full conflict detection and resolution flow when two clients
5
+ * make concurrent changes to the same row.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import {
10
+ enqueueOutboxCommit,
11
+ getNextSendableOutboxCommit,
12
+ resolveConflict,
13
+ } from '@syncular/client';
14
+ import {
15
+ createTestClient,
16
+ createTestServer,
17
+ destroyTestClient,
18
+ destroyTestServer,
19
+ type TestClient,
20
+ type TestServer,
21
+ } from './test-setup';
22
+
23
+ describe('Conflict Resolution', () => {
24
+ let server: TestServer;
25
+ let clientA: TestClient;
26
+ let clientB: TestClient;
27
+
28
+ const sharedUserId = 'shared-user';
29
+
30
+ beforeEach(async () => {
31
+ server = await createTestServer();
32
+ clientA = await createTestClient(server, {
33
+ actorId: sharedUserId,
34
+ clientId: 'client-a',
35
+ });
36
+ clientB = await createTestClient(server, {
37
+ actorId: sharedUserId,
38
+ clientId: 'client-b',
39
+ });
40
+
41
+ await clientA.engine.start();
42
+ await clientB.engine.start();
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await destroyTestClient(clientA);
47
+ await destroyTestClient(clientB);
48
+ await destroyTestServer(server);
49
+ });
50
+
51
+ it('detects version conflict when two clients update same row', async () => {
52
+ // Client A creates a task
53
+ await enqueueOutboxCommit(clientA.db, {
54
+ operations: [
55
+ {
56
+ table: 'tasks',
57
+ row_id: 'conflict-task',
58
+ op: 'upsert',
59
+ payload: {
60
+ title: 'Original',
61
+ completed: 0,
62
+ user_id: sharedUserId,
63
+ },
64
+ base_version: null,
65
+ },
66
+ ],
67
+ });
68
+ await clientA.engine.sync();
69
+
70
+ // Client B pulls to get the task
71
+ await clientB.engine.sync();
72
+
73
+ // Verify both have the task at version 1
74
+ const taskA = await clientA.db
75
+ .selectFrom('tasks')
76
+ .where('id', '=', 'conflict-task')
77
+ .selectAll()
78
+ .executeTakeFirst();
79
+ const taskB = await clientB.db
80
+ .selectFrom('tasks')
81
+ .where('id', '=', 'conflict-task')
82
+ .selectAll()
83
+ .executeTakeFirst();
84
+
85
+ expect(taskA!.server_version).toBe(1);
86
+ expect(taskB!.server_version).toBe(1);
87
+
88
+ // Client A updates the task to version 2
89
+ await enqueueOutboxCommit(clientA.db, {
90
+ operations: [
91
+ {
92
+ table: 'tasks',
93
+ row_id: 'conflict-task',
94
+ op: 'upsert',
95
+ payload: {
96
+ title: 'Updated by A',
97
+ completed: 0,
98
+ user_id: sharedUserId,
99
+ },
100
+ base_version: 1,
101
+ },
102
+ ],
103
+ });
104
+ await clientA.engine.sync();
105
+
106
+ // Client B tries to update with stale base_version=1
107
+ await enqueueOutboxCommit(clientB.db, {
108
+ operations: [
109
+ {
110
+ table: 'tasks',
111
+ row_id: 'conflict-task',
112
+ op: 'upsert',
113
+ payload: {
114
+ title: 'Updated by B',
115
+ completed: 1,
116
+ user_id: sharedUserId,
117
+ },
118
+ base_version: 1, // Stale! Server is now at version 2
119
+ },
120
+ ],
121
+ });
122
+
123
+ // Sync client B - should detect conflict
124
+ await clientB.engine.sync();
125
+
126
+ // Check for conflicts in client B
127
+ const conflicts = await clientB.db
128
+ .selectFrom('sync_conflicts')
129
+ .selectAll()
130
+ .execute();
131
+
132
+ expect(conflicts.length).toBe(1);
133
+ expect(conflicts[0]!.result_status).toBe('conflict');
134
+ expect(conflicts[0]!.server_version).toBe(2);
135
+
136
+ // Server row should have A's version (may be JSON string or already parsed)
137
+ const serverRowJson = conflicts[0]!.server_row_json;
138
+ const serverRow =
139
+ typeof serverRowJson === 'string'
140
+ ? JSON.parse(serverRowJson)
141
+ : serverRowJson;
142
+ expect(serverRow.title).toBe('Updated by A');
143
+ });
144
+
145
+ it('does not retry rejected (conflict) commits automatically', async () => {
146
+ // Client A creates a task
147
+ await enqueueOutboxCommit(clientA.db, {
148
+ operations: [
149
+ {
150
+ table: 'tasks',
151
+ row_id: 'no-retry',
152
+ op: 'upsert',
153
+ payload: { title: 'Original', completed: 0, user_id: sharedUserId },
154
+ base_version: null,
155
+ },
156
+ ],
157
+ });
158
+ await clientA.engine.sync();
159
+
160
+ // Client B pulls to get the task
161
+ await clientB.engine.sync();
162
+
163
+ // A updates to v2
164
+ await enqueueOutboxCommit(clientA.db, {
165
+ operations: [
166
+ {
167
+ table: 'tasks',
168
+ row_id: 'no-retry',
169
+ op: 'upsert',
170
+ payload: { title: 'Server v2', completed: 0, user_id: sharedUserId },
171
+ base_version: 1,
172
+ },
173
+ ],
174
+ });
175
+ await clientA.engine.sync();
176
+
177
+ // B tries stale update => conflict
178
+ await enqueueOutboxCommit(clientB.db, {
179
+ operations: [
180
+ {
181
+ table: 'tasks',
182
+ row_id: 'no-retry',
183
+ op: 'upsert',
184
+ payload: {
185
+ title: 'Client B stale',
186
+ completed: 1,
187
+ user_id: sharedUserId,
188
+ },
189
+ base_version: 1,
190
+ },
191
+ ],
192
+ });
193
+
194
+ await clientB.engine.sync();
195
+
196
+ // Conflict commit should be marked failed exactly once and NOT re-sent in a tight loop.
197
+ const outbox = await clientB.db
198
+ .selectFrom('sync_outbox_commits')
199
+ .select(['status', 'attempt_count'])
200
+ .orderBy('created_at', 'desc')
201
+ .execute();
202
+
203
+ expect(outbox.length).toBe(1);
204
+ expect(outbox[0]!.status).toBe('failed');
205
+ expect(outbox[0]!.attempt_count).toBe(1);
206
+
207
+ // Failed commits are not sendable by default.
208
+ const next = await getNextSendableOutboxCommit(clientB.db);
209
+ expect(next).toBeNull();
210
+ });
211
+
212
+ it('accept resolution marks conflict as resolved', async () => {
213
+ // Set up conflict scenario
214
+ await enqueueOutboxCommit(clientA.db, {
215
+ operations: [
216
+ {
217
+ table: 'tasks',
218
+ row_id: 'accept-test',
219
+ op: 'upsert',
220
+ payload: { title: 'Original', completed: 0, user_id: sharedUserId },
221
+ base_version: null,
222
+ },
223
+ ],
224
+ });
225
+ await clientA.engine.sync();
226
+ await clientB.engine.sync();
227
+
228
+ // A updates to v2
229
+ await enqueueOutboxCommit(clientA.db, {
230
+ operations: [
231
+ {
232
+ table: 'tasks',
233
+ row_id: 'accept-test',
234
+ op: 'upsert',
235
+ payload: {
236
+ title: 'Server Version',
237
+ completed: 0,
238
+ user_id: sharedUserId,
239
+ },
240
+ base_version: 1,
241
+ },
242
+ ],
243
+ });
244
+ await clientA.engine.sync();
245
+
246
+ // B tries stale update - creates conflict
247
+ await enqueueOutboxCommit(clientB.db, {
248
+ operations: [
249
+ {
250
+ table: 'tasks',
251
+ row_id: 'accept-test',
252
+ op: 'upsert',
253
+ payload: {
254
+ title: 'Client B Version',
255
+ completed: 1,
256
+ user_id: sharedUserId,
257
+ },
258
+ base_version: 1,
259
+ },
260
+ ],
261
+ });
262
+ await clientB.engine.sync();
263
+
264
+ // Verify conflict exists
265
+ const conflictsBefore = await clientB.db
266
+ .selectFrom('sync_conflicts')
267
+ .where('resolved_at', 'is', null)
268
+ .selectAll()
269
+ .execute();
270
+ expect(conflictsBefore.length).toBe(1);
271
+
272
+ // Resolve with 'accept' (use server version)
273
+ await resolveConflict(clientB.db, {
274
+ id: conflictsBefore[0]!.id,
275
+ resolution: 'accept',
276
+ });
277
+
278
+ // Verify conflict is marked as resolved (resolved_at is set)
279
+ const resolvedConflict = await clientB.db
280
+ .selectFrom('sync_conflicts')
281
+ .where('id', '=', conflictsBefore[0]!.id)
282
+ .selectAll()
283
+ .executeTakeFirst();
284
+
285
+ expect(resolvedConflict!.resolved_at).not.toBe(null);
286
+ expect(resolvedConflict!.resolution).toBe('accept');
287
+
288
+ // No more unresolved conflicts
289
+ const unresolvedConflicts = await clientB.db
290
+ .selectFrom('sync_conflicts')
291
+ .where('resolved_at', 'is', null)
292
+ .selectAll()
293
+ .execute();
294
+ expect(unresolvedConflicts.length).toBe(0);
295
+ });
296
+
297
+ it('reject resolution retries with new base version', async () => {
298
+ // Set up conflict scenario
299
+ await enqueueOutboxCommit(clientA.db, {
300
+ operations: [
301
+ {
302
+ table: 'tasks',
303
+ row_id: 'reject-test',
304
+ op: 'upsert',
305
+ payload: { title: 'Original', completed: 0, user_id: sharedUserId },
306
+ base_version: null,
307
+ },
308
+ ],
309
+ });
310
+ await clientA.engine.sync();
311
+ await clientB.engine.sync();
312
+
313
+ // A updates to v2
314
+ await enqueueOutboxCommit(clientA.db, {
315
+ operations: [
316
+ {
317
+ table: 'tasks',
318
+ row_id: 'reject-test',
319
+ op: 'upsert',
320
+ payload: {
321
+ title: 'Server Version',
322
+ completed: 0,
323
+ user_id: sharedUserId,
324
+ },
325
+ base_version: 1,
326
+ },
327
+ ],
328
+ });
329
+ await clientA.engine.sync();
330
+
331
+ // B tries stale update - creates conflict
332
+ await enqueueOutboxCommit(clientB.db, {
333
+ operations: [
334
+ {
335
+ table: 'tasks',
336
+ row_id: 'reject-test',
337
+ op: 'upsert',
338
+ payload: {
339
+ title: 'Client B Wins',
340
+ completed: 1,
341
+ user_id: sharedUserId,
342
+ },
343
+ base_version: 1,
344
+ },
345
+ ],
346
+ });
347
+ await clientB.engine.sync();
348
+
349
+ // Verify conflict exists
350
+ const conflictsBefore = await clientB.db
351
+ .selectFrom('sync_conflicts')
352
+ .where('resolved_at', 'is', null)
353
+ .selectAll()
354
+ .execute();
355
+ expect(conflictsBefore.length).toBe(1);
356
+
357
+ // Resolve with 'reject' (keep local version, will retry with new base)
358
+ await resolveConflict(clientB.db, {
359
+ id: conflictsBefore[0]!.id,
360
+ resolution: 'reject',
361
+ });
362
+
363
+ // The reject resolution should allow retrying the push
364
+ // In a real scenario, the client would need to create a new commit
365
+ // with the updated base_version
366
+
367
+ // Verify conflict is marked as resolved
368
+ const conflict = await clientB.db
369
+ .selectFrom('sync_conflicts')
370
+ .where('id', '=', conflictsBefore[0]!.id)
371
+ .selectAll()
372
+ .executeTakeFirst();
373
+ expect(conflict!.resolved_at).not.toBe(null);
374
+ expect(conflict!.resolution).toBe('reject');
375
+ });
376
+
377
+ it('no conflict when updates are sequential', async () => {
378
+ // Client A creates a task
379
+ await enqueueOutboxCommit(clientA.db, {
380
+ operations: [
381
+ {
382
+ table: 'tasks',
383
+ row_id: 'sequential-test',
384
+ op: 'upsert',
385
+ payload: { title: 'Original', completed: 0, user_id: sharedUserId },
386
+ base_version: null,
387
+ },
388
+ ],
389
+ });
390
+ await clientA.engine.sync();
391
+ await clientB.engine.sync();
392
+
393
+ // Client A updates with correct base version
394
+ await enqueueOutboxCommit(clientA.db, {
395
+ operations: [
396
+ {
397
+ table: 'tasks',
398
+ row_id: 'sequential-test',
399
+ op: 'upsert',
400
+ payload: { title: 'Update 1', completed: 0, user_id: sharedUserId },
401
+ base_version: 1,
402
+ },
403
+ ],
404
+ });
405
+ await clientA.engine.sync();
406
+ await clientB.engine.sync();
407
+
408
+ // Client B updates with correct base version (now 2)
409
+ await enqueueOutboxCommit(clientB.db, {
410
+ operations: [
411
+ {
412
+ table: 'tasks',
413
+ row_id: 'sequential-test',
414
+ op: 'upsert',
415
+ payload: { title: 'Update 2', completed: 1, user_id: sharedUserId },
416
+ base_version: 2,
417
+ },
418
+ ],
419
+ });
420
+ await clientB.engine.sync();
421
+
422
+ // No conflicts should exist
423
+ const conflicts = await clientB.db
424
+ .selectFrom('sync_conflicts')
425
+ .selectAll()
426
+ .execute();
427
+ expect(conflicts.length).toBe(0);
428
+
429
+ // Server should have version 3
430
+ await clientA.engine.sync();
431
+ const taskA = await clientA.db
432
+ .selectFrom('tasks')
433
+ .where('id', '=', 'sequential-test')
434
+ .selectAll()
435
+ .executeTakeFirst();
436
+ expect(taskA!.title).toBe('Update 2');
437
+ expect(taskA!.server_version).toBe(3);
438
+ });
439
+ });