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