@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,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useMutation hook
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Fluent API (upsert, delete)
|
|
6
|
+
* - Legacy mutate interface
|
|
7
|
+
* - mutateMany for batch operations
|
|
8
|
+
* - isPending state
|
|
9
|
+
* - error handling and onError callback
|
|
10
|
+
* - onSuccess callback
|
|
11
|
+
* - syncImmediately option
|
|
12
|
+
* - reset function
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
16
|
+
import type { SyncClientDb } from '@syncular/client';
|
|
17
|
+
import { act, renderHook } from '@testing-library/react';
|
|
18
|
+
import type { Kysely } from 'kysely';
|
|
19
|
+
import type { ReactNode } from 'react';
|
|
20
|
+
import { createSyncularReact } from '../../index';
|
|
21
|
+
import {
|
|
22
|
+
createMockDb,
|
|
23
|
+
createMockShapeRegistry,
|
|
24
|
+
createMockTransport,
|
|
25
|
+
} from '../test-utils';
|
|
26
|
+
|
|
27
|
+
interface TestDbTasks {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
completed: number;
|
|
31
|
+
user_id: string;
|
|
32
|
+
server_version?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface TestDb extends SyncClientDb {
|
|
36
|
+
tasks: TestDbTasks;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { SyncProvider, useMutation } = createSyncularReact<TestDb>();
|
|
40
|
+
|
|
41
|
+
describe('useMutation', () => {
|
|
42
|
+
let db: Kysely<TestDb>;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
db = await createMockDb<TestDb>();
|
|
46
|
+
|
|
47
|
+
await db.schema
|
|
48
|
+
.createTable('tasks')
|
|
49
|
+
.ifNotExists()
|
|
50
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
51
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
52
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
53
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
54
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
55
|
+
col.notNull().defaultTo(0)
|
|
56
|
+
)
|
|
57
|
+
.execute();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function createWrapper() {
|
|
61
|
+
const transport = createMockTransport();
|
|
62
|
+
const shapes = createMockShapeRegistry<TestDb>();
|
|
63
|
+
|
|
64
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
65
|
+
<SyncProvider
|
|
66
|
+
db={db}
|
|
67
|
+
transport={transport}
|
|
68
|
+
shapes={shapes}
|
|
69
|
+
actorId="test-actor"
|
|
70
|
+
clientId="test-client"
|
|
71
|
+
subscriptions={[]}
|
|
72
|
+
pollIntervalMs={999999}
|
|
73
|
+
autoStart={false}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</SyncProvider>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return Wrapper;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('fluent API', () => {
|
|
83
|
+
it('mutate.upsert() enqueues an outbox commit', async () => {
|
|
84
|
+
const { result } = renderHook(
|
|
85
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
86
|
+
{ wrapper: createWrapper() }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
const res = await result.current.mutate.upsert('task-1', {
|
|
91
|
+
title: 'Test Task',
|
|
92
|
+
completed: 0,
|
|
93
|
+
user_id: 'test-actor',
|
|
94
|
+
});
|
|
95
|
+
expect(res.commitId).toBeTruthy();
|
|
96
|
+
expect(res.clientCommitId).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const outbox = await db
|
|
100
|
+
.selectFrom('sync_outbox_commits')
|
|
101
|
+
.select(['id', 'operations_json'])
|
|
102
|
+
.execute();
|
|
103
|
+
expect(outbox.length).toBe(1);
|
|
104
|
+
|
|
105
|
+
const ops = JSON.parse(outbox[0]!.operations_json);
|
|
106
|
+
expect(ops.length).toBe(1);
|
|
107
|
+
expect(ops[0].op).toBe('upsert');
|
|
108
|
+
expect(ops[0].row_id).toBe('task-1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('mutate.delete() enqueues a delete operation', async () => {
|
|
112
|
+
const { result } = renderHook(
|
|
113
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
114
|
+
{ wrapper: createWrapper() }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await result.current.mutate.delete('task-1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const outbox = await db
|
|
122
|
+
.selectFrom('sync_outbox_commits')
|
|
123
|
+
.select(['operations_json'])
|
|
124
|
+
.executeTakeFirstOrThrow();
|
|
125
|
+
|
|
126
|
+
const ops = JSON.parse(outbox.operations_json);
|
|
127
|
+
expect(ops.length).toBe(1);
|
|
128
|
+
expect(ops[0].op).toBe('delete');
|
|
129
|
+
expect(ops[0].row_id).toBe('task-1');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('mutate.upsert() supports baseVersion option', async () => {
|
|
133
|
+
const { result } = renderHook(
|
|
134
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
135
|
+
{ wrapper: createWrapper() }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
await result.current.mutate.upsert(
|
|
140
|
+
'task-1',
|
|
141
|
+
{ title: 'Updated', completed: 1, user_id: 'test-actor' },
|
|
142
|
+
{ baseVersion: 5 }
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const outbox = await db
|
|
147
|
+
.selectFrom('sync_outbox_commits')
|
|
148
|
+
.select(['operations_json'])
|
|
149
|
+
.executeTakeFirstOrThrow();
|
|
150
|
+
|
|
151
|
+
const ops = JSON.parse(outbox.operations_json);
|
|
152
|
+
expect(ops[0].base_version).toBe(5);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('mutate.delete() supports baseVersion option', async () => {
|
|
156
|
+
const { result } = renderHook(
|
|
157
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
158
|
+
{ wrapper: createWrapper() }
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await act(async () => {
|
|
162
|
+
await result.current.mutate.delete('task-1', { baseVersion: 3 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const outbox = await db
|
|
166
|
+
.selectFrom('sync_outbox_commits')
|
|
167
|
+
.select(['operations_json'])
|
|
168
|
+
.executeTakeFirstOrThrow();
|
|
169
|
+
|
|
170
|
+
const ops = JSON.parse(outbox.operations_json);
|
|
171
|
+
expect(ops[0].base_version).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('legacy mutate interface', () => {
|
|
176
|
+
it('mutate() with MutationInput works', async () => {
|
|
177
|
+
const { result } = renderHook(
|
|
178
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
179
|
+
{ wrapper: createWrapper() }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
await act(async () => {
|
|
183
|
+
await result.current.mutate({
|
|
184
|
+
rowId: 'task-legacy',
|
|
185
|
+
op: 'upsert',
|
|
186
|
+
payload: { title: 'Legacy', completed: 0, user_id: 'test-actor' },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const outbox = await db
|
|
191
|
+
.selectFrom('sync_outbox_commits')
|
|
192
|
+
.select(['operations_json'])
|
|
193
|
+
.executeTakeFirstOrThrow();
|
|
194
|
+
|
|
195
|
+
const ops = JSON.parse(outbox.operations_json);
|
|
196
|
+
expect(ops[0].row_id).toBe('task-legacy');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('throws when MutationInput.table does not match hook table', async () => {
|
|
200
|
+
const { result } = renderHook(
|
|
201
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
202
|
+
{ wrapper: createWrapper() }
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
let thrownError: Error | null = null;
|
|
206
|
+
await act(async () => {
|
|
207
|
+
try {
|
|
208
|
+
await result.current.mutate({
|
|
209
|
+
// @ts-expect-error - runtime guard for mismatched table
|
|
210
|
+
table: 'other_table',
|
|
211
|
+
rowId: 'task-1',
|
|
212
|
+
op: 'upsert',
|
|
213
|
+
payload: { title: 'Test' },
|
|
214
|
+
});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
thrownError = err as Error;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(thrownError).not.toBeNull();
|
|
221
|
+
expect(thrownError!.message).toContain(
|
|
222
|
+
'MutationInput.table must match hook table'
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('mutateMany', () => {
|
|
228
|
+
it('batches multiple operations into a single commit', async () => {
|
|
229
|
+
const { result } = renderHook(
|
|
230
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
231
|
+
{ wrapper: createWrapper() }
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
await act(async () => {
|
|
235
|
+
await result.current.mutateMany([
|
|
236
|
+
{
|
|
237
|
+
rowId: 'task-1',
|
|
238
|
+
op: 'upsert',
|
|
239
|
+
payload: { title: 'Task 1', completed: 0, user_id: 'test-actor' },
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
rowId: 'task-2',
|
|
243
|
+
op: 'upsert',
|
|
244
|
+
payload: { title: 'Task 2', completed: 0, user_id: 'test-actor' },
|
|
245
|
+
},
|
|
246
|
+
{ rowId: 'task-3', op: 'delete' },
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const outbox = await db
|
|
251
|
+
.selectFrom('sync_outbox_commits')
|
|
252
|
+
.select(['operations_json'])
|
|
253
|
+
.execute();
|
|
254
|
+
|
|
255
|
+
expect(outbox.length).toBe(1);
|
|
256
|
+
|
|
257
|
+
const ops = JSON.parse(outbox[0]!.operations_json);
|
|
258
|
+
expect(ops.length).toBe(3);
|
|
259
|
+
expect(ops[0].op).toBe('upsert');
|
|
260
|
+
expect(ops[1].op).toBe('upsert');
|
|
261
|
+
expect(ops[2].op).toBe('delete');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('isPending state', () => {
|
|
266
|
+
it('isPending is false before and after mutation', async () => {
|
|
267
|
+
const { result } = renderHook(
|
|
268
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
269
|
+
{ wrapper: createWrapper() }
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
expect(result.current.isPending).toBe(false);
|
|
273
|
+
|
|
274
|
+
await act(async () => {
|
|
275
|
+
await result.current.mutate.upsert('task-1', {
|
|
276
|
+
title: 'Test',
|
|
277
|
+
completed: 0,
|
|
278
|
+
user_id: 'test-actor',
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.current.isPending).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('isPending resets after error', async () => {
|
|
286
|
+
const { result } = renderHook(
|
|
287
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
288
|
+
{ wrapper: createWrapper() }
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
await act(async () => {
|
|
292
|
+
try {
|
|
293
|
+
await result.current.mutate({
|
|
294
|
+
// @ts-expect-error - runtime guard for mismatched table
|
|
295
|
+
table: 'wrong',
|
|
296
|
+
rowId: 'x',
|
|
297
|
+
op: 'upsert',
|
|
298
|
+
payload: {},
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
// Expected
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(result.current.isPending).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('error handling', () => {
|
|
310
|
+
it('sets error state on failure', async () => {
|
|
311
|
+
const { result } = renderHook(
|
|
312
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
313
|
+
{ wrapper: createWrapper() }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await act(async () => {
|
|
317
|
+
try {
|
|
318
|
+
await result.current.mutate({
|
|
319
|
+
// @ts-expect-error - runtime guard for mismatched table
|
|
320
|
+
table: 'wrong',
|
|
321
|
+
rowId: 'x',
|
|
322
|
+
op: 'upsert',
|
|
323
|
+
payload: {},
|
|
324
|
+
});
|
|
325
|
+
} catch {
|
|
326
|
+
// Expected
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result.current.error).not.toBeNull();
|
|
331
|
+
expect(result.current.error?.message).toContain(
|
|
332
|
+
'MutationInput.table must match'
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('calls onError callback on failure', async () => {
|
|
337
|
+
let capturedError: Error | null = null;
|
|
338
|
+
|
|
339
|
+
const { result } = renderHook(
|
|
340
|
+
() =>
|
|
341
|
+
useMutation({
|
|
342
|
+
table: 'tasks',
|
|
343
|
+
syncImmediately: false,
|
|
344
|
+
onError: (err) => {
|
|
345
|
+
capturedError = err;
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
{ wrapper: createWrapper() }
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
await act(async () => {
|
|
352
|
+
try {
|
|
353
|
+
await result.current.mutate({
|
|
354
|
+
// @ts-expect-error - runtime guard for mismatched table
|
|
355
|
+
table: 'wrong',
|
|
356
|
+
rowId: 'x',
|
|
357
|
+
op: 'upsert',
|
|
358
|
+
payload: {},
|
|
359
|
+
});
|
|
360
|
+
} catch {
|
|
361
|
+
// Expected
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(capturedError).not.toBeNull();
|
|
366
|
+
expect(capturedError!.message).toContain(
|
|
367
|
+
'MutationInput.table must match'
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('reset() clears error state', async () => {
|
|
372
|
+
const { result } = renderHook(
|
|
373
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
374
|
+
{ wrapper: createWrapper() }
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
await act(async () => {
|
|
378
|
+
try {
|
|
379
|
+
await result.current.mutate({
|
|
380
|
+
// @ts-expect-error - runtime guard for mismatched table
|
|
381
|
+
table: 'wrong',
|
|
382
|
+
rowId: 'x',
|
|
383
|
+
op: 'upsert',
|
|
384
|
+
payload: {},
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
// Expected
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(result.current.error).not.toBeNull();
|
|
392
|
+
|
|
393
|
+
act(() => {
|
|
394
|
+
result.current.reset();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(result.current.error).toBeNull();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('onSuccess callback', () => {
|
|
402
|
+
it('calls onSuccess after successful mutation', async () => {
|
|
403
|
+
let successResult: { commitId: string; clientCommitId: string } | null =
|
|
404
|
+
null;
|
|
405
|
+
|
|
406
|
+
const { result } = renderHook(
|
|
407
|
+
() =>
|
|
408
|
+
useMutation({
|
|
409
|
+
table: 'tasks',
|
|
410
|
+
syncImmediately: false,
|
|
411
|
+
onSuccess: (res) => {
|
|
412
|
+
successResult = res;
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
{ wrapper: createWrapper() }
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
await act(async () => {
|
|
419
|
+
await result.current.mutate.upsert('task-1', {
|
|
420
|
+
title: 'Test',
|
|
421
|
+
completed: 0,
|
|
422
|
+
user_id: 'test-actor',
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(successResult).not.toBeNull();
|
|
427
|
+
expect(successResult!.commitId).toBeTruthy();
|
|
428
|
+
expect(successResult!.clientCommitId).toBeTruthy();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('syncImmediately option', () => {
|
|
433
|
+
it('syncImmediately=true triggers sync after mutation (default)', async () => {
|
|
434
|
+
const { result } = renderHook(() => useMutation({ table: 'tasks' }), {
|
|
435
|
+
wrapper: createWrapper(),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await act(async () => {
|
|
439
|
+
await result.current.mutate.upsert('task-1', {
|
|
440
|
+
title: 'Test',
|
|
441
|
+
completed: 0,
|
|
442
|
+
user_id: 'test-actor',
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Sync is called in background - we just verify no error is thrown
|
|
447
|
+
// The actual sync behavior is tested in integration tests
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('syncImmediately=false does not trigger sync', async () => {
|
|
451
|
+
const { result } = renderHook(
|
|
452
|
+
() => useMutation({ table: 'tasks', syncImmediately: false }),
|
|
453
|
+
{ wrapper: createWrapper() }
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
await act(async () => {
|
|
457
|
+
await result.current.mutate.upsert('task-1', {
|
|
458
|
+
title: 'Test',
|
|
459
|
+
completed: 0,
|
|
460
|
+
user_id: 'test-actor',
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// With syncImmediately=false, no sync is triggered
|
|
465
|
+
// This is verified by the mock transport not receiving push requests
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|