@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,321 @@
1
+ /**
2
+ * Integration tests for push flow
3
+ *
4
+ * Tests that verify the full push flow from mutation to server.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import {
9
+ enqueueOutboxCommit,
10
+ getNextSendableOutboxCommit,
11
+ } from '@syncular/client';
12
+
13
+ import {
14
+ createTestClient,
15
+ createTestServer,
16
+ destroyTestClient,
17
+ destroyTestServer,
18
+ type TestClient,
19
+ type TestServer,
20
+ } from './test-setup';
21
+
22
+ describe('Push Flow', () => {
23
+ let server: TestServer;
24
+ let client: TestClient;
25
+
26
+ const userId = 'test-user';
27
+
28
+ beforeEach(async () => {
29
+ server = await createTestServer();
30
+ client = await createTestClient(server, {
31
+ actorId: userId,
32
+ clientId: 'test-client',
33
+ });
34
+ await client.engine.start();
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await destroyTestClient(client);
39
+ await destroyTestServer(server);
40
+ });
41
+
42
+ it('enqueue creates outbox commit with pending status', async () => {
43
+ // Enqueue a commit
44
+ const result = await enqueueOutboxCommit(client.db, {
45
+ operations: [
46
+ {
47
+ table: 'tasks',
48
+ row_id: 'task-1',
49
+ op: 'upsert',
50
+ payload: { title: 'Test Task', completed: 0, user_id: userId },
51
+ base_version: null,
52
+ },
53
+ ],
54
+ });
55
+
56
+ expect(result.id).toBeTruthy();
57
+ expect(result.clientCommitId).toBeTruthy();
58
+
59
+ // Verify the commit is in the outbox
60
+ // Note: getNextSendableOutboxCommit atomically claims the commit, so it returns with 'sending' status
61
+ const nextCommit = await getNextSendableOutboxCommit(client.db);
62
+ expect(nextCommit).not.toBeNull();
63
+ expect(nextCommit!.status).toBe('sending');
64
+ expect(nextCommit!.operations.length).toBe(1);
65
+ });
66
+
67
+ it('sync pushes outbox commits to server', async () => {
68
+ // Enqueue a commit
69
+ await enqueueOutboxCommit(client.db, {
70
+ operations: [
71
+ {
72
+ table: 'tasks',
73
+ row_id: 'push-task-1',
74
+ op: 'upsert',
75
+ payload: { title: 'Pushed Task', completed: 0, user_id: userId },
76
+ base_version: null,
77
+ },
78
+ ],
79
+ });
80
+
81
+ // Before sync, verify commit exists with pending status (using direct query to avoid claiming)
82
+ const pendingCommit = await client.db
83
+ .selectFrom('sync_outbox_commits')
84
+ .selectAll()
85
+ .where('status', '=', 'pending')
86
+ .executeTakeFirst();
87
+ expect(pendingCommit).not.toBeNull();
88
+
89
+ // Sync
90
+ const result = await client.engine.sync();
91
+ expect(result.success).toBe(true);
92
+ expect(result.pushedCommits).toBe(1);
93
+
94
+ // After sync, outbox should be empty (commit acked)
95
+ const remainingCommit = await getNextSendableOutboxCommit(client.db);
96
+ expect(remainingCommit).toBeNull();
97
+
98
+ // Verify task is on server
99
+ const serverTasks = await server.db
100
+ .selectFrom('tasks')
101
+ .where('id', '=', 'push-task-1')
102
+ .selectAll()
103
+ .execute();
104
+
105
+ expect(serverTasks.length).toBe(1);
106
+ expect(serverTasks[0]!.title).toBe('Pushed Task');
107
+ expect(serverTasks[0]!.server_version).toBe(1);
108
+ });
109
+
110
+ it('sync updates local row with server version after push', async () => {
111
+ // First, apply the mutation locally (this is what useMutation does)
112
+ const shapes = client.shapes;
113
+ const handler = shapes.get('tasks');
114
+
115
+ await client.db.transaction().execute(async (trx) => {
116
+ await handler!.applyChange(
117
+ { trx },
118
+ {
119
+ table: 'tasks',
120
+ row_id: 'version-test',
121
+ op: 'upsert',
122
+ row_json: { title: 'Local Task', completed: 0, user_id: userId },
123
+ row_version: null, // Local optimistic - no server version yet
124
+ scopes: { user_id: userId },
125
+ }
126
+ );
127
+ });
128
+
129
+ // Verify local row has version 0
130
+ let localRow = await client.db
131
+ .selectFrom('tasks')
132
+ .where('id', '=', 'version-test')
133
+ .selectAll()
134
+ .executeTakeFirst();
135
+ expect(localRow).toBeTruthy();
136
+ expect(localRow!.server_version).toBe(0);
137
+
138
+ // Enqueue the commit
139
+ await enqueueOutboxCommit(client.db, {
140
+ operations: [
141
+ {
142
+ table: 'tasks',
143
+ row_id: 'version-test',
144
+ op: 'upsert',
145
+ payload: { title: 'Local Task', completed: 0, user_id: userId },
146
+ base_version: null,
147
+ },
148
+ ],
149
+ });
150
+
151
+ // Sync (push + pull)
152
+ await client.engine.sync();
153
+
154
+ // After sync, local row should have server version
155
+ localRow = await client.db
156
+ .selectFrom('tasks')
157
+ .where('id', '=', 'version-test')
158
+ .selectAll()
159
+ .executeTakeFirst();
160
+ expect(localRow).toBeTruthy();
161
+ expect(localRow!.server_version).toBe(1);
162
+ });
163
+
164
+ it('multiple syncs push multiple commits', async () => {
165
+ // Enqueue first commit
166
+ await enqueueOutboxCommit(client.db, {
167
+ operations: [
168
+ {
169
+ table: 'tasks',
170
+ row_id: 'multi-1',
171
+ op: 'upsert',
172
+ payload: { title: 'Task 1', completed: 0, user_id: userId },
173
+ base_version: null,
174
+ },
175
+ ],
176
+ });
177
+
178
+ // First sync
179
+ let result = await client.engine.sync();
180
+ expect(result.pushedCommits).toBe(1);
181
+
182
+ // Enqueue second commit
183
+ await enqueueOutboxCommit(client.db, {
184
+ operations: [
185
+ {
186
+ table: 'tasks',
187
+ row_id: 'multi-2',
188
+ op: 'upsert',
189
+ payload: { title: 'Task 2', completed: 0, user_id: userId },
190
+ base_version: null,
191
+ },
192
+ ],
193
+ });
194
+
195
+ // Second sync
196
+ result = await client.engine.sync();
197
+ expect(result.pushedCommits).toBe(1);
198
+
199
+ // Verify both on server
200
+ const serverTasks = await server.db
201
+ .selectFrom('tasks')
202
+ .where('user_id', '=', userId)
203
+ .selectAll()
204
+ .execute();
205
+
206
+ expect(serverTasks.length).toBe(2);
207
+ });
208
+
209
+ it('outbox stats reflect pending commits', async () => {
210
+ // Initially no pending
211
+ let stats = await client.engine.refreshOutboxStats();
212
+ expect(stats.pending).toBe(0);
213
+
214
+ // Enqueue a commit
215
+ await enqueueOutboxCommit(client.db, {
216
+ operations: [
217
+ {
218
+ table: 'tasks',
219
+ row_id: 'stats-test',
220
+ op: 'upsert',
221
+ payload: { title: 'Stats Task', completed: 0, user_id: userId },
222
+ base_version: null,
223
+ },
224
+ ],
225
+ });
226
+
227
+ // Should show 1 pending
228
+ stats = await client.engine.refreshOutboxStats();
229
+ expect(stats.pending).toBe(1);
230
+
231
+ // Sync
232
+ await client.engine.sync();
233
+
234
+ // Should show 0 pending
235
+ stats = await client.engine.refreshOutboxStats();
236
+ expect(stats.pending).toBe(0);
237
+ });
238
+
239
+ it('keeps commit pending when server returns retriable error', async () => {
240
+ // Create a client with a transport that returns retriable errors
241
+ const retriableClient = await createTestClient(server, {
242
+ actorId: userId,
243
+ clientId: 'retriable-client',
244
+ });
245
+
246
+ // Override the transport to return retriable errors on push
247
+ const originalSync = retriableClient.transport.sync.bind(
248
+ retriableClient.transport
249
+ );
250
+ let retriableErrorCount = 0;
251
+ retriableClient.transport.sync = async (request) => {
252
+ if (request.push && retriableErrorCount < 2) {
253
+ // Return retriable error for first two push attempts
254
+ retriableErrorCount++;
255
+ return {
256
+ ok: true as const,
257
+ push: {
258
+ ok: true as const,
259
+ status: 'rejected' as const,
260
+ results: request.push.operations.map((_, i) => ({
261
+ opIndex: i,
262
+ status: 'error' as const,
263
+ error: 'TEMPORARY_FAILURE',
264
+ code: 'TEMPORARY',
265
+ retriable: true,
266
+ })),
267
+ },
268
+ };
269
+ }
270
+ // After that, use the real transport
271
+ return originalSync(request);
272
+ };
273
+
274
+ await retriableClient.engine.start();
275
+
276
+ try {
277
+ // Enqueue a commit
278
+ await enqueueOutboxCommit(retriableClient.db, {
279
+ operations: [
280
+ {
281
+ table: 'tasks',
282
+ row_id: 'retriable-task',
283
+ op: 'upsert',
284
+ payload: { title: 'Retriable Task', completed: 0, user_id: userId },
285
+ base_version: null,
286
+ },
287
+ ],
288
+ });
289
+
290
+ // Verify commit is pending before sync
291
+ let stats = await retriableClient.engine.refreshOutboxStats();
292
+ expect(stats.pending).toBe(1);
293
+ expect(stats.failed).toBe(0);
294
+
295
+ // First sync - will hit retriable error but commit stays pending (not failed)
296
+ await retriableClient.engine.sync();
297
+
298
+ // Commit should still be pending (not failed) after retriable error
299
+ stats = await retriableClient.engine.refreshOutboxStats();
300
+ expect(stats.failed).toBe(0);
301
+ // Commit may still be pending since it wasn't terminal failure
302
+ // Note: pushedCommits counts attempts, which may be > 0
303
+
304
+ // After enough retries, the commit should eventually succeed
305
+ // Keep syncing until successful or we've tried enough times
306
+ for (let i = 0; i < 5 && stats.pending > 0; i++) {
307
+ await retriableClient.engine.sync();
308
+ stats = await retriableClient.engine.refreshOutboxStats();
309
+ }
310
+
311
+ // Now should be empty (commit succeeded) and no failures
312
+ expect(stats.pending).toBe(0);
313
+ expect(stats.failed).toBe(0);
314
+
315
+ // Verify the retriable error was actually returned (at least twice before success)
316
+ expect(retriableErrorCount).toBe(2);
317
+ } finally {
318
+ await destroyTestClient(retriableClient);
319
+ }
320
+ });
321
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Integration tests for realtime sync
3
+ *
4
+ * These tests focus on multi-client correctness. Realtime wake-ups are handled
5
+ * via WebSocket in production, but correctness remains pull-driven.
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('Realtime Sync via WebSocket', () => {
20
+ let server: TestServer;
21
+ let clientA: TestClient;
22
+ let clientB: TestClient;
23
+
24
+ const sharedUserId = 'realtime-user';
25
+
26
+ beforeEach(async () => {
27
+ server = await createTestServer();
28
+ clientA = await createTestClient(server, {
29
+ actorId: sharedUserId,
30
+ clientId: 'client-a',
31
+ });
32
+ clientB = await createTestClient(server, {
33
+ actorId: sharedUserId,
34
+ clientId: 'client-b',
35
+ });
36
+
37
+ await clientA.engine.start();
38
+ await clientB.engine.start();
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await destroyTestClient(clientA);
43
+ await destroyTestClient(clientB);
44
+ await destroyTestServer(server);
45
+ });
46
+
47
+ it('client B receives data pushed by client A', async () => {
48
+ // Client A creates a task
49
+ await enqueueOutboxCommit(clientA.db, {
50
+ operations: [
51
+ {
52
+ table: 'tasks',
53
+ row_id: 'realtime-task-1',
54
+ op: 'upsert',
55
+ payload: {
56
+ title: 'Realtime Task',
57
+ completed: 0,
58
+ user_id: sharedUserId,
59
+ },
60
+ base_version: null,
61
+ },
62
+ ],
63
+ });
64
+
65
+ // Client A syncs (pushes to server)
66
+ await clientA.engine.sync();
67
+
68
+ // Client B syncs (pulls from server)
69
+ await clientB.engine.sync();
70
+
71
+ // Check client B has the task
72
+ const tasksB = await clientB.db.selectFrom('tasks').selectAll().execute();
73
+
74
+ expect(tasksB.length).toBe(1);
75
+ expect(tasksB[0]!.title).toBe('Realtime Task');
76
+ });
77
+
78
+ it('updates propagate between clients', async () => {
79
+ // Create initial task
80
+ await enqueueOutboxCommit(clientA.db, {
81
+ operations: [
82
+ {
83
+ table: 'tasks',
84
+ row_id: 'update-test',
85
+ op: 'upsert',
86
+ payload: {
87
+ title: 'Original',
88
+ completed: 0,
89
+ user_id: sharedUserId,
90
+ },
91
+ base_version: null,
92
+ },
93
+ ],
94
+ });
95
+ await clientA.engine.sync();
96
+ await clientB.engine.sync();
97
+
98
+ // Verify B has original
99
+ let taskB = await clientB.db
100
+ .selectFrom('tasks')
101
+ .where('id', '=', 'update-test')
102
+ .selectAll()
103
+ .executeTakeFirst();
104
+ expect(taskB!.title).toBe('Original');
105
+
106
+ // A updates the task
107
+ await enqueueOutboxCommit(clientA.db, {
108
+ operations: [
109
+ {
110
+ table: 'tasks',
111
+ row_id: 'update-test',
112
+ op: 'upsert',
113
+ payload: {
114
+ title: 'Updated',
115
+ completed: 1,
116
+ user_id: sharedUserId,
117
+ },
118
+ base_version: 1,
119
+ },
120
+ ],
121
+ });
122
+ await clientA.engine.sync();
123
+ await clientB.engine.sync();
124
+
125
+ // Verify B has update
126
+ taskB = await clientB.db
127
+ .selectFrom('tasks')
128
+ .where('id', '=', 'update-test')
129
+ .selectAll()
130
+ .executeTakeFirst();
131
+ expect(taskB!.title).toBe('Updated');
132
+ expect(taskB!.completed).toBe(1);
133
+ });
134
+
135
+ it('deletes propagate between clients', async () => {
136
+ // Create task
137
+ await enqueueOutboxCommit(clientA.db, {
138
+ operations: [
139
+ {
140
+ table: 'tasks',
141
+ row_id: 'delete-test',
142
+ op: 'upsert',
143
+ payload: {
144
+ title: 'To Delete',
145
+ completed: 0,
146
+ user_id: sharedUserId,
147
+ },
148
+ base_version: null,
149
+ },
150
+ ],
151
+ });
152
+ await clientA.engine.sync();
153
+ await clientB.engine.sync();
154
+
155
+ // Verify B has task
156
+ let countB = await clientB.db
157
+ .selectFrom('tasks')
158
+ .where('id', '=', 'delete-test')
159
+ .select((eb) => eb.fn.count('id').as('count'))
160
+ .executeTakeFirst();
161
+ expect(Number(countB!.count)).toBe(1);
162
+
163
+ // A deletes the task
164
+ await enqueueOutboxCommit(clientA.db, {
165
+ operations: [
166
+ {
167
+ table: 'tasks',
168
+ row_id: 'delete-test',
169
+ op: 'delete',
170
+ payload: {},
171
+ base_version: 1,
172
+ },
173
+ ],
174
+ });
175
+ await clientA.engine.sync();
176
+ await clientB.engine.sync();
177
+
178
+ // Verify B no longer has task
179
+ countB = await clientB.db
180
+ .selectFrom('tasks')
181
+ .where('id', '=', 'delete-test')
182
+ .select((eb) => eb.fn.count('id').as('count'))
183
+ .executeTakeFirst();
184
+ expect(Number(countB!.count)).toBe(0);
185
+ });
186
+
187
+ it('multiple rapid changes sync correctly', async () => {
188
+ // Create 5 tasks rapidly
189
+ for (let i = 0; i < 5; i++) {
190
+ await enqueueOutboxCommit(clientA.db, {
191
+ operations: [
192
+ {
193
+ table: 'tasks',
194
+ row_id: `rapid-${i}`,
195
+ op: 'upsert',
196
+ payload: {
197
+ title: `Task ${i}`,
198
+ completed: 0,
199
+ user_id: sharedUserId,
200
+ },
201
+ base_version: null,
202
+ },
203
+ ],
204
+ });
205
+ }
206
+
207
+ // Sync A
208
+ await clientA.engine.sync();
209
+
210
+ // Sync B
211
+ await clientB.engine.sync();
212
+
213
+ // Verify B has all 5 tasks
214
+ const tasksB = await clientB.db
215
+ .selectFrom('tasks')
216
+ .where('id', 'like', 'rapid-%')
217
+ .selectAll()
218
+ .execute();
219
+
220
+ expect(tasksB.length).toBe(5);
221
+ });
222
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Integration tests for avoiding "self-conflicts" on hot rows.
3
+ *
4
+ * This happens when UI code enqueues multiple updates quickly using the same
5
+ * (stale) base_version value.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import {
10
+ createIncrementingVersionPlugin,
11
+ enqueueOutboxCommit,
12
+ } from '@syncular/client';
13
+ import {
14
+ createTestClient,
15
+ createTestServer,
16
+ destroyTestClient,
17
+ destroyTestServer,
18
+ type TestClient,
19
+ type TestServer,
20
+ } from './test-setup';
21
+
22
+ describe('Self-conflict avoidance', () => {
23
+ let server: TestServer;
24
+ let client: TestClient;
25
+
26
+ const userId = 'self-conflict-user';
27
+
28
+ beforeEach(async () => {
29
+ server = await createTestServer();
30
+ client = await createTestClient(server, {
31
+ actorId: userId,
32
+ clientId: 'client-a',
33
+ plugins: [createIncrementingVersionPlugin()],
34
+ });
35
+
36
+ await client.engine.start();
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await destroyTestClient(client);
41
+ await destroyTestServer(server);
42
+ });
43
+
44
+ it('advances base_version across rapid sequential updates', async () => {
45
+ await enqueueOutboxCommit(client.db, {
46
+ operations: [
47
+ {
48
+ table: 'tasks',
49
+ row_id: 'hot-row',
50
+ op: 'upsert',
51
+ payload: { title: 'Hot Row', completed: 0, user_id: userId },
52
+ base_version: null,
53
+ },
54
+ ],
55
+ });
56
+ await client.engine.sync();
57
+
58
+ // Enqueue multiple updates that (incorrectly) all use base_version=1.
59
+ for (let i = 0; i < 5; i++) {
60
+ await enqueueOutboxCommit(client.db, {
61
+ operations: [
62
+ {
63
+ table: 'tasks',
64
+ row_id: 'hot-row',
65
+ op: 'upsert',
66
+ payload: { completed: i % 2 },
67
+ base_version: 1,
68
+ },
69
+ ],
70
+ });
71
+ }
72
+
73
+ await client.engine.sync();
74
+
75
+ const conflicts = await client.db
76
+ .selectFrom('sync_conflicts')
77
+ .selectAll()
78
+ .execute();
79
+ expect(conflicts.length).toBe(0);
80
+
81
+ const task = await client.db
82
+ .selectFrom('tasks')
83
+ .selectAll()
84
+ .where('id', '=', 'hot-row')
85
+ .executeTakeFirstOrThrow();
86
+
87
+ // v1 after insert + 5 updates => v6
88
+ expect(task.server_version).toBe(6);
89
+ expect(task.completed).toBe(0);
90
+ });
91
+ });