@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,373 @@
1
+ /**
2
+ * Integration tests for two-client sync
3
+ *
4
+ * Tests that two clients with the same user ID can sync data between each other
5
+ * through a shared server.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import { enqueueOutboxCommit } from '@syncular/client';
10
+ import {
11
+ createTestClient,
12
+ createTestServer,
13
+ destroyTestClient,
14
+ destroyTestServer,
15
+ type TestClient,
16
+ type TestServer,
17
+ } from './test-setup';
18
+
19
+ describe('Two Client Sync', () => {
20
+ let server: TestServer;
21
+ let clientA: TestClient;
22
+ let clientB: TestClient;
23
+
24
+ // Both clients use the same userId so they share the same scope
25
+ const sharedUserId = 'shared-user';
26
+
27
+ beforeEach(async () => {
28
+ server = await createTestServer();
29
+ clientA = await createTestClient(server, {
30
+ actorId: sharedUserId,
31
+ clientId: 'client-a',
32
+ });
33
+ clientB = await createTestClient(server, {
34
+ actorId: sharedUserId,
35
+ clientId: 'client-b',
36
+ });
37
+
38
+ // Start both engines
39
+ await clientA.engine.start();
40
+ await clientB.engine.start();
41
+ });
42
+
43
+ afterEach(async () => {
44
+ await destroyTestClient(clientA);
45
+ await destroyTestClient(clientB);
46
+ await destroyTestServer(server);
47
+ });
48
+
49
+ it('changes from client A appear in client B after sync', async () => {
50
+ // Client A creates a task via outbox
51
+ await enqueueOutboxCommit(clientA.db, {
52
+ operations: [
53
+ {
54
+ table: 'tasks',
55
+ row_id: 'task-1',
56
+ op: 'upsert',
57
+ payload: {
58
+ title: 'Task from Client A',
59
+ completed: 0,
60
+ user_id: sharedUserId,
61
+ },
62
+ base_version: null,
63
+ },
64
+ ],
65
+ });
66
+
67
+ // Sync client A (push to server)
68
+ await clientA.engine.sync();
69
+
70
+ // Sync client B (pull from server)
71
+ await clientB.engine.sync();
72
+
73
+ // Check client B has the task
74
+ const tasksB = await clientB.db.selectFrom('tasks').selectAll().execute();
75
+
76
+ expect(tasksB.length).toBe(1);
77
+ expect(tasksB[0]!.id).toBe('task-1');
78
+ expect(tasksB[0]!.title).toBe('Task from Client A');
79
+ });
80
+
81
+ it('changes sync bidirectionally', async () => {
82
+ // Client A creates task-1
83
+ await enqueueOutboxCommit(clientA.db, {
84
+ operations: [
85
+ {
86
+ table: 'tasks',
87
+ row_id: 'task-1',
88
+ op: 'upsert',
89
+ payload: {
90
+ title: 'Task from A',
91
+ completed: 0,
92
+ user_id: sharedUserId,
93
+ },
94
+ base_version: null,
95
+ },
96
+ ],
97
+ });
98
+
99
+ // Client B creates task-2
100
+ await enqueueOutboxCommit(clientB.db, {
101
+ operations: [
102
+ {
103
+ table: 'tasks',
104
+ row_id: 'task-2',
105
+ op: 'upsert',
106
+ payload: {
107
+ title: 'Task from B',
108
+ completed: 1,
109
+ user_id: sharedUserId,
110
+ },
111
+ base_version: null,
112
+ },
113
+ ],
114
+ });
115
+
116
+ // Sync both clients
117
+ await clientA.engine.sync();
118
+ await clientB.engine.sync();
119
+ // Sync again to ensure A gets B's changes
120
+ await clientA.engine.sync();
121
+
122
+ // Check both clients have both tasks
123
+ const tasksA = await clientA.db
124
+ .selectFrom('tasks')
125
+ .selectAll()
126
+ .orderBy('id', 'asc')
127
+ .execute();
128
+
129
+ const tasksB = await clientB.db
130
+ .selectFrom('tasks')
131
+ .selectAll()
132
+ .orderBy('id', 'asc')
133
+ .execute();
134
+
135
+ expect(tasksA.length).toBe(2);
136
+ expect(tasksB.length).toBe(2);
137
+
138
+ expect(tasksA.map((t) => t.id)).toEqual(['task-1', 'task-2']);
139
+ expect(tasksB.map((t) => t.id)).toEqual(['task-1', 'task-2']);
140
+ });
141
+
142
+ it('offline client queues changes until reconnect', async () => {
143
+ // Client A goes offline (stop engine)
144
+ clientA.engine.stop();
145
+
146
+ // Client A creates a task while offline
147
+ await enqueueOutboxCommit(clientA.db, {
148
+ operations: [
149
+ {
150
+ table: 'tasks',
151
+ row_id: 'offline-task',
152
+ op: 'upsert',
153
+ payload: {
154
+ title: 'Created while offline',
155
+ completed: 0,
156
+ user_id: sharedUserId,
157
+ },
158
+ base_version: null,
159
+ },
160
+ ],
161
+ });
162
+
163
+ // Verify task is in outbox (pending)
164
+ const outbox = await clientA.db
165
+ .selectFrom('sync_outbox_commits')
166
+ .selectAll()
167
+ .execute();
168
+ expect(outbox.length).toBe(1);
169
+ expect(outbox[0]!.status).toBe('pending');
170
+
171
+ // Client B syncs - should not see the task
172
+ await clientB.engine.sync();
173
+ const tasksBefore = await clientB.db
174
+ .selectFrom('tasks')
175
+ .selectAll()
176
+ .execute();
177
+ expect(tasksBefore.length).toBe(0);
178
+
179
+ // Client A reconnects
180
+ await clientA.engine.start();
181
+ await clientA.engine.sync();
182
+
183
+ // Now client B syncs and should see the task
184
+ await clientB.engine.sync();
185
+ const tasksAfter = await clientB.db
186
+ .selectFrom('tasks')
187
+ .selectAll()
188
+ .execute();
189
+ expect(tasksAfter.length).toBe(1);
190
+ expect(tasksAfter[0]!.title).toBe('Created while offline');
191
+ });
192
+
193
+ it('updates to existing tasks sync correctly', async () => {
194
+ // Client A creates a task
195
+ await enqueueOutboxCommit(clientA.db, {
196
+ operations: [
197
+ {
198
+ table: 'tasks',
199
+ row_id: 'task-1',
200
+ op: 'upsert',
201
+ payload: {
202
+ title: 'Original title',
203
+ completed: 0,
204
+ user_id: sharedUserId,
205
+ },
206
+ base_version: null,
207
+ },
208
+ ],
209
+ });
210
+ await clientA.engine.sync();
211
+ await clientB.engine.sync();
212
+
213
+ // Verify client B has the task with original title
214
+ let taskB = await clientB.db
215
+ .selectFrom('tasks')
216
+ .where('id', '=', 'task-1')
217
+ .selectAll()
218
+ .executeTakeFirst();
219
+ expect(taskB!.title).toBe('Original title');
220
+ expect(taskB!.server_version).toBe(1);
221
+
222
+ // Client A updates the task
223
+ await enqueueOutboxCommit(clientA.db, {
224
+ operations: [
225
+ {
226
+ table: 'tasks',
227
+ row_id: 'task-1',
228
+ op: 'upsert',
229
+ payload: {
230
+ title: 'Updated title',
231
+ completed: 1,
232
+ user_id: sharedUserId,
233
+ },
234
+ base_version: 1, // Current server version
235
+ },
236
+ ],
237
+ });
238
+ await clientA.engine.sync();
239
+ await clientB.engine.sync();
240
+
241
+ // Verify client B has the updated task
242
+ taskB = await clientB.db
243
+ .selectFrom('tasks')
244
+ .where('id', '=', 'task-1')
245
+ .selectAll()
246
+ .executeTakeFirst();
247
+ expect(taskB!.title).toBe('Updated title');
248
+ expect(taskB!.completed).toBe(1);
249
+ expect(taskB!.server_version).toBe(2);
250
+ });
251
+
252
+ it('deletes sync correctly', async () => {
253
+ // Client A creates a task
254
+ await enqueueOutboxCommit(clientA.db, {
255
+ operations: [
256
+ {
257
+ table: 'tasks',
258
+ row_id: 'task-to-delete',
259
+ op: 'upsert',
260
+ payload: {
261
+ title: 'Will be deleted',
262
+ completed: 0,
263
+ user_id: sharedUserId,
264
+ },
265
+ base_version: null,
266
+ },
267
+ ],
268
+ });
269
+ await clientA.engine.sync();
270
+ await clientB.engine.sync();
271
+
272
+ // Verify both clients have the task
273
+ let countA = await clientA.db
274
+ .selectFrom('tasks')
275
+ .select((eb) => eb.fn.count('id').as('count'))
276
+ .executeTakeFirst();
277
+ let countB = await clientB.db
278
+ .selectFrom('tasks')
279
+ .select((eb) => eb.fn.count('id').as('count'))
280
+ .executeTakeFirst();
281
+ expect(Number(countA!.count)).toBe(1);
282
+ expect(Number(countB!.count)).toBe(1);
283
+
284
+ // Client A deletes the task
285
+ await enqueueOutboxCommit(clientA.db, {
286
+ operations: [
287
+ {
288
+ table: 'tasks',
289
+ row_id: 'task-to-delete',
290
+ op: 'delete',
291
+ payload: {},
292
+ base_version: 1,
293
+ },
294
+ ],
295
+ });
296
+ await clientA.engine.sync();
297
+ await clientB.engine.sync();
298
+
299
+ // Verify both clients no longer have the task
300
+ countA = await clientA.db
301
+ .selectFrom('tasks')
302
+ .select((eb) => eb.fn.count('id').as('count'))
303
+ .executeTakeFirst();
304
+ countB = await clientB.db
305
+ .selectFrom('tasks')
306
+ .select((eb) => eb.fn.count('id').as('count'))
307
+ .executeTakeFirst();
308
+ expect(Number(countA!.count)).toBe(0);
309
+ expect(Number(countB!.count)).toBe(0);
310
+ });
311
+
312
+ it('clients with different user IDs do NOT see each other data', async () => {
313
+ // Create a third client with a different user ID
314
+ const clientC = await createTestClient(server, {
315
+ actorId: 'different-user',
316
+ clientId: 'client-c',
317
+ });
318
+ await clientC.engine.start();
319
+
320
+ try {
321
+ // Client A creates a task (for shared-user scope)
322
+ await enqueueOutboxCommit(clientA.db, {
323
+ operations: [
324
+ {
325
+ table: 'tasks',
326
+ row_id: 'task-for-shared-user',
327
+ op: 'upsert',
328
+ payload: {
329
+ title: 'Shared user task',
330
+ completed: 0,
331
+ user_id: sharedUserId,
332
+ },
333
+ base_version: null,
334
+ },
335
+ ],
336
+ });
337
+
338
+ // Client C creates a task (for different-user scope)
339
+ await enqueueOutboxCommit(clientC.db, {
340
+ operations: [
341
+ {
342
+ table: 'tasks',
343
+ row_id: 'task-for-different-user',
344
+ op: 'upsert',
345
+ payload: {
346
+ title: 'Different user task',
347
+ completed: 0,
348
+ user_id: 'different-user',
349
+ },
350
+ base_version: null,
351
+ },
352
+ ],
353
+ });
354
+
355
+ // Sync all clients
356
+ await clientA.engine.sync();
357
+ await clientB.engine.sync();
358
+ await clientC.engine.sync();
359
+
360
+ // Client B should ONLY see shared-user's task
361
+ const tasksB = await clientB.db.selectFrom('tasks').selectAll().execute();
362
+ expect(tasksB.length).toBe(1);
363
+ expect(tasksB[0]!.title).toBe('Shared user task');
364
+
365
+ // Client C should ONLY see different-user's task
366
+ const tasksC = await clientC.db.selectFrom('tasks').selectAll().execute();
367
+ expect(tasksC.length).toBe(1);
368
+ expect(tasksC[0]!.title).toBe('Different user task');
369
+ } finally {
370
+ await destroyTestClient(clientC);
371
+ }
372
+ });
373
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Test setup - configures happy-dom for React testing
3
+ */
4
+
5
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
6
+
7
+ GlobalRegistrator.register();
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Test utilities for @syncular/client-react
3
+ */
4
+
5
+ import type {
6
+ SyncPullRequest,
7
+ SyncPullResponse,
8
+ SyncPushRequest,
9
+ SyncPushResponse,
10
+ SyncTransport,
11
+ } from '@syncular/client';
12
+ import { ClientTableRegistry, type SyncClientDb } from '@syncular/client';
13
+ import type { Kysely } from 'kysely';
14
+
15
+ /**
16
+ * Create a mock transport for testing
17
+ */
18
+ export function createMockTransport(
19
+ options: {
20
+ pullResponse?: Partial<SyncPullResponse>;
21
+ pushResponse?: Partial<SyncPushResponse>;
22
+ onPull?: (request: SyncPullRequest) => void;
23
+ onPush?: (request: SyncPushRequest) => void;
24
+ } = {}
25
+ ): SyncTransport {
26
+ return {
27
+ async pull(request: SyncPullRequest): Promise<SyncPullResponse> {
28
+ options.onPull?.(request);
29
+ return {
30
+ ok: true,
31
+ subscriptions: [],
32
+ ...options.pullResponse,
33
+ };
34
+ },
35
+ async push(request: SyncPushRequest): Promise<SyncPushResponse> {
36
+ options.onPush?.(request);
37
+ return {
38
+ ok: true,
39
+ status: 'applied',
40
+ results: request.operations.map((_, i) => ({
41
+ opIndex: i,
42
+ status: 'applied' as const,
43
+ })),
44
+ ...options.pushResponse,
45
+ };
46
+ },
47
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
48
+ // Return empty gzipped NDJSON (empty array)
49
+ return new Uint8Array();
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Create a mock shape registry
56
+ */
57
+ export function createMockShapeRegistry<
58
+ DB extends SyncClientDb = SyncClientDb,
59
+ >(): ClientTableRegistry<DB> {
60
+ return new ClientTableRegistry<DB>();
61
+ }
62
+
63
+ /**
64
+ * Create a mock in-memory database for testing
65
+ */
66
+ export async function createMockDb<
67
+ DB extends SyncClientDb = SyncClientDb,
68
+ >(): Promise<Kysely<DB>> {
69
+ // Dynamic import to avoid bundling issues
70
+ const { Kysely } = await import('kysely');
71
+ const { BunSqliteDialect } = await import('kysely-bun-sqlite');
72
+ const { Database } = await import('bun:sqlite');
73
+
74
+ const db = new Kysely<DB>({
75
+ dialect: new BunSqliteDialect({
76
+ database: new Database(':memory:'),
77
+ }),
78
+ });
79
+
80
+ // Create sync tables
81
+ await db.schema
82
+ .createTable('sync_subscription_state')
83
+ .ifNotExists()
84
+ .addColumn('state_id', 'text', (col) => col.notNull())
85
+ .addColumn('subscription_id', 'text', (col) => col.notNull())
86
+ .addColumn('shape', 'text', (col) => col.notNull())
87
+ .addColumn('scopes_json', 'text', (col) => col.notNull())
88
+ .addColumn('params_json', 'text', (col) => col.notNull())
89
+ .addColumn('cursor', 'integer', (col) => col.notNull())
90
+ .addColumn('bootstrap_state_json', 'text')
91
+ .addColumn('status', 'text', (col) => col.notNull())
92
+ .addColumn('created_at', 'integer', (col) => col.notNull())
93
+ .addColumn('updated_at', 'integer', (col) => col.notNull())
94
+ .addPrimaryKeyConstraint('pk_sync_subscription_state', [
95
+ 'state_id',
96
+ 'subscription_id',
97
+ ])
98
+ .execute();
99
+
100
+ await db.schema
101
+ .createTable('sync_outbox_commits')
102
+ .ifNotExists()
103
+ .addColumn('id', 'text', (col) => col.primaryKey())
104
+ .addColumn('client_commit_id', 'text', (col) => col.notNull())
105
+ .addColumn('status', 'text', (col) => col.notNull())
106
+ .addColumn('operations_json', 'text', (col) => col.notNull())
107
+ .addColumn('last_response_json', 'text')
108
+ .addColumn('error', 'text')
109
+ .addColumn('created_at', 'integer', (col) => col.notNull())
110
+ .addColumn('updated_at', 'integer', (col) => col.notNull())
111
+ .addColumn('attempt_count', 'integer', (col) => col.notNull().defaultTo(0))
112
+ .addColumn('acked_commit_seq', 'integer')
113
+ .addColumn('schema_version', 'integer', (col) => col.notNull().defaultTo(1))
114
+ .execute();
115
+
116
+ await db.schema
117
+ .createTable('sync_conflicts')
118
+ .ifNotExists()
119
+ .addColumn('id', 'text', (col) => col.primaryKey())
120
+ .addColumn('outbox_commit_id', 'text', (col) => col.notNull())
121
+ .addColumn('client_commit_id', 'text', (col) => col.notNull())
122
+ .addColumn('op_index', 'integer', (col) => col.notNull())
123
+ .addColumn('result_status', 'text', (col) => col.notNull())
124
+ .addColumn('message', 'text', (col) => col.notNull())
125
+ .addColumn('code', 'text')
126
+ .addColumn('server_version', 'integer')
127
+ .addColumn('server_row_json', 'text')
128
+ .addColumn('created_at', 'integer', (col) => col.notNull())
129
+ .addColumn('resolved_at', 'integer')
130
+ .addColumn('resolution', 'text')
131
+ .execute();
132
+
133
+ await db.schema
134
+ .createTable('sync_blob_cache')
135
+ .ifNotExists()
136
+ .addColumn('hash', 'text', (col) => col.primaryKey())
137
+ .addColumn('size', 'integer', (col) => col.notNull())
138
+ .addColumn('mime_type', 'text', (col) => col.notNull())
139
+ .addColumn('body', 'blob', (col) => col.notNull())
140
+ .addColumn('encrypted', 'integer', (col) => col.notNull())
141
+ .addColumn('key_id', 'text')
142
+ .addColumn('cached_at', 'integer', (col) => col.notNull())
143
+ .addColumn('last_accessed_at', 'integer', (col) => col.notNull())
144
+ .execute();
145
+
146
+ await db.schema
147
+ .createTable('sync_blob_outbox')
148
+ .ifNotExists()
149
+ .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
150
+ .addColumn('hash', 'text', (col) => col.notNull())
151
+ .addColumn('size', 'integer', (col) => col.notNull())
152
+ .addColumn('mime_type', 'text', (col) => col.notNull())
153
+ .addColumn('body', 'blob', (col) => col.notNull())
154
+ .addColumn('encrypted', 'integer', (col) => col.notNull())
155
+ .addColumn('key_id', 'text')
156
+ .addColumn('status', 'text', (col) => col.notNull())
157
+ .addColumn('attempt_count', 'integer', (col) => col.notNull())
158
+ .addColumn('error', 'text')
159
+ .addColumn('created_at', 'integer', (col) => col.notNull())
160
+ .addColumn('updated_at', 'integer', (col) => col.notNull())
161
+ .execute();
162
+
163
+ return db;
164
+ }
165
+
166
+ /**
167
+ * Wait for a condition to be true
168
+ */
169
+ export async function waitFor(
170
+ condition: () => boolean,
171
+ timeout = 1000
172
+ ): Promise<void> {
173
+ const start = Date.now();
174
+ while (!condition()) {
175
+ if (Date.now() - start > timeout) {
176
+ throw new Error('waitFor timeout');
177
+ }
178
+ await new Promise((resolve) => setTimeout(resolve, 10));
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Flush promises
184
+ */
185
+ export function flushPromises(): Promise<void> {
186
+ return new Promise((resolve) => setTimeout(resolve, 0));
187
+ }