@syncular/testkit 0.0.6-95 → 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.
- package/dist/audit.d.ts +22 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +40 -0
- package/dist/audit.js.map +1 -0
- package/dist/client-bridge.d.ts +77 -0
- package/dist/client-bridge.d.ts.map +1 -0
- package/dist/client-bridge.js +540 -0
- package/dist/client-bridge.js.map +1 -0
- package/dist/deterministic.js.map +1 -1
- package/dist/disposable.js.map +1 -1
- package/dist/faults.d.ts +14 -2
- package/dist/faults.d.ts.map +1 -1
- package/dist/faults.js +104 -42
- package/dist/faults.js.map +1 -1
- package/dist/hono-node-server.js +2 -2
- package/dist/hono-node-server.js.map +1 -1
- package/dist/http-fixtures.d.ts +1 -30
- package/dist/http-fixtures.d.ts.map +1 -1
- package/dist/http-fixtures.js +6 -66
- package/dist/http-fixtures.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -16
- package/dist/index.js.map +1 -1
- package/dist/project-scoped-tasks.d.ts.map +1 -1
- package/dist/project-scoped-tasks.js +6 -4
- package/dist/project-scoped-tasks.js.map +1 -1
- package/dist/realtime-ws.js +1 -1
- package/dist/realtime-ws.js.map +1 -1
- package/dist/runtime-process.js +1 -1
- package/dist/runtime-process.js.map +1 -1
- package/dist/sync-builders.d.ts +5 -3
- package/dist/sync-builders.d.ts.map +1 -1
- package/dist/sync-builders.js +9 -3
- package/dist/sync-builders.js.map +1 -1
- package/dist/sync-http.js +1 -1
- package/dist/sync-http.js.map +1 -1
- package/dist/sync-parse.js.map +1 -1
- package/dist/sync-response.d.ts +3 -4
- package/dist/sync-response.d.ts.map +1 -1
- package/dist/sync-response.js.map +1 -1
- package/package.json +15 -16
- package/src/audit.ts +74 -0
- package/src/client-bridge.ts +841 -0
- package/src/faults.ts +178 -55
- package/src/hono-node-server.ts +1 -1
- package/src/http-fixtures.ts +6 -127
- package/src/index.ts +2 -4
- package/src/project-scoped-tasks.ts +6 -4
- package/src/sync-builders.ts +14 -6
- package/src/sync-response.ts +3 -5
- package/dist/assertions.d.ts +0 -61
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -239
- package/dist/assertions.js.map +0 -1
- package/dist/fixtures.d.ts +0 -104
- package/dist/fixtures.d.ts.map +0 -1
- package/dist/fixtures.js +0 -558
- package/dist/fixtures.js.map +0 -1
- package/dist/resources.d.ts +0 -25
- package/dist/resources.d.ts.map +0 -1
- package/dist/resources.js +0 -64
- package/dist/resources.js.map +0 -1
- package/dist/scenario-flow.d.ts +0 -39
- package/dist/scenario-flow.d.ts.map +0 -1
- package/dist/scenario-flow.js +0 -41
- package/dist/scenario-flow.js.map +0 -1
- package/src/assertions.ts +0 -432
- package/src/fixtures.ts +0 -867
- package/src/resources.ts +0 -186
- 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
|
-
}
|