@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.
Files changed (56) hide show
  1. package/README.md +23 -0
  2. package/dist/api-key-auth.js +1 -1
  3. package/dist/blobs.d.ts.map +1 -1
  4. package/dist/blobs.js +31 -8
  5. package/dist/blobs.js.map +1 -1
  6. package/dist/console/index.d.ts +1 -1
  7. package/dist/console/index.d.ts.map +1 -1
  8. package/dist/console/index.js +1 -1
  9. package/dist/console/index.js.map +1 -1
  10. package/dist/console/routes.d.ts +1 -2
  11. package/dist/console/routes.d.ts.map +1 -1
  12. package/dist/console/routes.js +65 -2
  13. package/dist/console/routes.js.map +1 -1
  14. package/dist/console/schemas.d.ts +138 -496
  15. package/dist/console/schemas.d.ts.map +1 -1
  16. package/dist/console/schemas.js +3 -9
  17. package/dist/console/schemas.js.map +1 -1
  18. package/dist/create-server.d.ts +3 -1
  19. package/dist/create-server.d.ts.map +1 -1
  20. package/dist/create-server.js +4 -3
  21. package/dist/create-server.js.map +1 -1
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -9
  25. package/dist/index.js.map +1 -1
  26. package/dist/proxy/connection-manager.d.ts +1 -1
  27. package/dist/proxy/connection-manager.d.ts.map +1 -1
  28. package/dist/proxy/connection-manager.js +1 -1
  29. package/dist/proxy/connection-manager.js.map +1 -1
  30. package/dist/proxy/index.js +2 -2
  31. package/dist/proxy/routes.d.ts +2 -2
  32. package/dist/proxy/routes.d.ts.map +1 -1
  33. package/dist/proxy/routes.js +3 -3
  34. package/dist/proxy/routes.js.map +1 -1
  35. package/dist/routes.d.ts +2 -2
  36. package/dist/routes.d.ts.map +1 -1
  37. package/dist/routes.js +447 -260
  38. package/dist/routes.js.map +1 -1
  39. package/dist/ws.d.ts +40 -3
  40. package/dist/ws.d.ts.map +1 -1
  41. package/dist/ws.js +51 -6
  42. package/dist/ws.js.map +1 -1
  43. package/package.json +32 -9
  44. package/src/__tests__/pull-chunk-storage.test.ts +415 -27
  45. package/src/__tests__/realtime-bridge.test.ts +3 -1
  46. package/src/__tests__/sync-rate-limit-routing.test.ts +181 -0
  47. package/src/blobs.ts +31 -8
  48. package/src/console/index.ts +1 -0
  49. package/src/console/routes.ts +78 -25
  50. package/src/console/schemas.ts +0 -31
  51. package/src/create-server.ts +6 -0
  52. package/src/index.ts +12 -3
  53. package/src/proxy/connection-manager.ts +2 -2
  54. package/src/proxy/routes.ts +3 -3
  55. package/src/routes.ts +570 -327
  56. 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/pull', {
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
- limitCommits: 10,
135
- limitSnapshotRows: 100,
136
- maxSnapshotPages: 1,
137
- subscriptions: [
138
- {
139
- id: 'sub-1',
140
- shape: 'tasks',
141
- scopes: { user_id: 'u1' },
142
- cursor: -1,
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 parsed = SyncPullResponseSchema.parse(await pullResponse.json());
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 decoded = new TextDecoder().decode(gunzipSync(storedExternal));
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
+ });