@syncular/server-hono 0.0.1-100
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/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +80 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +100 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +884 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +572 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/sync-rate-limit-routing.test.ts +181 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +186 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1305 -0
- package/src/ws.ts +789 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { gunzipSync } from 'node:zlib';
|
|
3
|
+
import {
|
|
4
|
+
configureSyncTelemetry,
|
|
5
|
+
decodeSnapshotRows,
|
|
6
|
+
getSyncTelemetry,
|
|
7
|
+
SyncCombinedResponseSchema,
|
|
8
|
+
type SyncPullResponse,
|
|
9
|
+
type SyncSnapshotChunkRef,
|
|
10
|
+
type SyncSpan,
|
|
11
|
+
type SyncTelemetry,
|
|
12
|
+
} from '@syncular/core';
|
|
13
|
+
import {
|
|
14
|
+
createServerHandler,
|
|
15
|
+
ensureSyncSchema,
|
|
16
|
+
type SnapshotChunkStorage,
|
|
17
|
+
type SyncCoreDb,
|
|
18
|
+
} from '@syncular/server';
|
|
19
|
+
import { Hono } from 'hono';
|
|
20
|
+
import type { Kysely } from 'kysely';
|
|
21
|
+
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
22
|
+
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
23
|
+
import { createSyncRoutes } from '../routes';
|
|
24
|
+
|
|
25
|
+
interface TasksTable {
|
|
26
|
+
id: string;
|
|
27
|
+
user_id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
server_version: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ServerDb extends SyncCoreDb {
|
|
33
|
+
tasks: TasksTable;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ClientDb {
|
|
37
|
+
tasks: TasksTable;
|
|
38
|
+
}
|
|
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
|
+
|
|
69
|
+
function mustGetFirstChunkId(payload: SyncPullResponse): string {
|
|
70
|
+
const chunkId = payload.subscriptions[0]?.snapshots?.[0]?.chunks?.[0]?.id;
|
|
71
|
+
if (!chunkId) {
|
|
72
|
+
throw new Error('Expected pull bootstrap response to include a chunk id.');
|
|
73
|
+
}
|
|
74
|
+
return chunkId;
|
|
75
|
+
}
|
|
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
|
+
|
|
104
|
+
describe('createSyncRoutes chunkStorage wiring', () => {
|
|
105
|
+
let db: Kysely<ServerDb>;
|
|
106
|
+
const dialect = createSqliteServerDialect();
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
db = createBunSqliteDb<ServerDb>({ path: ':memory:' });
|
|
110
|
+
await ensureSyncSchema(db, dialect);
|
|
111
|
+
|
|
112
|
+
await db.schema
|
|
113
|
+
.createTable('tasks')
|
|
114
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
115
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
116
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
117
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
118
|
+
col.notNull().defaultTo(0)
|
|
119
|
+
)
|
|
120
|
+
.execute();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
await db.destroy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('uses external chunk storage in /pull and serves chunks from it', async () => {
|
|
128
|
+
await db
|
|
129
|
+
.insertInto('tasks')
|
|
130
|
+
.values({
|
|
131
|
+
id: 't1',
|
|
132
|
+
user_id: 'u1',
|
|
133
|
+
title: 'Task 1',
|
|
134
|
+
server_version: 1,
|
|
135
|
+
})
|
|
136
|
+
.execute();
|
|
137
|
+
|
|
138
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
139
|
+
table: 'tasks',
|
|
140
|
+
scopes: ['user:{user_id}'],
|
|
141
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const externalChunkBodies = new Map<string, Uint8Array>();
|
|
145
|
+
let storeChunkCalls = 0;
|
|
146
|
+
const chunkStorage: SnapshotChunkStorage = {
|
|
147
|
+
name: 'test-external',
|
|
148
|
+
async storeChunk(metadata) {
|
|
149
|
+
storeChunkCalls += 1;
|
|
150
|
+
const ref: SyncSnapshotChunkRef = {
|
|
151
|
+
id: `chunk-${storeChunkCalls}`,
|
|
152
|
+
sha256: metadata.sha256,
|
|
153
|
+
byteLength: metadata.body.length,
|
|
154
|
+
encoding: metadata.encoding,
|
|
155
|
+
compression: metadata.compression,
|
|
156
|
+
};
|
|
157
|
+
externalChunkBodies.set(ref.id, new Uint8Array(metadata.body));
|
|
158
|
+
return ref;
|
|
159
|
+
},
|
|
160
|
+
async readChunk(chunkId: string) {
|
|
161
|
+
const body = externalChunkBodies.get(chunkId);
|
|
162
|
+
return body ? new Uint8Array(body) : null;
|
|
163
|
+
},
|
|
164
|
+
async findChunk() {
|
|
165
|
+
return null;
|
|
166
|
+
},
|
|
167
|
+
async cleanupExpired() {
|
|
168
|
+
return 0;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const routes = createSyncRoutes({
|
|
173
|
+
db,
|
|
174
|
+
dialect,
|
|
175
|
+
handlers: [tasksHandler],
|
|
176
|
+
authenticate: async (c) => {
|
|
177
|
+
const actorId = c.req.header('x-user-id');
|
|
178
|
+
return actorId ? { actorId } : null;
|
|
179
|
+
},
|
|
180
|
+
chunkStorage,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const app = new Hono();
|
|
184
|
+
app.route('/sync', routes);
|
|
185
|
+
|
|
186
|
+
const pullResponse = await app.request(
|
|
187
|
+
new Request('http://localhost/sync', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
'content-type': 'application/json',
|
|
191
|
+
'x-user-id': 'u1',
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
clientId: 'client-1',
|
|
195
|
+
pull: {
|
|
196
|
+
limitCommits: 10,
|
|
197
|
+
limitSnapshotRows: 100,
|
|
198
|
+
maxSnapshotPages: 1,
|
|
199
|
+
subscriptions: [
|
|
200
|
+
{
|
|
201
|
+
id: 'sub-1',
|
|
202
|
+
shape: 'tasks',
|
|
203
|
+
scopes: { user_id: 'u1' },
|
|
204
|
+
cursor: -1,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(pullResponse.status).toBe(200);
|
|
213
|
+
const combined = SyncCombinedResponseSchema.parse(
|
|
214
|
+
await pullResponse.json()
|
|
215
|
+
);
|
|
216
|
+
const parsed = combined.pull!;
|
|
217
|
+
const chunkId = mustGetFirstChunkId(parsed);
|
|
218
|
+
expect(storeChunkCalls).toBe(1);
|
|
219
|
+
expect(externalChunkBodies.has(chunkId)).toBe(true);
|
|
220
|
+
|
|
221
|
+
const storedExternal = externalChunkBodies.get(chunkId);
|
|
222
|
+
if (!storedExternal) {
|
|
223
|
+
throw new Error('Expected external chunk body to be stored.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const rows = decodeSnapshotRows(gunzipSync(storedExternal));
|
|
227
|
+
|
|
228
|
+
const snapshotChunkCountRow = await db
|
|
229
|
+
.selectFrom('sync_snapshot_chunks')
|
|
230
|
+
.select(({ fn }) => fn.countAll().as('count'))
|
|
231
|
+
.executeTakeFirstOrThrow();
|
|
232
|
+
|
|
233
|
+
expect(Number(snapshotChunkCountRow.count)).toBe(0);
|
|
234
|
+
expect(rows).toEqual([
|
|
235
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
236
|
+
]);
|
|
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
|
+
shape: '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
|
+
shape: '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
|
+
shape: '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
|
+
});
|
|
572
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { createRateLimiter, resetRateLimitStore } from '../rate-limit';
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
resetRateLimitStore();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('rate limiter store isolation', () => {
|
|
10
|
+
it('does not share counters across independently configured routes', async () => {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
app.use(
|
|
14
|
+
'/pull',
|
|
15
|
+
createRateLimiter({
|
|
16
|
+
maxRequests: 1,
|
|
17
|
+
windowMs: 60_000,
|
|
18
|
+
keyGenerator: () => 'actor-1',
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
app.use(
|
|
22
|
+
'/push',
|
|
23
|
+
createRateLimiter({
|
|
24
|
+
maxRequests: 1,
|
|
25
|
+
windowMs: 60_000,
|
|
26
|
+
keyGenerator: () => 'actor-1',
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
app.get('/pull', (c) => c.text('ok'));
|
|
31
|
+
app.get('/push', (c) => c.text('ok'));
|
|
32
|
+
|
|
33
|
+
const pullFirst = await app.request('http://localhost/pull');
|
|
34
|
+
const pushFirst = await app.request('http://localhost/push');
|
|
35
|
+
const pushSecond = await app.request('http://localhost/push');
|
|
36
|
+
|
|
37
|
+
expect(pullFirst.status).toBe(200);
|
|
38
|
+
expect(pushFirst.status).toBe(200);
|
|
39
|
+
expect(pushSecond.status).toBe(429);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps window durations isolated per limiter', async () => {
|
|
43
|
+
const app = new Hono();
|
|
44
|
+
|
|
45
|
+
app.use(
|
|
46
|
+
'/short',
|
|
47
|
+
createRateLimiter({
|
|
48
|
+
maxRequests: 1,
|
|
49
|
+
windowMs: 10,
|
|
50
|
+
keyGenerator: () => 'actor-1',
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
app.use(
|
|
54
|
+
'/long',
|
|
55
|
+
createRateLimiter({
|
|
56
|
+
maxRequests: 1,
|
|
57
|
+
windowMs: 1_000,
|
|
58
|
+
keyGenerator: () => 'actor-1',
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
app.get('/short', (c) => c.text('ok'));
|
|
63
|
+
app.get('/long', (c) => c.text('ok'));
|
|
64
|
+
|
|
65
|
+
// Initialize the short-window limiter first.
|
|
66
|
+
expect((await app.request('http://localhost/short')).status).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Exhaust long-window limiter.
|
|
69
|
+
expect((await app.request('http://localhost/long')).status).toBe(200);
|
|
70
|
+
expect((await app.request('http://localhost/long')).status).toBe(429);
|
|
71
|
+
|
|
72
|
+
// Wait longer than short window but shorter than long window.
|
|
73
|
+
await Bun.sleep(30);
|
|
74
|
+
|
|
75
|
+
// Long limiter must still be limited.
|
|
76
|
+
expect((await app.request('http://localhost/long')).status).toBe(429);
|
|
77
|
+
});
|
|
78
|
+
});
|