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