@syncular/testkit 0.0.0
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/package.json +65 -0
- package/src/assertions.ts +432 -0
- package/src/faults.ts +229 -0
- package/src/fixtures.ts +849 -0
- package/src/hono-node-server.ts +86 -0
- package/src/http-fixtures.ts +213 -0
- package/src/index.ts +6 -0
- package/src/project-scoped-tasks.ts +299 -0
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClientClearContext,
|
|
3
|
+
type ClientSnapshotHookContext,
|
|
4
|
+
type ClientTableHandler,
|
|
5
|
+
ClientTableRegistry,
|
|
6
|
+
enqueueOutboxCommit,
|
|
7
|
+
ensureClientSyncSchema,
|
|
8
|
+
type SyncClientDb,
|
|
9
|
+
type SyncClientPlugin,
|
|
10
|
+
SyncEngine,
|
|
11
|
+
type SyncOnceOptions,
|
|
12
|
+
type SyncOnceResult,
|
|
13
|
+
type SyncPullOnceOptions,
|
|
14
|
+
type SyncPullResponse,
|
|
15
|
+
type SyncPushOnceOptions,
|
|
16
|
+
type SyncPushOnceResult,
|
|
17
|
+
syncOnce,
|
|
18
|
+
syncPullOnce,
|
|
19
|
+
syncPushOnce,
|
|
20
|
+
} from '@syncular/client';
|
|
21
|
+
import {
|
|
22
|
+
isRecord,
|
|
23
|
+
type SyncCombinedResponse,
|
|
24
|
+
type SyncOperation,
|
|
25
|
+
type SyncSubscriptionRequest,
|
|
26
|
+
type SyncTransport,
|
|
27
|
+
} from '@syncular/core';
|
|
28
|
+
import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
|
|
29
|
+
import { createLibsqlDb } from '@syncular/dialect-libsql';
|
|
30
|
+
import { createPgliteDb } from '@syncular/dialect-pglite';
|
|
31
|
+
import { createSqlite3Db } from '@syncular/dialect-sqlite3';
|
|
32
|
+
import {
|
|
33
|
+
type ApplyOperationResult,
|
|
34
|
+
type EmittedChange,
|
|
35
|
+
ensureSyncSchema,
|
|
36
|
+
pull,
|
|
37
|
+
pushCommit,
|
|
38
|
+
readSnapshotChunk,
|
|
39
|
+
recordClientCursor,
|
|
40
|
+
type ServerSyncDialect,
|
|
41
|
+
type ServerTableHandler,
|
|
42
|
+
type SyncCoreDb,
|
|
43
|
+
TableRegistry,
|
|
44
|
+
} from '@syncular/server';
|
|
45
|
+
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
46
|
+
import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
|
|
47
|
+
import type { Kysely } from 'kysely';
|
|
48
|
+
|
|
49
|
+
export type ServerDialect = 'sqlite' | 'pglite';
|
|
50
|
+
export type ClientDialect = 'bun-sqlite' | 'pglite';
|
|
51
|
+
|
|
52
|
+
export type TestSqliteDbDialect = 'bun-sqlite' | 'sqlite3' | 'libsql';
|
|
53
|
+
export type TestClientDialect = ClientDialect | 'sqlite3' | 'libsql';
|
|
54
|
+
|
|
55
|
+
export interface TasksServerDb extends SyncCoreDb {
|
|
56
|
+
tasks: {
|
|
57
|
+
id: string;
|
|
58
|
+
title: string;
|
|
59
|
+
completed: number;
|
|
60
|
+
user_id: string;
|
|
61
|
+
server_version: number;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TasksClientDb extends SyncClientDb {
|
|
66
|
+
tasks: {
|
|
67
|
+
id: string;
|
|
68
|
+
title: string;
|
|
69
|
+
completed: number;
|
|
70
|
+
user_id: string;
|
|
71
|
+
server_version: number;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TestServer {
|
|
76
|
+
db: Kysely<TasksServerDb>;
|
|
77
|
+
dialect: ServerSyncDialect;
|
|
78
|
+
handlers: TableRegistry<TasksServerDb>;
|
|
79
|
+
destroy: () => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TestClient {
|
|
83
|
+
mode: 'raw';
|
|
84
|
+
db: Kysely<TasksClientDb>;
|
|
85
|
+
transport: SyncTransport;
|
|
86
|
+
handlers: ClientTableRegistry<TasksClientDb>;
|
|
87
|
+
actorId: string;
|
|
88
|
+
clientId: string;
|
|
89
|
+
enqueue: (
|
|
90
|
+
args: Parameters<typeof enqueueOutboxCommit<TasksClientDb>>[1]
|
|
91
|
+
) => Promise<{ id: string; clientCommitId: string }>;
|
|
92
|
+
push: (
|
|
93
|
+
options?: Omit<SyncPushOnceOptions, 'clientId' | 'actorId'>
|
|
94
|
+
) => Promise<SyncPushOnceResult>;
|
|
95
|
+
pull: (
|
|
96
|
+
options: Omit<SyncPullOnceOptions, 'clientId' | 'actorId'>
|
|
97
|
+
) => Promise<SyncPullResponse>;
|
|
98
|
+
syncOnce: (
|
|
99
|
+
options: Omit<SyncOnceOptions, 'clientId' | 'actorId'>
|
|
100
|
+
) => Promise<SyncOnceResult>;
|
|
101
|
+
destroy: () => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EngineTestClient extends Omit<TestClient, 'mode'> {
|
|
105
|
+
mode: 'engine';
|
|
106
|
+
engine: SyncEngine<TasksClientDb>;
|
|
107
|
+
startEngine: () => Promise<void>;
|
|
108
|
+
stopEngine: () => void;
|
|
109
|
+
syncEngine: () => Promise<
|
|
110
|
+
Awaited<ReturnType<SyncEngine<TasksClientDb>['sync']>>
|
|
111
|
+
>;
|
|
112
|
+
refreshOutboxStats: () => Promise<
|
|
113
|
+
Awaited<ReturnType<SyncEngine<TasksClientDb>['refreshOutboxStats']>>
|
|
114
|
+
>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CreateTestClientOptions {
|
|
118
|
+
actorId: string;
|
|
119
|
+
clientId: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface CreateEngineTestClientOptions extends CreateTestClientOptions {
|
|
123
|
+
clientDialect?: TestClientDialect;
|
|
124
|
+
plugins?: SyncClientPlugin[];
|
|
125
|
+
subscriptions?: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
|
|
126
|
+
pollIntervalMs?: number;
|
|
127
|
+
realtimeEnabled?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CreateSyncFixtureOptions {
|
|
131
|
+
serverDialect: ServerDialect;
|
|
132
|
+
defaultClientDialect?: TestClientDialect;
|
|
133
|
+
defaultMode?: 'raw' | 'engine';
|
|
134
|
+
defaultSubscriptions?: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
|
|
135
|
+
pollIntervalMs?: number;
|
|
136
|
+
realtimeEnabled?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CreateSyncClientOptions {
|
|
140
|
+
actorId: string;
|
|
141
|
+
clientId: string;
|
|
142
|
+
mode?: 'raw' | 'engine';
|
|
143
|
+
clientDialect?: TestClientDialect;
|
|
144
|
+
plugins?: SyncClientPlugin[];
|
|
145
|
+
subscriptions?: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SyncFixture {
|
|
149
|
+
server: TestServer;
|
|
150
|
+
createClient: (
|
|
151
|
+
options: CreateSyncClientOptions
|
|
152
|
+
) => Promise<TestClient | EngineTestClient>;
|
|
153
|
+
destroyAll: () => Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createTestSqliteDb<T>(
|
|
157
|
+
dialect: TestSqliteDbDialect,
|
|
158
|
+
options: { path?: string; url?: string } = {}
|
|
159
|
+
): Kysely<T> {
|
|
160
|
+
if (dialect === 'bun-sqlite') {
|
|
161
|
+
return createBunSqliteDb<T>({ path: options.path ?? ':memory:' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (dialect === 'sqlite3') {
|
|
165
|
+
return createSqlite3Db<T>({ path: options.path ?? ':memory:' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return createLibsqlDb<T>({ url: options.url ?? ':memory:' });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseTaskPayload(payload: SyncOperation['payload']): {
|
|
172
|
+
title?: string;
|
|
173
|
+
completed?: number;
|
|
174
|
+
} {
|
|
175
|
+
if (!isRecord(payload)) {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
181
|
+
completed:
|
|
182
|
+
typeof payload.completed === 'number' ? payload.completed : undefined,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tasksServerHandler: ServerTableHandler<TasksServerDb> = {
|
|
187
|
+
table: 'tasks',
|
|
188
|
+
scopePatterns: ['user:{user_id}'],
|
|
189
|
+
|
|
190
|
+
async resolveScopes(ctx) {
|
|
191
|
+
return { user_id: ctx.actorId };
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
extractScopes(row) {
|
|
195
|
+
return { user_id: String(row.user_id ?? '') };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async snapshot(ctx): Promise<{ rows: unknown[]; nextCursor: string | null }> {
|
|
199
|
+
const userIdValue = ctx.scopeValues.user_id;
|
|
200
|
+
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
201
|
+
|
|
202
|
+
if (!userId || userId !== ctx.actorId) {
|
|
203
|
+
return { rows: [], nextCursor: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const query = ctx.db
|
|
207
|
+
.selectFrom('tasks')
|
|
208
|
+
.select(['id', 'title', 'completed', 'user_id', 'server_version'])
|
|
209
|
+
.where('user_id', '=', userId);
|
|
210
|
+
|
|
211
|
+
const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
|
|
212
|
+
const cursor = ctx.cursor;
|
|
213
|
+
|
|
214
|
+
const rows = await (cursor ? query.where('id', '>', cursor) : query)
|
|
215
|
+
.orderBy('id', 'asc')
|
|
216
|
+
.limit(pageSize + 1)
|
|
217
|
+
.execute();
|
|
218
|
+
|
|
219
|
+
const hasMore = rows.length > pageSize;
|
|
220
|
+
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
221
|
+
const nextCursor = hasMore
|
|
222
|
+
? (pageRows[pageRows.length - 1]?.id ?? null)
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
rows: pageRows,
|
|
227
|
+
nextCursor:
|
|
228
|
+
typeof nextCursor === 'string' && nextCursor.length > 0
|
|
229
|
+
? nextCursor
|
|
230
|
+
: null,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async applyOperation(
|
|
235
|
+
ctx,
|
|
236
|
+
op: SyncOperation,
|
|
237
|
+
opIndex: number
|
|
238
|
+
): Promise<ApplyOperationResult> {
|
|
239
|
+
const db = ctx.trx;
|
|
240
|
+
|
|
241
|
+
if (op.table !== 'tasks') {
|
|
242
|
+
return {
|
|
243
|
+
result: {
|
|
244
|
+
opIndex,
|
|
245
|
+
status: 'error',
|
|
246
|
+
error: `UNKNOWN_TABLE:${op.table}`,
|
|
247
|
+
code: 'UNKNOWN_TABLE',
|
|
248
|
+
retriable: false,
|
|
249
|
+
},
|
|
250
|
+
emittedChanges: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (op.op === 'delete') {
|
|
255
|
+
const existing = await db
|
|
256
|
+
.selectFrom('tasks')
|
|
257
|
+
.select(['id'])
|
|
258
|
+
.where('id', '=', op.row_id)
|
|
259
|
+
.where('user_id', '=', ctx.actorId)
|
|
260
|
+
.executeTakeFirst();
|
|
261
|
+
|
|
262
|
+
if (!existing) {
|
|
263
|
+
return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await db
|
|
267
|
+
.deleteFrom('tasks')
|
|
268
|
+
.where('id', '=', op.row_id)
|
|
269
|
+
.where('user_id', '=', ctx.actorId)
|
|
270
|
+
.execute();
|
|
271
|
+
|
|
272
|
+
const emitted: EmittedChange = {
|
|
273
|
+
table: 'tasks',
|
|
274
|
+
row_id: op.row_id,
|
|
275
|
+
op: 'delete',
|
|
276
|
+
row_json: null,
|
|
277
|
+
row_version: null,
|
|
278
|
+
scopes: { user_id: ctx.actorId },
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
result: { opIndex, status: 'applied' },
|
|
283
|
+
emittedChanges: [emitted],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const payload = parseTaskPayload(op.payload);
|
|
288
|
+
|
|
289
|
+
const existing = await db
|
|
290
|
+
.selectFrom('tasks')
|
|
291
|
+
.select(['id', 'title', 'completed', 'server_version'])
|
|
292
|
+
.where('id', '=', op.row_id)
|
|
293
|
+
.where('user_id', '=', ctx.actorId)
|
|
294
|
+
.executeTakeFirst();
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
existing &&
|
|
298
|
+
op.base_version != null &&
|
|
299
|
+
existing.server_version !== op.base_version
|
|
300
|
+
) {
|
|
301
|
+
return {
|
|
302
|
+
result: {
|
|
303
|
+
opIndex,
|
|
304
|
+
status: 'conflict',
|
|
305
|
+
message: `Version conflict: server=${existing.server_version}, base=${op.base_version}`,
|
|
306
|
+
server_version: existing.server_version,
|
|
307
|
+
server_row: {
|
|
308
|
+
id: existing.id,
|
|
309
|
+
title: existing.title,
|
|
310
|
+
completed: existing.completed,
|
|
311
|
+
user_id: ctx.actorId,
|
|
312
|
+
server_version: existing.server_version,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
emittedChanges: [],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (existing) {
|
|
320
|
+
const nextVersion = existing.server_version + 1;
|
|
321
|
+
|
|
322
|
+
await db
|
|
323
|
+
.updateTable('tasks')
|
|
324
|
+
.set({
|
|
325
|
+
title: payload.title ?? existing.title,
|
|
326
|
+
completed: payload.completed ?? existing.completed,
|
|
327
|
+
server_version: nextVersion,
|
|
328
|
+
})
|
|
329
|
+
.where('id', '=', op.row_id)
|
|
330
|
+
.where('user_id', '=', ctx.actorId)
|
|
331
|
+
.execute();
|
|
332
|
+
} else {
|
|
333
|
+
await db
|
|
334
|
+
.insertInto('tasks')
|
|
335
|
+
.values({
|
|
336
|
+
id: op.row_id,
|
|
337
|
+
title: payload.title ?? '',
|
|
338
|
+
completed: payload.completed ?? 0,
|
|
339
|
+
user_id: ctx.actorId,
|
|
340
|
+
server_version: 1,
|
|
341
|
+
})
|
|
342
|
+
.execute();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const updated = await db
|
|
346
|
+
.selectFrom('tasks')
|
|
347
|
+
.select(['id', 'title', 'completed', 'user_id', 'server_version'])
|
|
348
|
+
.where('id', '=', op.row_id)
|
|
349
|
+
.where('user_id', '=', ctx.actorId)
|
|
350
|
+
.executeTakeFirst();
|
|
351
|
+
|
|
352
|
+
if (!updated) {
|
|
353
|
+
throw new Error('TASK_NOT_FOUND_AFTER_UPSERT');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const emitted: EmittedChange = {
|
|
357
|
+
table: 'tasks',
|
|
358
|
+
row_id: op.row_id,
|
|
359
|
+
op: 'upsert',
|
|
360
|
+
row_json: {
|
|
361
|
+
id: updated.id,
|
|
362
|
+
title: updated.title,
|
|
363
|
+
completed: updated.completed,
|
|
364
|
+
user_id: updated.user_id,
|
|
365
|
+
server_version: updated.server_version,
|
|
366
|
+
},
|
|
367
|
+
row_version: updated.server_version,
|
|
368
|
+
scopes: { user_id: ctx.actorId },
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
result: {
|
|
373
|
+
opIndex,
|
|
374
|
+
status: 'applied',
|
|
375
|
+
},
|
|
376
|
+
emittedChanges: [emitted],
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
function parseTaskSnapshotRow(value: unknown): TasksClientDb['tasks'] | null {
|
|
382
|
+
if (!isRecord(value)) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const id = typeof value.id === 'string' ? value.id : null;
|
|
387
|
+
const title = typeof value.title === 'string' ? value.title : null;
|
|
388
|
+
const completed =
|
|
389
|
+
typeof value.completed === 'number' ? value.completed : null;
|
|
390
|
+
const userId = typeof value.user_id === 'string' ? value.user_id : null;
|
|
391
|
+
const serverVersion =
|
|
392
|
+
typeof value.server_version === 'number' ? value.server_version : null;
|
|
393
|
+
|
|
394
|
+
if (
|
|
395
|
+
id === null ||
|
|
396
|
+
title === null ||
|
|
397
|
+
completed === null ||
|
|
398
|
+
userId === null ||
|
|
399
|
+
serverVersion === null
|
|
400
|
+
) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
id,
|
|
406
|
+
title,
|
|
407
|
+
completed,
|
|
408
|
+
user_id: userId,
|
|
409
|
+
server_version: serverVersion,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function createTasksClientHandler(): ClientTableHandler<
|
|
414
|
+
TasksClientDb,
|
|
415
|
+
'tasks'
|
|
416
|
+
> {
|
|
417
|
+
return {
|
|
418
|
+
table: 'tasks',
|
|
419
|
+
|
|
420
|
+
async onSnapshotStart(ctx: ClientSnapshotHookContext<TasksClientDb>) {
|
|
421
|
+
const userIdValue = ctx.scopes.user_id;
|
|
422
|
+
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
423
|
+
|
|
424
|
+
if (userId) {
|
|
425
|
+
await ctx.trx
|
|
426
|
+
.deleteFrom('tasks')
|
|
427
|
+
.where('user_id', '=', userId)
|
|
428
|
+
.execute();
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async applySnapshot(ctx, snapshot) {
|
|
433
|
+
const rows: TasksClientDb['tasks'][] = [];
|
|
434
|
+
for (const row of snapshot.rows ?? []) {
|
|
435
|
+
const parsed = parseTaskSnapshotRow(row);
|
|
436
|
+
if (parsed) {
|
|
437
|
+
rows.push(parsed);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (rows.length === 0) return;
|
|
442
|
+
|
|
443
|
+
await ctx.trx
|
|
444
|
+
.insertInto('tasks')
|
|
445
|
+
.values(rows)
|
|
446
|
+
.onConflict((oc) =>
|
|
447
|
+
oc.column('id').doUpdateSet({
|
|
448
|
+
title: (eb) => eb.ref('excluded.title'),
|
|
449
|
+
completed: (eb) => eb.ref('excluded.completed'),
|
|
450
|
+
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
451
|
+
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
452
|
+
})
|
|
453
|
+
)
|
|
454
|
+
.execute();
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
async clearAll(ctx: ClientClearContext<TasksClientDb>) {
|
|
458
|
+
const userIdValue = ctx.scopes?.user_id;
|
|
459
|
+
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
460
|
+
|
|
461
|
+
if (userId) {
|
|
462
|
+
await ctx.trx
|
|
463
|
+
.deleteFrom('tasks')
|
|
464
|
+
.where('user_id', '=', userId)
|
|
465
|
+
.execute();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await ctx.trx.deleteFrom('tasks').execute();
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
async applyChange(ctx, change) {
|
|
473
|
+
if (change.op === 'delete') {
|
|
474
|
+
await ctx.trx
|
|
475
|
+
.deleteFrom('tasks')
|
|
476
|
+
.where('id', '=', change.row_id)
|
|
477
|
+
.execute();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const parsed = parseTaskSnapshotRow(change.row_json);
|
|
482
|
+
const row =
|
|
483
|
+
parsed ??
|
|
484
|
+
({
|
|
485
|
+
id: change.row_id,
|
|
486
|
+
title: '',
|
|
487
|
+
completed: 0,
|
|
488
|
+
user_id: '',
|
|
489
|
+
server_version: change.row_version ?? 0,
|
|
490
|
+
} satisfies TasksClientDb['tasks']);
|
|
491
|
+
|
|
492
|
+
await ctx.trx
|
|
493
|
+
.insertInto('tasks')
|
|
494
|
+
.values({
|
|
495
|
+
id: change.row_id,
|
|
496
|
+
title: row.title,
|
|
497
|
+
completed: row.completed,
|
|
498
|
+
user_id: row.user_id,
|
|
499
|
+
server_version: change.row_version ?? row.server_version,
|
|
500
|
+
})
|
|
501
|
+
.onConflict((oc) =>
|
|
502
|
+
oc.column('id').doUpdateSet({
|
|
503
|
+
title: (eb) => eb.ref('excluded.title'),
|
|
504
|
+
completed: (eb) => eb.ref('excluded.completed'),
|
|
505
|
+
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
506
|
+
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
507
|
+
})
|
|
508
|
+
)
|
|
509
|
+
.execute();
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function setupTestServer(
|
|
515
|
+
db: Kysely<TasksServerDb>,
|
|
516
|
+
dialect: ServerSyncDialect
|
|
517
|
+
): Promise<TestServer> {
|
|
518
|
+
await ensureSyncSchema(db, dialect);
|
|
519
|
+
|
|
520
|
+
await db.schema
|
|
521
|
+
.createTable('tasks')
|
|
522
|
+
.ifNotExists()
|
|
523
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
524
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
525
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
526
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
527
|
+
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(1))
|
|
528
|
+
.execute();
|
|
529
|
+
|
|
530
|
+
const handlers = new TableRegistry<TasksServerDb>();
|
|
531
|
+
handlers.register(tasksServerHandler);
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
db,
|
|
535
|
+
dialect,
|
|
536
|
+
handlers,
|
|
537
|
+
destroy: async () => {
|
|
538
|
+
await db.destroy();
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function createInProcessTransport(
|
|
544
|
+
server: TestServer,
|
|
545
|
+
actorId: string
|
|
546
|
+
): SyncTransport {
|
|
547
|
+
const toBytes = async (
|
|
548
|
+
body: Uint8Array | ReadableStream<Uint8Array>
|
|
549
|
+
): Promise<Uint8Array> => {
|
|
550
|
+
if (body instanceof Uint8Array) return body;
|
|
551
|
+
|
|
552
|
+
const reader = body.getReader();
|
|
553
|
+
try {
|
|
554
|
+
const chunks: Uint8Array[] = [];
|
|
555
|
+
let total = 0;
|
|
556
|
+
while (true) {
|
|
557
|
+
const { done, value } = await reader.read();
|
|
558
|
+
if (done) break;
|
|
559
|
+
if (!value) continue;
|
|
560
|
+
chunks.push(value);
|
|
561
|
+
total += value.length;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const out = new Uint8Array(total);
|
|
565
|
+
let offset = 0;
|
|
566
|
+
for (const chunk of chunks) {
|
|
567
|
+
out.set(chunk, offset);
|
|
568
|
+
offset += chunk.length;
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
} finally {
|
|
572
|
+
reader.releaseLock();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
async sync(request) {
|
|
578
|
+
const result: SyncCombinedResponse = { ok: true };
|
|
579
|
+
|
|
580
|
+
if (request.push) {
|
|
581
|
+
const pushed = await pushCommit({
|
|
582
|
+
db: server.db,
|
|
583
|
+
dialect: server.dialect,
|
|
584
|
+
handlers: server.handlers,
|
|
585
|
+
actorId,
|
|
586
|
+
request: {
|
|
587
|
+
clientId: request.clientId,
|
|
588
|
+
clientCommitId: request.push.clientCommitId,
|
|
589
|
+
operations: request.push.operations,
|
|
590
|
+
schemaVersion: request.push.schemaVersion,
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
result.push = pushed.response;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (request.pull) {
|
|
597
|
+
const pulled = await pull({
|
|
598
|
+
db: server.db,
|
|
599
|
+
dialect: server.dialect,
|
|
600
|
+
handlers: server.handlers,
|
|
601
|
+
actorId,
|
|
602
|
+
request: {
|
|
603
|
+
clientId: request.clientId,
|
|
604
|
+
...request.pull,
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
recordClientCursor(server.db, server.dialect, {
|
|
609
|
+
clientId: request.clientId,
|
|
610
|
+
actorId,
|
|
611
|
+
cursor: pulled.clientCursor,
|
|
612
|
+
effectiveScopes: pulled.effectiveScopes,
|
|
613
|
+
}).catch(() => {});
|
|
614
|
+
|
|
615
|
+
result.pull = pulled.response;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return result;
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
async fetchSnapshotChunk(request) {
|
|
622
|
+
const chunk = await readSnapshotChunk(server.db, request.chunkId);
|
|
623
|
+
if (!chunk) {
|
|
624
|
+
throw new Error(`Chunk not found: ${request.chunkId}`);
|
|
625
|
+
}
|
|
626
|
+
return toBytes(chunk.body);
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function defaultSubscriptions(
|
|
632
|
+
actorId: string
|
|
633
|
+
): Array<Omit<SyncSubscriptionRequest, 'cursor'>> {
|
|
634
|
+
return [{ id: 'my-tasks', table: 'tasks', scopes: { user_id: actorId } }];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function createTestServer(
|
|
638
|
+
serverDialect: ServerDialect
|
|
639
|
+
): Promise<TestServer> {
|
|
640
|
+
if (serverDialect === 'pglite') {
|
|
641
|
+
return setupTestServer(
|
|
642
|
+
createPgliteDb<TasksServerDb>(),
|
|
643
|
+
createPostgresServerDialect()
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return setupTestServer(
|
|
648
|
+
createTestSqliteDb<TasksServerDb>('bun-sqlite'),
|
|
649
|
+
createSqliteServerDialect()
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export async function createTestSqliteServer(
|
|
654
|
+
dialect: TestSqliteDbDialect
|
|
655
|
+
): Promise<TestServer> {
|
|
656
|
+
return setupTestServer(
|
|
657
|
+
createTestSqliteDb<TasksServerDb>(dialect),
|
|
658
|
+
createSqliteServerDialect()
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function createTestClient(
|
|
663
|
+
clientDialect: TestClientDialect,
|
|
664
|
+
server: TestServer,
|
|
665
|
+
options: CreateTestClientOptions
|
|
666
|
+
): Promise<TestClient> {
|
|
667
|
+
const db =
|
|
668
|
+
clientDialect === 'pglite'
|
|
669
|
+
? createPgliteDb<TasksClientDb>()
|
|
670
|
+
: createTestSqliteDb<TasksClientDb>(clientDialect);
|
|
671
|
+
|
|
672
|
+
await ensureClientSyncSchema(db);
|
|
673
|
+
|
|
674
|
+
await db.schema
|
|
675
|
+
.createTable('tasks')
|
|
676
|
+
.ifNotExists()
|
|
677
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
678
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
679
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
680
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
681
|
+
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
|
|
682
|
+
.execute();
|
|
683
|
+
|
|
684
|
+
const handlers = new ClientTableRegistry<TasksClientDb>();
|
|
685
|
+
handlers.register(createTasksClientHandler());
|
|
686
|
+
|
|
687
|
+
const transport = createInProcessTransport(server, options.actorId);
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
mode: 'raw',
|
|
691
|
+
db,
|
|
692
|
+
transport,
|
|
693
|
+
handlers,
|
|
694
|
+
actorId: options.actorId,
|
|
695
|
+
clientId: options.clientId,
|
|
696
|
+
enqueue: (args) => enqueueOutboxCommit(db, args),
|
|
697
|
+
push: (pushOptions) =>
|
|
698
|
+
syncPushOnce(db, transport, {
|
|
699
|
+
clientId: options.clientId,
|
|
700
|
+
actorId: options.actorId,
|
|
701
|
+
plugins: pushOptions?.plugins,
|
|
702
|
+
}),
|
|
703
|
+
pull: (pullOptions) =>
|
|
704
|
+
syncPullOnce(db, transport, handlers, {
|
|
705
|
+
...pullOptions,
|
|
706
|
+
clientId: options.clientId,
|
|
707
|
+
actorId: options.actorId,
|
|
708
|
+
}),
|
|
709
|
+
syncOnce: (syncOptions) =>
|
|
710
|
+
syncOnce(db, transport, handlers, {
|
|
711
|
+
...syncOptions,
|
|
712
|
+
clientId: options.clientId,
|
|
713
|
+
actorId: options.actorId,
|
|
714
|
+
}),
|
|
715
|
+
destroy: async () => {
|
|
716
|
+
await db.destroy();
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export async function createEngineTestClient(
|
|
722
|
+
server: TestServer,
|
|
723
|
+
options: CreateEngineTestClientOptions
|
|
724
|
+
): Promise<EngineTestClient> {
|
|
725
|
+
const rawClient = await createTestClient(
|
|
726
|
+
options.clientDialect ?? 'bun-sqlite',
|
|
727
|
+
server,
|
|
728
|
+
{
|
|
729
|
+
actorId: options.actorId,
|
|
730
|
+
clientId: options.clientId,
|
|
731
|
+
}
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const subscriptions =
|
|
735
|
+
options.subscriptions ?? defaultSubscriptions(options.actorId);
|
|
736
|
+
|
|
737
|
+
const engine = new SyncEngine<TasksClientDb>({
|
|
738
|
+
db: rawClient.db,
|
|
739
|
+
transport: rawClient.transport,
|
|
740
|
+
handlers: rawClient.handlers,
|
|
741
|
+
actorId: options.actorId,
|
|
742
|
+
clientId: options.clientId,
|
|
743
|
+
subscriptions,
|
|
744
|
+
pollIntervalMs: options.pollIntervalMs ?? 999999,
|
|
745
|
+
realtimeEnabled: options.realtimeEnabled ?? false,
|
|
746
|
+
plugins: options.plugins,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
...rawClient,
|
|
751
|
+
mode: 'engine',
|
|
752
|
+
engine,
|
|
753
|
+
startEngine: () => engine.start(),
|
|
754
|
+
stopEngine: () => {
|
|
755
|
+
engine.destroy();
|
|
756
|
+
},
|
|
757
|
+
syncEngine: () => engine.sync(),
|
|
758
|
+
refreshOutboxStats: () => engine.refreshOutboxStats(),
|
|
759
|
+
destroy: async () => {
|
|
760
|
+
engine.destroy();
|
|
761
|
+
await rawClient.db.destroy();
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export async function createSyncFixture(
|
|
767
|
+
options: CreateSyncFixtureOptions
|
|
768
|
+
): Promise<SyncFixture> {
|
|
769
|
+
const server = await createTestServer(options.serverDialect);
|
|
770
|
+
const createdClients: Array<TestClient | EngineTestClient> = [];
|
|
771
|
+
|
|
772
|
+
const createClient = async (
|
|
773
|
+
clientOptions: CreateSyncClientOptions
|
|
774
|
+
): Promise<TestClient | EngineTestClient> => {
|
|
775
|
+
const mode = clientOptions.mode ?? options.defaultMode ?? 'raw';
|
|
776
|
+
|
|
777
|
+
if (mode === 'engine') {
|
|
778
|
+
const client = await createEngineTestClient(server, {
|
|
779
|
+
actorId: clientOptions.actorId,
|
|
780
|
+
clientId: clientOptions.clientId,
|
|
781
|
+
clientDialect:
|
|
782
|
+
clientOptions.clientDialect ?? options.defaultClientDialect,
|
|
783
|
+
plugins: clientOptions.plugins,
|
|
784
|
+
subscriptions:
|
|
785
|
+
clientOptions.subscriptions ?? options.defaultSubscriptions,
|
|
786
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
787
|
+
realtimeEnabled: options.realtimeEnabled,
|
|
788
|
+
});
|
|
789
|
+
createdClients.push(client);
|
|
790
|
+
return client;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const client = await createTestClient(
|
|
794
|
+
clientOptions.clientDialect ??
|
|
795
|
+
options.defaultClientDialect ??
|
|
796
|
+
'bun-sqlite',
|
|
797
|
+
server,
|
|
798
|
+
{
|
|
799
|
+
actorId: clientOptions.actorId,
|
|
800
|
+
clientId: clientOptions.clientId,
|
|
801
|
+
}
|
|
802
|
+
);
|
|
803
|
+
createdClients.push(client);
|
|
804
|
+
return client;
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const destroyAll = async () => {
|
|
808
|
+
for (const client of createdClients) {
|
|
809
|
+
await client.destroy();
|
|
810
|
+
}
|
|
811
|
+
await server.destroy();
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
return { server, createClient, destroyAll };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export async function seedServerData(
|
|
818
|
+
server: TestServer,
|
|
819
|
+
options: { userId: string; count: number }
|
|
820
|
+
): Promise<void> {
|
|
821
|
+
const rows = Array.from({ length: options.count }, (_, i) => ({
|
|
822
|
+
id: `task-${i + 1}`,
|
|
823
|
+
title: `Task ${i + 1}`,
|
|
824
|
+
completed: 0,
|
|
825
|
+
user_id: options.userId,
|
|
826
|
+
server_version: 1,
|
|
827
|
+
}));
|
|
828
|
+
|
|
829
|
+
const batchSize = 1000;
|
|
830
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
831
|
+
const batch = rows.slice(i, i + batchSize);
|
|
832
|
+
await server.db.insertInto('tasks').values(batch).execute();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export async function destroyTestClient(
|
|
837
|
+
client: Pick<TestClient, 'destroy'> | Pick<EngineTestClient, 'destroy'>
|
|
838
|
+
): Promise<void> {
|
|
839
|
+
await client.destroy();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export async function destroyTestServer(
|
|
843
|
+
server: Pick<TestServer, 'destroy'>
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
await server.destroy();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export const createServerFixture = createTestServer;
|
|
849
|
+
export const createClientFixture = createTestClient;
|