@syncular/client-react 0.0.1-60
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 +222 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +775 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +9 -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 +72 -0
- package/src/__tests__/SyncEngine.test.ts +1332 -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 +279 -0
- package/src/__tests__/integration/push-flow.test.ts +321 -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 +550 -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 +199 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1346 -0
- package/src/index.ts +36 -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
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for SyncProvider reconfiguration
|
|
3
|
+
*
|
|
4
|
+
* Tests that verify the SyncProvider correctly handles changes to critical props.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
+
import {
|
|
9
|
+
ClientTableRegistry,
|
|
10
|
+
ensureClientSyncSchema,
|
|
11
|
+
type SyncClientDb,
|
|
12
|
+
SyncEngine,
|
|
13
|
+
} from '@syncular/client';
|
|
14
|
+
import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
|
|
15
|
+
import { cleanup, render } from '@testing-library/react';
|
|
16
|
+
import type { Kysely } from 'kysely';
|
|
17
|
+
import { createElement } from 'react';
|
|
18
|
+
import { createSyncularReact } from '../../index';
|
|
19
|
+
import {
|
|
20
|
+
createTestServer,
|
|
21
|
+
destroyTestServer,
|
|
22
|
+
type TestServer,
|
|
23
|
+
} from './test-setup';
|
|
24
|
+
import '../setup'; // Ensure happy-dom is registered
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Client database schema for tests
|
|
28
|
+
*/
|
|
29
|
+
interface ClientDb extends SyncClientDb {
|
|
30
|
+
tasks: {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
completed: number;
|
|
34
|
+
user_id: string;
|
|
35
|
+
server_version: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { SyncProvider } = createSyncularReact<ClientDb>();
|
|
40
|
+
|
|
41
|
+
// Create a mock ClientTableRegistry for tests
|
|
42
|
+
function createMockClientTableRegistry(): ClientTableRegistry<ClientDb> {
|
|
43
|
+
return new ClientTableRegistry<ClientDb>();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('SyncProvider Reconfiguration', () => {
|
|
47
|
+
let server: TestServer;
|
|
48
|
+
let db: Kysely<ClientDb>;
|
|
49
|
+
let mockShapes: ClientTableRegistry<ClientDb>;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
server = await createTestServer();
|
|
53
|
+
db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
|
|
54
|
+
mockShapes = createMockClientTableRegistry();
|
|
55
|
+
|
|
56
|
+
await ensureClientSyncSchema(db);
|
|
57
|
+
|
|
58
|
+
// Create tasks table
|
|
59
|
+
await db.schema
|
|
60
|
+
.createTable('tasks')
|
|
61
|
+
.ifNotExists()
|
|
62
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
63
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
64
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
65
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
66
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
67
|
+
col.notNull().defaultTo(0)
|
|
68
|
+
)
|
|
69
|
+
.execute();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await db.destroy();
|
|
74
|
+
await destroyTestServer(server);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('SyncEngine recreates when actorId changes', async () => {
|
|
78
|
+
// Create a simple transport for testing
|
|
79
|
+
const transport = {
|
|
80
|
+
async sync() {
|
|
81
|
+
return { ok: true as const };
|
|
82
|
+
},
|
|
83
|
+
async fetchSnapshotChunk() {
|
|
84
|
+
return new Uint8Array();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Create first engine with actorId 'user-1'
|
|
89
|
+
const engine1 = new SyncEngine({
|
|
90
|
+
db,
|
|
91
|
+
transport,
|
|
92
|
+
shapes: mockShapes,
|
|
93
|
+
actorId: 'user-1',
|
|
94
|
+
clientId: 'client-1',
|
|
95
|
+
subscriptions: [],
|
|
96
|
+
pollIntervalMs: 999999,
|
|
97
|
+
realtimeEnabled: false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Verify engine has correct actorId
|
|
101
|
+
expect(engine1.getActorId()).toBe('user-1');
|
|
102
|
+
|
|
103
|
+
// Create second engine with different actorId
|
|
104
|
+
const engine2 = new SyncEngine({
|
|
105
|
+
db,
|
|
106
|
+
transport,
|
|
107
|
+
shapes: mockShapes,
|
|
108
|
+
actorId: 'user-2',
|
|
109
|
+
clientId: 'client-1',
|
|
110
|
+
subscriptions: [],
|
|
111
|
+
pollIntervalMs: 999999,
|
|
112
|
+
realtimeEnabled: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Verify new engine has different actorId
|
|
116
|
+
expect(engine2.getActorId()).toBe('user-2');
|
|
117
|
+
|
|
118
|
+
// Engines should be different instances
|
|
119
|
+
expect(engine1).not.toBe(engine2);
|
|
120
|
+
|
|
121
|
+
// Cleanup
|
|
122
|
+
engine1.destroy();
|
|
123
|
+
engine2.destroy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('error message format for prop changes is correct', () => {
|
|
127
|
+
// Tests that the error message format is correct for prop changes
|
|
128
|
+
// In dev mode, SyncProvider throws an error when critical props change
|
|
129
|
+
// In production, it logs an error
|
|
130
|
+
|
|
131
|
+
const initialProps = {
|
|
132
|
+
actorId: 'user-1',
|
|
133
|
+
clientId: 'client-1',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const newActorId = 'user-2';
|
|
137
|
+
const changedProps: string[] = [];
|
|
138
|
+
|
|
139
|
+
if (newActorId !== initialProps.actorId) changedProps.push('actorId');
|
|
140
|
+
|
|
141
|
+
// Verify message construction
|
|
142
|
+
expect(changedProps.length).toBe(1);
|
|
143
|
+
expect(changedProps[0]).toBe('actorId');
|
|
144
|
+
|
|
145
|
+
const message =
|
|
146
|
+
`[SyncProvider] Critical props changed after mount: ${changedProps.join(', ')}. ` +
|
|
147
|
+
'This is not supported. Use a React key prop to force remount, e.g., ' +
|
|
148
|
+
'<SyncProvider key={actorId} ...>';
|
|
149
|
+
|
|
150
|
+
expect(message).toContain(
|
|
151
|
+
'[SyncProvider] Critical props changed after mount'
|
|
152
|
+
);
|
|
153
|
+
expect(message).toContain('actorId');
|
|
154
|
+
expect(message).toContain('This is not supported');
|
|
155
|
+
expect(message).toContain('<SyncProvider key={actorId} ...>');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('engine config is immutable after creation', () => {
|
|
159
|
+
const transport = {
|
|
160
|
+
async sync() {
|
|
161
|
+
return { ok: true as const };
|
|
162
|
+
},
|
|
163
|
+
async fetchSnapshotChunk() {
|
|
164
|
+
return new Uint8Array();
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const engine = new SyncEngine({
|
|
169
|
+
db,
|
|
170
|
+
transport,
|
|
171
|
+
shapes: mockShapes,
|
|
172
|
+
actorId: 'user-1',
|
|
173
|
+
clientId: 'client-1',
|
|
174
|
+
subscriptions: [],
|
|
175
|
+
pollIntervalMs: 999999,
|
|
176
|
+
realtimeEnabled: false,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const originalActorId = engine.getActorId();
|
|
180
|
+
expect(originalActorId).toBe('user-1');
|
|
181
|
+
|
|
182
|
+
// Engine should maintain its original config
|
|
183
|
+
expect(engine.getActorId()).toBe('user-1');
|
|
184
|
+
|
|
185
|
+
engine.destroy();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('SyncProvider React render tests', () => {
|
|
190
|
+
let db: Kysely<ClientDb>;
|
|
191
|
+
let mockShapes: ClientTableRegistry<ClientDb>;
|
|
192
|
+
const mockTransport = {
|
|
193
|
+
async sync() {
|
|
194
|
+
return { ok: true as const };
|
|
195
|
+
},
|
|
196
|
+
async fetchSnapshotChunk() {
|
|
197
|
+
return new Uint8Array();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
beforeEach(async () => {
|
|
202
|
+
db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
|
|
203
|
+
mockShapes = new ClientTableRegistry<ClientDb>();
|
|
204
|
+
await ensureClientSyncSchema(db);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterEach(async () => {
|
|
208
|
+
cleanup();
|
|
209
|
+
await db.destroy();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('warns when critical props change after mount', () => {
|
|
213
|
+
const child = createElement('div', null, 'Test Child');
|
|
214
|
+
|
|
215
|
+
// Render with initial props
|
|
216
|
+
const { rerender } = render(
|
|
217
|
+
createElement(SyncProvider, {
|
|
218
|
+
db,
|
|
219
|
+
transport: mockTransport,
|
|
220
|
+
shapes: mockShapes,
|
|
221
|
+
actorId: 'user-1',
|
|
222
|
+
clientId: 'client-1',
|
|
223
|
+
autoStart: false, // Disable auto-start for faster test
|
|
224
|
+
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
225
|
+
children: child,
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Re-render with changed actorId should warn but not throw
|
|
230
|
+
expect(() => {
|
|
231
|
+
rerender(
|
|
232
|
+
createElement(SyncProvider, {
|
|
233
|
+
db,
|
|
234
|
+
transport: mockTransport,
|
|
235
|
+
shapes: mockShapes,
|
|
236
|
+
actorId: 'user-2', // Changed!
|
|
237
|
+
clientId: 'client-1',
|
|
238
|
+
autoStart: false,
|
|
239
|
+
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
240
|
+
children: child,
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
}).not.toThrow();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('does not throw when non-critical props change', () => {
|
|
247
|
+
const child = createElement('div', null, 'Test Child');
|
|
248
|
+
const { rerender } = render(
|
|
249
|
+
createElement(SyncProvider, {
|
|
250
|
+
db,
|
|
251
|
+
transport: mockTransport,
|
|
252
|
+
shapes: mockShapes,
|
|
253
|
+
actorId: 'user-1',
|
|
254
|
+
clientId: 'client-1',
|
|
255
|
+
autoStart: false,
|
|
256
|
+
pollIntervalMs: 1000,
|
|
257
|
+
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
258
|
+
children: child,
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Re-render with changed pollIntervalMs should not throw
|
|
263
|
+
expect(() => {
|
|
264
|
+
rerender(
|
|
265
|
+
createElement(SyncProvider, {
|
|
266
|
+
db,
|
|
267
|
+
transport: mockTransport,
|
|
268
|
+
shapes: mockShapes,
|
|
269
|
+
actorId: 'user-1',
|
|
270
|
+
clientId: 'client-1',
|
|
271
|
+
autoStart: false,
|
|
272
|
+
pollIntervalMs: 5000, // Changed non-critical prop
|
|
273
|
+
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
274
|
+
children: child,
|
|
275
|
+
})
|
|
276
|
+
);
|
|
277
|
+
}).not.toThrow();
|
|
278
|
+
});
|
|
279
|
+
});
|