@syncular/server-hono 0.0.1 → 0.0.2-127
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/README.md +23 -0
- package/dist/api-key-auth.js +1 -1
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +31 -8
- package/dist/blobs.js.map +1 -1
- package/dist/console/index.d.ts +1 -1
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +1 -1
- package/dist/console/index.js.map +1 -1
- package/dist/console/routes.d.ts +1 -2
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +65 -2
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schemas.d.ts +138 -496
- package/dist/console/schemas.d.ts.map +1 -1
- package/dist/console/schemas.js +3 -9
- package/dist/console/schemas.js.map +1 -1
- package/dist/create-server.d.ts +3 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +4 -3
- package/dist/create-server.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/proxy/connection-manager.d.ts +1 -1
- package/dist/proxy/connection-manager.d.ts.map +1 -1
- package/dist/proxy/connection-manager.js +1 -1
- package/dist/proxy/connection-manager.js.map +1 -1
- package/dist/proxy/index.js +2 -2
- package/dist/proxy/routes.d.ts +2 -2
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +3 -3
- package/dist/proxy/routes.js.map +1 -1
- package/dist/routes.d.ts +2 -2
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +447 -260
- package/dist/routes.js.map +1 -1
- package/dist/ws.d.ts +40 -3
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +51 -6
- package/dist/ws.js.map +1 -1
- package/package.json +32 -9
- package/src/__tests__/pull-chunk-storage.test.ts +415 -27
- package/src/__tests__/realtime-bridge.test.ts +3 -1
- package/src/__tests__/sync-rate-limit-routing.test.ts +181 -0
- package/src/blobs.ts +31 -8
- package/src/console/index.ts +1 -0
- package/src/console/routes.ts +78 -25
- package/src/console/schemas.ts +0 -31
- package/src/create-server.ts +6 -0
- package/src/index.ts +12 -3
- package/src/proxy/connection-manager.ts +2 -2
- package/src/proxy/routes.ts +3 -3
- package/src/routes.ts +570 -327
- package/src/ws.ts +76 -13
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
2
|
import { gunzipSync } from 'node:zlib';
|
|
3
3
|
import {
|
|
4
|
+
configureSyncTelemetry,
|
|
5
|
+
decodeSnapshotRows,
|
|
6
|
+
getSyncTelemetry,
|
|
7
|
+
SyncCombinedResponseSchema,
|
|
4
8
|
type SyncPullResponse,
|
|
5
|
-
SyncPullResponseSchema,
|
|
6
9
|
type SyncSnapshotChunkRef,
|
|
10
|
+
type SyncSpan,
|
|
11
|
+
type SyncTelemetry,
|
|
7
12
|
} from '@syncular/core';
|
|
8
13
|
import {
|
|
9
14
|
createServerHandler,
|
|
@@ -32,6 +37,35 @@ interface ClientDb {
|
|
|
32
37
|
tasks: TasksTable;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
function createExceptionCaptureTelemetry(calls: {
|
|
41
|
+
exceptions: Array<{
|
|
42
|
+
error: unknown;
|
|
43
|
+
context: Record<string, unknown> | undefined;
|
|
44
|
+
}>;
|
|
45
|
+
}): SyncTelemetry {
|
|
46
|
+
return {
|
|
47
|
+
log() {},
|
|
48
|
+
tracer: {
|
|
49
|
+
startSpan(_options, callback) {
|
|
50
|
+
const span: SyncSpan = {
|
|
51
|
+
setAttribute() {},
|
|
52
|
+
setAttributes() {},
|
|
53
|
+
setStatus() {},
|
|
54
|
+
};
|
|
55
|
+
return callback(span);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
metrics: {
|
|
59
|
+
count() {},
|
|
60
|
+
gauge() {},
|
|
61
|
+
distribution() {},
|
|
62
|
+
},
|
|
63
|
+
captureException(error, context) {
|
|
64
|
+
calls.exceptions.push({ error, context });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
function mustGetFirstChunkId(payload: SyncPullResponse): string {
|
|
36
70
|
const chunkId = payload.subscriptions[0]?.snapshots?.[0]?.chunks?.[0]?.id;
|
|
37
71
|
if (!chunkId) {
|
|
@@ -40,6 +74,33 @@ function mustGetFirstChunkId(payload: SyncPullResponse): string {
|
|
|
40
74
|
return chunkId;
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
async function streamToBytes(
|
|
78
|
+
stream: ReadableStream<Uint8Array>
|
|
79
|
+
): Promise<Uint8Array> {
|
|
80
|
+
const reader = stream.getReader();
|
|
81
|
+
try {
|
|
82
|
+
const chunks: Uint8Array[] = [];
|
|
83
|
+
let total = 0;
|
|
84
|
+
while (true) {
|
|
85
|
+
const { done, value } = await reader.read();
|
|
86
|
+
if (done) break;
|
|
87
|
+
if (!value) continue;
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
total += value.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const out = new Uint8Array(total);
|
|
93
|
+
let offset = 0;
|
|
94
|
+
for (const chunk of chunks) {
|
|
95
|
+
out.set(chunk, offset);
|
|
96
|
+
offset += chunk.length;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
} finally {
|
|
100
|
+
reader.releaseLock();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
43
104
|
describe('createSyncRoutes chunkStorage wiring', () => {
|
|
44
105
|
let db: Kysely<ServerDb>;
|
|
45
106
|
const dialect = createSqliteServerDialect();
|
|
@@ -123,7 +184,7 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
123
184
|
app.route('/sync', routes);
|
|
124
185
|
|
|
125
186
|
const pullResponse = await app.request(
|
|
126
|
-
new Request('http://localhost/sync
|
|
187
|
+
new Request('http://localhost/sync', {
|
|
127
188
|
method: 'POST',
|
|
128
189
|
headers: {
|
|
129
190
|
'content-type': 'application/json',
|
|
@@ -131,23 +192,28 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
131
192
|
},
|
|
132
193
|
body: JSON.stringify({
|
|
133
194
|
clientId: 'client-1',
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
195
|
+
pull: {
|
|
196
|
+
limitCommits: 10,
|
|
197
|
+
limitSnapshotRows: 100,
|
|
198
|
+
maxSnapshotPages: 1,
|
|
199
|
+
subscriptions: [
|
|
200
|
+
{
|
|
201
|
+
id: 'sub-1',
|
|
202
|
+
table: 'tasks',
|
|
203
|
+
scopes: { user_id: 'u1' },
|
|
204
|
+
cursor: -1,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
145
208
|
}),
|
|
146
209
|
})
|
|
147
210
|
);
|
|
148
211
|
|
|
149
212
|
expect(pullResponse.status).toBe(200);
|
|
150
|
-
const
|
|
213
|
+
const combined = SyncCombinedResponseSchema.parse(
|
|
214
|
+
await pullResponse.json()
|
|
215
|
+
);
|
|
216
|
+
const parsed = combined.pull!;
|
|
151
217
|
const chunkId = mustGetFirstChunkId(parsed);
|
|
152
218
|
expect(storeChunkCalls).toBe(1);
|
|
153
219
|
expect(externalChunkBodies.has(chunkId)).toBe(true);
|
|
@@ -157,19 +223,7 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
157
223
|
throw new Error('Expected external chunk body to be stored.');
|
|
158
224
|
}
|
|
159
225
|
|
|
160
|
-
const
|
|
161
|
-
const rows = decoded
|
|
162
|
-
.split('\n')
|
|
163
|
-
.filter((line) => line.length > 0)
|
|
164
|
-
.map(
|
|
165
|
-
(line) =>
|
|
166
|
-
JSON.parse(line) as {
|
|
167
|
-
id: string;
|
|
168
|
-
user_id: string;
|
|
169
|
-
title: string;
|
|
170
|
-
server_version: number;
|
|
171
|
-
}
|
|
172
|
-
);
|
|
226
|
+
const rows = decodeSnapshotRows(gunzipSync(storedExternal));
|
|
173
227
|
|
|
174
228
|
const snapshotChunkCountRow = await db
|
|
175
229
|
.selectFrom('sync_snapshot_chunks')
|
|
@@ -181,4 +235,338 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
181
235
|
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
182
236
|
]);
|
|
183
237
|
}, 10_000);
|
|
238
|
+
|
|
239
|
+
it('uses storeChunkStream when the adapter provides it', async () => {
|
|
240
|
+
await db
|
|
241
|
+
.insertInto('tasks')
|
|
242
|
+
.values([
|
|
243
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
244
|
+
{ id: 't2', user_id: 'u1', title: 'Task 2', server_version: 2 },
|
|
245
|
+
])
|
|
246
|
+
.execute();
|
|
247
|
+
|
|
248
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
249
|
+
table: 'tasks',
|
|
250
|
+
scopes: ['user:{user_id}'],
|
|
251
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const externalChunkBodies = new Map<string, Uint8Array>();
|
|
255
|
+
let storeChunkCalls = 0;
|
|
256
|
+
let storeChunkStreamCalls = 0;
|
|
257
|
+
const chunkStorage: SnapshotChunkStorage = {
|
|
258
|
+
name: 'test-external-stream',
|
|
259
|
+
async storeChunk(metadata) {
|
|
260
|
+
storeChunkCalls += 1;
|
|
261
|
+
const ref: SyncSnapshotChunkRef = {
|
|
262
|
+
id: `chunk-${storeChunkCalls}`,
|
|
263
|
+
sha256: metadata.sha256,
|
|
264
|
+
byteLength: metadata.body.length,
|
|
265
|
+
encoding: metadata.encoding,
|
|
266
|
+
compression: metadata.compression,
|
|
267
|
+
};
|
|
268
|
+
externalChunkBodies.set(ref.id, new Uint8Array(metadata.body));
|
|
269
|
+
return ref;
|
|
270
|
+
},
|
|
271
|
+
async storeChunkStream(metadata) {
|
|
272
|
+
storeChunkStreamCalls += 1;
|
|
273
|
+
const body = await streamToBytes(metadata.bodyStream);
|
|
274
|
+
const ref: SyncSnapshotChunkRef = {
|
|
275
|
+
id: `chunk-stream-${storeChunkStreamCalls}`,
|
|
276
|
+
sha256: metadata.sha256,
|
|
277
|
+
byteLength: body.length,
|
|
278
|
+
encoding: metadata.encoding,
|
|
279
|
+
compression: metadata.compression,
|
|
280
|
+
};
|
|
281
|
+
externalChunkBodies.set(ref.id, body);
|
|
282
|
+
return ref;
|
|
283
|
+
},
|
|
284
|
+
async readChunk(chunkId: string) {
|
|
285
|
+
const body = externalChunkBodies.get(chunkId);
|
|
286
|
+
return body ? new Uint8Array(body) : null;
|
|
287
|
+
},
|
|
288
|
+
async findChunk() {
|
|
289
|
+
return null;
|
|
290
|
+
},
|
|
291
|
+
async cleanupExpired() {
|
|
292
|
+
return 0;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const routes = createSyncRoutes({
|
|
297
|
+
db,
|
|
298
|
+
dialect,
|
|
299
|
+
handlers: [tasksHandler],
|
|
300
|
+
authenticate: async (c) => {
|
|
301
|
+
const actorId = c.req.header('x-user-id');
|
|
302
|
+
return actorId ? { actorId } : null;
|
|
303
|
+
},
|
|
304
|
+
chunkStorage,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const app = new Hono();
|
|
308
|
+
app.route('/sync', routes);
|
|
309
|
+
|
|
310
|
+
const pullResponse = await app.request(
|
|
311
|
+
new Request('http://localhost/sync', {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: {
|
|
314
|
+
'content-type': 'application/json',
|
|
315
|
+
'x-user-id': 'u1',
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
clientId: 'client-1',
|
|
319
|
+
pull: {
|
|
320
|
+
limitCommits: 10,
|
|
321
|
+
limitSnapshotRows: 1,
|
|
322
|
+
maxSnapshotPages: 2,
|
|
323
|
+
subscriptions: [
|
|
324
|
+
{
|
|
325
|
+
id: 'sub-1',
|
|
326
|
+
table: 'tasks',
|
|
327
|
+
scopes: { user_id: 'u1' },
|
|
328
|
+
cursor: -1,
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
expect(pullResponse.status).toBe(200);
|
|
337
|
+
const combined = SyncCombinedResponseSchema.parse(
|
|
338
|
+
await pullResponse.json()
|
|
339
|
+
);
|
|
340
|
+
const parsed = combined.pull!;
|
|
341
|
+
const chunkId = mustGetFirstChunkId(parsed);
|
|
342
|
+
|
|
343
|
+
expect(storeChunkStreamCalls).toBe(1);
|
|
344
|
+
expect(storeChunkCalls).toBe(0);
|
|
345
|
+
expect(externalChunkBodies.has(chunkId)).toBe(true);
|
|
346
|
+
|
|
347
|
+
const storedExternal = externalChunkBodies.get(chunkId);
|
|
348
|
+
if (!storedExternal) {
|
|
349
|
+
throw new Error('Expected external chunk body to be stored.');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const rows = decodeSnapshotRows(gunzipSync(storedExternal));
|
|
353
|
+
expect(rows).toEqual([
|
|
354
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
355
|
+
{ id: 't2', user_id: 'u1', title: 'Task 2', server_version: 2 },
|
|
356
|
+
]);
|
|
357
|
+
}, 10_000);
|
|
358
|
+
|
|
359
|
+
it('bundles multiple snapshot pages into one stored chunk', async () => {
|
|
360
|
+
await db
|
|
361
|
+
.insertInto('tasks')
|
|
362
|
+
.values([
|
|
363
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
364
|
+
{ id: 't2', user_id: 'u1', title: 'Task 2', server_version: 2 },
|
|
365
|
+
{ id: 't3', user_id: 'u1', title: 'Task 3', server_version: 3 },
|
|
366
|
+
])
|
|
367
|
+
.execute();
|
|
368
|
+
|
|
369
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
370
|
+
table: 'tasks',
|
|
371
|
+
scopes: ['user:{user_id}'],
|
|
372
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const externalChunkBodies = new Map<string, Uint8Array>();
|
|
376
|
+
let storeChunkCalls = 0;
|
|
377
|
+
const chunkStorage: SnapshotChunkStorage = {
|
|
378
|
+
name: 'test-external',
|
|
379
|
+
async storeChunk(metadata) {
|
|
380
|
+
storeChunkCalls += 1;
|
|
381
|
+
const ref: SyncSnapshotChunkRef = {
|
|
382
|
+
id: `chunk-${storeChunkCalls}`,
|
|
383
|
+
sha256: metadata.sha256,
|
|
384
|
+
byteLength: metadata.body.length,
|
|
385
|
+
encoding: metadata.encoding,
|
|
386
|
+
compression: metadata.compression,
|
|
387
|
+
};
|
|
388
|
+
externalChunkBodies.set(ref.id, new Uint8Array(metadata.body));
|
|
389
|
+
return ref;
|
|
390
|
+
},
|
|
391
|
+
async readChunk(chunkId: string) {
|
|
392
|
+
const body = externalChunkBodies.get(chunkId);
|
|
393
|
+
return body ? new Uint8Array(body) : null;
|
|
394
|
+
},
|
|
395
|
+
async findChunk() {
|
|
396
|
+
return null;
|
|
397
|
+
},
|
|
398
|
+
async cleanupExpired() {
|
|
399
|
+
return 0;
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const routes = createSyncRoutes({
|
|
404
|
+
db,
|
|
405
|
+
dialect,
|
|
406
|
+
handlers: [tasksHandler],
|
|
407
|
+
authenticate: async (c) => {
|
|
408
|
+
const actorId = c.req.header('x-user-id');
|
|
409
|
+
return actorId ? { actorId } : null;
|
|
410
|
+
},
|
|
411
|
+
chunkStorage,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const app = new Hono();
|
|
415
|
+
app.route('/sync', routes);
|
|
416
|
+
|
|
417
|
+
const pullResponse = await app.request(
|
|
418
|
+
new Request('http://localhost/sync', {
|
|
419
|
+
method: 'POST',
|
|
420
|
+
headers: {
|
|
421
|
+
'content-type': 'application/json',
|
|
422
|
+
'x-user-id': 'u1',
|
|
423
|
+
},
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
clientId: 'client-1',
|
|
426
|
+
pull: {
|
|
427
|
+
limitCommits: 10,
|
|
428
|
+
limitSnapshotRows: 1,
|
|
429
|
+
maxSnapshotPages: 3,
|
|
430
|
+
subscriptions: [
|
|
431
|
+
{
|
|
432
|
+
id: 'sub-1',
|
|
433
|
+
table: 'tasks',
|
|
434
|
+
scopes: { user_id: 'u1' },
|
|
435
|
+
cursor: -1,
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
expect(pullResponse.status).toBe(200);
|
|
444
|
+
const combined = SyncCombinedResponseSchema.parse(
|
|
445
|
+
await pullResponse.json()
|
|
446
|
+
);
|
|
447
|
+
const parsed = combined.pull!;
|
|
448
|
+
const chunkId = mustGetFirstChunkId(parsed);
|
|
449
|
+
|
|
450
|
+
expect(parsed.subscriptions[0]?.snapshots?.length).toBe(1);
|
|
451
|
+
expect(storeChunkCalls).toBe(1);
|
|
452
|
+
|
|
453
|
+
const storedExternal = externalChunkBodies.get(chunkId);
|
|
454
|
+
if (!storedExternal) {
|
|
455
|
+
throw new Error('Expected external chunk body to be stored.');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const rows = decodeSnapshotRows(gunzipSync(storedExternal));
|
|
459
|
+
|
|
460
|
+
expect(rows).toEqual([
|
|
461
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
462
|
+
{ id: 't2', user_id: 'u1', title: 'Task 2', server_version: 2 },
|
|
463
|
+
{ id: 't3', user_id: 'u1', title: 'Task 3', server_version: 3 },
|
|
464
|
+
]);
|
|
465
|
+
}, 10_000);
|
|
466
|
+
|
|
467
|
+
it('captures unhandled pull exceptions via telemetry', async () => {
|
|
468
|
+
await db
|
|
469
|
+
.insertInto('tasks')
|
|
470
|
+
.values({
|
|
471
|
+
id: 't1',
|
|
472
|
+
user_id: 'u1',
|
|
473
|
+
title: 'Task 1',
|
|
474
|
+
server_version: 1,
|
|
475
|
+
})
|
|
476
|
+
.execute();
|
|
477
|
+
|
|
478
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
479
|
+
table: 'tasks',
|
|
480
|
+
scopes: ['user:{user_id}'],
|
|
481
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const chunkStorageError = new Error('chunk storage failed');
|
|
485
|
+
const chunkStorage: SnapshotChunkStorage = {
|
|
486
|
+
name: 'failing-external',
|
|
487
|
+
async storeChunk() {
|
|
488
|
+
throw chunkStorageError;
|
|
489
|
+
},
|
|
490
|
+
async storeChunkStream() {
|
|
491
|
+
throw chunkStorageError;
|
|
492
|
+
},
|
|
493
|
+
async readChunk() {
|
|
494
|
+
return null;
|
|
495
|
+
},
|
|
496
|
+
async findChunk() {
|
|
497
|
+
return null;
|
|
498
|
+
},
|
|
499
|
+
async cleanupExpired() {
|
|
500
|
+
return 0;
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const captured = {
|
|
505
|
+
exceptions: [] as Array<{
|
|
506
|
+
error: unknown;
|
|
507
|
+
context: Record<string, unknown> | undefined;
|
|
508
|
+
}>,
|
|
509
|
+
};
|
|
510
|
+
const previousTelemetry = getSyncTelemetry();
|
|
511
|
+
configureSyncTelemetry(createExceptionCaptureTelemetry(captured));
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const routes = createSyncRoutes({
|
|
515
|
+
db,
|
|
516
|
+
dialect,
|
|
517
|
+
handlers: [tasksHandler],
|
|
518
|
+
authenticate: async (c) => {
|
|
519
|
+
const actorId = c.req.header('x-user-id');
|
|
520
|
+
return actorId ? { actorId } : null;
|
|
521
|
+
},
|
|
522
|
+
chunkStorage,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const app = new Hono();
|
|
526
|
+
app.route('/sync', routes);
|
|
527
|
+
|
|
528
|
+
const response = await app.request(
|
|
529
|
+
new Request('http://localhost/sync', {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
headers: {
|
|
532
|
+
'content-type': 'application/json',
|
|
533
|
+
'x-user-id': 'u1',
|
|
534
|
+
},
|
|
535
|
+
body: JSON.stringify({
|
|
536
|
+
clientId: 'client-1',
|
|
537
|
+
pull: {
|
|
538
|
+
limitCommits: 10,
|
|
539
|
+
limitSnapshotRows: 100,
|
|
540
|
+
maxSnapshotPages: 1,
|
|
541
|
+
subscriptions: [
|
|
542
|
+
{
|
|
543
|
+
id: 'sub-1',
|
|
544
|
+
table: 'tasks',
|
|
545
|
+
scopes: { user_id: 'u1' },
|
|
546
|
+
cursor: -1,
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
expect(response.status).toBe(500);
|
|
554
|
+
|
|
555
|
+
const capturedUnhandledException = captured.exceptions.find(
|
|
556
|
+
(entry) => entry.context?.event === 'sync.route.unhandled'
|
|
557
|
+
);
|
|
558
|
+
expect(capturedUnhandledException).toBeDefined();
|
|
559
|
+
if (!capturedUnhandledException) {
|
|
560
|
+
throw new Error('Expected unhandled exception telemetry entry');
|
|
561
|
+
}
|
|
562
|
+
expect(capturedUnhandledException.context).toEqual({
|
|
563
|
+
event: 'sync.route.unhandled',
|
|
564
|
+
method: 'POST',
|
|
565
|
+
path: '/sync',
|
|
566
|
+
});
|
|
567
|
+
expect(capturedUnhandledException.error).toBe(chunkStorageError);
|
|
568
|
+
} finally {
|
|
569
|
+
configureSyncTelemetry(previousTelemetry);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
184
572
|
});
|
|
@@ -22,6 +22,7 @@ describe('realtime broadcaster bridge', () => {
|
|
|
22
22
|
const commit = await db
|
|
23
23
|
.insertInto('sync_commits')
|
|
24
24
|
.values({
|
|
25
|
+
partition_id: 'default',
|
|
25
26
|
actor_id: 'u1',
|
|
26
27
|
client_id: 'client-1',
|
|
27
28
|
client_commit_id: 'c1',
|
|
@@ -37,6 +38,7 @@ describe('realtime broadcaster bridge', () => {
|
|
|
37
38
|
.insertInto('sync_changes')
|
|
38
39
|
.values({
|
|
39
40
|
commit_seq: commitSeq,
|
|
41
|
+
partition_id: 'default',
|
|
40
42
|
table: 'tasks',
|
|
41
43
|
row_id: 't1',
|
|
42
44
|
op: 'upsert',
|
|
@@ -97,7 +99,7 @@ describe('realtime broadcaster bridge', () => {
|
|
|
97
99
|
sendError: mock(() => {}),
|
|
98
100
|
close: mock(() => {}),
|
|
99
101
|
},
|
|
100
|
-
['user:u1']
|
|
102
|
+
['default::user:u1']
|
|
101
103
|
);
|
|
102
104
|
|
|
103
105
|
// Publish without scopeKeys to exercise DB lookup on the receiving instance.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createServerHandler,
|
|
4
|
+
ensureSyncSchema,
|
|
5
|
+
type SyncCoreDb,
|
|
6
|
+
} from '@syncular/server';
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import type { Kysely } from 'kysely';
|
|
9
|
+
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
10
|
+
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
11
|
+
import { resetRateLimitStore } from '../rate-limit';
|
|
12
|
+
import { createSyncRoutes } from '../routes';
|
|
13
|
+
|
|
14
|
+
interface TasksTable {
|
|
15
|
+
id: string;
|
|
16
|
+
user_id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
server_version: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ServerDb extends SyncCoreDb {
|
|
22
|
+
tasks: TasksTable;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ClientDb {
|
|
26
|
+
tasks: TasksTable;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('createSyncRoutes rate limit routing', () => {
|
|
30
|
+
let db: Kysely<ServerDb>;
|
|
31
|
+
const dialect = createSqliteServerDialect();
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
db = createBunSqliteDb<ServerDb>({ path: ':memory:' });
|
|
35
|
+
await ensureSyncSchema(db, dialect);
|
|
36
|
+
|
|
37
|
+
await db.schema
|
|
38
|
+
.createTable('tasks')
|
|
39
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
40
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
41
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
42
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
43
|
+
col.notNull().defaultTo(0)
|
|
44
|
+
)
|
|
45
|
+
.execute();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
resetRateLimitStore();
|
|
50
|
+
await db.destroy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
54
|
+
table: 'tasks',
|
|
55
|
+
scopes: ['user:{user_id}'],
|
|
56
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const pullPayload = {
|
|
60
|
+
limitCommits: 10,
|
|
61
|
+
subscriptions: [
|
|
62
|
+
{
|
|
63
|
+
id: 'sub-1',
|
|
64
|
+
table: 'tasks',
|
|
65
|
+
scopes: { user_id: 'u1' },
|
|
66
|
+
cursor: -1,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function pushPayload(clientCommitId: string, rowId: string) {
|
|
72
|
+
return {
|
|
73
|
+
clientCommitId,
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
operations: [
|
|
76
|
+
{
|
|
77
|
+
table: 'tasks',
|
|
78
|
+
row_id: rowId,
|
|
79
|
+
op: 'upsert' as const,
|
|
80
|
+
base_version: null,
|
|
81
|
+
payload: {
|
|
82
|
+
id: rowId,
|
|
83
|
+
user_id: 'u1',
|
|
84
|
+
title: `Task ${rowId}`,
|
|
85
|
+
server_version: 0,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createJsonRequest(body: object): Request {
|
|
93
|
+
return new Request('http://localhost/sync', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'content-type': 'application/json' },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('does not consume push quota for pull-only requests', async () => {
|
|
101
|
+
const routes = createSyncRoutes({
|
|
102
|
+
db,
|
|
103
|
+
dialect,
|
|
104
|
+
handlers: [tasksHandler],
|
|
105
|
+
authenticate: async () => ({ actorId: 'u1' }),
|
|
106
|
+
sync: {
|
|
107
|
+
rateLimit: {
|
|
108
|
+
pull: { maxRequests: 1, windowMs: 60_000 },
|
|
109
|
+
push: { maxRequests: 1, windowMs: 60_000 },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const app = new Hono();
|
|
115
|
+
app.route('/sync', routes);
|
|
116
|
+
|
|
117
|
+
const pullFirst = await app.request(
|
|
118
|
+
createJsonRequest({
|
|
119
|
+
clientId: 'client-1',
|
|
120
|
+
pull: pullPayload,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
const pushFirst = await app.request(
|
|
124
|
+
createJsonRequest({
|
|
125
|
+
clientId: 'client-1',
|
|
126
|
+
push: pushPayload('commit-1', 't1'),
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
const pullSecond = await app.request(
|
|
130
|
+
createJsonRequest({
|
|
131
|
+
clientId: 'client-1',
|
|
132
|
+
pull: pullPayload,
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
const pushSecond = await app.request(
|
|
136
|
+
createJsonRequest({
|
|
137
|
+
clientId: 'client-1',
|
|
138
|
+
push: pushPayload('commit-2', 't2'),
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(pullFirst.status).toBe(200);
|
|
143
|
+
expect(pushFirst.status).toBe(200);
|
|
144
|
+
expect(pullSecond.status).toBe(429);
|
|
145
|
+
expect(pushSecond.status).toBe(429);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('authenticates once per combined sync request with rate limiting enabled', async () => {
|
|
149
|
+
let authCalls = 0;
|
|
150
|
+
|
|
151
|
+
const routes = createSyncRoutes({
|
|
152
|
+
db,
|
|
153
|
+
dialect,
|
|
154
|
+
handlers: [tasksHandler],
|
|
155
|
+
authenticate: async () => {
|
|
156
|
+
authCalls += 1;
|
|
157
|
+
return { actorId: 'u1' };
|
|
158
|
+
},
|
|
159
|
+
sync: {
|
|
160
|
+
rateLimit: {
|
|
161
|
+
pull: { maxRequests: 10, windowMs: 60_000 },
|
|
162
|
+
push: { maxRequests: 10, windowMs: 60_000 },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const app = new Hono();
|
|
168
|
+
app.route('/sync', routes);
|
|
169
|
+
|
|
170
|
+
const response = await app.request(
|
|
171
|
+
createJsonRequest({
|
|
172
|
+
clientId: 'client-1',
|
|
173
|
+
push: pushPayload('commit-combined-1', 't-combined-1'),
|
|
174
|
+
pull: pullPayload,
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(response.status).toBe(200);
|
|
179
|
+
expect(authCalls).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|