@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.
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +212 -40
- package/dist/console/routes.js.map +1 -1
- package/dist/console/types.d.ts +38 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +7 -1
- package/dist/create-server.js.map +1 -1
- package/dist/routes.d.ts +21 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +92 -56
- package/dist/routes.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/console-routes.test.ts +155 -5
- package/src/__tests__/create-server.test.ts +147 -4
- package/src/console/routes.ts +280 -48
- package/src/console/types.ts +39 -0
- package/src/create-server.ts +8 -1
- package/src/routes.ts +134 -63
|
@@ -270,15 +270,35 @@ describe('console timeline route filters', () => {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
async function requestEvents(
|
|
273
|
-
|
|
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(
|
|
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:
|
|
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({
|
|
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
|
|
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(
|
|
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: {
|
|
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 () => {});
|