@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.
@@ -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;