@syncular/server-hono 0.0.4-26 → 0.0.6-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/console/gateway.d.ts +3 -1
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +218 -41
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/index.d.ts +1 -0
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +1 -0
- package/dist/console/index.js.map +1 -1
- package/dist/console/routes.d.ts +3 -97
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +507 -80
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schemas.d.ts +29 -0
- package/dist/console/schemas.d.ts.map +1 -1
- package/dist/console/schemas.js +22 -0
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +175 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +2 -0
- package/dist/console/types.js.map +1 -0
- package/dist/create-server.d.ts +17 -34
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +26 -26
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/connection-manager.d.ts +3 -3
- package/dist/proxy/connection-manager.d.ts.map +1 -1
- package/dist/proxy/routes.d.ts +4 -4
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +1 -1
- package/dist/routes.d.ts +33 -9
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +153 -70
- package/dist/routes.js.map +1 -1
- package/package.json +21 -7
- package/src/__tests__/blob-routes.test.ts +424 -0
- package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
- package/src/__tests__/console-routes.test.ts +161 -7
- package/src/__tests__/console-ui.test.ts +114 -0
- package/src/__tests__/create-server.test.ts +233 -10
- package/src/__tests__/pull-chunk-storage.test.ts +6 -2
- package/src/__tests__/realtime-bridge.test.ts +6 -2
- package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
- package/src/console/gateway.ts +277 -53
- package/src/console/index.ts +1 -0
- package/src/console/routes.ts +654 -198
- package/src/console/schemas.ts +29 -0
- package/src/console/types.ts +185 -0
- package/src/create-server.ts +56 -53
- package/src/proxy/connection-manager.ts +3 -3
- package/src/proxy/routes.ts +4 -4
- package/src/routes.ts +225 -96
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { createDatabase } from '@syncular/core';
|
|
3
|
+
import { createPgliteDialect } from '@syncular/dialect-pglite';
|
|
3
4
|
import { ensureSyncSchema, type SyncCoreDb } from '@syncular/server';
|
|
4
5
|
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
5
6
|
import { Hono } from 'hono';
|
|
@@ -269,15 +270,35 @@ describe('console timeline route filters', () => {
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
async function requestEvents(
|
|
272
|
-
|
|
273
|
+
args: {
|
|
274
|
+
query?: Record<string, string | number | undefined>;
|
|
275
|
+
targetApp?: Hono;
|
|
276
|
+
} = {}
|
|
273
277
|
): Promise<Response> {
|
|
274
278
|
const params = new URLSearchParams({ limit: '50', offset: '0' });
|
|
275
|
-
for (const [key, value] of Object.entries(query)) {
|
|
279
|
+
for (const [key, value] of Object.entries(args.query ?? {})) {
|
|
276
280
|
if (value === undefined) continue;
|
|
277
281
|
params.set(key, String(value));
|
|
278
282
|
}
|
|
279
283
|
|
|
280
|
-
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',
|
|
281
302
|
headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
|
|
282
303
|
});
|
|
283
304
|
}
|
|
@@ -425,7 +446,9 @@ describe('console timeline route filters', () => {
|
|
|
425
446
|
}
|
|
426
447
|
|
|
427
448
|
function createTestApp(
|
|
428
|
-
overrides:
|
|
449
|
+
overrides: Partial<
|
|
450
|
+
Pick<CreateConsoleRoutesOptions<TestDb>, 'metrics' | 'maintenance'>
|
|
451
|
+
> = {}
|
|
429
452
|
): Hono {
|
|
430
453
|
const routes = createConsoleRoutes({
|
|
431
454
|
db,
|
|
@@ -443,6 +466,18 @@ describe('console timeline route filters', () => {
|
|
|
443
466
|
return nextApp;
|
|
444
467
|
}
|
|
445
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
|
+
|
|
446
481
|
beforeEach(async () => {
|
|
447
482
|
// Keep fixture events within the current metrics windows (for example 24h).
|
|
448
483
|
baseTimeMs =
|
|
@@ -450,7 +485,10 @@ describe('console timeline route filters', () => {
|
|
|
450
485
|
60 * 60 * 1000;
|
|
451
486
|
|
|
452
487
|
dialect = createPostgresServerDialect();
|
|
453
|
-
db =
|
|
488
|
+
db = createDatabase<TestDb>({
|
|
489
|
+
dialect: createPgliteDialect(),
|
|
490
|
+
family: 'postgres',
|
|
491
|
+
});
|
|
454
492
|
await ensureSyncSchema(db, dialect);
|
|
455
493
|
await dialect.ensureConsoleSchema?.(db);
|
|
456
494
|
|
|
@@ -940,7 +978,9 @@ describe('console timeline route filters', () => {
|
|
|
940
978
|
expect(clientsPayload.total).toBe(1);
|
|
941
979
|
expect(clientsPayload.items[0]?.actorId).toBe('actor-z');
|
|
942
980
|
|
|
943
|
-
const eventsResponse = await requestEvents({
|
|
981
|
+
const eventsResponse = await requestEvents({
|
|
982
|
+
query: { partitionId: 'tenant-b' },
|
|
983
|
+
});
|
|
944
984
|
expect(eventsResponse.status).toBe(200);
|
|
945
985
|
const eventsPayload = (await eventsResponse.json()) as {
|
|
946
986
|
items: Array<{ partitionId: string }>;
|
|
@@ -1469,4 +1509,118 @@ describe('console timeline route filters', () => {
|
|
|
1469
1509
|
expect(payload.requestPayload.clientCommitId).toBe('commit-a');
|
|
1470
1510
|
expect(payload.responsePayload.status).toBe('applied');
|
|
1471
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
|
+
});
|
|
1472
1626
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
CONSOLE_BASEPATH_META,
|
|
7
|
+
CONSOLE_SERVER_URL_META,
|
|
8
|
+
CONSOLE_TOKEN_META,
|
|
9
|
+
} from '@syncular/console/runtime-config';
|
|
10
|
+
import { Hono } from 'hono';
|
|
11
|
+
import { mountConsoleUi } from '../console/ui';
|
|
12
|
+
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
async function createStaticFixture(): Promise<string> {
|
|
16
|
+
const staticDir = await mkdtemp(path.join(tmpdir(), 'syncular-console-ui-'));
|
|
17
|
+
tempDirs.push(staticDir);
|
|
18
|
+
|
|
19
|
+
await mkdir(path.join(staticDir, 'assets'), { recursive: true });
|
|
20
|
+
await writeFile(
|
|
21
|
+
path.join(staticDir, 'index.html'),
|
|
22
|
+
`<!doctype html>
|
|
23
|
+
<html>
|
|
24
|
+
<head>
|
|
25
|
+
<meta name="${CONSOLE_BASEPATH_META}" content="" />
|
|
26
|
+
<meta name="${CONSOLE_SERVER_URL_META}" content="" />
|
|
27
|
+
<meta name="${CONSOLE_TOKEN_META}" content="" />
|
|
28
|
+
<link rel="stylesheet" href="/assets/console.css" />
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<script type="module" src="/assets/main.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>`
|
|
34
|
+
);
|
|
35
|
+
await writeFile(
|
|
36
|
+
path.join(staticDir, 'assets', 'main.js'),
|
|
37
|
+
'console.log(1);\n'
|
|
38
|
+
);
|
|
39
|
+
await writeFile(path.join(staticDir, 'assets', 'console.css'), 'body{}\n');
|
|
40
|
+
|
|
41
|
+
return staticDir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(
|
|
46
|
+
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('mountConsoleUi', () => {
|
|
51
|
+
it('serves console UI with default prefill derived from mount/api paths', async () => {
|
|
52
|
+
const staticDir = await createStaticFixture();
|
|
53
|
+
const app = new Hono();
|
|
54
|
+
|
|
55
|
+
mountConsoleUi(app, {
|
|
56
|
+
mountPath: 'console',
|
|
57
|
+
apiBasePath: 'api',
|
|
58
|
+
staticDir,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const response = await app.request('http://example.test/console');
|
|
62
|
+
const html = await response.text();
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
expect(html).toContain(
|
|
66
|
+
`<meta name="${CONSOLE_BASEPATH_META}" content="/console" />`
|
|
67
|
+
);
|
|
68
|
+
expect(html).toContain(
|
|
69
|
+
`<meta name="${CONSOLE_SERVER_URL_META}" content="http://example.test/api" />`
|
|
70
|
+
);
|
|
71
|
+
expect(html).toContain(`<meta name="${CONSOLE_TOKEN_META}" content="" />`);
|
|
72
|
+
expect(html).toContain('href="/console/assets/console.css"');
|
|
73
|
+
expect(html).toContain('src="/console/assets/main.js"');
|
|
74
|
+
|
|
75
|
+
const asset = await app.request(
|
|
76
|
+
'http://example.test/console/assets/main.js'
|
|
77
|
+
);
|
|
78
|
+
expect(asset.status).toBe(200);
|
|
79
|
+
expect(await asset.text()).toContain('console.log(1);');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('merges resolvePrefill and allows resolveToken to override token', async () => {
|
|
83
|
+
const staticDir = await createStaticFixture();
|
|
84
|
+
const app = new Hono();
|
|
85
|
+
|
|
86
|
+
mountConsoleUi(app, {
|
|
87
|
+
mountPath: '/ops-console',
|
|
88
|
+
apiBasePath: '/api/internal',
|
|
89
|
+
staticDir,
|
|
90
|
+
resolvePrefill: async () => ({
|
|
91
|
+
basePath: '/prefilled',
|
|
92
|
+
serverUrl: 'https://prefill.example/api',
|
|
93
|
+
token: 'prefill-token',
|
|
94
|
+
}),
|
|
95
|
+
resolveToken: async () => 'resolver-token',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const response = await app.request('http://localhost/ops-console');
|
|
99
|
+
const html = await response.text();
|
|
100
|
+
|
|
101
|
+
expect(response.status).toBe(200);
|
|
102
|
+
expect(html).toContain(
|
|
103
|
+
`<meta name="${CONSOLE_BASEPATH_META}" content="/prefilled" />`
|
|
104
|
+
);
|
|
105
|
+
expect(html).toContain(
|
|
106
|
+
`<meta name="${CONSOLE_SERVER_URL_META}" content="https://prefill.example/api" />`
|
|
107
|
+
);
|
|
108
|
+
expect(html).toContain(
|
|
109
|
+
`<meta name="${CONSOLE_TOKEN_META}" content="resolver-token" />`
|
|
110
|
+
);
|
|
111
|
+
expect(html).toContain('href="/prefilled/assets/console.css"');
|
|
112
|
+
expect(html).toContain('src="/prefilled/assets/main.js"');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { createDatabase } from '@syncular/core';
|
|
3
|
+
import { createPgliteDialect } from '@syncular/dialect-pglite';
|
|
3
4
|
import {
|
|
4
5
|
createServerHandler,
|
|
5
6
|
ensureSyncSchema,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
9
10
|
import { Hono } from 'hono';
|
|
10
11
|
import { defineWebSocketHelper } from 'hono/ws';
|
|
11
|
-
import type
|
|
12
|
+
import { type Kysely, sql } from 'kysely';
|
|
12
13
|
import { createSyncServer } from '../create-server';
|
|
13
14
|
import { getSyncWebSocketConnectionManager } from '../routes';
|
|
14
15
|
import type { WebSocketConnection } from '../ws';
|
|
@@ -33,7 +34,10 @@ describe('createSyncServer console configuration', () => {
|
|
|
33
34
|
let previousConsoleToken: string | undefined;
|
|
34
35
|
|
|
35
36
|
beforeEach(async () => {
|
|
36
|
-
db =
|
|
37
|
+
db = createDatabase<ServerDb>({
|
|
38
|
+
dialect: createPgliteDialect(),
|
|
39
|
+
family: 'postgres',
|
|
40
|
+
});
|
|
37
41
|
await ensureSyncSchema(db, createPostgresServerDialect());
|
|
38
42
|
await db.schema
|
|
39
43
|
.createTable('tasks')
|
|
@@ -70,8 +74,10 @@ describe('createSyncServer console configuration', () => {
|
|
|
70
74
|
return {
|
|
71
75
|
db,
|
|
72
76
|
dialect: createPostgresServerDialect(),
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
sync: {
|
|
78
|
+
handlers: [createTestHandler()],
|
|
79
|
+
authenticate: async () => ({ actorId: 'u1' }),
|
|
80
|
+
},
|
|
75
81
|
};
|
|
76
82
|
}
|
|
77
83
|
|
|
@@ -94,10 +100,16 @@ describe('createSyncServer console configuration', () => {
|
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
function createPushRequest(
|
|
103
|
+
function createPushRequest(args?: {
|
|
104
|
+
requestId?: string;
|
|
105
|
+
title?: string;
|
|
106
|
+
}): Request {
|
|
98
107
|
return new Request('http://localhost/sync', {
|
|
99
108
|
method: 'POST',
|
|
100
|
-
headers: {
|
|
109
|
+
headers: {
|
|
110
|
+
'content-type': 'application/json',
|
|
111
|
+
...(args?.requestId ? { 'x-request-id': args.requestId } : {}),
|
|
112
|
+
},
|
|
101
113
|
body: JSON.stringify({
|
|
102
114
|
clientId: 'client-1',
|
|
103
115
|
push: {
|
|
@@ -111,7 +123,7 @@ describe('createSyncServer console configuration', () => {
|
|
|
111
123
|
payload: {
|
|
112
124
|
id: 'task-1',
|
|
113
125
|
user_id: 'u1',
|
|
114
|
-
title: 'Task 1',
|
|
126
|
+
title: args?.title ?? 'Task 1',
|
|
115
127
|
server_version: 0,
|
|
116
128
|
},
|
|
117
129
|
},
|
|
@@ -121,6 +133,62 @@ describe('createSyncServer console configuration', () => {
|
|
|
121
133
|
});
|
|
122
134
|
}
|
|
123
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
|
+
|
|
124
192
|
it('keeps console routes disabled when console config is omitted', () => {
|
|
125
193
|
const server = createSyncServer(createOptions());
|
|
126
194
|
expect(server.consoleRoutes).toBeUndefined();
|
|
@@ -158,6 +226,80 @@ describe('createSyncServer console configuration', () => {
|
|
|
158
226
|
expect(server.consoleRoutes).toBeDefined();
|
|
159
227
|
});
|
|
160
228
|
|
|
229
|
+
it('returns not implemented when blobBucket is not configured', async () => {
|
|
230
|
+
const options = createOptions();
|
|
231
|
+
const server = createSyncServer({
|
|
232
|
+
...options,
|
|
233
|
+
console: {
|
|
234
|
+
token: 'console-token',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const app = new Hono();
|
|
239
|
+
app.route('/console', server.consoleRoutes!);
|
|
240
|
+
|
|
241
|
+
const response = await app.request('http://localhost/console/storage', {
|
|
242
|
+
headers: { Authorization: 'Bearer console-token' },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(response.status).toBe(501);
|
|
246
|
+
expect(await response.json()).toEqual({
|
|
247
|
+
error: 'BLOB_STORAGE_NOT_CONFIGURED',
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('enables storage console routes when blobBucket is configured', async () => {
|
|
252
|
+
const options = createOptions();
|
|
253
|
+
const server = createSyncServer({
|
|
254
|
+
...options,
|
|
255
|
+
console: {
|
|
256
|
+
token: 'blob-token',
|
|
257
|
+
blobBucket: {
|
|
258
|
+
list: async () => ({
|
|
259
|
+
objects: [
|
|
260
|
+
{
|
|
261
|
+
key: 'hello.txt',
|
|
262
|
+
size: 12,
|
|
263
|
+
uploaded: new Date('2025-01-01T00:00:00.000Z'),
|
|
264
|
+
httpMetadata: { contentType: 'text/plain' },
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
truncated: false,
|
|
268
|
+
cursor: undefined,
|
|
269
|
+
}),
|
|
270
|
+
get: async () => null,
|
|
271
|
+
delete: async () => {},
|
|
272
|
+
head: async () => null,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const app = new Hono();
|
|
278
|
+
app.route('/console', server.consoleRoutes!);
|
|
279
|
+
|
|
280
|
+
const unauthenticated = await app.request(
|
|
281
|
+
'http://localhost/console/storage'
|
|
282
|
+
);
|
|
283
|
+
expect(unauthenticated.status).toBe(401);
|
|
284
|
+
|
|
285
|
+
const response = await app.request('http://localhost/console/storage', {
|
|
286
|
+
headers: { Authorization: 'Bearer blob-token' },
|
|
287
|
+
});
|
|
288
|
+
expect(response.status).toBe(200);
|
|
289
|
+
expect(await response.json()).toEqual({
|
|
290
|
+
items: [
|
|
291
|
+
{
|
|
292
|
+
key: 'hello.txt',
|
|
293
|
+
size: 12,
|
|
294
|
+
uploaded: '2025-01-01T00:00:00.000Z',
|
|
295
|
+
httpMetadata: { contentType: 'text/plain' },
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
truncated: false,
|
|
299
|
+
cursor: null,
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
161
303
|
it('forwards maxConnectionsPerClient from factory to realtime route', async () => {
|
|
162
304
|
const options = createOptions();
|
|
163
305
|
const upgradeWebSocket = defineWebSocketHelper(async () => {});
|
|
@@ -165,7 +307,7 @@ describe('createSyncServer console configuration', () => {
|
|
|
165
307
|
const server = createSyncServer({
|
|
166
308
|
...options,
|
|
167
309
|
upgradeWebSocket,
|
|
168
|
-
|
|
310
|
+
routes: {
|
|
169
311
|
websocket: {
|
|
170
312
|
maxConnectionsPerClient: 1,
|
|
171
313
|
},
|
|
@@ -190,6 +332,87 @@ describe('createSyncServer console configuration', () => {
|
|
|
190
332
|
});
|
|
191
333
|
});
|
|
192
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
|
+
|
|
193
416
|
it('forwards maxConnectionsTotal from factory to realtime route', async () => {
|
|
194
417
|
const options = createOptions();
|
|
195
418
|
const upgradeWebSocket = defineWebSocketHelper(async () => {});
|
|
@@ -197,7 +420,7 @@ describe('createSyncServer console configuration', () => {
|
|
|
197
420
|
const server = createSyncServer({
|
|
198
421
|
...options,
|
|
199
422
|
upgradeWebSocket,
|
|
200
|
-
|
|
423
|
+
routes: {
|
|
201
424
|
websocket: {
|
|
202
425
|
maxConnectionsTotal: 1,
|
|
203
426
|
},
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
|
2
2
|
import { gunzipSync } from 'node:zlib';
|
|
3
3
|
import {
|
|
4
4
|
configureSyncTelemetry,
|
|
5
|
+
createDatabase,
|
|
5
6
|
decodeSnapshotRows,
|
|
6
7
|
getSyncTelemetry,
|
|
7
8
|
SyncCombinedResponseSchema,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
} from '@syncular/server';
|
|
19
20
|
import { Hono } from 'hono';
|
|
20
21
|
import type { Kysely } from 'kysely';
|
|
21
|
-
import {
|
|
22
|
+
import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
|
|
22
23
|
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
23
24
|
import { createSyncRoutes } from '../routes';
|
|
24
25
|
|
|
@@ -106,7 +107,10 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
106
107
|
const dialect = createSqliteServerDialect();
|
|
107
108
|
|
|
108
109
|
beforeEach(async () => {
|
|
109
|
-
db =
|
|
110
|
+
db = createDatabase<ServerDb>({
|
|
111
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
112
|
+
family: 'sqlite',
|
|
113
|
+
});
|
|
110
114
|
await ensureSyncSchema(db, dialect);
|
|
111
115
|
|
|
112
116
|
await db.schema
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { createDatabase } from '@syncular/core';
|
|
3
|
+
import { createPgliteDialect } from '@syncular/dialect-pglite';
|
|
3
4
|
import {
|
|
4
5
|
ensureSyncSchema,
|
|
5
6
|
InMemorySyncRealtimeBroadcaster,
|
|
@@ -15,7 +16,10 @@ import {
|
|
|
15
16
|
|
|
16
17
|
describe('realtime broadcaster bridge', () => {
|
|
17
18
|
it('notifies local WebSocket connections when another instance publishes a commit', async () => {
|
|
18
|
-
const db =
|
|
19
|
+
const db = createDatabase<SyncCoreDb>({
|
|
20
|
+
dialect: createPgliteDialect(),
|
|
21
|
+
family: 'postgres',
|
|
22
|
+
});
|
|
19
23
|
const dialect = createPostgresServerDialect();
|
|
20
24
|
await ensureSyncSchema(db, dialect);
|
|
21
25
|
|