@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 ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@syncular/testkit",
3
+ "version": "0.0.0",
4
+ "description": "Testing fixtures and utilities for Syncular",
5
+ "license": "MIT",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/testkit"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "testing",
20
+ "testkit",
21
+ "typescript"
22
+ ],
23
+ "private": false,
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "bun": "./src/index.ts",
31
+ "import": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ }
36
+ },
37
+ "scripts": {
38
+ "test": "bun test --pass-with-no-tests",
39
+ "tsgo": "tsgo --noEmit",
40
+ "build": "tsgo",
41
+ "release": "bunx syncular-publish"
42
+ },
43
+ "dependencies": {
44
+ "@syncular/client": "0.0.0",
45
+ "@syncular/core": "0.0.0",
46
+ "@syncular/dialect-bun-sqlite": "0.0.0",
47
+ "@syncular/dialect-libsql": "0.0.0",
48
+ "@syncular/dialect-pglite": "0.0.0",
49
+ "@syncular/dialect-sqlite3": "0.0.0",
50
+ "@syncular/server": "0.0.0",
51
+ "@syncular/server-dialect-postgres": "0.0.0",
52
+ "@syncular/server-dialect-sqlite": "0.0.0",
53
+ "@syncular/server-hono": "0.0.0",
54
+ "@syncular/transport-http": "0.0.0",
55
+ "hono": "^4.11.9"
56
+ },
57
+ "devDependencies": {
58
+ "@syncular/config": "0.0.0",
59
+ "kysely": "*"
60
+ },
61
+ "files": [
62
+ "dist",
63
+ "src"
64
+ ]
65
+ }
@@ -0,0 +1,432 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import type { OutboxCommitStatus, SyncClientDb } from '@syncular/client';
3
+ import type { SyncCoreDb } from '@syncular/server';
4
+ import type { Kysely } from 'kysely';
5
+
6
+ function formatValue(value: unknown): string {
7
+ if (typeof value === 'string') {
8
+ return value;
9
+ }
10
+
11
+ try {
12
+ return JSON.stringify(value);
13
+ } catch {
14
+ return String(value);
15
+ }
16
+ }
17
+
18
+ function fail(message: string): never {
19
+ throw new Error(message);
20
+ }
21
+
22
+ function assertEqual(
23
+ actual: unknown,
24
+ expected: unknown,
25
+ message: string
26
+ ): void {
27
+ if (!isDeepStrictEqual(actual, expected)) {
28
+ fail(
29
+ `${message} (expected=${formatValue(expected)} actual=${formatValue(actual)})`
30
+ );
31
+ }
32
+ }
33
+
34
+ function assertDefined<T>(
35
+ value: T | null | undefined,
36
+ message: string
37
+ ): asserts value is T {
38
+ if (value == null) {
39
+ fail(message);
40
+ }
41
+ }
42
+
43
+ export async function outboxCount(db: Kysely<SyncClientDb>): Promise<number> {
44
+ const count = await db
45
+ .selectFrom('sync_outbox_commits')
46
+ .select(({ fn }) => fn.countAll().as('count'))
47
+ .executeTakeFirstOrThrow();
48
+
49
+ return Number(count.count);
50
+ }
51
+
52
+ export async function conflictCount(db: Kysely<SyncClientDb>): Promise<number> {
53
+ const count = await db
54
+ .selectFrom('sync_conflicts')
55
+ .where('resolved_at', 'is', null)
56
+ .select(({ fn }) => fn.countAll().as('count'))
57
+ .executeTakeFirstOrThrow();
58
+
59
+ return Number(count.count);
60
+ }
61
+
62
+ export async function serverCommitCount(
63
+ db: Kysely<SyncCoreDb>
64
+ ): Promise<number> {
65
+ const count = await db
66
+ .selectFrom('sync_commits')
67
+ .select(({ fn }) => fn.countAll().as('count'))
68
+ .executeTakeFirstOrThrow();
69
+
70
+ return Number(count.count);
71
+ }
72
+
73
+ export async function serverChangeCount(
74
+ db: Kysely<SyncCoreDb>
75
+ ): Promise<number> {
76
+ const count = await db
77
+ .selectFrom('sync_changes')
78
+ .select(({ fn }) => fn.countAll().as('count'))
79
+ .executeTakeFirstOrThrow();
80
+
81
+ return Number(count.count);
82
+ }
83
+
84
+ export async function assertOutboxEmpty(
85
+ db: Kysely<SyncClientDb>
86
+ ): Promise<void> {
87
+ const count = await outboxCount(db);
88
+ assertEqual(count, 0, 'Outbox is not empty');
89
+ }
90
+
91
+ export async function assertOutboxHas(
92
+ db: Kysely<SyncClientDb>,
93
+ expectedCount: number
94
+ ): Promise<void> {
95
+ const count = await outboxCount(db);
96
+ assertEqual(count, expectedCount, 'Unexpected outbox commit count');
97
+ }
98
+
99
+ export async function assertOutboxStatus(
100
+ db: Kysely<SyncClientDb>,
101
+ status: OutboxCommitStatus,
102
+ expectedCount: number
103
+ ): Promise<void> {
104
+ const count = await db
105
+ .selectFrom('sync_outbox_commits')
106
+ .where('status', '=', status)
107
+ .select(({ fn }) => fn.countAll().as('count'))
108
+ .executeTakeFirstOrThrow();
109
+
110
+ assertEqual(
111
+ Number(count.count),
112
+ expectedCount,
113
+ `Unexpected outbox count for status=${status}`
114
+ );
115
+ }
116
+
117
+ export async function assertConflictCount(
118
+ db: Kysely<SyncClientDb>,
119
+ expectedCount: number
120
+ ): Promise<void> {
121
+ const count = await conflictCount(db);
122
+ assertEqual(count, expectedCount, 'Unexpected unresolved conflict count');
123
+ }
124
+
125
+ export async function assertConflictExists(
126
+ db: Kysely<SyncClientDb>,
127
+ options: {
128
+ clientCommitId: string;
129
+ resultStatus?: 'conflict' | 'error';
130
+ resolved?: boolean;
131
+ }
132
+ ): Promise<void> {
133
+ let query = db
134
+ .selectFrom('sync_conflicts')
135
+ .where('client_commit_id', '=', options.clientCommitId);
136
+
137
+ if (options.resultStatus) {
138
+ query = query.where('result_status', '=', options.resultStatus);
139
+ }
140
+
141
+ if (options.resolved !== undefined) {
142
+ query = options.resolved
143
+ ? query.where('resolved_at', 'is not', null)
144
+ : query.where('resolved_at', 'is', null);
145
+ }
146
+
147
+ const conflict = await query.selectAll().executeTakeFirst();
148
+ assertDefined(
149
+ conflict,
150
+ `Expected conflict for client_commit_id=${options.clientCommitId}`
151
+ );
152
+ }
153
+
154
+ export async function assertRowExists<
155
+ DB extends SyncClientDb,
156
+ T extends keyof DB & string,
157
+ >(
158
+ db: Kysely<DB>,
159
+ table: T,
160
+ rowId: string,
161
+ expected?: Partial<DB[T]>,
162
+ idColumn = 'id'
163
+ ): Promise<void> {
164
+ const row = await db
165
+ .selectFrom(table)
166
+ // @ts-expect-error - dynamic column name
167
+ .where(idColumn, '=', rowId)
168
+ .selectAll()
169
+ .executeTakeFirst();
170
+
171
+ assertDefined(row, `Expected row ${rowId} to exist in table ${table}`);
172
+
173
+ if (!expected) {
174
+ return;
175
+ }
176
+
177
+ const rowRecord = row as Record<string, unknown>;
178
+ for (const [key, value] of Object.entries(expected)) {
179
+ assertEqual(
180
+ rowRecord[key],
181
+ value,
182
+ `Unexpected value for ${table}.${key} (row ${rowId})`
183
+ );
184
+ }
185
+ }
186
+
187
+ export async function assertRowNotExists<
188
+ DB extends SyncClientDb,
189
+ T extends keyof DB & string,
190
+ >(db: Kysely<DB>, table: T, rowId: string, idColumn = 'id'): Promise<void> {
191
+ const row = await db
192
+ .selectFrom(table)
193
+ // @ts-expect-error - dynamic column name
194
+ .where(idColumn, '=', rowId)
195
+ .selectAll()
196
+ .executeTakeFirst();
197
+
198
+ if (row !== undefined) {
199
+ fail(`Expected row ${rowId} to be absent in table ${table}`);
200
+ }
201
+ }
202
+
203
+ export async function assertRowVersion<
204
+ DB extends SyncClientDb,
205
+ T extends keyof DB & string,
206
+ >(
207
+ db: Kysely<DB>,
208
+ table: T,
209
+ rowId: string,
210
+ expectedVersion: number,
211
+ versionColumn = 'server_version',
212
+ idColumn = 'id'
213
+ ): Promise<void> {
214
+ const row = await db
215
+ .selectFrom(table)
216
+ // @ts-expect-error - dynamic column name
217
+ .where(idColumn, '=', rowId)
218
+ .select(versionColumn)
219
+ .executeTakeFirst();
220
+
221
+ assertDefined(row, `Expected row ${rowId} to exist in table ${table}`);
222
+ const rowRecord = row as Record<string, unknown>;
223
+ assertEqual(
224
+ rowRecord[versionColumn],
225
+ expectedVersion,
226
+ `Unexpected version for ${table}.${rowId}`
227
+ );
228
+ }
229
+
230
+ export async function assertSubscriptionCursor(
231
+ db: Kysely<SyncClientDb>,
232
+ subscriptionId: string,
233
+ expectedCursor: number,
234
+ stateId = 'default'
235
+ ): Promise<void> {
236
+ const sub = await db
237
+ .selectFrom('sync_subscription_state')
238
+ .where('state_id', '=', stateId)
239
+ .where('subscription_id', '=', subscriptionId)
240
+ .select(['cursor'])
241
+ .executeTakeFirst();
242
+
243
+ assertDefined(
244
+ sub,
245
+ `Expected subscription state row for ${stateId}/${subscriptionId}`
246
+ );
247
+ assertEqual(
248
+ sub.cursor,
249
+ expectedCursor,
250
+ `Unexpected cursor for subscription ${subscriptionId}`
251
+ );
252
+ }
253
+
254
+ export async function assertSubscriptionStatus(
255
+ db: Kysely<SyncClientDb>,
256
+ subscriptionId: string,
257
+ expectedStatus: 'active' | 'revoked',
258
+ stateId = 'default'
259
+ ): Promise<void> {
260
+ const sub = await db
261
+ .selectFrom('sync_subscription_state')
262
+ .where('state_id', '=', stateId)
263
+ .where('subscription_id', '=', subscriptionId)
264
+ .select(['status'])
265
+ .executeTakeFirst();
266
+
267
+ assertDefined(
268
+ sub,
269
+ `Expected subscription state row for ${stateId}/${subscriptionId}`
270
+ );
271
+ assertEqual(
272
+ sub.status,
273
+ expectedStatus,
274
+ `Unexpected status for subscription ${subscriptionId}`
275
+ );
276
+ }
277
+
278
+ export async function assertServerCommitCount(
279
+ db: Kysely<SyncCoreDb>,
280
+ expectedCount: number
281
+ ): Promise<void> {
282
+ const count = await serverCommitCount(db);
283
+ assertEqual(count, expectedCount, 'Unexpected server commit count');
284
+ }
285
+
286
+ export async function assertServerChangeCount(
287
+ db: Kysely<SyncCoreDb>,
288
+ expectedCount: number
289
+ ): Promise<void> {
290
+ const count = await serverChangeCount(db);
291
+ assertEqual(count, expectedCount, 'Unexpected server change count');
292
+ }
293
+
294
+ export async function assertServerChangeExists(
295
+ db: Kysely<SyncCoreDb>,
296
+ options: {
297
+ table: string;
298
+ rowId: string;
299
+ op?: 'upsert' | 'delete';
300
+ commitSeq?: number;
301
+ }
302
+ ): Promise<void> {
303
+ let query = db
304
+ .selectFrom('sync_changes')
305
+ .where('table', '=', options.table)
306
+ .where('row_id', '=', options.rowId);
307
+
308
+ if (options.op) {
309
+ query = query.where('op', '=', options.op);
310
+ }
311
+
312
+ if (options.commitSeq) {
313
+ query = query.where('commit_seq', '=', options.commitSeq);
314
+ }
315
+
316
+ const change = await query.selectAll().executeTakeFirst();
317
+ assertDefined(
318
+ change,
319
+ `Expected server change for table=${options.table} rowId=${options.rowId}`
320
+ );
321
+ }
322
+
323
+ export async function assertServerClientCursor(
324
+ db: Kysely<SyncCoreDb>,
325
+ clientId: string,
326
+ expectedCursor: number
327
+ ): Promise<void> {
328
+ const cursor = await db
329
+ .selectFrom('sync_client_cursors')
330
+ .where('client_id', '=', clientId)
331
+ .select(['cursor'])
332
+ .executeTakeFirst();
333
+
334
+ assertDefined(cursor, `Expected sync_client_cursors row for ${clientId}`);
335
+ assertEqual(
336
+ cursor.cursor,
337
+ expectedCursor,
338
+ `Unexpected cursor for ${clientId}`
339
+ );
340
+ }
341
+
342
+ async function waitFor(
343
+ condition: () => Promise<boolean>,
344
+ options?: {
345
+ timeoutMs?: number;
346
+ intervalMs?: number;
347
+ message?: string;
348
+ }
349
+ ): Promise<void> {
350
+ const timeoutMs = options?.timeoutMs ?? 5000;
351
+ const intervalMs = options?.intervalMs ?? 50;
352
+ const message = options?.message ?? 'Condition not met within timeout';
353
+
354
+ const startTime = Date.now();
355
+
356
+ while (Date.now() - startTime < timeoutMs) {
357
+ if (await condition()) {
358
+ return;
359
+ }
360
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
361
+ }
362
+
363
+ fail(message);
364
+ }
365
+
366
+ export async function waitForOutboxEmpty(
367
+ db: Kysely<SyncClientDb>,
368
+ timeoutMs = 5000
369
+ ): Promise<void> {
370
+ await waitFor(
371
+ async () => {
372
+ const count = await outboxCount(db);
373
+ return count === 0;
374
+ },
375
+ { timeoutMs, message: 'Outbox not empty within timeout' }
376
+ );
377
+ }
378
+
379
+ export async function waitForAckedCommits(
380
+ db: Kysely<SyncClientDb>,
381
+ expectedCount: number,
382
+ timeoutMs = 5000
383
+ ): Promise<void> {
384
+ await waitFor(
385
+ async () => {
386
+ const count = await db
387
+ .selectFrom('sync_outbox_commits')
388
+ .where('status', '=', 'acked')
389
+ .select(({ fn }) => fn.countAll().as('count'))
390
+ .executeTakeFirstOrThrow();
391
+ return Number(count.count) >= expectedCount;
392
+ },
393
+ {
394
+ timeoutMs,
395
+ message: `Expected ${expectedCount} acked commits within timeout`,
396
+ }
397
+ );
398
+ }
399
+
400
+ export const assertOutbox = {
401
+ empty: assertOutboxEmpty,
402
+ count: assertOutboxHas,
403
+ status: assertOutboxStatus,
404
+ };
405
+
406
+ export const assertConflicts = {
407
+ count: assertConflictCount,
408
+ exists: assertConflictExists,
409
+ };
410
+
411
+ export const assertRows = {
412
+ exists: assertRowExists,
413
+ missing: assertRowNotExists,
414
+ version: assertRowVersion,
415
+ };
416
+
417
+ export const assertServer = {
418
+ commits: assertServerCommitCount,
419
+ changes: assertServerChangeCount,
420
+ changeExists: assertServerChangeExists,
421
+ clientCursor: assertServerClientCursor,
422
+ };
423
+
424
+ export const assertSubscription = {
425
+ cursor: assertSubscriptionCursor,
426
+ status: assertSubscriptionStatus,
427
+ };
428
+
429
+ export const waitForSync = {
430
+ outboxEmpty: waitForOutboxEmpty,
431
+ ackedCommits: waitForAckedCommits,
432
+ };
package/src/faults.ts ADDED
@@ -0,0 +1,229 @@
1
+ import type {
2
+ SyncCombinedRequest,
3
+ SyncPullResponse,
4
+ SyncPushResponse,
5
+ SyncTransport,
6
+ SyncTransportOptions,
7
+ } from '@syncular/core';
8
+
9
+ export interface FaultTransportOptions {
10
+ failAfter?: number;
11
+ failWith?: Error;
12
+ latencyMs?: number;
13
+ flaky?: number;
14
+ failOnPush?: boolean;
15
+ failOnPull?: boolean;
16
+ failOnFetch?: boolean;
17
+ onFail?: (operation: 'push' | 'pull' | 'fetch', error: Error) => void;
18
+ onSuccess?: (operation: 'push' | 'pull' | 'fetch') => void;
19
+ }
20
+
21
+ export interface FaultTransportState {
22
+ pushCount: number;
23
+ pullCount: number;
24
+ fetchCount: number;
25
+ failureCount: number;
26
+ }
27
+
28
+ export interface FaultTransportResult {
29
+ transport: SyncTransport;
30
+ getState: () => FaultTransportState;
31
+ reset: () => void;
32
+ setOptions: (options: Partial<FaultTransportOptions>) => void;
33
+ }
34
+
35
+ export function withFaults(
36
+ baseTransport: SyncTransport,
37
+ options: FaultTransportOptions = {}
38
+ ): FaultTransportResult {
39
+ let currentOptions = { ...options };
40
+ const state: FaultTransportState = {
41
+ pushCount: 0,
42
+ pullCount: 0,
43
+ fetchCount: 0,
44
+ failureCount: 0,
45
+ };
46
+
47
+ const defaultError = new Error('Simulated transport error');
48
+
49
+ const maybeDelay = async (): Promise<void> => {
50
+ if (currentOptions.latencyMs && currentOptions.latencyMs > 0) {
51
+ await new Promise((resolve) =>
52
+ setTimeout(resolve, currentOptions.latencyMs)
53
+ );
54
+ }
55
+ };
56
+
57
+ const shouldFail = (
58
+ operation: 'push' | 'pull' | 'fetch',
59
+ count: number
60
+ ): boolean => {
61
+ if (operation === 'push' && currentOptions.failOnPull) {
62
+ return false;
63
+ }
64
+ if (operation === 'pull' && currentOptions.failOnPush) {
65
+ return false;
66
+ }
67
+ if (operation === 'fetch' && !currentOptions.failOnFetch) {
68
+ if (currentOptions.failOnPush || currentOptions.failOnPull) {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ if (
74
+ currentOptions.failAfter !== undefined &&
75
+ count >= currentOptions.failAfter
76
+ ) {
77
+ return true;
78
+ }
79
+
80
+ if (currentOptions.flaky !== undefined && currentOptions.flaky > 0) {
81
+ return Math.random() < currentOptions.flaky;
82
+ }
83
+
84
+ return false;
85
+ };
86
+
87
+ const getError = (): Error => currentOptions.failWith ?? defaultError;
88
+
89
+ const transport: SyncTransport = {
90
+ async sync(request, transportOptions) {
91
+ await maybeDelay();
92
+
93
+ const operation = request.push ? 'push' : 'pull';
94
+ const count = operation === 'push' ? state.pushCount : state.pullCount;
95
+
96
+ if (shouldFail(operation, count)) {
97
+ const error = getError();
98
+ state.failureCount++;
99
+ currentOptions.onFail?.(operation, error);
100
+ throw error;
101
+ }
102
+
103
+ if (operation === 'push') {
104
+ state.pushCount++;
105
+ } else {
106
+ state.pullCount++;
107
+ }
108
+
109
+ const result = await baseTransport.sync(request, transportOptions);
110
+ currentOptions.onSuccess?.(operation);
111
+ return result;
112
+ },
113
+
114
+ async fetchSnapshotChunk(
115
+ request: { chunkId: string },
116
+ transportOptions?: SyncTransportOptions
117
+ ): Promise<Uint8Array> {
118
+ await maybeDelay();
119
+
120
+ if (shouldFail('fetch', state.fetchCount)) {
121
+ const error = getError();
122
+ state.failureCount++;
123
+ currentOptions.onFail?.('fetch', error);
124
+ throw error;
125
+ }
126
+
127
+ state.fetchCount++;
128
+ const result = await baseTransport.fetchSnapshotChunk(
129
+ request,
130
+ transportOptions
131
+ );
132
+ currentOptions.onSuccess?.('fetch');
133
+ return result;
134
+ },
135
+ };
136
+
137
+ return {
138
+ transport,
139
+ getState: () => ({ ...state }),
140
+ reset: () => {
141
+ state.pushCount = 0;
142
+ state.pullCount = 0;
143
+ state.fetchCount = 0;
144
+ state.failureCount = 0;
145
+ },
146
+ setOptions: (newOptions) => {
147
+ currentOptions = { ...currentOptions, ...newOptions };
148
+ },
149
+ };
150
+ }
151
+
152
+ export function createMockTransport(options?: {
153
+ pullResponse?: SyncPullResponse;
154
+ pushResponse?: SyncPushResponse;
155
+ chunkData?: Uint8Array;
156
+ }): SyncTransport {
157
+ return {
158
+ async sync(request) {
159
+ const result: {
160
+ ok: true;
161
+ push?: SyncPushResponse;
162
+ pull?: SyncPullResponse;
163
+ } = { ok: true };
164
+
165
+ if (request.push) {
166
+ result.push = options?.pushResponse ?? {
167
+ ok: true,
168
+ status: 'applied',
169
+ results: request.push.operations.map((_, i) => ({
170
+ opIndex: i,
171
+ status: 'applied',
172
+ })),
173
+ };
174
+ }
175
+
176
+ if (request.pull) {
177
+ result.pull = options?.pullResponse ?? {
178
+ ok: true,
179
+ subscriptions: [],
180
+ };
181
+ }
182
+
183
+ return result;
184
+ },
185
+
186
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
187
+ return options?.chunkData ?? new Uint8Array();
188
+ },
189
+ };
190
+ }
191
+
192
+ export interface RecordingTransportResult {
193
+ transport: SyncTransport;
194
+ syncRequests: SyncCombinedRequest[];
195
+ fetchRequests: { chunkId: string }[];
196
+ clear: () => void;
197
+ }
198
+
199
+ export function withRecording(
200
+ baseTransport: SyncTransport
201
+ ): RecordingTransportResult {
202
+ const syncRequests: SyncCombinedRequest[] = [];
203
+ const fetchRequests: { chunkId: string }[] = [];
204
+
205
+ const transport: SyncTransport = {
206
+ async sync(request, options) {
207
+ syncRequests.push(structuredClone(request));
208
+ return baseTransport.sync(request, options);
209
+ },
210
+
211
+ async fetchSnapshotChunk(request, options) {
212
+ fetchRequests.push({ ...request });
213
+ return baseTransport.fetchSnapshotChunk(request, options);
214
+ },
215
+ };
216
+
217
+ return {
218
+ transport,
219
+ syncRequests,
220
+ fetchRequests,
221
+ clear: () => {
222
+ syncRequests.length = 0;
223
+ fetchRequests.length = 0;
224
+ },
225
+ };
226
+ }
227
+
228
+ export const createErrorTransport = withFaults;
229
+ export const createRecordingTransport = withRecording;