@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.
@@ -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
+ });