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