@syncular/server-hono 0.0.6-96 → 0.2.1

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 (104) hide show
  1. package/README.md +5 -23
  2. package/dist/admin-page.d.ts +14 -0
  3. package/dist/admin-page.js +217 -0
  4. package/dist/admin.d.ts +39 -0
  5. package/dist/admin.js +142 -0
  6. package/dist/index.d.ts +16 -14
  7. package/dist/index.js +134 -23
  8. package/package.json +19 -53
  9. package/src/admin-page.ts +217 -0
  10. package/src/admin.ts +193 -0
  11. package/src/index.ts +175 -31
  12. package/dist/api-key-auth.d.ts +0 -49
  13. package/dist/api-key-auth.d.ts.map +0 -1
  14. package/dist/api-key-auth.js +0 -110
  15. package/dist/api-key-auth.js.map +0 -1
  16. package/dist/blobs.d.ts +0 -69
  17. package/dist/blobs.d.ts.map +0 -1
  18. package/dist/blobs.js +0 -383
  19. package/dist/blobs.js.map +0 -1
  20. package/dist/console/gateway.d.ts +0 -33
  21. package/dist/console/gateway.d.ts.map +0 -1
  22. package/dist/console/gateway.js +0 -2534
  23. package/dist/console/gateway.js.map +0 -1
  24. package/dist/console/index.d.ts +0 -11
  25. package/dist/console/index.d.ts.map +0 -1
  26. package/dist/console/index.js +0 -11
  27. package/dist/console/index.js.map +0 -1
  28. package/dist/console/routes.d.ts +0 -33
  29. package/dist/console/routes.d.ts.map +0 -1
  30. package/dist/console/routes.js +0 -3029
  31. package/dist/console/routes.js.map +0 -1
  32. package/dist/console/schemas.d.ts +0 -490
  33. package/dist/console/schemas.d.ts.map +0 -1
  34. package/dist/console/schemas.js +0 -303
  35. package/dist/console/schemas.js.map +0 -1
  36. package/dist/console/types.d.ts +0 -175
  37. package/dist/console/types.d.ts.map +0 -1
  38. package/dist/console/types.js +0 -2
  39. package/dist/console/types.js.map +0 -1
  40. package/dist/console/ui.d.ts +0 -38
  41. package/dist/console/ui.d.ts.map +0 -1
  42. package/dist/console/ui.js +0 -43
  43. package/dist/console/ui.js.map +0 -1
  44. package/dist/create-server.d.ts +0 -68
  45. package/dist/create-server.d.ts.map +0 -1
  46. package/dist/create-server.js +0 -110
  47. package/dist/create-server.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
  50. package/dist/openapi.d.ts +0 -45
  51. package/dist/openapi.d.ts.map +0 -1
  52. package/dist/openapi.js +0 -59
  53. package/dist/openapi.js.map +0 -1
  54. package/dist/proxy/connection-manager.d.ts +0 -78
  55. package/dist/proxy/connection-manager.d.ts.map +0 -1
  56. package/dist/proxy/connection-manager.js +0 -251
  57. package/dist/proxy/connection-manager.js.map +0 -1
  58. package/dist/proxy/index.d.ts +0 -8
  59. package/dist/proxy/index.d.ts.map +0 -1
  60. package/dist/proxy/index.js +0 -8
  61. package/dist/proxy/index.js.map +0 -1
  62. package/dist/proxy/routes.d.ts +0 -74
  63. package/dist/proxy/routes.d.ts.map +0 -1
  64. package/dist/proxy/routes.js +0 -147
  65. package/dist/proxy/routes.js.map +0 -1
  66. package/dist/rate-limit.d.ts +0 -101
  67. package/dist/rate-limit.d.ts.map +0 -1
  68. package/dist/rate-limit.js +0 -186
  69. package/dist/rate-limit.js.map +0 -1
  70. package/dist/routes.d.ts +0 -161
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1493
  73. package/dist/routes.js.map +0 -1
  74. package/dist/ws.d.ts +0 -235
  75. package/dist/ws.d.ts.map +0 -1
  76. package/dist/ws.js +0 -614
  77. package/dist/ws.js.map +0 -1
  78. package/src/__tests__/blob-routes.test.ts +0 -424
  79. package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
  80. package/src/__tests__/console-gateway-routes.test.ts +0 -1364
  81. package/src/__tests__/console-routes.test.ts +0 -1626
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -485
  84. package/src/__tests__/pull-chunk-storage.test.ts +0 -576
  85. package/src/__tests__/rate-limit.test.ts +0 -78
  86. package/src/__tests__/realtime-bridge.test.ts +0 -135
  87. package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
  88. package/src/__tests__/ws-connection-manager.test.ts +0 -176
  89. package/src/api-key-auth.ts +0 -179
  90. package/src/blobs.ts +0 -534
  91. package/src/console/gateway.ts +0 -3603
  92. package/src/console/index.ts +0 -11
  93. package/src/console/routes.ts +0 -3936
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -185
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -207
  98. package/src/openapi.ts +0 -74
  99. package/src/proxy/connection-manager.ts +0 -340
  100. package/src/proxy/index.ts +0 -8
  101. package/src/proxy/routes.ts +0 -223
  102. package/src/rate-limit.ts +0 -321
  103. package/src/routes.ts +0 -2059
  104. package/src/ws.ts +0 -802
@@ -1,1626 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import { createDatabase } from '@syncular/core';
3
- import { createPgliteDialect } from '@syncular/dialect-pglite';
4
- import { ensureSyncSchema, type SyncCoreDb } from '@syncular/server';
5
- import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
6
- import { Hono } from 'hono';
7
- import type { Generated, Kysely } from 'kysely';
8
- import {
9
- type CreateConsoleRoutesOptions,
10
- createConsoleRoutes,
11
- } from '../console';
12
-
13
- interface SyncRequestEventsTable {
14
- event_id: Generated<number>;
15
- partition_id: string;
16
- request_id: string | null;
17
- trace_id: string | null;
18
- span_id: string | null;
19
- event_type: 'push' | 'pull';
20
- sync_path: 'http-combined' | 'ws-push';
21
- actor_id: string;
22
- client_id: string;
23
- transport_path: 'direct' | 'relay';
24
- status_code: number;
25
- outcome: string;
26
- response_status: string;
27
- error_code: string | null;
28
- duration_ms: number;
29
- commit_seq: number | null;
30
- operation_count: number | null;
31
- row_count: number | null;
32
- subscription_count: number | null;
33
- scopes_summary: unknown | null;
34
- tables: string[];
35
- error_message: string | null;
36
- payload_ref: string | null;
37
- created_at: Generated<string>;
38
- }
39
-
40
- interface SyncRequestPayloadsTable {
41
- payload_ref: string;
42
- partition_id: string;
43
- request_payload: unknown;
44
- response_payload: unknown | null;
45
- created_at: Generated<string>;
46
- }
47
-
48
- interface SyncOperationEventsTable {
49
- operation_id: Generated<number>;
50
- operation_type: string;
51
- console_user_id: string | null;
52
- partition_id: string | null;
53
- target_client_id: string | null;
54
- request_payload: unknown | null;
55
- result_payload: unknown | null;
56
- created_at: Generated<string>;
57
- }
58
-
59
- interface SyncApiKeysTable {
60
- key_id: string;
61
- key_hash: string;
62
- key_prefix: string;
63
- name: string;
64
- key_type: 'relay' | 'proxy' | 'admin';
65
- scope_keys: unknown | null;
66
- actor_id: string | null;
67
- created_at: Generated<string>;
68
- expires_at: string | null;
69
- last_used_at: string | null;
70
- revoked_at: string | null;
71
- }
72
-
73
- interface TestDb extends SyncCoreDb {
74
- sync_request_events: SyncRequestEventsTable;
75
- sync_request_payloads: SyncRequestPayloadsTable;
76
- sync_operation_events: SyncOperationEventsTable;
77
- sync_api_keys: SyncApiKeysTable;
78
- }
79
-
80
- type TimelineResponse = {
81
- items: Array<{
82
- type: 'commit' | 'event';
83
- timestamp: string;
84
- commit: {
85
- commitSeq: number;
86
- actorId: string;
87
- clientId: string;
88
- affectedTables: string[];
89
- } | null;
90
- event: {
91
- eventId: number;
92
- actorId: string;
93
- clientId: string;
94
- eventType: 'push' | 'pull';
95
- outcome: string;
96
- tables: string[];
97
- } | null;
98
- }>;
99
- total: number;
100
- offset: number;
101
- limit: number;
102
- };
103
-
104
- type OperationsResponse = {
105
- items: Array<{
106
- operationId: number;
107
- operationType: 'prune' | 'compact' | 'notify_data_change' | 'evict_client';
108
- consoleUserId: string | null;
109
- partitionId: string | null;
110
- targetClientId: string | null;
111
- requestPayload: unknown;
112
- resultPayload: unknown;
113
- createdAt: string;
114
- }>;
115
- total: number;
116
- offset: number;
117
- limit: number;
118
- };
119
-
120
- type ApiKeysResponse = {
121
- items: Array<{
122
- keyId: string;
123
- keyPrefix: string;
124
- name: string;
125
- keyType: 'relay' | 'proxy' | 'admin';
126
- scopeKeys: string[];
127
- actorId: string | null;
128
- createdAt: string;
129
- expiresAt: string | null;
130
- lastUsedAt: string | null;
131
- revokedAt: string | null;
132
- }>;
133
- total: number;
134
- offset: number;
135
- limit: number;
136
- };
137
-
138
- function timelineItemKey(item: TimelineResponse['items'][number]): string {
139
- if (item.type === 'commit') {
140
- return `C${item.commit?.commitSeq ?? 'unknown'}`;
141
- }
142
- return `E${item.event?.eventId ?? 'unknown'}`;
143
- }
144
-
145
- const CONSOLE_TOKEN = 'console-test-token';
146
-
147
- describe('console timeline route filters', () => {
148
- let db: Kysely<TestDb>;
149
- let dialect: ReturnType<typeof createPostgresServerDialect>;
150
- let app: Hono;
151
- let baseTimeMs: number;
152
-
153
- function atIso(minutes: number): string {
154
- return new Date(baseTimeMs + minutes * 60_000).toISOString();
155
- }
156
-
157
- async function requestTimeline(args: {
158
- query?: Record<string, string | number | undefined>;
159
- authenticated?: boolean;
160
- }): Promise<Response> {
161
- const params = new URLSearchParams({ limit: '50', offset: '0' });
162
- for (const [key, value] of Object.entries(args.query ?? {})) {
163
- if (value === undefined) continue;
164
- params.set(key, String(value));
165
- }
166
-
167
- return app.request(
168
- `http://localhost/console/timeline?${params.toString()}`,
169
- {
170
- headers:
171
- args.authenticated === false
172
- ? undefined
173
- : { Authorization: `Bearer ${CONSOLE_TOKEN}` },
174
- }
175
- );
176
- }
177
-
178
- async function readTimeline(
179
- query: Record<string, string | number | undefined> = {}
180
- ): Promise<TimelineResponse> {
181
- const response = await requestTimeline({ query });
182
- expect(response.status).toBe(200);
183
- return (await response.json()) as TimelineResponse;
184
- }
185
-
186
- async function requestEventPayload(
187
- eventId: number,
188
- query: Record<string, string | number | undefined> = {}
189
- ): Promise<Response> {
190
- const params = new URLSearchParams();
191
- for (const [key, value] of Object.entries(query)) {
192
- if (value === undefined) continue;
193
- params.set(key, String(value));
194
- }
195
- const queryString = params.toString();
196
-
197
- return app.request(
198
- `http://localhost/console/events/${eventId}/payload${queryString ? `?${queryString}` : ''}`,
199
- {
200
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
201
- }
202
- );
203
- }
204
-
205
- async function requestNotifyDataChange(body: {
206
- tables: string[];
207
- partitionId?: string;
208
- }): Promise<Response> {
209
- return app.request('http://localhost/console/notify-data-change', {
210
- method: 'POST',
211
- headers: {
212
- Authorization: `Bearer ${CONSOLE_TOKEN}`,
213
- 'Content-Type': 'application/json',
214
- },
215
- body: JSON.stringify(body),
216
- });
217
- }
218
-
219
- async function readOperations(
220
- query: Record<string, string | number | undefined> = {}
221
- ): Promise<OperationsResponse> {
222
- const params = new URLSearchParams({ limit: '50', offset: '0' });
223
- for (const [key, value] of Object.entries(query)) {
224
- if (value === undefined) continue;
225
- params.set(key, String(value));
226
- }
227
-
228
- const response = await app.request(
229
- `http://localhost/console/operations?${params.toString()}`,
230
- {
231
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
232
- }
233
- );
234
- expect(response.status).toBe(200);
235
- return (await response.json()) as OperationsResponse;
236
- }
237
-
238
- async function requestCommits(
239
- query: Record<string, string | number | undefined> = {}
240
- ): Promise<Response> {
241
- const params = new URLSearchParams({ limit: '50', offset: '0' });
242
- for (const [key, value] of Object.entries(query)) {
243
- if (value === undefined) continue;
244
- params.set(key, String(value));
245
- }
246
-
247
- return app.request(
248
- `http://localhost/console/commits?${params.toString()}`,
249
- {
250
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
251
- }
252
- );
253
- }
254
-
255
- async function requestClients(
256
- query: Record<string, string | number | undefined> = {}
257
- ): Promise<Response> {
258
- const params = new URLSearchParams({ limit: '50', offset: '0' });
259
- for (const [key, value] of Object.entries(query)) {
260
- if (value === undefined) continue;
261
- params.set(key, String(value));
262
- }
263
-
264
- return app.request(
265
- `http://localhost/console/clients?${params.toString()}`,
266
- {
267
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
268
- }
269
- );
270
- }
271
-
272
- async function requestEvents(
273
- args: {
274
- query?: Record<string, string | number | undefined>;
275
- targetApp?: Hono;
276
- } = {}
277
- ): Promise<Response> {
278
- const params = new URLSearchParams({ limit: '50', offset: '0' });
279
- for (const [key, value] of Object.entries(args.query ?? {})) {
280
- if (value === undefined) continue;
281
- params.set(key, String(value));
282
- }
283
-
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',
302
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
303
- });
304
- }
305
-
306
- async function requestApiKeys(
307
- query: Record<string, string | number | undefined> = {}
308
- ): Promise<Response> {
309
- const params = new URLSearchParams({ limit: '50', offset: '0' });
310
- for (const [key, value] of Object.entries(query)) {
311
- if (value === undefined) continue;
312
- params.set(key, String(value));
313
- }
314
-
315
- return app.request(
316
- `http://localhost/console/api-keys?${params.toString()}`,
317
- {
318
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
319
- }
320
- );
321
- }
322
-
323
- async function readApiKeys(
324
- query: Record<string, string | number | undefined> = {}
325
- ): Promise<ApiKeysResponse> {
326
- const response = await requestApiKeys(query);
327
- expect(response.status).toBe(200);
328
- return (await response.json()) as ApiKeysResponse;
329
- }
330
-
331
- async function requestBulkRevokeApiKeys(keyIds: string[]): Promise<Response> {
332
- return app.request('http://localhost/console/api-keys/bulk-revoke', {
333
- method: 'POST',
334
- headers: {
335
- Authorization: `Bearer ${CONSOLE_TOKEN}`,
336
- 'Content-Type': 'application/json',
337
- },
338
- body: JSON.stringify({ keyIds }),
339
- });
340
- }
341
-
342
- async function requestStageRotateApiKey(keyId: string): Promise<Response> {
343
- return app.request(
344
- `http://localhost/console/api-keys/${keyId}/rotate/stage`,
345
- {
346
- method: 'POST',
347
- headers: {
348
- Authorization: `Bearer ${CONSOLE_TOKEN}`,
349
- },
350
- }
351
- );
352
- }
353
-
354
- async function requestCommitDetail(
355
- seq: number,
356
- query: Record<string, string | number | undefined> = {}
357
- ): Promise<Response> {
358
- const params = new URLSearchParams();
359
- for (const [key, value] of Object.entries(query)) {
360
- if (value === undefined) continue;
361
- params.set(key, String(value));
362
- }
363
- const queryString = params.toString();
364
-
365
- return app.request(
366
- `http://localhost/console/commits/${seq}${queryString ? `?${queryString}` : ''}`,
367
- {
368
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
369
- }
370
- );
371
- }
372
-
373
- async function requestEventDetail(
374
- eventId: number,
375
- query: Record<string, string | number | undefined> = {}
376
- ): Promise<Response> {
377
- const params = new URLSearchParams();
378
- for (const [key, value] of Object.entries(query)) {
379
- if (value === undefined) continue;
380
- params.set(key, String(value));
381
- }
382
- const queryString = params.toString();
383
-
384
- return app.request(
385
- `http://localhost/console/events/${eventId}${queryString ? `?${queryString}` : ''}`,
386
- {
387
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
388
- }
389
- );
390
- }
391
-
392
- async function requestEvictClient(
393
- clientId: string,
394
- query: Record<string, string | number | undefined> = {}
395
- ): Promise<Response> {
396
- const params = new URLSearchParams();
397
- for (const [key, value] of Object.entries(query)) {
398
- if (value === undefined) continue;
399
- params.set(key, String(value));
400
- }
401
- const queryString = params.toString();
402
-
403
- return app.request(
404
- `http://localhost/console/clients/${clientId}${queryString ? `?${queryString}` : ''}`,
405
- {
406
- method: 'DELETE',
407
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
408
- }
409
- );
410
- }
411
-
412
- async function requestTimeseriesStats(args: {
413
- query?: Record<string, string | number | undefined>;
414
- targetApp?: Hono;
415
- }): Promise<Response> {
416
- const params = new URLSearchParams({ interval: 'hour', range: '24h' });
417
- for (const [key, value] of Object.entries(args.query ?? {})) {
418
- if (value === undefined) continue;
419
- params.set(key, String(value));
420
- }
421
-
422
- return (args.targetApp ?? app).request(
423
- `http://localhost/console/stats/timeseries?${params.toString()}`,
424
- {
425
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
426
- }
427
- );
428
- }
429
-
430
- async function requestLatencyStats(args: {
431
- query?: Record<string, string | number | undefined>;
432
- targetApp?: Hono;
433
- }): Promise<Response> {
434
- const params = new URLSearchParams({ range: '24h' });
435
- for (const [key, value] of Object.entries(args.query ?? {})) {
436
- if (value === undefined) continue;
437
- params.set(key, String(value));
438
- }
439
-
440
- return (args.targetApp ?? app).request(
441
- `http://localhost/console/stats/latency?${params.toString()}`,
442
- {
443
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
444
- }
445
- );
446
- }
447
-
448
- function createTestApp(
449
- overrides: Partial<
450
- Pick<CreateConsoleRoutesOptions<TestDb>, 'metrics' | 'maintenance'>
451
- > = {}
452
- ): Hono {
453
- const routes = createConsoleRoutes({
454
- db,
455
- dialect,
456
- handlers: [],
457
- authenticate: async (c) =>
458
- c.req.header('Authorization') === `Bearer ${CONSOLE_TOKEN}`
459
- ? { consoleUserId: 'console-test' }
460
- : null,
461
- corsOrigins: '*',
462
- ...overrides,
463
- });
464
- const nextApp = new Hono();
465
- nextApp.route('/console', routes);
466
- return nextApp;
467
- }
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
-
481
- beforeEach(async () => {
482
- // Keep fixture events within the current metrics windows (for example 24h).
483
- baseTimeMs =
484
- Math.floor(Date.now() / (60 * 60 * 1000)) * (60 * 60 * 1000) -
485
- 60 * 60 * 1000;
486
-
487
- dialect = createPostgresServerDialect();
488
- db = createDatabase<TestDb>({
489
- dialect: createPgliteDialect(),
490
- family: 'postgres',
491
- });
492
- await ensureSyncSchema(db, dialect);
493
- await dialect.ensureConsoleSchema?.(db);
494
-
495
- await db
496
- .insertInto('sync_commits')
497
- .values([
498
- {
499
- partition_id: 'default',
500
- actor_id: 'actor-a',
501
- client_id: 'client-a',
502
- client_commit_id: 'commit-a',
503
- created_at: atIso(10),
504
- meta: null,
505
- result_json: null,
506
- change_count: 2,
507
- affected_tables: ['tasks'],
508
- },
509
- {
510
- partition_id: 'default',
511
- actor_id: 'actor-b',
512
- client_id: 'client-b',
513
- client_commit_id: 'commit-b',
514
- created_at: atIso(20),
515
- meta: null,
516
- result_json: null,
517
- change_count: 1,
518
- affected_tables: ['notes'],
519
- },
520
- ])
521
- .execute();
522
-
523
- const commitRows = await db
524
- .selectFrom('sync_commits')
525
- .select(['commit_seq', 'client_commit_id'])
526
- .execute();
527
-
528
- const commitSeqByClientCommitId = new Map(
529
- commitRows.map((row) => [row.client_commit_id, Number(row.commit_seq)])
530
- );
531
-
532
- await db
533
- .insertInto('sync_request_events')
534
- .values([
535
- {
536
- partition_id: 'default',
537
- request_id: 'req-1',
538
- trace_id: 'trace-1',
539
- span_id: 'span-1',
540
- event_type: 'push',
541
- sync_path: 'http-combined',
542
- actor_id: 'actor-a',
543
- client_id: 'client-a',
544
- transport_path: 'direct',
545
- status_code: 200,
546
- outcome: 'applied',
547
- response_status: 'success',
548
- error_code: null,
549
- duration_ms: 18,
550
- commit_seq: commitSeqByClientCommitId.get('commit-a') ?? null,
551
- operation_count: 2,
552
- row_count: 2,
553
- subscription_count: null,
554
- scopes_summary: null,
555
- tables: ['tasks'],
556
- error_message: null,
557
- payload_ref: 'payload-1',
558
- created_at: atIso(30),
559
- },
560
- {
561
- partition_id: 'default',
562
- request_id: 'req-2',
563
- trace_id: 'trace-2',
564
- span_id: 'span-2',
565
- event_type: 'pull',
566
- sync_path: 'http-combined',
567
- actor_id: 'actor-c',
568
- client_id: 'client-c',
569
- transport_path: 'direct',
570
- status_code: 500,
571
- outcome: 'error',
572
- response_status: 'server_error',
573
- error_code: 'INTERNAL_SERVER_ERROR',
574
- duration_ms: 44,
575
- commit_seq: null,
576
- operation_count: null,
577
- row_count: null,
578
- subscription_count: 2,
579
- scopes_summary: JSON.stringify({ org_id: 'org-1' }),
580
- tables: ['notes'],
581
- error_message: 'pull failed',
582
- payload_ref: null,
583
- created_at: atIso(40),
584
- },
585
- {
586
- partition_id: 'default',
587
- request_id: 'req-3',
588
- trace_id: null,
589
- span_id: null,
590
- event_type: 'pull',
591
- sync_path: 'http-combined',
592
- actor_id: 'actor-a',
593
- client_id: 'client-d',
594
- transport_path: 'relay',
595
- status_code: 409,
596
- outcome: 'rejected',
597
- response_status: 'client_error',
598
- error_code: 'CONFLICT',
599
- duration_ms: 22,
600
- commit_seq: null,
601
- operation_count: 1,
602
- row_count: 1,
603
- subscription_count: 1,
604
- scopes_summary: JSON.stringify({ org_id: ['org-1', 'org-2'] }),
605
- tables: ['tasks', 'notes'],
606
- error_message: null,
607
- payload_ref: null,
608
- created_at: atIso(50),
609
- },
610
- ])
611
- .execute();
612
-
613
- await db
614
- .insertInto('sync_request_payloads')
615
- .values({
616
- payload_ref: 'payload-1',
617
- partition_id: 'default',
618
- request_payload: JSON.stringify({
619
- clientCommitId: 'commit-a',
620
- operations: [{ table: 'tasks', op: 'upsert' }],
621
- }),
622
- response_payload: JSON.stringify({
623
- status: 'applied',
624
- commitSeq: commitSeqByClientCommitId.get('commit-a') ?? null,
625
- }),
626
- created_at: atIso(31),
627
- })
628
- .execute();
629
-
630
- await db
631
- .insertInto('sync_operation_events')
632
- .values([
633
- {
634
- operation_type: 'prune',
635
- console_user_id: 'console-test',
636
- partition_id: null,
637
- target_client_id: null,
638
- request_payload: JSON.stringify({ watermarkCommitSeq: 2 }),
639
- result_payload: JSON.stringify({ deletedCommits: 1 }),
640
- created_at: atIso(32),
641
- },
642
- {
643
- operation_type: 'evict_client',
644
- console_user_id: 'console-test',
645
- partition_id: null,
646
- target_client_id: 'client-d',
647
- request_payload: JSON.stringify({ clientId: 'client-d' }),
648
- result_payload: JSON.stringify({ evicted: true }),
649
- created_at: atIso(52),
650
- },
651
- ])
652
- .execute();
653
-
654
- app = createTestApp();
655
- });
656
-
657
- afterEach(async () => {
658
- await db.destroy();
659
- });
660
-
661
- it('filters by actor, client, and table across merged timeline rows', async () => {
662
- const actorFiltered = await readTimeline({ actorId: 'actor-a' });
663
- expect(actorFiltered.total).toBe(3);
664
- expect(
665
- actorFiltered.items.every(
666
- (item) =>
667
- item.commit?.actorId === 'actor-a' ||
668
- item.event?.actorId === 'actor-a'
669
- )
670
- ).toBe(true);
671
-
672
- const clientFiltered = await readTimeline({ clientId: 'client-d' });
673
- expect(clientFiltered.total).toBe(1);
674
- expect(clientFiltered.items[0]?.event?.clientId).toBe('client-d');
675
-
676
- const tableFiltered = await readTimeline({ table: 'tasks' });
677
- expect(tableFiltered.total).toBe(3);
678
- expect(
679
- tableFiltered.items.every((item) =>
680
- item.type === 'commit'
681
- ? (item.commit?.affectedTables ?? []).includes('tasks')
682
- : (item.event?.tables ?? []).includes('tasks')
683
- )
684
- ).toBe(true);
685
- });
686
-
687
- it('applies outcome and event-type filters to event rows in all-view mode', async () => {
688
- const outcomeFiltered = await readTimeline({ outcome: 'error' });
689
- expect(outcomeFiltered.total).toBe(1);
690
- expect(outcomeFiltered.items[0]?.type).toBe('event');
691
- expect(outcomeFiltered.items[0]?.event?.outcome).toBe('error');
692
-
693
- const eventTypeFiltered = await readTimeline({ eventType: 'push' });
694
- expect(eventTypeFiltered.total).toBe(1);
695
- expect(eventTypeFiltered.items[0]?.type).toBe('event');
696
- expect(eventTypeFiltered.items[0]?.event?.eventType).toBe('push');
697
- });
698
-
699
- it('applies request-id and trace-id filters to event rows', async () => {
700
- const requestIdFiltered = await readTimeline({ requestId: 'req-2' });
701
- expect(requestIdFiltered.total).toBe(1);
702
- expect(requestIdFiltered.items[0]?.type).toBe('event');
703
- expect(requestIdFiltered.items[0]?.event?.eventId).toBeDefined();
704
-
705
- const traceIdFiltered = await readTimeline({ traceId: 'trace-1' });
706
- expect(traceIdFiltered.total).toBe(1);
707
- expect(traceIdFiltered.items[0]?.type).toBe('event');
708
- expect(traceIdFiltered.items[0]?.event?.eventType).toBe('push');
709
- });
710
-
711
- it('applies time-window and search filtering', async () => {
712
- const fromFiltered = await readTimeline({ from: atIso(35) });
713
- expect(fromFiltered.total).toBe(2);
714
- expect(fromFiltered.items[0]?.timestamp >= atIso(35)).toBe(true);
715
- expect(fromFiltered.items[1]?.timestamp >= atIso(35)).toBe(true);
716
-
717
- const searchFiltered = await readTimeline({ search: 'client-d' });
718
- expect(searchFiltered.total).toBe(1);
719
- expect(searchFiltered.items[0]?.event?.clientId).toBe('client-d');
720
- });
721
-
722
- it('returns deterministic pagination slices for merged timeline rows', async () => {
723
- const pageOne = await readTimeline({ limit: 2, offset: 0 });
724
- const pageTwo = await readTimeline({ limit: 2, offset: 2 });
725
-
726
- expect(pageOne.total).toBe(5);
727
- expect(pageTwo.total).toBe(5);
728
- expect(pageOne.items.length).toBe(2);
729
- expect(pageTwo.items.length).toBe(2);
730
-
731
- const pageOneKeys = pageOne.items.map(timelineItemKey);
732
- const pageTwoKeys = pageTwo.items.map(timelineItemKey);
733
-
734
- expect(pageOneKeys[0]).not.toBe(pageTwoKeys[0]);
735
- expect(pageOneKeys.some((key) => pageTwoKeys.includes(key))).toBe(false);
736
- });
737
-
738
- it('lists operation audit events and filters by operation type', async () => {
739
- const allOps = await readOperations();
740
- expect(allOps.total).toBe(2);
741
- expect(allOps.items[0]?.operationType).toBe('evict_client');
742
- expect(allOps.items[1]?.operationType).toBe('prune');
743
-
744
- const pruneOps = await readOperations({ operationType: 'prune' });
745
- expect(pruneOps.total).toBe(1);
746
- expect(pruneOps.items[0]?.operationType).toBe('prune');
747
- expect(pruneOps.items[0]?.consoleUserId).toBe('console-test');
748
- });
749
-
750
- it('supports notify-data-change and records an operation audit event', async () => {
751
- const response = await requestNotifyDataChange({
752
- tables: ['tasks', 'notes'],
753
- partitionId: 'default',
754
- });
755
-
756
- expect(response.status).toBe(200);
757
- const payload = (await response.json()) as {
758
- commitSeq: number;
759
- tables: string[];
760
- deletedChunks: number;
761
- };
762
-
763
- expect(payload.commitSeq).toBeGreaterThan(0);
764
- expect(payload.tables).toEqual(['tasks', 'notes']);
765
- expect(payload.deletedChunks).toBeGreaterThanOrEqual(0);
766
-
767
- const notifyOps = await readOperations({
768
- operationType: 'notify_data_change',
769
- });
770
- expect(notifyOps.total).toBe(1);
771
- expect(notifyOps.items[0]?.consoleUserId).toBe('console-test');
772
- expect(notifyOps.items[0]?.requestPayload).toEqual({
773
- tables: ['tasks', 'notes'],
774
- partitionId: 'default',
775
- });
776
- expect(notifyOps.items[0]?.resultPayload).toEqual(payload);
777
- });
778
-
779
- it('returns stats from aggregated metrics mode with partition filtering', async () => {
780
- const aggregatedApp = createTestApp({
781
- metrics: {
782
- aggregationMode: 'aggregated',
783
- },
784
- });
785
-
786
- const timeseriesResponse = await requestTimeseriesStats({
787
- query: { partitionId: 'default' },
788
- targetApp: aggregatedApp,
789
- });
790
- expect(timeseriesResponse.status).toBe(200);
791
- const timeseriesPayload = (await timeseriesResponse.json()) as {
792
- buckets: Array<{
793
- pushCount: number;
794
- pullCount: number;
795
- errorCount: number;
796
- }>;
797
- };
798
-
799
- const totals = timeseriesPayload.buckets.reduce(
800
- (acc, bucket) => ({
801
- pushCount: acc.pushCount + bucket.pushCount,
802
- pullCount: acc.pullCount + bucket.pullCount,
803
- errorCount: acc.errorCount + bucket.errorCount,
804
- }),
805
- { pushCount: 0, pullCount: 0, errorCount: 0 }
806
- );
807
- expect(totals.pushCount).toBe(1);
808
- expect(totals.pullCount).toBe(2);
809
- expect(totals.errorCount).toBe(1);
810
-
811
- const latencyResponse = await requestLatencyStats({
812
- query: { partitionId: 'default' },
813
- targetApp: aggregatedApp,
814
- });
815
- expect(latencyResponse.status).toBe(200);
816
- const latencyPayload = (await latencyResponse.json()) as {
817
- push: { p50: number; p90: number; p99: number };
818
- pull: { p50: number; p90: number; p99: number };
819
- };
820
-
821
- expect(latencyPayload.push.p50).toBe(18);
822
- expect(latencyPayload.push.p90).toBe(18);
823
- expect(latencyPayload.push.p99).toBe(18);
824
- expect(latencyPayload.pull.p50).toBe(22);
825
- expect(latencyPayload.pull.p90).toBe(44);
826
- expect(latencyPayload.pull.p99).toBe(44);
827
- });
828
-
829
- it('applies partition filters across timeline, list, and operation endpoints', async () => {
830
- await db
831
- .insertInto('sync_commits')
832
- .values({
833
- partition_id: 'tenant-b',
834
- actor_id: 'actor-z',
835
- client_id: 'shared-client',
836
- client_commit_id: 'commit-z',
837
- created_at: atIso(55),
838
- meta: null,
839
- result_json: null,
840
- change_count: 1,
841
- affected_tables: ['tasks'],
842
- })
843
- .execute();
844
-
845
- const tenantCommitRow = await db
846
- .selectFrom('sync_commits')
847
- .select(['commit_seq'])
848
- .where('partition_id', '=', 'tenant-b')
849
- .where('client_commit_id', '=', 'commit-z')
850
- .executeTakeFirst();
851
-
852
- const tenantCommitSeq = Number(tenantCommitRow?.commit_seq);
853
- expect(Number.isFinite(tenantCommitSeq)).toBe(true);
854
-
855
- await db
856
- .insertInto('sync_changes')
857
- .values({
858
- partition_id: 'tenant-b',
859
- commit_seq: tenantCommitSeq,
860
- table: 'tasks',
861
- row_id: 'row-z',
862
- op: 'upsert',
863
- row_json: JSON.stringify({ id: 'row-z', title: 'tenant row' }),
864
- row_version: 1,
865
- scopes: JSON.stringify({ org_id: 'tenant-b' }),
866
- })
867
- .execute();
868
-
869
- await db
870
- .insertInto('sync_request_events')
871
- .values({
872
- partition_id: 'tenant-b',
873
- request_id: 'req-z',
874
- trace_id: 'trace-z',
875
- span_id: 'span-z',
876
- event_type: 'push',
877
- sync_path: 'http-combined',
878
- actor_id: 'actor-z',
879
- client_id: 'shared-client',
880
- transport_path: 'direct',
881
- status_code: 200,
882
- outcome: 'applied',
883
- response_status: 'success',
884
- error_code: null,
885
- duration_ms: 12,
886
- commit_seq: tenantCommitSeq,
887
- operation_count: 1,
888
- row_count: 1,
889
- subscription_count: null,
890
- scopes_summary: null,
891
- tables: ['tasks'],
892
- error_message: null,
893
- payload_ref: 'payload-z',
894
- created_at: atIso(56),
895
- })
896
- .execute();
897
-
898
- await db
899
- .insertInto('sync_request_payloads')
900
- .values({
901
- payload_ref: 'payload-z',
902
- partition_id: 'tenant-b',
903
- request_payload: JSON.stringify({
904
- clientCommitId: 'commit-z',
905
- operations: [{ table: 'tasks', op: 'upsert' }],
906
- }),
907
- response_payload: JSON.stringify({
908
- status: 'applied',
909
- commitSeq: tenantCommitSeq,
910
- }),
911
- created_at: atIso(56),
912
- })
913
- .execute();
914
-
915
- await db
916
- .insertInto('sync_client_cursors')
917
- .values([
918
- {
919
- partition_id: 'default',
920
- client_id: 'shared-client',
921
- actor_id: 'actor-a',
922
- cursor: 1,
923
- effective_scopes: JSON.stringify({ org_id: 'default' }),
924
- updated_at: atIso(57),
925
- },
926
- {
927
- partition_id: 'tenant-b',
928
- client_id: 'shared-client',
929
- actor_id: 'actor-z',
930
- cursor: tenantCommitSeq,
931
- effective_scopes: JSON.stringify({ org_id: 'tenant-b' }),
932
- updated_at: atIso(58),
933
- },
934
- ])
935
- .execute();
936
-
937
- await db
938
- .insertInto('sync_operation_events')
939
- .values({
940
- operation_type: 'notify_data_change',
941
- console_user_id: 'console-test',
942
- partition_id: 'tenant-b',
943
- target_client_id: null,
944
- request_payload: JSON.stringify({
945
- tables: ['tasks'],
946
- partitionId: 'tenant-b',
947
- }),
948
- result_payload: JSON.stringify({ commitSeq: tenantCommitSeq }),
949
- created_at: atIso(59),
950
- })
951
- .execute();
952
-
953
- const tenantTimeline = await readTimeline({ partitionId: 'tenant-b' });
954
- expect(tenantTimeline.total).toBe(2);
955
- expect(
956
- tenantTimeline.items.every((item) =>
957
- item.type === 'commit'
958
- ? item.commit?.clientId === 'shared-client'
959
- : item.event?.partitionId === 'tenant-b'
960
- )
961
- ).toBe(true);
962
-
963
- const commitsResponse = await requestCommits({ partitionId: 'tenant-b' });
964
- expect(commitsResponse.status).toBe(200);
965
- const commitsPayload = (await commitsResponse.json()) as {
966
- items: Array<{ clientId: string }>;
967
- total: number;
968
- };
969
- expect(commitsPayload.total).toBe(1);
970
- expect(commitsPayload.items[0]?.clientId).toBe('shared-client');
971
-
972
- const clientsResponse = await requestClients({ partitionId: 'tenant-b' });
973
- expect(clientsResponse.status).toBe(200);
974
- const clientsPayload = (await clientsResponse.json()) as {
975
- items: Array<{ actorId: string }>;
976
- total: number;
977
- };
978
- expect(clientsPayload.total).toBe(1);
979
- expect(clientsPayload.items[0]?.actorId).toBe('actor-z');
980
-
981
- const eventsResponse = await requestEvents({
982
- query: { partitionId: 'tenant-b' },
983
- });
984
- expect(eventsResponse.status).toBe(200);
985
- const eventsPayload = (await eventsResponse.json()) as {
986
- items: Array<{ partitionId: string }>;
987
- total: number;
988
- };
989
- expect(eventsPayload.total).toBe(1);
990
- expect(eventsPayload.items[0]?.partitionId).toBe('tenant-b');
991
-
992
- const tenantOps = await readOperations({ partitionId: 'tenant-b' });
993
- expect(tenantOps.total).toBe(1);
994
- expect(tenantOps.items[0]?.partitionId).toBe('tenant-b');
995
- expect(tenantOps.items[0]?.operationType).toBe('notify_data_change');
996
- });
997
-
998
- it('guards detail endpoints and client eviction with partition filters', async () => {
999
- await db
1000
- .insertInto('sync_commits')
1001
- .values({
1002
- partition_id: 'tenant-b',
1003
- actor_id: 'actor-z',
1004
- client_id: 'shared-client',
1005
- client_commit_id: 'commit-z-detail',
1006
- created_at: atIso(55),
1007
- meta: null,
1008
- result_json: null,
1009
- change_count: 1,
1010
- affected_tables: ['tasks'],
1011
- })
1012
- .execute();
1013
-
1014
- const tenantCommitRow = await db
1015
- .selectFrom('sync_commits')
1016
- .select(['commit_seq'])
1017
- .where('partition_id', '=', 'tenant-b')
1018
- .where('client_commit_id', '=', 'commit-z-detail')
1019
- .executeTakeFirst();
1020
- const tenantCommitSeq = Number(tenantCommitRow?.commit_seq);
1021
- expect(Number.isFinite(tenantCommitSeq)).toBe(true);
1022
-
1023
- await db
1024
- .insertInto('sync_changes')
1025
- .values({
1026
- partition_id: 'tenant-b',
1027
- commit_seq: tenantCommitSeq,
1028
- table: 'tasks',
1029
- row_id: 'row-z-detail',
1030
- op: 'upsert',
1031
- row_json: JSON.stringify({ id: 'row-z-detail' }),
1032
- row_version: 1,
1033
- scopes: JSON.stringify({ org_id: 'tenant-b' }),
1034
- })
1035
- .execute();
1036
-
1037
- await db
1038
- .insertInto('sync_request_events')
1039
- .values({
1040
- partition_id: 'tenant-b',
1041
- request_id: 'req-z-detail',
1042
- trace_id: 'trace-z-detail',
1043
- span_id: 'span-z-detail',
1044
- event_type: 'push',
1045
- sync_path: 'http-combined',
1046
- actor_id: 'actor-z',
1047
- client_id: 'shared-client',
1048
- transport_path: 'direct',
1049
- status_code: 200,
1050
- outcome: 'applied',
1051
- response_status: 'success',
1052
- error_code: null,
1053
- duration_ms: 15,
1054
- commit_seq: tenantCommitSeq,
1055
- operation_count: 1,
1056
- row_count: 1,
1057
- subscription_count: null,
1058
- scopes_summary: null,
1059
- tables: ['tasks'],
1060
- error_message: null,
1061
- payload_ref: 'payload-z-detail',
1062
- created_at: atIso(56),
1063
- })
1064
- .execute();
1065
-
1066
- await db
1067
- .insertInto('sync_request_payloads')
1068
- .values({
1069
- payload_ref: 'payload-z-detail',
1070
- partition_id: 'tenant-b',
1071
- request_payload: JSON.stringify({ clientCommitId: 'commit-z-detail' }),
1072
- response_payload: JSON.stringify({ status: 'applied' }),
1073
- created_at: atIso(56),
1074
- })
1075
- .execute();
1076
-
1077
- await db
1078
- .insertInto('sync_client_cursors')
1079
- .values([
1080
- {
1081
- partition_id: 'default',
1082
- client_id: 'shared-client',
1083
- actor_id: 'actor-a',
1084
- cursor: 1,
1085
- effective_scopes: JSON.stringify({ org_id: 'default' }),
1086
- updated_at: atIso(57),
1087
- },
1088
- {
1089
- partition_id: 'tenant-b',
1090
- client_id: 'shared-client',
1091
- actor_id: 'actor-z',
1092
- cursor: tenantCommitSeq,
1093
- effective_scopes: JSON.stringify({ org_id: 'tenant-b' }),
1094
- updated_at: atIso(58),
1095
- },
1096
- ])
1097
- .execute();
1098
-
1099
- const tenantEventRow = await db
1100
- .selectFrom('sync_request_events')
1101
- .select(['event_id'])
1102
- .where('request_id', '=', 'req-z-detail')
1103
- .executeTakeFirst();
1104
- const tenantEventId = Number(tenantEventRow?.event_id);
1105
- expect(Number.isFinite(tenantEventId)).toBe(true);
1106
-
1107
- const commitDetailOk = await requestCommitDetail(tenantCommitSeq, {
1108
- partitionId: 'tenant-b',
1109
- });
1110
- expect(commitDetailOk.status).toBe(200);
1111
-
1112
- const commitDetailWrongPartition = await requestCommitDetail(
1113
- tenantCommitSeq,
1114
- {
1115
- partitionId: 'default',
1116
- }
1117
- );
1118
- expect(commitDetailWrongPartition.status).toBe(404);
1119
-
1120
- const eventDetailOk = await requestEventDetail(tenantEventId, {
1121
- partitionId: 'tenant-b',
1122
- });
1123
- expect(eventDetailOk.status).toBe(200);
1124
-
1125
- const eventDetailWrongPartition = await requestEventDetail(tenantEventId, {
1126
- partitionId: 'default',
1127
- });
1128
- expect(eventDetailWrongPartition.status).toBe(404);
1129
-
1130
- const payloadOk = await requestEventPayload(tenantEventId, {
1131
- partitionId: 'tenant-b',
1132
- });
1133
- expect(payloadOk.status).toBe(200);
1134
-
1135
- const payloadWrongPartition = await requestEventPayload(tenantEventId, {
1136
- partitionId: 'default',
1137
- });
1138
- expect(payloadWrongPartition.status).toBe(404);
1139
-
1140
- const evictDefault = await requestEvictClient('shared-client', {
1141
- partitionId: 'default',
1142
- });
1143
- expect(evictDefault.status).toBe(200);
1144
- expect((await evictDefault.json()) as { evicted: boolean }).toEqual({
1145
- evicted: true,
1146
- });
1147
-
1148
- const tenantCursorAfterDefaultEvict = await db
1149
- .selectFrom('sync_client_cursors')
1150
- .select(['client_id'])
1151
- .where('partition_id', '=', 'tenant-b')
1152
- .where('client_id', '=', 'shared-client')
1153
- .executeTakeFirst();
1154
- expect(tenantCursorAfterDefaultEvict).toBeDefined();
1155
-
1156
- const evictTenant = await requestEvictClient('shared-client', {
1157
- partitionId: 'tenant-b',
1158
- });
1159
- expect(evictTenant.status).toBe(200);
1160
- expect((await evictTenant.json()) as { evicted: boolean }).toEqual({
1161
- evicted: true,
1162
- });
1163
-
1164
- const tenantCursorAfterTenantEvict = await db
1165
- .selectFrom('sync_client_cursors')
1166
- .select(['client_id'])
1167
- .where('partition_id', '=', 'tenant-b')
1168
- .where('client_id', '=', 'shared-client')
1169
- .executeTakeFirst();
1170
- expect(tenantCursorAfterTenantEvict).toBeUndefined();
1171
- });
1172
-
1173
- it('filters API keys by type and lifecycle status', async () => {
1174
- const dayMs = 24 * 60 * 60 * 1000;
1175
- const now = Date.now();
1176
- const isoAfter = (days: number): string =>
1177
- new Date(now + days * dayMs).toISOString();
1178
-
1179
- await db
1180
- .insertInto('sync_api_keys')
1181
- .values([
1182
- {
1183
- key_id: 'key-relay-active',
1184
- key_hash: 'hash-1',
1185
- key_prefix: 'srly_active_',
1186
- name: 'relay-active',
1187
- key_type: 'relay',
1188
- scope_keys: ['scope-a'],
1189
- actor_id: 'actor-a',
1190
- created_at: atIso(11),
1191
- expires_at: null,
1192
- last_used_at: null,
1193
- revoked_at: null,
1194
- },
1195
- {
1196
- key_id: 'key-relay-expiring',
1197
- key_hash: 'hash-2',
1198
- key_prefix: 'srly_expire_',
1199
- name: 'relay-expiring',
1200
- key_type: 'relay',
1201
- scope_keys: ['scope-a', 'scope-b'],
1202
- actor_id: null,
1203
- created_at: atIso(12),
1204
- expires_at: isoAfter(3),
1205
- last_used_at: null,
1206
- revoked_at: null,
1207
- },
1208
- {
1209
- key_id: 'key-relay-expired',
1210
- key_hash: 'hash-3',
1211
- key_prefix: 'srly_expired',
1212
- name: 'relay-expired',
1213
- key_type: 'relay',
1214
- scope_keys: [],
1215
- actor_id: null,
1216
- created_at: atIso(13),
1217
- expires_at: isoAfter(-1),
1218
- last_used_at: null,
1219
- revoked_at: null,
1220
- },
1221
- {
1222
- key_id: 'key-relay-revoked',
1223
- key_hash: 'hash-4',
1224
- key_prefix: 'srly_revoked',
1225
- name: 'relay-revoked',
1226
- key_type: 'relay',
1227
- scope_keys: [],
1228
- actor_id: null,
1229
- created_at: atIso(14),
1230
- expires_at: isoAfter(10),
1231
- last_used_at: null,
1232
- revoked_at: isoAfter(0),
1233
- },
1234
- {
1235
- key_id: 'key-admin-future',
1236
- key_hash: 'hash-5',
1237
- key_prefix: 'sadm_future_',
1238
- name: 'admin-future',
1239
- key_type: 'admin',
1240
- scope_keys: ['org:1'],
1241
- actor_id: 'actor-admin',
1242
- created_at: atIso(15),
1243
- expires_at: isoAfter(60),
1244
- last_used_at: null,
1245
- revoked_at: null,
1246
- },
1247
- ])
1248
- .execute();
1249
-
1250
- const relayOnly = await readApiKeys({ type: 'relay' });
1251
- expect(relayOnly.total).toBe(4);
1252
- expect(relayOnly.items.every((item) => item.keyType === 'relay')).toBe(
1253
- true
1254
- );
1255
-
1256
- const revokedOnly = await readApiKeys({ status: 'revoked' });
1257
- expect(revokedOnly.total).toBe(1);
1258
- expect(revokedOnly.items[0]?.name).toBe('relay-revoked');
1259
-
1260
- const activeOnly = await readApiKeys({ status: 'active' });
1261
- expect(activeOnly.total).toBe(3);
1262
- expect(activeOnly.items.some((item) => item.name === 'relay-active')).toBe(
1263
- true
1264
- );
1265
- expect(
1266
- activeOnly.items.some((item) => item.name === 'relay-expiring')
1267
- ).toBe(true);
1268
- expect(activeOnly.items.some((item) => item.name === 'admin-future')).toBe(
1269
- true
1270
- );
1271
- expect(activeOnly.items.some((item) => item.name === 'relay-expired')).toBe(
1272
- false
1273
- );
1274
- expect(activeOnly.items.some((item) => item.name === 'relay-revoked')).toBe(
1275
- false
1276
- );
1277
-
1278
- const expiringDefault = await readApiKeys({ status: 'expiring' });
1279
- expect(expiringDefault.total).toBe(1);
1280
- expect(expiringDefault.items[0]?.name).toBe('relay-expiring');
1281
- });
1282
-
1283
- it('applies custom expiring-window filters for API keys', async () => {
1284
- const dayMs = 24 * 60 * 60 * 1000;
1285
- const now = Date.now();
1286
- const isoAfter = (days: number): string =>
1287
- new Date(now + days * dayMs).toISOString();
1288
-
1289
- await db
1290
- .insertInto('sync_api_keys')
1291
- .values([
1292
- {
1293
- key_id: 'key-expiring-3',
1294
- key_hash: 'hash-e3',
1295
- key_prefix: 'srel_exp_03',
1296
- name: 'expiring-3',
1297
- key_type: 'relay',
1298
- scope_keys: [],
1299
- actor_id: null,
1300
- created_at: atIso(16),
1301
- expires_at: isoAfter(3),
1302
- last_used_at: null,
1303
- revoked_at: null,
1304
- },
1305
- {
1306
- key_id: 'key-expiring-10',
1307
- key_hash: 'hash-e10',
1308
- key_prefix: 'srel_exp_10',
1309
- name: 'expiring-10',
1310
- key_type: 'relay',
1311
- scope_keys: [],
1312
- actor_id: null,
1313
- created_at: atIso(17),
1314
- expires_at: isoAfter(10),
1315
- last_used_at: null,
1316
- revoked_at: null,
1317
- },
1318
- ])
1319
- .execute();
1320
-
1321
- const expiringInSevenDays = await readApiKeys({
1322
- status: 'expiring',
1323
- expiresWithinDays: 7,
1324
- });
1325
- expect(expiringInSevenDays.total).toBe(1);
1326
- expect(expiringInSevenDays.items[0]?.name).toBe('expiring-3');
1327
-
1328
- const expiringInFourteenDays = await readApiKeys({
1329
- status: 'expiring',
1330
- expiresWithinDays: 14,
1331
- });
1332
- expect(expiringInFourteenDays.total).toBe(2);
1333
- const expiringNames = expiringInFourteenDays.items
1334
- .map((item) => item.name)
1335
- .sort();
1336
- expect(expiringNames).toEqual(['expiring-10', 'expiring-3']);
1337
- });
1338
-
1339
- it('bulk revokes active keys and reports already-revoked/not-found ids', async () => {
1340
- const nowIso = atIso(18);
1341
-
1342
- await db
1343
- .insertInto('sync_api_keys')
1344
- .values([
1345
- {
1346
- key_id: 'bulk-active-1',
1347
- key_hash: 'bulk-hash-1',
1348
- key_prefix: 'bulk_active1',
1349
- name: 'bulk-active-1',
1350
- key_type: 'relay',
1351
- scope_keys: [],
1352
- actor_id: null,
1353
- created_at: nowIso,
1354
- expires_at: null,
1355
- last_used_at: null,
1356
- revoked_at: null,
1357
- },
1358
- {
1359
- key_id: 'bulk-active-2',
1360
- key_hash: 'bulk-hash-2',
1361
- key_prefix: 'bulk_active2',
1362
- name: 'bulk-active-2',
1363
- key_type: 'proxy',
1364
- scope_keys: [],
1365
- actor_id: null,
1366
- created_at: nowIso,
1367
- expires_at: null,
1368
- last_used_at: null,
1369
- revoked_at: null,
1370
- },
1371
- {
1372
- key_id: 'bulk-revoked-1',
1373
- key_hash: 'bulk-hash-3',
1374
- key_prefix: 'bulk_revoked',
1375
- name: 'bulk-revoked-1',
1376
- key_type: 'admin',
1377
- scope_keys: [],
1378
- actor_id: null,
1379
- created_at: nowIso,
1380
- expires_at: null,
1381
- last_used_at: null,
1382
- revoked_at: nowIso,
1383
- },
1384
- ])
1385
- .execute();
1386
-
1387
- const response = await requestBulkRevokeApiKeys([
1388
- 'bulk-active-1',
1389
- 'bulk-active-2',
1390
- 'bulk-revoked-1',
1391
- 'bulk-missing-1',
1392
- ]);
1393
- expect(response.status).toBe(200);
1394
-
1395
- const payload = (await response.json()) as {
1396
- requestedCount: number;
1397
- revokedCount: number;
1398
- alreadyRevokedCount: number;
1399
- notFoundCount: number;
1400
- revokedKeyIds: string[];
1401
- alreadyRevokedKeyIds: string[];
1402
- notFoundKeyIds: string[];
1403
- };
1404
-
1405
- expect(payload.requestedCount).toBe(4);
1406
- expect(payload.revokedCount).toBe(2);
1407
- expect(payload.alreadyRevokedCount).toBe(1);
1408
- expect(payload.notFoundCount).toBe(1);
1409
- expect(payload.revokedKeyIds.sort()).toEqual([
1410
- 'bulk-active-1',
1411
- 'bulk-active-2',
1412
- ]);
1413
- expect(payload.alreadyRevokedKeyIds).toEqual(['bulk-revoked-1']);
1414
- expect(payload.notFoundKeyIds).toEqual(['bulk-missing-1']);
1415
-
1416
- const revokedRows = await db
1417
- .selectFrom('sync_api_keys')
1418
- .select(['key_id', 'revoked_at'])
1419
- .where('key_id', 'in', ['bulk-active-1', 'bulk-active-2'])
1420
- .execute();
1421
- expect(revokedRows.every((row) => row.revoked_at !== null)).toBe(true);
1422
- });
1423
-
1424
- it('stages key rotation without revoking the original key', async () => {
1425
- const nowIso = atIso(19);
1426
-
1427
- await db
1428
- .insertInto('sync_api_keys')
1429
- .values({
1430
- key_id: 'stage-old-key',
1431
- key_hash: 'stage-hash-1',
1432
- key_prefix: 'stage_old__',
1433
- name: 'stage-old',
1434
- key_type: 'relay',
1435
- scope_keys: ['scope-x'],
1436
- actor_id: 'actor-stage',
1437
- created_at: nowIso,
1438
- expires_at: null,
1439
- last_used_at: null,
1440
- revoked_at: null,
1441
- })
1442
- .execute();
1443
-
1444
- const response = await requestStageRotateApiKey('stage-old-key');
1445
- expect(response.status).toBe(200);
1446
-
1447
- const payload = (await response.json()) as {
1448
- key: {
1449
- keyId: string;
1450
- keyPrefix: string;
1451
- name: string;
1452
- keyType: 'relay' | 'proxy' | 'admin';
1453
- scopeKeys: string[];
1454
- actorId: string | null;
1455
- };
1456
- secretKey: string;
1457
- };
1458
-
1459
- expect(payload.key.keyId).not.toBe('stage-old-key');
1460
- expect(payload.key.name).toBe('stage-old');
1461
- expect(payload.key.keyType).toBe('relay');
1462
- expect(payload.key.scopeKeys).toEqual(['scope-x']);
1463
- expect(payload.key.actorId).toBe('actor-stage');
1464
- expect(payload.secretKey.startsWith(payload.key.keyPrefix)).toBe(true);
1465
-
1466
- const oldRow = await db
1467
- .selectFrom('sync_api_keys')
1468
- .select(['revoked_at'])
1469
- .where('key_id', '=', 'stage-old-key')
1470
- .executeTakeFirst();
1471
- expect(oldRow?.revoked_at).toBeNull();
1472
-
1473
- const newRow = await db
1474
- .selectFrom('sync_api_keys')
1475
- .select(['key_id', 'revoked_at'])
1476
- .where('key_id', '=', payload.key.keyId)
1477
- .executeTakeFirst();
1478
- expect(newRow?.key_id).toBe(payload.key.keyId);
1479
- expect(newRow?.revoked_at).toBeNull();
1480
- });
1481
-
1482
- it('rejects unauthenticated timeline requests', async () => {
1483
- const response = await requestTimeline({ authenticated: false });
1484
- expect(response.status).toBe(401);
1485
- expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' });
1486
- });
1487
-
1488
- it('returns payload snapshots for events with payload refs', async () => {
1489
- const row = await db
1490
- .selectFrom('sync_request_events')
1491
- .select(['event_id'])
1492
- .where('request_id', '=', 'req-1')
1493
- .executeTakeFirst();
1494
-
1495
- expect(row).toBeDefined();
1496
- const eventId = Number(row?.event_id);
1497
- const response = await requestEventPayload(eventId);
1498
-
1499
- expect(response.status).toBe(200);
1500
- const payload = (await response.json()) as {
1501
- payloadRef: string;
1502
- partitionId: string;
1503
- requestPayload: { clientCommitId: string };
1504
- responsePayload: { status: string };
1505
- };
1506
-
1507
- expect(payload.payloadRef).toBe('payload-1');
1508
- expect(payload.partitionId).toBe('default');
1509
- expect(payload.requestPayload.clientCommitId).toBe('commit-a');
1510
- expect(payload.responsePayload.status).toBe('applied');
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
- });
1626
- });