@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.
- package/dist/createSyncularReact.d.ts +221 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +773 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/SyncEngine.test.ts +653 -0
- package/src/__tests__/SyncProvider.strictmode.test.tsx +117 -0
- package/src/__tests__/fingerprint.test.ts +181 -0
- package/src/__tests__/hooks/useMutation.test.tsx +468 -0
- package/src/__tests__/hooks.test.tsx +384 -0
- package/src/__tests__/integration/conflict-resolution.test.ts +439 -0
- package/src/__tests__/integration/provider-reconfig.test.ts +291 -0
- package/src/__tests__/integration/push-flow.test.ts +320 -0
- package/src/__tests__/integration/realtime-sync.test.ts +222 -0
- package/src/__tests__/integration/self-conflict.test.ts +91 -0
- package/src/__tests__/integration/test-setup.ts +538 -0
- package/src/__tests__/integration/two-client-sync.test.ts +373 -0
- package/src/__tests__/setup.ts +7 -0
- package/src/__tests__/test-utils.ts +187 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1340 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
});
|