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