@syncular/server-hono 0.0.6-93 → 0.0.6-96

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.
@@ -270,15 +270,35 @@ describe('console timeline route filters', () => {
270
270
  }
271
271
 
272
272
  async function requestEvents(
273
- query: Record<string, string | number | undefined> = {}
273
+ args: {
274
+ query?: Record<string, string | number | undefined>;
275
+ targetApp?: Hono;
276
+ } = {}
274
277
  ): Promise<Response> {
275
278
  const params = new URLSearchParams({ limit: '50', offset: '0' });
276
- for (const [key, value] of Object.entries(query)) {
279
+ for (const [key, value] of Object.entries(args.query ?? {})) {
277
280
  if (value === undefined) continue;
278
281
  params.set(key, String(value));
279
282
  }
280
283
 
281
- return app.request(`http://localhost/console/events?${params.toString()}`, {
284
+ return (args.targetApp ?? app).request(
285
+ `http://localhost/console/events?${params.toString()}`,
286
+ {
287
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
288
+ }
289
+ );
290
+ }
291
+
292
+ async function requestClearEvents(): Promise<Response> {
293
+ return app.request('http://localhost/console/events', {
294
+ method: 'DELETE',
295
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
296
+ });
297
+ }
298
+
299
+ async function requestPruneEvents(targetApp: Hono = app): Promise<Response> {
300
+ return targetApp.request('http://localhost/console/events/prune', {
301
+ method: 'POST',
282
302
  headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
283
303
  });
284
304
  }
@@ -426,7 +446,9 @@ describe('console timeline route filters', () => {
426
446
  }
427
447
 
428
448
  function createTestApp(
429
- overrides: Pick<CreateConsoleRoutesOptions<TestDb>, 'metrics'> = {}
449
+ overrides: Partial<
450
+ Pick<CreateConsoleRoutesOptions<TestDb>, 'metrics' | 'maintenance'>
451
+ > = {}
430
452
  ): Hono {
431
453
  const routes = createConsoleRoutes({
432
454
  db,
@@ -444,6 +466,18 @@ describe('console timeline route filters', () => {
444
466
  return nextApp;
445
467
  }
446
468
 
469
+ async function waitForCondition(
470
+ evaluate: () => Promise<boolean>
471
+ ): Promise<void> {
472
+ for (let attempt = 0; attempt < 40; attempt++) {
473
+ if (await evaluate()) {
474
+ return;
475
+ }
476
+ await new Promise((resolve) => setTimeout(resolve, 25));
477
+ }
478
+ throw new Error('Condition was not met within timeout.');
479
+ }
480
+
447
481
  beforeEach(async () => {
448
482
  // Keep fixture events within the current metrics windows (for example 24h).
449
483
  baseTimeMs =
@@ -944,7 +978,9 @@ describe('console timeline route filters', () => {
944
978
  expect(clientsPayload.total).toBe(1);
945
979
  expect(clientsPayload.items[0]?.actorId).toBe('actor-z');
946
980
 
947
- const eventsResponse = await requestEvents({ partitionId: 'tenant-b' });
981
+ const eventsResponse = await requestEvents({
982
+ query: { partitionId: 'tenant-b' },
983
+ });
948
984
  expect(eventsResponse.status).toBe(200);
949
985
  const eventsPayload = (await eventsResponse.json()) as {
950
986
  items: Array<{ partitionId: string }>;
@@ -1473,4 +1509,118 @@ describe('console timeline route filters', () => {
1473
1509
  expect(payload.requestPayload.clientCommitId).toBe('commit-a');
1474
1510
  expect(payload.responsePayload.status).toBe('applied');
1475
1511
  });
1512
+
1513
+ it('deletes payload snapshots when clearing events', async () => {
1514
+ const response = await requestClearEvents();
1515
+ expect(response.status).toBe(200);
1516
+
1517
+ const eventCountRow = await db
1518
+ .selectFrom('sync_request_events')
1519
+ .select(({ fn }) => fn.countAll().as('total'))
1520
+ .executeTakeFirst();
1521
+ expect(Number(eventCountRow?.total ?? 0)).toBe(0);
1522
+
1523
+ const payloadCountRow = await db
1524
+ .selectFrom('sync_request_payloads')
1525
+ .select(({ fn }) => fn.countAll().as('total'))
1526
+ .executeTakeFirst();
1527
+ expect(Number(payloadCountRow?.total ?? 0)).toBe(0);
1528
+ });
1529
+
1530
+ it('prunes orphaned payload snapshots during event pruning', async () => {
1531
+ await db
1532
+ .insertInto('sync_request_payloads')
1533
+ .values({
1534
+ payload_ref: 'payload-orphan',
1535
+ partition_id: 'default',
1536
+ request_payload: JSON.stringify({ orphan: true }),
1537
+ response_payload: JSON.stringify({ ok: true }),
1538
+ created_at: atIso(33),
1539
+ })
1540
+ .execute();
1541
+
1542
+ const response = await requestPruneEvents();
1543
+ expect(response.status).toBe(200);
1544
+
1545
+ const orphan = await db
1546
+ .selectFrom('sync_request_payloads')
1547
+ .select(['payload_ref'])
1548
+ .where('payload_ref', '=', 'payload-orphan')
1549
+ .executeTakeFirst();
1550
+ expect(orphan).toBeUndefined();
1551
+ });
1552
+
1553
+ it('prunes operation audit events during /events/prune retention', async () => {
1554
+ await db
1555
+ .insertInto('sync_operation_events')
1556
+ .values({
1557
+ operation_type: 'compact',
1558
+ console_user_id: 'console-old',
1559
+ partition_id: null,
1560
+ target_client_id: null,
1561
+ request_payload: JSON.stringify({ fullHistoryHours: 12 }),
1562
+ result_payload: JSON.stringify({ deletedChanges: 22 }),
1563
+ created_at: '2000-01-01T00:00:00.000Z',
1564
+ })
1565
+ .execute();
1566
+
1567
+ const response = await requestPruneEvents();
1568
+ expect(response.status).toBe(200);
1569
+
1570
+ const oldOperation = await db
1571
+ .selectFrom('sync_operation_events')
1572
+ .select(['operation_id'])
1573
+ .where('console_user_id', '=', 'console-old')
1574
+ .executeTakeFirst();
1575
+ expect(oldOperation).toBeUndefined();
1576
+
1577
+ const operationCountRow = await db
1578
+ .selectFrom('sync_operation_events')
1579
+ .select(({ fn }) => fn.countAll().as('total'))
1580
+ .executeTakeFirst();
1581
+ expect(Number(operationCountRow?.total ?? 0)).toBe(2);
1582
+ });
1583
+
1584
+ it('runs automatic event pruning cadence using maintenance config', async () => {
1585
+ const autoPruneApp = createTestApp({
1586
+ maintenance: {
1587
+ autoPruneIntervalMs: 1,
1588
+ requestEventsMaxAgeMs: 0,
1589
+ requestEventsMaxRows: 2,
1590
+ operationEventsMaxAgeMs: 0,
1591
+ operationEventsMaxRows: 1,
1592
+ },
1593
+ });
1594
+
1595
+ const trigger = await requestEvents({ targetApp: autoPruneApp });
1596
+ expect(trigger.status).toBe(200);
1597
+
1598
+ await waitForCondition(async () => {
1599
+ const requestCountRow = await db
1600
+ .selectFrom('sync_request_events')
1601
+ .select(({ fn }) => fn.countAll().as('total'))
1602
+ .executeTakeFirst();
1603
+ const operationCountRow = await db
1604
+ .selectFrom('sync_operation_events')
1605
+ .select(({ fn }) => fn.countAll().as('total'))
1606
+ .executeTakeFirst();
1607
+
1608
+ const requestCount = Number(requestCountRow?.total ?? 0);
1609
+ const operationCount = Number(operationCountRow?.total ?? 0);
1610
+ return requestCount === 2 && operationCount === 1;
1611
+ });
1612
+ });
1613
+
1614
+ it('disables credentialed CORS headers when wildcard origin is configured', async () => {
1615
+ const response = await app.request('http://localhost/console/events', {
1616
+ method: 'OPTIONS',
1617
+ headers: {
1618
+ Origin: 'https://example.com',
1619
+ 'Access-Control-Request-Method': 'GET',
1620
+ },
1621
+ });
1622
+
1623
+ expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
1624
+ expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull();
1625
+ });
1476
1626
  });
@@ -9,7 +9,7 @@ import {
9
9
  import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
10
10
  import { Hono } from 'hono';
11
11
  import { defineWebSocketHelper } from 'hono/ws';
12
- import type { Kysely } from 'kysely';
12
+ import { type Kysely, sql } from 'kysely';
13
13
  import { createSyncServer } from '../create-server';
14
14
  import { getSyncWebSocketConnectionManager } from '../routes';
15
15
  import type { WebSocketConnection } from '../ws';
@@ -100,10 +100,16 @@ describe('createSyncServer console configuration', () => {
100
100
  };
101
101
  }
102
102
 
103
- function createPushRequest(): Request {
103
+ function createPushRequest(args?: {
104
+ requestId?: string;
105
+ title?: string;
106
+ }): Request {
104
107
  return new Request('http://localhost/sync', {
105
108
  method: 'POST',
106
- headers: { 'content-type': 'application/json' },
109
+ headers: {
110
+ 'content-type': 'application/json',
111
+ ...(args?.requestId ? { 'x-request-id': args.requestId } : {}),
112
+ },
107
113
  body: JSON.stringify({
108
114
  clientId: 'client-1',
109
115
  push: {
@@ -117,7 +123,7 @@ describe('createSyncServer console configuration', () => {
117
123
  payload: {
118
124
  id: 'task-1',
119
125
  user_id: 'u1',
120
- title: 'Task 1',
126
+ title: args?.title ?? 'Task 1',
121
127
  server_version: 0,
122
128
  },
123
129
  },
@@ -127,6 +133,62 @@ describe('createSyncServer console configuration', () => {
127
133
  });
128
134
  }
129
135
 
136
+ function parseSnapshotValue(value: unknown): unknown {
137
+ if (typeof value !== 'string') {
138
+ return value;
139
+ }
140
+ try {
141
+ return JSON.parse(value);
142
+ } catch {
143
+ return value;
144
+ }
145
+ }
146
+
147
+ async function waitForRequestEventRow(requestId: string): Promise<{
148
+ payload_ref: string | null;
149
+ }> {
150
+ for (let attempt = 0; attempt < 40; attempt++) {
151
+ const result = await sql<{ payload_ref: string | null }>`
152
+ SELECT payload_ref
153
+ FROM sync_request_events
154
+ WHERE request_id = ${requestId}
155
+ ORDER BY event_id DESC
156
+ LIMIT 1
157
+ `.execute(db);
158
+
159
+ const row = result.rows[0];
160
+ if (row) {
161
+ return row;
162
+ }
163
+
164
+ await new Promise((resolve) => setTimeout(resolve, 25));
165
+ }
166
+
167
+ throw new Error(`Timed out waiting for request event: ${requestId}`);
168
+ }
169
+
170
+ async function waitForRequestPayloadSnapshot(
171
+ payloadRef: string
172
+ ): Promise<unknown> {
173
+ for (let attempt = 0; attempt < 40; attempt++) {
174
+ const result = await sql<{ request_payload: unknown | null }>`
175
+ SELECT request_payload
176
+ FROM sync_request_payloads
177
+ WHERE payload_ref = ${payloadRef}
178
+ LIMIT 1
179
+ `.execute(db);
180
+
181
+ const row = result.rows[0];
182
+ if (row && row.request_payload !== null) {
183
+ return parseSnapshotValue(row.request_payload);
184
+ }
185
+
186
+ await new Promise((resolve) => setTimeout(resolve, 25));
187
+ }
188
+
189
+ throw new Error(`Timed out waiting for payload snapshot: ${payloadRef}`);
190
+ }
191
+
130
192
  it('keeps console routes disabled when console config is omitted', () => {
131
193
  const server = createSyncServer(createOptions());
132
194
  expect(server.consoleRoutes).toBeUndefined();
@@ -270,6 +332,87 @@ describe('createSyncServer console configuration', () => {
270
332
  });
271
333
  });
272
334
 
335
+ it('allows disabling request payload snapshots for privacy-sensitive deployments', async () => {
336
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
337
+ const options = createOptions();
338
+ const server = createSyncServer({
339
+ ...options,
340
+ console: {},
341
+ routes: {
342
+ requestPayloadSnapshots: {
343
+ enabled: false,
344
+ },
345
+ },
346
+ });
347
+
348
+ const app = new Hono();
349
+ app.route('/sync', server.syncRoutes);
350
+
351
+ const requestId = 'req-no-payload-snapshot';
352
+ const response = await app.request(createPushRequest({ requestId }));
353
+ expect(response.status).toBe(200);
354
+
355
+ const eventRow = await waitForRequestEventRow(requestId);
356
+ expect(eventRow.payload_ref).toBeNull();
357
+
358
+ const payloadCountResult = await sql<{ total: number | string }>`
359
+ SELECT COUNT(*)::int AS total
360
+ FROM sync_request_payloads
361
+ `.execute(db);
362
+ const payloadCount = Number(payloadCountResult.rows[0]?.total ?? 0);
363
+ expect(payloadCount).toBe(0);
364
+ });
365
+
366
+ it('supports aggressively reducing stored payload snapshot size', async () => {
367
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
368
+ const options = createOptions();
369
+ const server = createSyncServer({
370
+ ...options,
371
+ console: {},
372
+ routes: {
373
+ requestPayloadSnapshots: {
374
+ maxBytes: 32,
375
+ },
376
+ },
377
+ });
378
+
379
+ const app = new Hono();
380
+ app.route('/sync', server.syncRoutes);
381
+
382
+ const requestId = 'req-small-payload-preview';
383
+ const response = await app.request(
384
+ createPushRequest({
385
+ requestId,
386
+ title: 'x'.repeat(1024),
387
+ })
388
+ );
389
+ expect(response.status).toBe(200);
390
+
391
+ const eventRow = await waitForRequestEventRow(requestId);
392
+ expect(typeof eventRow.payload_ref).toBe('string');
393
+ if (!eventRow.payload_ref) {
394
+ throw new Error('Expected payload_ref to be present.');
395
+ }
396
+
397
+ const storedPayload = await waitForRequestPayloadSnapshot(
398
+ eventRow.payload_ref
399
+ );
400
+ expect(typeof storedPayload).toBe('object');
401
+ expect(Array.isArray(storedPayload)).toBe(false);
402
+ if (!storedPayload || typeof storedPayload !== 'object') {
403
+ throw new Error('Expected stored payload snapshot to be an object.');
404
+ }
405
+
406
+ const truncated = Reflect.get(storedPayload, 'truncated');
407
+ const preview = Reflect.get(storedPayload, 'preview');
408
+
409
+ expect(truncated).toBe(true);
410
+ expect(typeof preview).toBe('string');
411
+ if (typeof preview === 'string') {
412
+ expect(preview.length).toBeLessThanOrEqual(32);
413
+ }
414
+ });
415
+
273
416
  it('forwards maxConnectionsTotal from factory to realtime route', async () => {
274
417
  const options = createOptions();
275
418
  const upgradeWebSocket = defineWebSocketHelper(async () => {});