@syncular/client-react 0.0.2-133 → 0.0.2-135
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client-react",
|
|
3
|
-
"version": "0.0.2-
|
|
3
|
+
"version": "0.0.2-135",
|
|
4
4
|
"description": "React hooks and bindings for the Syncular client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"release": "bunx syncular-publish"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@syncular/client": "0.0.2-
|
|
47
|
+
"@syncular/client": "0.0.2-135"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"kysely": "^0.28.0",
|
|
@@ -52,19 +52,16 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@happy-dom/global-registrator": "^20.6.1",
|
|
55
|
+
"@syncular/config": "0.0.0",
|
|
56
|
+
"@syncular/dialect-bun-sqlite": "0.0.2-135",
|
|
57
|
+
"@syncular/testkit": "0.0.2-135",
|
|
55
58
|
"@testing-library/react": "^16.3.2",
|
|
56
|
-
"@syncular/core": "0.0.2-133",
|
|
57
59
|
"@types/react": "^19.2.14",
|
|
58
60
|
"happy-dom": "^20.6.1",
|
|
59
61
|
"kysely": "*",
|
|
60
62
|
"kysely-bun-sqlite": "^0.4.0",
|
|
61
63
|
"react": "^19.2.4",
|
|
62
|
-
"react-dom": "^19.2.4"
|
|
63
|
-
"@syncular/dialect-bun-sqlite": "0.0.2-133",
|
|
64
|
-
"@syncular/dialect-pglite": "0.0.2-133",
|
|
65
|
-
"@syncular/server-dialect-postgres": "0.0.2-133",
|
|
66
|
-
"@syncular/server": "0.0.2-133",
|
|
67
|
-
"@syncular/config": "0.0.0"
|
|
64
|
+
"react-dom": "^19.2.4"
|
|
68
65
|
},
|
|
69
66
|
"files": [
|
|
70
67
|
"dist",
|
|
@@ -1,460 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration test setup for @syncular/client-react
|
|
2
|
+
* Integration test setup for @syncular/client-react.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Reuses the shared @syncular/testkit fixtures to keep a single
|
|
5
|
+
* implementation of server/client setup logic.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
SyncClientDb,
|
|
10
|
-
SyncClientPlugin,
|
|
11
|
-
SyncTransport,
|
|
12
|
-
} from '@syncular/client';
|
|
8
|
+
import type { SyncClientPlugin } from '@syncular/client';
|
|
13
9
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
} from '@syncular/
|
|
19
|
-
import type { SyncOperation } from '@syncular/core';
|
|
20
|
-
import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
|
|
21
|
-
import { createPgliteDb } from '@syncular/dialect-pglite';
|
|
22
|
-
import {
|
|
23
|
-
type ApplyOperationResult,
|
|
24
|
-
type EmittedChange,
|
|
25
|
-
ensureSyncSchema,
|
|
26
|
-
pull,
|
|
27
|
-
pushCommit,
|
|
28
|
-
readSnapshotChunk,
|
|
29
|
-
recordClientCursor,
|
|
30
|
-
type ServerSyncDialect,
|
|
31
|
-
type ServerTableHandler,
|
|
32
|
-
type SyncCoreDb,
|
|
33
|
-
TableRegistry,
|
|
34
|
-
} from '@syncular/server';
|
|
35
|
-
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
36
|
-
import { type Kysely, sql } from 'kysely';
|
|
37
|
-
|
|
38
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Server database schema for tests
|
|
44
|
-
*/
|
|
45
|
-
interface ServerDb extends SyncCoreDb {
|
|
46
|
-
tasks: {
|
|
47
|
-
id: string;
|
|
48
|
-
title: string;
|
|
49
|
-
completed: number;
|
|
50
|
-
user_id: string;
|
|
51
|
-
server_version: number;
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Client database schema for tests (extends SyncClientDb to include sync tables)
|
|
57
|
-
*/
|
|
58
|
-
interface ClientDb extends SyncClientDb {
|
|
59
|
-
tasks: {
|
|
60
|
-
id: string;
|
|
61
|
-
title: string;
|
|
62
|
-
completed: number;
|
|
63
|
-
user_id: string;
|
|
64
|
-
server_version: number;
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Test server instance
|
|
70
|
-
*/
|
|
71
|
-
export interface TestServer {
|
|
72
|
-
/** Full database instance with app tables (also includes sync tables) */
|
|
73
|
-
db: Kysely<ServerDb>;
|
|
74
|
-
dialect: ServerSyncDialect;
|
|
75
|
-
handlers: TableRegistry<ServerDb>;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Test client instance
|
|
80
|
-
*/
|
|
81
|
-
export interface TestClient {
|
|
82
|
-
/** Full database instance with app tables (also includes sync tables) */
|
|
83
|
-
db: Kysely<ClientDb>;
|
|
84
|
-
engine: SyncEngine<ClientDb>;
|
|
85
|
-
transport: SyncTransport;
|
|
86
|
-
/** Client handler registry */
|
|
87
|
-
handlers: ClientTableRegistry<ClientDb>;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Server-side tasks table handler for tests
|
|
92
|
-
*/
|
|
93
|
-
const tasksServerHandler: ServerTableHandler<ServerDb> = {
|
|
94
|
-
table: 'tasks',
|
|
95
|
-
scopePatterns: ['user:{user_id}'],
|
|
96
|
-
|
|
97
|
-
async resolveScopes(ctx) {
|
|
98
|
-
return { user_id: ctx.actorId };
|
|
99
|
-
},
|
|
10
|
+
createEngineTestClient,
|
|
11
|
+
createTestServer as createFixtureServer,
|
|
12
|
+
type EngineTestClient,
|
|
13
|
+
type TestServer,
|
|
14
|
+
} from '@syncular/testkit';
|
|
100
15
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
16
|
+
export type TestClient = EngineTestClient;
|
|
17
|
+
export type { TestServer };
|
|
104
18
|
|
|
105
|
-
async snapshot(ctx): Promise<{ rows: unknown[]; nextCursor: string | null }> {
|
|
106
|
-
const userIdValue = ctx.scopeValues.user_id;
|
|
107
|
-
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
108
|
-
if (!userId || userId !== ctx.actorId)
|
|
109
|
-
return { rows: [], nextCursor: null };
|
|
110
|
-
|
|
111
|
-
const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
|
|
112
|
-
const cursor = ctx.cursor;
|
|
113
|
-
|
|
114
|
-
const cursorFilter =
|
|
115
|
-
cursor && cursor.length > 0
|
|
116
|
-
? sql`and ${sql.ref('id')} > ${sql.val(cursor)}`
|
|
117
|
-
: sql``;
|
|
118
|
-
|
|
119
|
-
const result = await sql<{
|
|
120
|
-
id: string;
|
|
121
|
-
title: string;
|
|
122
|
-
completed: number;
|
|
123
|
-
user_id: string;
|
|
124
|
-
server_version: number;
|
|
125
|
-
}>`
|
|
126
|
-
select
|
|
127
|
-
${sql.ref('id')},
|
|
128
|
-
${sql.ref('title')},
|
|
129
|
-
${sql.ref('completed')},
|
|
130
|
-
${sql.ref('user_id')},
|
|
131
|
-
${sql.ref('server_version')}
|
|
132
|
-
from ${sql.table('tasks')}
|
|
133
|
-
where ${sql.ref('user_id')} = ${sql.val(userId)}
|
|
134
|
-
${cursorFilter}
|
|
135
|
-
order by ${sql.ref('id')} asc
|
|
136
|
-
limit ${sql.val(pageSize + 1)}
|
|
137
|
-
`.execute(ctx.db);
|
|
138
|
-
|
|
139
|
-
const rows = result.rows;
|
|
140
|
-
|
|
141
|
-
const hasMore = rows.length > pageSize;
|
|
142
|
-
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
143
|
-
const nextCursor = hasMore
|
|
144
|
-
? (pageRows[pageRows.length - 1]?.id ?? null)
|
|
145
|
-
: null;
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
rows: pageRows,
|
|
149
|
-
nextCursor:
|
|
150
|
-
typeof nextCursor === 'string' && nextCursor.length > 0
|
|
151
|
-
? nextCursor
|
|
152
|
-
: null,
|
|
153
|
-
};
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
async applyOperation(
|
|
157
|
-
ctx,
|
|
158
|
-
op: SyncOperation,
|
|
159
|
-
opIndex: number
|
|
160
|
-
): Promise<ApplyOperationResult> {
|
|
161
|
-
if (op.table !== 'tasks') {
|
|
162
|
-
return {
|
|
163
|
-
result: {
|
|
164
|
-
opIndex,
|
|
165
|
-
status: 'error',
|
|
166
|
-
error: `UNKNOWN_TABLE:${op.table}`,
|
|
167
|
-
code: 'UNKNOWN_TABLE',
|
|
168
|
-
retriable: false,
|
|
169
|
-
},
|
|
170
|
-
emittedChanges: [],
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (op.op === 'delete') {
|
|
175
|
-
const existingResult = await sql<{ id: string }>`
|
|
176
|
-
select ${sql.ref('id')}
|
|
177
|
-
from ${sql.table('tasks')}
|
|
178
|
-
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
179
|
-
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
180
|
-
limit ${sql.val(1)}
|
|
181
|
-
`.execute(ctx.trx);
|
|
182
|
-
const existing = existingResult.rows[0];
|
|
183
|
-
|
|
184
|
-
if (!existing) {
|
|
185
|
-
return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
await sql`
|
|
189
|
-
delete from ${sql.table('tasks')}
|
|
190
|
-
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
191
|
-
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
192
|
-
`.execute(ctx.trx);
|
|
193
|
-
|
|
194
|
-
const emitted: EmittedChange = {
|
|
195
|
-
table: 'tasks',
|
|
196
|
-
row_id: op.row_id,
|
|
197
|
-
op: 'delete',
|
|
198
|
-
row_json: null,
|
|
199
|
-
row_version: null,
|
|
200
|
-
scopes: { user_id: ctx.actorId },
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
result: { opIndex, status: 'applied' },
|
|
205
|
-
emittedChanges: [emitted],
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const payload = isRecord(op.payload) ? op.payload : {};
|
|
210
|
-
const nextTitle =
|
|
211
|
-
typeof payload.title === 'string' ? payload.title : undefined;
|
|
212
|
-
const nextCompleted =
|
|
213
|
-
typeof payload.completed === 'number' ? payload.completed : undefined;
|
|
214
|
-
|
|
215
|
-
const existingResult = await sql<{
|
|
216
|
-
id: string;
|
|
217
|
-
title: string;
|
|
218
|
-
completed: number;
|
|
219
|
-
server_version: number;
|
|
220
|
-
}>`
|
|
221
|
-
select
|
|
222
|
-
${sql.ref('id')},
|
|
223
|
-
${sql.ref('title')},
|
|
224
|
-
${sql.ref('completed')},
|
|
225
|
-
${sql.ref('server_version')}
|
|
226
|
-
from ${sql.table('tasks')}
|
|
227
|
-
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
228
|
-
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
229
|
-
limit ${sql.val(1)}
|
|
230
|
-
`.execute(ctx.trx);
|
|
231
|
-
const existing = existingResult.rows[0];
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
existing &&
|
|
235
|
-
op.base_version != null &&
|
|
236
|
-
existing.server_version !== op.base_version
|
|
237
|
-
) {
|
|
238
|
-
return {
|
|
239
|
-
result: {
|
|
240
|
-
opIndex,
|
|
241
|
-
status: 'conflict',
|
|
242
|
-
message: `Version conflict: server=${existing.server_version}, base=${op.base_version}`,
|
|
243
|
-
server_version: existing.server_version,
|
|
244
|
-
server_row: {
|
|
245
|
-
id: existing.id,
|
|
246
|
-
title: existing.title,
|
|
247
|
-
completed: existing.completed,
|
|
248
|
-
user_id: ctx.actorId,
|
|
249
|
-
server_version: existing.server_version,
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
emittedChanges: [],
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (existing) {
|
|
257
|
-
const nextVersion = existing.server_version + 1;
|
|
258
|
-
await sql`
|
|
259
|
-
update ${sql.table('tasks')}
|
|
260
|
-
set
|
|
261
|
-
${sql.ref('title')} = ${sql.val(nextTitle ?? existing.title)},
|
|
262
|
-
${sql.ref('completed')} = ${sql.val(nextCompleted ?? existing.completed)},
|
|
263
|
-
${sql.ref('server_version')} = ${sql.val(nextVersion)}
|
|
264
|
-
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
265
|
-
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
266
|
-
`.execute(ctx.trx);
|
|
267
|
-
} else {
|
|
268
|
-
await sql`
|
|
269
|
-
insert into ${sql.table('tasks')} (
|
|
270
|
-
${sql.join([
|
|
271
|
-
sql.ref('id'),
|
|
272
|
-
sql.ref('title'),
|
|
273
|
-
sql.ref('completed'),
|
|
274
|
-
sql.ref('user_id'),
|
|
275
|
-
sql.ref('server_version'),
|
|
276
|
-
])}
|
|
277
|
-
) values (
|
|
278
|
-
${sql.join([
|
|
279
|
-
sql.val(op.row_id),
|
|
280
|
-
sql.val(nextTitle ?? ''),
|
|
281
|
-
sql.val(nextCompleted ?? 0),
|
|
282
|
-
sql.val(ctx.actorId),
|
|
283
|
-
sql.val(1),
|
|
284
|
-
])}
|
|
285
|
-
)
|
|
286
|
-
`.execute(ctx.trx);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const updatedResult = await sql<{
|
|
290
|
-
id: string;
|
|
291
|
-
title: string;
|
|
292
|
-
completed: number;
|
|
293
|
-
user_id: string;
|
|
294
|
-
server_version: number;
|
|
295
|
-
}>`
|
|
296
|
-
select
|
|
297
|
-
${sql.ref('id')},
|
|
298
|
-
${sql.ref('title')},
|
|
299
|
-
${sql.ref('completed')},
|
|
300
|
-
${sql.ref('user_id')},
|
|
301
|
-
${sql.ref('server_version')}
|
|
302
|
-
from ${sql.table('tasks')}
|
|
303
|
-
where ${sql.ref('id')} = ${sql.val(op.row_id)}
|
|
304
|
-
and ${sql.ref('user_id')} = ${sql.val(ctx.actorId)}
|
|
305
|
-
limit ${sql.val(1)}
|
|
306
|
-
`.execute(ctx.trx);
|
|
307
|
-
const updated = updatedResult.rows[0];
|
|
308
|
-
if (!updated) {
|
|
309
|
-
throw new Error(`Failed to read updated task ${op.row_id}`);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const emitted: EmittedChange = {
|
|
313
|
-
table: 'tasks',
|
|
314
|
-
row_id: op.row_id,
|
|
315
|
-
op: 'upsert',
|
|
316
|
-
row_json: updated,
|
|
317
|
-
row_version: updated.server_version,
|
|
318
|
-
scopes: { user_id: ctx.actorId },
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
return {
|
|
322
|
-
result: { opIndex, status: 'applied' },
|
|
323
|
-
emittedChanges: [emitted],
|
|
324
|
-
};
|
|
325
|
-
},
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Create an in-memory test server with PGlite
|
|
330
|
-
*/
|
|
331
19
|
export async function createTestServer(): Promise<TestServer> {
|
|
332
|
-
|
|
333
|
-
const dialect = createPostgresServerDialect();
|
|
334
|
-
|
|
335
|
-
await ensureSyncSchema(db, dialect);
|
|
336
|
-
|
|
337
|
-
// Create tasks table
|
|
338
|
-
await db.schema
|
|
339
|
-
.createTable('tasks')
|
|
340
|
-
.ifNotExists()
|
|
341
|
-
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
342
|
-
.addColumn('title', 'text', (col) => col.notNull())
|
|
343
|
-
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
344
|
-
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
345
|
-
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(1))
|
|
346
|
-
.execute();
|
|
347
|
-
|
|
348
|
-
// Register handlers
|
|
349
|
-
const handlers = new TableRegistry<ServerDb>();
|
|
350
|
-
handlers.register(tasksServerHandler);
|
|
351
|
-
|
|
352
|
-
return {
|
|
353
|
-
db,
|
|
354
|
-
dialect,
|
|
355
|
-
handlers,
|
|
356
|
-
};
|
|
20
|
+
return createFixtureServer('pglite');
|
|
357
21
|
}
|
|
358
22
|
|
|
359
|
-
/**
|
|
360
|
-
* Create an in-process transport that calls server functions directly
|
|
361
|
-
*/
|
|
362
|
-
function createInProcessTransport(
|
|
363
|
-
server: TestServer,
|
|
364
|
-
actorId: string
|
|
365
|
-
): SyncTransport {
|
|
366
|
-
const syncDb = server.db;
|
|
367
|
-
|
|
368
|
-
async function streamToBytes(
|
|
369
|
-
stream: ReadableStream<Uint8Array>
|
|
370
|
-
): Promise<Uint8Array> {
|
|
371
|
-
const reader = stream.getReader();
|
|
372
|
-
try {
|
|
373
|
-
const chunks: Uint8Array[] = [];
|
|
374
|
-
let total = 0;
|
|
375
|
-
while (true) {
|
|
376
|
-
const { done, value } = await reader.read();
|
|
377
|
-
if (done) break;
|
|
378
|
-
if (!value) continue;
|
|
379
|
-
chunks.push(value);
|
|
380
|
-
total += value.length;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const merged = new Uint8Array(total);
|
|
384
|
-
let offset = 0;
|
|
385
|
-
for (const chunk of chunks) {
|
|
386
|
-
merged.set(chunk, offset);
|
|
387
|
-
offset += chunk.length;
|
|
388
|
-
}
|
|
389
|
-
return merged;
|
|
390
|
-
} finally {
|
|
391
|
-
reader.releaseLock();
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return {
|
|
396
|
-
async sync(request) {
|
|
397
|
-
const result: { ok: true; push?: any; pull?: any } = { ok: true };
|
|
398
|
-
|
|
399
|
-
if (request.push) {
|
|
400
|
-
const pushed = await pushCommit({
|
|
401
|
-
db: syncDb,
|
|
402
|
-
dialect: server.dialect,
|
|
403
|
-
handlers: server.handlers,
|
|
404
|
-
actorId,
|
|
405
|
-
request: {
|
|
406
|
-
clientId: request.clientId,
|
|
407
|
-
clientCommitId: request.push.clientCommitId,
|
|
408
|
-
operations: request.push.operations,
|
|
409
|
-
schemaVersion: request.push.schemaVersion,
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
result.push = pushed.response;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (request.pull) {
|
|
416
|
-
const pulled = await pull({
|
|
417
|
-
db: syncDb,
|
|
418
|
-
dialect: server.dialect,
|
|
419
|
-
handlers: server.handlers,
|
|
420
|
-
actorId,
|
|
421
|
-
request: {
|
|
422
|
-
clientId: request.clientId,
|
|
423
|
-
...request.pull,
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
recordClientCursor(syncDb, server.dialect, {
|
|
428
|
-
clientId: request.clientId,
|
|
429
|
-
actorId,
|
|
430
|
-
cursor: pulled.clientCursor,
|
|
431
|
-
effectiveScopes: pulled.effectiveScopes,
|
|
432
|
-
}).catch(() => {});
|
|
433
|
-
|
|
434
|
-
result.pull = pulled.response;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return result;
|
|
438
|
-
},
|
|
439
|
-
|
|
440
|
-
async fetchSnapshotChunk(request) {
|
|
441
|
-
const chunk = await readSnapshotChunk(syncDb, request.chunkId);
|
|
442
|
-
if (!chunk) {
|
|
443
|
-
throw new Error(`Snapshot chunk not found: ${request.chunkId}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (chunk.body instanceof Uint8Array) {
|
|
447
|
-
return new Uint8Array(chunk.body);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return streamToBytes(chunk.body);
|
|
451
|
-
},
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Create an in-memory test client with SQLite
|
|
457
|
-
*/
|
|
458
23
|
export async function createTestClient(
|
|
459
24
|
server: TestServer,
|
|
460
25
|
options: {
|
|
@@ -463,125 +28,26 @@ export async function createTestClient(
|
|
|
463
28
|
plugins?: SyncClientPlugin[];
|
|
464
29
|
}
|
|
465
30
|
): Promise<TestClient> {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
await ensureClientSyncSchema(db);
|
|
469
|
-
|
|
470
|
-
// Create tasks table
|
|
471
|
-
await db.schema
|
|
472
|
-
.createTable('tasks')
|
|
473
|
-
.ifNotExists()
|
|
474
|
-
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
475
|
-
.addColumn('title', 'text', (col) => col.notNull())
|
|
476
|
-
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
477
|
-
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
478
|
-
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
|
|
479
|
-
.execute();
|
|
480
|
-
|
|
481
|
-
// Create client handler registry
|
|
482
|
-
const handlers = new ClientTableRegistry<ClientDb>();
|
|
483
|
-
handlers.register({
|
|
484
|
-
table: 'tasks',
|
|
485
|
-
|
|
486
|
-
async applySnapshot(ctx, snapshot) {
|
|
487
|
-
if (snapshot.isFirstPage) {
|
|
488
|
-
await ctx.trx.deleteFrom('tasks').execute();
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const rows = (snapshot.rows ?? []).filter(isRecord).map((row) => ({
|
|
492
|
-
id: typeof row.id === 'string' ? row.id : '',
|
|
493
|
-
title: typeof row.title === 'string' ? row.title : '',
|
|
494
|
-
completed: typeof row.completed === 'number' ? row.completed : 0,
|
|
495
|
-
user_id: typeof row.user_id === 'string' ? row.user_id : '',
|
|
496
|
-
server_version:
|
|
497
|
-
typeof row.server_version === 'number' ? row.server_version : 0,
|
|
498
|
-
}));
|
|
499
|
-
if (rows.length === 0) return;
|
|
500
|
-
|
|
501
|
-
await ctx.trx
|
|
502
|
-
.insertInto('tasks')
|
|
503
|
-
.values(rows)
|
|
504
|
-
.onConflict((oc) =>
|
|
505
|
-
oc.column('id').doUpdateSet({
|
|
506
|
-
title: (eb) => eb.ref('excluded.title'),
|
|
507
|
-
completed: (eb) => eb.ref('excluded.completed'),
|
|
508
|
-
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
509
|
-
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
510
|
-
})
|
|
511
|
-
)
|
|
512
|
-
.execute();
|
|
513
|
-
},
|
|
514
|
-
|
|
515
|
-
async clearAll(ctx) {
|
|
516
|
-
await ctx.trx.deleteFrom('tasks').execute();
|
|
517
|
-
},
|
|
518
|
-
|
|
519
|
-
async applyChange(ctx, change) {
|
|
520
|
-
if (change.op === 'delete') {
|
|
521
|
-
await ctx.trx
|
|
522
|
-
.deleteFrom('tasks')
|
|
523
|
-
.where('id', '=', change.row_id)
|
|
524
|
-
.execute();
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const row = isRecord(change.row_json) ? change.row_json : {};
|
|
529
|
-
const title = typeof row.title === 'string' ? row.title : '';
|
|
530
|
-
const completed = typeof row.completed === 'number' ? row.completed : 0;
|
|
531
|
-
const userId = typeof row.user_id === 'string' ? row.user_id : '';
|
|
532
|
-
const baseVersion =
|
|
533
|
-
typeof row.server_version === 'number' ? row.server_version : 0;
|
|
534
|
-
|
|
535
|
-
await ctx.trx
|
|
536
|
-
.insertInto('tasks')
|
|
537
|
-
.values({
|
|
538
|
-
id: change.row_id,
|
|
539
|
-
title,
|
|
540
|
-
completed,
|
|
541
|
-
user_id: userId,
|
|
542
|
-
server_version: change.row_version ?? baseVersion,
|
|
543
|
-
})
|
|
544
|
-
.onConflict((oc) =>
|
|
545
|
-
oc.column('id').doUpdateSet({
|
|
546
|
-
title: (eb) => eb.ref('excluded.title'),
|
|
547
|
-
completed: (eb) => eb.ref('excluded.completed'),
|
|
548
|
-
user_id: (eb) => eb.ref('excluded.user_id'),
|
|
549
|
-
server_version: (eb) => eb.ref('excluded.server_version'),
|
|
550
|
-
})
|
|
551
|
-
)
|
|
552
|
-
.execute();
|
|
553
|
-
},
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
const transport = createInProcessTransport(server, options.actorId);
|
|
557
|
-
|
|
558
|
-
const config: SyncEngineConfig<ClientDb> = {
|
|
559
|
-
db,
|
|
560
|
-
transport,
|
|
561
|
-
handlers,
|
|
31
|
+
return createEngineTestClient(server, {
|
|
562
32
|
actorId: options.actorId,
|
|
563
33
|
clientId: options.clientId,
|
|
34
|
+
plugins: options.plugins,
|
|
564
35
|
subscriptions: [
|
|
565
|
-
{
|
|
36
|
+
{
|
|
37
|
+
id: 'my-tasks',
|
|
38
|
+
table: 'tasks',
|
|
39
|
+
scopes: { user_id: options.actorId },
|
|
40
|
+
},
|
|
566
41
|
],
|
|
567
|
-
pollIntervalMs: 999999,
|
|
42
|
+
pollIntervalMs: 999999,
|
|
568
43
|
realtimeEnabled: false,
|
|
569
|
-
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
const engine = new SyncEngine<ClientDb>(config);
|
|
573
|
-
|
|
574
|
-
return { db, engine, transport, handlers };
|
|
44
|
+
});
|
|
575
45
|
}
|
|
576
46
|
|
|
577
|
-
/**
|
|
578
|
-
* Destroy test resources
|
|
579
|
-
*/
|
|
580
47
|
export async function destroyTestClient(client: TestClient): Promise<void> {
|
|
581
|
-
client.
|
|
582
|
-
await client.db.destroy();
|
|
48
|
+
await client.destroy();
|
|
583
49
|
}
|
|
584
50
|
|
|
585
51
|
export async function destroyTestServer(server: TestServer): Promise<void> {
|
|
586
|
-
await server.
|
|
52
|
+
await server.destroy();
|
|
587
53
|
}
|