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