@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,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test setup for @syncular/client-react
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for creating test clients and servers for integration testing.
|
|
5
|
+
* Based on the e2e test-utils pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SyncClientDb,
|
|
10
|
+
SyncClientPlugin,
|
|
11
|
+
SyncPullRequest,
|
|
12
|
+
SyncPushRequest,
|
|
13
|
+
SyncTransport,
|
|
14
|
+
} from '@syncular/client';
|
|
15
|
+
import {
|
|
16
|
+
ClientTableRegistry,
|
|
17
|
+
ensureClientSyncSchema,
|
|
18
|
+
SyncEngine,
|
|
19
|
+
type SyncEngineConfig,
|
|
20
|
+
} from '@syncular/client';
|
|
21
|
+
import type { SyncOperation } from '@syncular/core';
|
|
22
|
+
import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
|
|
23
|
+
import { createPgliteDb } from '@syncular/dialect-pglite';
|
|
24
|
+
import {
|
|
25
|
+
type ApplyOperationResult,
|
|
26
|
+
type EmittedChange,
|
|
27
|
+
ensureSyncSchema,
|
|
28
|
+
pull,
|
|
29
|
+
pushCommit,
|
|
30
|
+
recordClientCursor,
|
|
31
|
+
type ServerSyncDialect,
|
|
32
|
+
type ServerTableHandler,
|
|
33
|
+
type SyncCoreDb,
|
|
34
|
+
TableRegistry,
|
|
35
|
+
} from '@syncular/server';
|
|
36
|
+
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
37
|
+
import { type Kysely, sql } from 'kysely';
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Server database schema for tests
|
|
45
|
+
*/
|
|
46
|
+
interface ServerDb extends SyncCoreDb {
|
|
47
|
+
tasks: {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
completed: number;
|
|
51
|
+
user_id: string;
|
|
52
|
+
server_version: number;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Client database schema for tests (extends SyncClientDb to include sync tables)
|
|
58
|
+
*/
|
|
59
|
+
interface ClientDb extends SyncClientDb {
|
|
60
|
+
tasks: {
|
|
61
|
+
id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
completed: number;
|
|
64
|
+
user_id: string;
|
|
65
|
+
server_version: number;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test server instance
|
|
71
|
+
*/
|
|
72
|
+
export interface TestServer {
|
|
73
|
+
/** Full database instance with app tables (also includes sync tables) */
|
|
74
|
+
db: Kysely<ServerDb>;
|
|
75
|
+
dialect: ServerSyncDialect;
|
|
76
|
+
shapes: TableRegistry<ServerDb>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Test client instance
|
|
81
|
+
*/
|
|
82
|
+
export interface TestClient {
|
|
83
|
+
/** Full database instance with app tables (also includes sync tables) */
|
|
84
|
+
db: Kysely<ClientDb>;
|
|
85
|
+
engine: SyncEngine<ClientDb>;
|
|
86
|
+
transport: SyncTransport;
|
|
87
|
+
/** Client shapes registry */
|
|
88
|
+
shapes: ClientTableRegistry<ClientDb>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Server-side tasks shape handler for tests
|
|
93
|
+
*/
|
|
94
|
+
const tasksServerShape: ServerTableHandler<ServerDb> = {
|
|
95
|
+
table: 'tasks',
|
|
96
|
+
scopePatterns: ['user:{user_id}'],
|
|
97
|
+
|
|
98
|
+
async resolveScopes(ctx) {
|
|
99
|
+
return { user_id: ctx.actorId };
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
extractScopes(row: Record<string, unknown>) {
|
|
103
|
+
return { user_id: String(row.user_id ?? '') };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async snapshot(ctx): Promise<{ rows: unknown[]; nextCursor: string | null }> {
|
|
107
|
+
const userIdValue = ctx.scopeValues.user_id;
|
|
108
|
+
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
109
|
+
if (!userId || userId !== ctx.actorId)
|
|
110
|
+
return { rows: [], nextCursor: null };
|
|
111
|
+
|
|
112
|
+
const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
|
|
113
|
+
const cursor = ctx.cursor;
|
|
114
|
+
|
|
115
|
+
const cursorFilter =
|
|
116
|
+
cursor && cursor.length > 0
|
|
117
|
+
? sql`and ${sql.ref('id')} > ${sql.val(cursor)}`
|
|
118
|
+
: sql``;
|
|
119
|
+
|
|
120
|
+
const result = await sql<{
|
|
121
|
+
id: string;
|
|
122
|
+
title: string;
|
|
123
|
+
completed: number;
|
|
124
|
+
user_id: string;
|
|
125
|
+
server_version: number;
|
|
126
|
+
}>`
|
|
127
|
+
select
|
|
128
|
+
${sql.ref('id')},
|
|
129
|
+
${sql.ref('title')},
|
|
130
|
+
${sql.ref('completed')},
|
|
131
|
+
${sql.ref('user_id')},
|
|
132
|
+
${sql.ref('server_version')}
|
|
133
|
+
from ${sql.table('tasks')}
|
|
134
|
+
where ${sql.ref('user_id')} = ${sql.val(userId)}
|
|
135
|
+
${cursorFilter}
|
|
136
|
+
order by ${sql.ref('id')} asc
|
|
137
|
+
limit ${sql.val(pageSize + 1)}
|
|
138
|
+
`.execute(ctx.db);
|
|
139
|
+
|
|
140
|
+
const rows = result.rows;
|
|
141
|
+
|
|
142
|
+
const hasMore = rows.length > pageSize;
|
|
143
|
+
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
144
|
+
const nextCursor = hasMore
|
|
145
|
+
? (pageRows[pageRows.length - 1]?.id ?? null)
|
|
146
|
+
: null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
rows: pageRows,
|
|
150
|
+
nextCursor:
|
|
151
|
+
typeof nextCursor === 'string' && nextCursor.length > 0
|
|
152
|
+
? nextCursor
|
|
153
|
+
: null,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async applyOperation(
|
|
158
|
+
ctx,
|
|
159
|
+
op: SyncOperation,
|
|
160
|
+
opIndex: number
|
|
161
|
+
): Promise<ApplyOperationResult> {
|
|
162
|
+
if (op.table !== 'tasks') {
|
|
163
|
+
return {
|
|
164
|
+
result: {
|
|
165
|
+
opIndex,
|
|
166
|
+
status: 'error',
|
|
167
|
+
error: `UNKNOWN_TABLE:${op.table}`,
|
|
168
|
+
code: 'UNKNOWN_TABLE',
|
|
169
|
+
retriable: false,
|
|
170
|
+
},
|
|
171
|
+
emittedChanges: [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (op.op === 'delete') {
|
|
176
|
+
const existingResult = await sql<{ id: string }>`
|
|
177
|
+
select ${sql.ref('id')}
|
|
178
|
+
from ${sql.table('tasks')}
|
|
179
|
+
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
180
|
+
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
181
|
+
limit ${sql.val(1)}
|
|
182
|
+
`.execute(ctx.trx);
|
|
183
|
+
const existing = existingResult.rows[0];
|
|
184
|
+
|
|
185
|
+
if (!existing) {
|
|
186
|
+
return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await sql`
|
|
190
|
+
delete from ${sql.table('tasks')}
|
|
191
|
+
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
192
|
+
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
193
|
+
`.execute(ctx.trx);
|
|
194
|
+
|
|
195
|
+
const emitted: EmittedChange = {
|
|
196
|
+
table: 'tasks',
|
|
197
|
+
row_id: op.row_id,
|
|
198
|
+
op: 'delete',
|
|
199
|
+
row_json: null,
|
|
200
|
+
row_version: null,
|
|
201
|
+
scopes: { user_id: ctx.actorId },
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
result: { opIndex, status: 'applied' },
|
|
206
|
+
emittedChanges: [emitted],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const payload = isRecord(op.payload) ? op.payload : {};
|
|
211
|
+
const nextTitle =
|
|
212
|
+
typeof payload.title === 'string' ? payload.title : undefined;
|
|
213
|
+
const nextCompleted =
|
|
214
|
+
typeof payload.completed === 'number' ? payload.completed : undefined;
|
|
215
|
+
|
|
216
|
+
const existingResult = await sql<{
|
|
217
|
+
id: string;
|
|
218
|
+
title: string;
|
|
219
|
+
completed: number;
|
|
220
|
+
server_version: number;
|
|
221
|
+
}>`
|
|
222
|
+
select
|
|
223
|
+
${sql.ref('id')},
|
|
224
|
+
${sql.ref('title')},
|
|
225
|
+
${sql.ref('completed')},
|
|
226
|
+
${sql.ref('server_version')}
|
|
227
|
+
from ${sql.table('tasks')}
|
|
228
|
+
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
229
|
+
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
230
|
+
limit ${sql.val(1)}
|
|
231
|
+
`.execute(ctx.trx);
|
|
232
|
+
const existing = existingResult.rows[0];
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
existing &&
|
|
236
|
+
op.base_version != null &&
|
|
237
|
+
existing.server_version !== op.base_version
|
|
238
|
+
) {
|
|
239
|
+
return {
|
|
240
|
+
result: {
|
|
241
|
+
opIndex,
|
|
242
|
+
status: 'conflict',
|
|
243
|
+
message: `Version conflict: server=${existing.server_version}, base=${op.base_version}`,
|
|
244
|
+
server_version: existing.server_version,
|
|
245
|
+
server_row: {
|
|
246
|
+
id: existing.id,
|
|
247
|
+
title: existing.title,
|
|
248
|
+
completed: existing.completed,
|
|
249
|
+
user_id: ctx.actorId,
|
|
250
|
+
server_version: existing.server_version,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
emittedChanges: [],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (existing) {
|
|
258
|
+
const nextVersion = existing.server_version + 1;
|
|
259
|
+
await sql`
|
|
260
|
+
update ${sql.table('tasks')}
|
|
261
|
+
set
|
|
262
|
+
${sql.ref('title')} = ${sql.val(nextTitle ?? existing.title)},
|
|
263
|
+
${sql.ref('completed')} = ${sql.val(nextCompleted ?? existing.completed)},
|
|
264
|
+
${sql.ref('server_version')} = ${sql.val(nextVersion)}
|
|
265
|
+
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
266
|
+
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
267
|
+
`.execute(ctx.trx);
|
|
268
|
+
} else {
|
|
269
|
+
await sql`
|
|
270
|
+
insert into ${sql.table('tasks')} (
|
|
271
|
+
${sql.join([
|
|
272
|
+
sql.ref('id'),
|
|
273
|
+
sql.ref('title'),
|
|
274
|
+
sql.ref('completed'),
|
|
275
|
+
sql.ref('user_id'),
|
|
276
|
+
sql.ref('server_version'),
|
|
277
|
+
])}
|
|
278
|
+
) values (
|
|
279
|
+
${sql.join([
|
|
280
|
+
sql.val(op.row_id),
|
|
281
|
+
sql.val(nextTitle ?? ''),
|
|
282
|
+
sql.val(nextCompleted ?? 0),
|
|
283
|
+
sql.val(ctx.actorId),
|
|
284
|
+
sql.val(1),
|
|
285
|
+
])}
|
|
286
|
+
)
|
|
287
|
+
`.execute(ctx.trx);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const updatedResult = await sql<{
|
|
291
|
+
id: string;
|
|
292
|
+
title: string;
|
|
293
|
+
completed: number;
|
|
294
|
+
user_id: string;
|
|
295
|
+
server_version: number;
|
|
296
|
+
}>`
|
|
297
|
+
select
|
|
298
|
+
${sql.ref('id')},
|
|
299
|
+
${sql.ref('title')},
|
|
300
|
+
${sql.ref('completed')},
|
|
301
|
+
${sql.ref('user_id')},
|
|
302
|
+
${sql.ref('server_version')}
|
|
303
|
+
from ${sql.table('tasks')}
|
|
304
|
+
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
305
|
+
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
306
|
+
limit ${sql.val(1)}
|
|
307
|
+
`.execute(ctx.trx);
|
|
308
|
+
const updated = updatedResult.rows[0];
|
|
309
|
+
if (!updated) {
|
|
310
|
+
throw new Error(`Failed to read updated task ${op.row_id}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const emitted: EmittedChange = {
|
|
314
|
+
table: 'tasks',
|
|
315
|
+
row_id: op.row_id,
|
|
316
|
+
op: 'upsert',
|
|
317
|
+
row_json: updated,
|
|
318
|
+
row_version: updated.server_version,
|
|
319
|
+
scopes: { user_id: ctx.actorId },
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
result: { opIndex, status: 'applied' },
|
|
324
|
+
emittedChanges: [emitted],
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Create an in-memory test server with PGlite
|
|
331
|
+
*/
|
|
332
|
+
export async function createTestServer(): Promise<TestServer> {
|
|
333
|
+
const db = createPgliteDb<ServerDb>();
|
|
334
|
+
const dialect = createPostgresServerDialect();
|
|
335
|
+
|
|
336
|
+
await ensureSyncSchema(db, dialect);
|
|
337
|
+
|
|
338
|
+
// Create tasks table
|
|
339
|
+
await db.schema
|
|
340
|
+
.createTable('tasks')
|
|
341
|
+
.ifNotExists()
|
|
342
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
343
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
344
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
345
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
346
|
+
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(1))
|
|
347
|
+
.execute();
|
|
348
|
+
|
|
349
|
+
// Register shapes
|
|
350
|
+
const shapes = new TableRegistry<ServerDb>();
|
|
351
|
+
shapes.register(tasksServerShape);
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
db,
|
|
355
|
+
dialect,
|
|
356
|
+
shapes,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Create an in-process transport that calls server functions directly
|
|
362
|
+
*/
|
|
363
|
+
function createInProcessTransport(
|
|
364
|
+
server: TestServer,
|
|
365
|
+
actorId: string
|
|
366
|
+
): SyncTransport {
|
|
367
|
+
const syncDb = server.db;
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
async pull(request: SyncPullRequest) {
|
|
371
|
+
const pulled = await pull({
|
|
372
|
+
db: syncDb,
|
|
373
|
+
dialect: server.dialect,
|
|
374
|
+
shapes: server.shapes,
|
|
375
|
+
actorId,
|
|
376
|
+
request,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await recordClientCursor(syncDb, server.dialect, {
|
|
380
|
+
clientId: request.clientId,
|
|
381
|
+
actorId,
|
|
382
|
+
cursor: pulled.clientCursor,
|
|
383
|
+
effectiveScopes: pulled.effectiveScopes,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return pulled.response;
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async push(request: SyncPushRequest) {
|
|
390
|
+
const pushed = await pushCommit({
|
|
391
|
+
db: syncDb,
|
|
392
|
+
dialect: server.dialect,
|
|
393
|
+
shapes: server.shapes,
|
|
394
|
+
actorId,
|
|
395
|
+
request,
|
|
396
|
+
});
|
|
397
|
+
return pushed.response;
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
async fetchSnapshotChunk() {
|
|
401
|
+
return new Uint8Array();
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Create an in-memory test client with SQLite
|
|
408
|
+
*/
|
|
409
|
+
export async function createTestClient(
|
|
410
|
+
server: TestServer,
|
|
411
|
+
options: {
|
|
412
|
+
actorId: string;
|
|
413
|
+
clientId: string;
|
|
414
|
+
plugins?: SyncClientPlugin[];
|
|
415
|
+
}
|
|
416
|
+
): Promise<TestClient> {
|
|
417
|
+
const db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
|
|
418
|
+
|
|
419
|
+
await ensureClientSyncSchema(db);
|
|
420
|
+
|
|
421
|
+
// Create tasks table
|
|
422
|
+
await db.schema
|
|
423
|
+
.createTable('tasks')
|
|
424
|
+
.ifNotExists()
|
|
425
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
426
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
427
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
428
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
429
|
+
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
|
|
430
|
+
.execute();
|
|
431
|
+
|
|
432
|
+
// Create client shapes registry
|
|
433
|
+
const shapes = new ClientTableRegistry<ClientDb>();
|
|
434
|
+
shapes.register({
|
|
435
|
+
table: 'tasks',
|
|
436
|
+
|
|
437
|
+
async applySnapshot(ctx, snapshot) {
|
|
438
|
+
if (snapshot.isFirstPage) {
|
|
439
|
+
await ctx.trx.deleteFrom('tasks').execute();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const rows = (snapshot.rows ?? []).filter(isRecord).map((row) => ({
|
|
443
|
+
id: typeof row.id === 'string' ? row.id : '',
|
|
444
|
+
title: typeof row.title === 'string' ? row.title : '',
|
|
445
|
+
completed: typeof row.completed === 'number' ? row.completed : 0,
|
|
446
|
+
user_id: typeof row.user_id === 'string' ? row.user_id : '',
|
|
447
|
+
server_version:
|
|
448
|
+
typeof row.server_version === 'number' ? row.server_version : 0,
|
|
449
|
+
}));
|
|
450
|
+
if (rows.length === 0) return;
|
|
451
|
+
|
|
452
|
+
await ctx.trx
|
|
453
|
+
.insertInto('tasks')
|
|
454
|
+
.values(rows)
|
|
455
|
+
.onConflict((oc) =>
|
|
456
|
+
oc.column('id').doUpdateSet({
|
|
457
|
+
title: (eb) => eb.ref('excluded.title'),
|
|
458
|
+
completed: (eb) => eb.ref('excluded.completed'),
|
|
459
|
+
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
460
|
+
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
461
|
+
})
|
|
462
|
+
)
|
|
463
|
+
.execute();
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async clearAll(ctx) {
|
|
467
|
+
await ctx.trx.deleteFrom('tasks').execute();
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
async applyChange(ctx, change) {
|
|
471
|
+
if (change.op === 'delete') {
|
|
472
|
+
await ctx.trx
|
|
473
|
+
.deleteFrom('tasks')
|
|
474
|
+
.where('id', '=', change.row_id)
|
|
475
|
+
.execute();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const row = isRecord(change.row_json) ? change.row_json : {};
|
|
480
|
+
const title = typeof row.title === 'string' ? row.title : '';
|
|
481
|
+
const completed = typeof row.completed === 'number' ? row.completed : 0;
|
|
482
|
+
const userId = typeof row.user_id === 'string' ? row.user_id : '';
|
|
483
|
+
const baseVersion =
|
|
484
|
+
typeof row.server_version === 'number' ? row.server_version : 0;
|
|
485
|
+
|
|
486
|
+
await ctx.trx
|
|
487
|
+
.insertInto('tasks')
|
|
488
|
+
.values({
|
|
489
|
+
id: change.row_id,
|
|
490
|
+
title,
|
|
491
|
+
completed,
|
|
492
|
+
user_id: userId,
|
|
493
|
+
server_version: change.row_version ?? baseVersion,
|
|
494
|
+
})
|
|
495
|
+
.onConflict((oc) =>
|
|
496
|
+
oc.column('id').doUpdateSet({
|
|
497
|
+
title: (eb) => eb.ref('excluded.title'),
|
|
498
|
+
completed: (eb) => eb.ref('excluded.completed'),
|
|
499
|
+
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
500
|
+
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
501
|
+
})
|
|
502
|
+
)
|
|
503
|
+
.execute();
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const transport = createInProcessTransport(server, options.actorId);
|
|
508
|
+
|
|
509
|
+
const config: SyncEngineConfig<ClientDb> = {
|
|
510
|
+
db,
|
|
511
|
+
transport,
|
|
512
|
+
shapes,
|
|
513
|
+
actorId: options.actorId,
|
|
514
|
+
clientId: options.clientId,
|
|
515
|
+
subscriptions: [
|
|
516
|
+
{ id: 'my-tasks', shape: 'tasks', scopes: { user_id: options.actorId } },
|
|
517
|
+
],
|
|
518
|
+
pollIntervalMs: 999999, // Disable polling for tests
|
|
519
|
+
realtimeEnabled: false,
|
|
520
|
+
plugins: options.plugins,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const engine = new SyncEngine<ClientDb>(config);
|
|
524
|
+
|
|
525
|
+
return { db, engine, transport, shapes };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Destroy test resources
|
|
530
|
+
*/
|
|
531
|
+
export async function destroyTestClient(client: TestClient): Promise<void> {
|
|
532
|
+
client.engine.destroy();
|
|
533
|
+
await client.db.destroy();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export async function destroyTestServer(server: TestServer): Promise<void> {
|
|
537
|
+
await server.db.destroy();
|
|
538
|
+
}
|