@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,3936 +0,0 @@
1
- /**
2
- * @syncular/server-hono - Console API routes
3
- *
4
- * Provides monitoring and operations endpoints for the @syncular dashboard.
5
- *
6
- * Endpoints:
7
- * - GET /stats - Sync statistics
8
- * - GET /commits - Paginated commit list
9
- * - GET /commits/:seq - Single commit with changes
10
- * - GET /clients - Client cursor list
11
- * - GET /handlers - Registered handlers
12
- * - POST /prune - Trigger pruning
13
- * - POST /prune/preview - Preview pruning (dry run)
14
- * - POST /compact - Trigger compaction
15
- * - DELETE /clients/:id - Evict client
16
- */
17
-
18
- import { logSyncEvent } from '@syncular/core';
19
- import type { SqlFamily, SyncCoreDb, SyncServerAuth } from '@syncular/server';
20
- import {
21
- compactChanges,
22
- computePruneWatermarkCommitSeq,
23
- notifyExternalDataChange,
24
- pruneSync,
25
- readSyncStats,
26
- } from '@syncular/server';
27
- import type { Context } from 'hono';
28
- import { Hono } from 'hono';
29
- import { cors } from 'hono/cors';
30
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
31
- import { type Generated, type Kysely, type Selectable, sql } from 'kysely';
32
- import { z } from 'zod';
33
- import {
34
- type ApiKeyType,
35
- ApiKeyTypeSchema,
36
- type ConsoleApiKey,
37
- ConsoleApiKeyBulkRevokeRequestSchema,
38
- type ConsoleApiKeyBulkRevokeResponse,
39
- ConsoleApiKeyBulkRevokeResponseSchema,
40
- ConsoleApiKeyCreateRequestSchema,
41
- type ConsoleApiKeyCreateResponse,
42
- ConsoleApiKeyCreateResponseSchema,
43
- ConsoleApiKeyRevokeResponseSchema,
44
- ConsoleApiKeySchema,
45
- ConsoleBlobDeleteResponseSchema,
46
- ConsoleBlobListQuerySchema,
47
- ConsoleBlobListResponseSchema,
48
- type ConsoleChange,
49
- type ConsoleClearEventsResult,
50
- ConsoleClearEventsResultSchema,
51
- type ConsoleClient,
52
- ConsoleClientSchema,
53
- type ConsoleCommitDetail,
54
- ConsoleCommitDetailSchema,
55
- type ConsoleCommitListItem,
56
- ConsoleCommitListItemSchema,
57
- type ConsoleCompactResult,
58
- ConsoleCompactResultSchema,
59
- type ConsoleEvictResult,
60
- ConsoleEvictResultSchema,
61
- type ConsoleHandler,
62
- ConsoleHandlerSchema,
63
- type ConsoleOperationEvent,
64
- ConsoleOperationEventSchema,
65
- ConsoleOperationsQuerySchema,
66
- type ConsoleOperationType,
67
- type ConsolePaginatedResponse,
68
- ConsolePaginatedResponseSchema,
69
- ConsolePaginationQuerySchema,
70
- ConsolePartitionedPaginationQuerySchema,
71
- ConsolePartitionQuerySchema,
72
- type ConsolePruneEventsResult,
73
- ConsolePruneEventsResultSchema,
74
- type ConsolePrunePreview,
75
- ConsolePrunePreviewSchema,
76
- type ConsolePruneResult,
77
- ConsolePruneResultSchema,
78
- type ConsoleRequestEvent,
79
- ConsoleRequestEventSchema,
80
- type ConsoleRequestPayload,
81
- ConsoleRequestPayloadSchema,
82
- type ConsoleTimelineItem,
83
- ConsoleTimelineItemSchema,
84
- ConsoleTimelineQuerySchema,
85
- type LatencyPercentiles,
86
- LatencyQuerySchema,
87
- type LatencyStatsResponse,
88
- LatencyStatsResponseSchema,
89
- type LiveEvent,
90
- type SyncStats,
91
- SyncStatsSchema,
92
- type TimeseriesBucket,
93
- TimeseriesQuerySchema,
94
- type TimeseriesStatsResponse,
95
- TimeseriesStatsResponseSchema,
96
- } from './schemas';
97
- import type {
98
- ConsoleAuthResult,
99
- ConsoleEventEmitter,
100
- ConsoleEventListener,
101
- CreateConsoleRoutesOptions,
102
- } from './types';
103
-
104
- /**
105
- * Create a simple console event emitter for broadcasting live events.
106
- */
107
- export function createConsoleEventEmitter(options?: {
108
- maxHistory?: number;
109
- }): ConsoleEventEmitter {
110
- const listeners = new Set<ConsoleEventListener>();
111
- const history: LiveEvent[] = [];
112
- const maxHistory = Math.max(1, options?.maxHistory ?? 500);
113
-
114
- return {
115
- addListener(listener: ConsoleEventListener) {
116
- listeners.add(listener);
117
- },
118
- removeListener(listener: ConsoleEventListener) {
119
- listeners.delete(listener);
120
- },
121
- emit(event: LiveEvent) {
122
- history.push(event);
123
- if (history.length > maxHistory) {
124
- history.splice(0, history.length - maxHistory);
125
- }
126
- for (const listener of listeners) {
127
- try {
128
- listener(event);
129
- } catch {
130
- // Ignore errors in listeners
131
- }
132
- }
133
- },
134
- replay(replayOptions) {
135
- const sinceMs = replayOptions?.since
136
- ? Date.parse(replayOptions.since)
137
- : Number.NaN;
138
- const hasSince = Number.isFinite(sinceMs);
139
- const normalizedPartitionId = replayOptions?.partitionId?.trim();
140
- const hasPartitionFilter = Boolean(normalizedPartitionId);
141
-
142
- const filteredByTime = hasSince
143
- ? history.filter((event) => {
144
- const eventMs = Date.parse(event.timestamp);
145
- return Number.isFinite(eventMs) && eventMs > sinceMs;
146
- })
147
- : history;
148
-
149
- const filtered = hasPartitionFilter
150
- ? filteredByTime.filter((event) => {
151
- const eventPartitionId = event.data.partitionId;
152
- return (
153
- typeof eventPartitionId === 'string' &&
154
- eventPartitionId === normalizedPartitionId
155
- );
156
- })
157
- : filteredByTime;
158
-
159
- const normalizedLimit =
160
- replayOptions?.limit && replayOptions.limit > 0
161
- ? Math.floor(replayOptions.limit)
162
- : 100;
163
- const limited = filtered.slice(-normalizedLimit);
164
- return [...limited];
165
- },
166
- };
167
- }
168
-
169
- function coerceNumber(value: unknown): number | null {
170
- if (value === null || value === undefined) return null;
171
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
172
- if (typeof value === 'bigint')
173
- return Number.isFinite(Number(value)) ? Number(value) : null;
174
- if (typeof value === 'string') {
175
- const n = Number(value);
176
- return Number.isFinite(n) ? n : null;
177
- }
178
- return null;
179
- }
180
-
181
- function parseDate(value: string | null | undefined): number | null {
182
- if (!value) return null;
183
- const parsed = Date.parse(value);
184
- return Number.isFinite(parsed) ? parsed : null;
185
- }
186
-
187
- function includesSearchTerm(
188
- value: string | null | undefined,
189
- searchTerm: string | null
190
- ): boolean {
191
- if (!searchTerm) return true;
192
- if (!value) return false;
193
- return value.toLowerCase().includes(searchTerm);
194
- }
195
-
196
- function parseJsonValue(value: unknown): unknown {
197
- if (value === null || value === undefined) return null;
198
- if (typeof value !== 'string') return value;
199
- try {
200
- return JSON.parse(value);
201
- } catch {
202
- return value;
203
- }
204
- }
205
-
206
- function parseScopesSummary(
207
- value: unknown
208
- ): Record<string, string | string[]> | null {
209
- const parsed = parseJsonValue(value);
210
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
211
- return null;
212
- }
213
-
214
- const summary: Record<string, string | string[]> = {};
215
- for (const [key, entry] of Object.entries(parsed)) {
216
- if (typeof entry === 'string') {
217
- summary[key] = entry;
218
- continue;
219
- }
220
- if (!Array.isArray(entry)) continue;
221
- summary[key] = entry.filter(
222
- (value): value is string => typeof value === 'string'
223
- );
224
- }
225
-
226
- return Object.keys(summary).length > 0 ? summary : null;
227
- }
228
-
229
- function getClientActivityState(args: {
230
- connectionCount: number;
231
- updatedAt: string | null | undefined;
232
- }): 'active' | 'idle' | 'stale' {
233
- if (args.connectionCount > 0) {
234
- return 'active';
235
- }
236
-
237
- const updatedAtMs = parseDate(args.updatedAt);
238
- if (updatedAtMs === null) {
239
- return 'stale';
240
- }
241
-
242
- const ageMs = Date.now() - updatedAtMs;
243
- if (ageMs <= 60_000) {
244
- return 'active';
245
- }
246
- if (ageMs <= 5 * 60_000) {
247
- return 'idle';
248
- }
249
- return 'stale';
250
- }
251
-
252
- type TimeseriesInterval = 'minute' | 'hour' | 'day';
253
- type TimeseriesRange = '1h' | '6h' | '24h' | '7d' | '30d';
254
-
255
- function rangeToMs(range: TimeseriesRange): number {
256
- if (range === '1h') return 60 * 60 * 1000;
257
- if (range === '6h') return 6 * 60 * 60 * 1000;
258
- if (range === '24h') return 24 * 60 * 60 * 1000;
259
- if (range === '7d') return 7 * 24 * 60 * 60 * 1000;
260
- return 30 * 24 * 60 * 60 * 1000;
261
- }
262
-
263
- function intervalToMs(interval: TimeseriesInterval): number {
264
- if (interval === 'minute') return 60 * 1000;
265
- if (interval === 'hour') return 60 * 60 * 1000;
266
- return 24 * 60 * 60 * 1000;
267
- }
268
-
269
- function intervalToSqliteBucketFormat(interval: TimeseriesInterval): string {
270
- if (interval === 'minute') return '%Y-%m-%dT%H:%M:00.000Z';
271
- if (interval === 'hour') return '%Y-%m-%dT%H:00:00.000Z';
272
- return '%Y-%m-%dT00:00:00.000Z';
273
- }
274
-
275
- type TimeseriesBucketAccumulator = {
276
- pushCount: number;
277
- pullCount: number;
278
- errorCount: number;
279
- totalLatency: number;
280
- eventCount: number;
281
- };
282
-
283
- function createEmptyTimeseriesAccumulator(): TimeseriesBucketAccumulator {
284
- return {
285
- pushCount: 0,
286
- pullCount: 0,
287
- errorCount: 0,
288
- totalLatency: 0,
289
- eventCount: 0,
290
- };
291
- }
292
-
293
- function createTimeseriesBucketMap(args: {
294
- startTime: Date;
295
- rangeMs: number;
296
- intervalMs: number;
297
- }): Map<string, TimeseriesBucketAccumulator> {
298
- const map = new Map<string, TimeseriesBucketAccumulator>();
299
- const bucketCount = Math.ceil(args.rangeMs / args.intervalMs);
300
-
301
- for (let i = 0; i < bucketCount; i++) {
302
- const bucketTimestamp = new Date(
303
- args.startTime.getTime() + i * args.intervalMs
304
- ).toISOString();
305
- map.set(bucketTimestamp, createEmptyTimeseriesAccumulator());
306
- }
307
-
308
- return map;
309
- }
310
-
311
- function normalizeBucketTimestamp(value: unknown): string | null {
312
- if (value instanceof Date) {
313
- return value.toISOString();
314
- }
315
- if (typeof value !== 'string') {
316
- return null;
317
- }
318
- const parsed = Date.parse(value);
319
- if (!Number.isFinite(parsed)) return null;
320
- return new Date(parsed).toISOString();
321
- }
322
-
323
- function finalizeTimeseriesBuckets(
324
- bucketMap: Map<string, TimeseriesBucketAccumulator>
325
- ): TimeseriesBucket[] {
326
- return Array.from(bucketMap.entries())
327
- .sort(([a], [b]) => a.localeCompare(b))
328
- .map(([timestamp, data]) => ({
329
- timestamp,
330
- pushCount: data.pushCount,
331
- pullCount: data.pullCount,
332
- errorCount: data.errorCount,
333
- avgLatencyMs:
334
- data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
335
- }));
336
- }
337
-
338
- function calculatePercentiles(latencies: number[]): LatencyPercentiles {
339
- if (latencies.length === 0) {
340
- return { p50: 0, p90: 0, p99: 0 };
341
- }
342
-
343
- const sorted = [...latencies].sort((a, b) => a - b);
344
- const getPercentile = (p: number): number => {
345
- const index = Math.ceil((p / 100) * sorted.length) - 1;
346
- return sorted[Math.max(0, index)] ?? 0;
347
- };
348
-
349
- return {
350
- p50: getPercentile(50),
351
- p90: getPercentile(90),
352
- p99: getPercentile(99),
353
- };
354
- }
355
-
356
- // ============================================================================
357
- // Route Schemas
358
- // ============================================================================
359
-
360
- const ErrorResponseSchema = z.object({
361
- error: z.string(),
362
- message: z.string().optional(),
363
- });
364
-
365
- const commitSeqParamSchema = z.object({ seq: z.coerce.number().int() });
366
- const clientIdParamSchema = z.object({ id: z.string().min(1) });
367
- const eventIdParamSchema = z.object({ id: z.coerce.number().int() });
368
- const apiKeyIdParamSchema = z.object({ id: z.string().min(1) });
369
-
370
- const eventsQuerySchema = ConsolePartitionedPaginationQuerySchema.extend({
371
- eventType: z.enum(['push', 'pull']).optional(),
372
- actorId: z.string().optional(),
373
- clientId: z.string().optional(),
374
- requestId: z.string().optional(),
375
- traceId: z.string().optional(),
376
- outcome: z.string().optional(),
377
- });
378
-
379
- const commitDetailQuerySchema = ConsolePartitionQuerySchema;
380
- const eventDetailQuerySchema = ConsolePartitionQuerySchema;
381
- const evictClientQuerySchema = ConsolePartitionQuerySchema;
382
- const apiKeyStatusSchema = z.enum(['active', 'revoked', 'expiring']);
383
-
384
- const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
385
- type: ApiKeyTypeSchema.optional(),
386
- status: apiKeyStatusSchema.optional(),
387
- expiresWithinDays: z.coerce.number().int().min(1).max(365).optional(),
388
- });
389
-
390
- const handlersResponseSchema = z.object({
391
- items: z.array(ConsoleHandlerSchema),
392
- });
393
-
394
- const DEFAULT_REQUEST_EVENTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
395
- const DEFAULT_REQUEST_EVENTS_MAX_ROWS = 10_000;
396
- const DEFAULT_OPERATION_EVENTS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
397
- const DEFAULT_OPERATION_EVENTS_MAX_ROWS = 5_000;
398
- const DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
399
-
400
- function readNonNegativeInteger(
401
- value: number | undefined,
402
- fallback: number
403
- ): number {
404
- if (typeof value !== 'number' || !Number.isFinite(value)) {
405
- return fallback;
406
- }
407
- if (value < 0) {
408
- return fallback;
409
- }
410
- return Math.floor(value);
411
- }
412
-
413
- export function createConsoleRoutes<
414
- DB extends SyncCoreDb,
415
- Auth extends SyncServerAuth,
416
- F extends SqlFamily = SqlFamily,
417
- >(options: CreateConsoleRoutesOptions<DB, Auth, F>): Hono {
418
- const routes = new Hono();
419
-
420
- routes.onError((error, context) => {
421
- const message =
422
- error instanceof Error ? error.message : 'Unknown console error';
423
- console.error('[console] route error', error);
424
- return context.json(
425
- {
426
- error: 'CONSOLE_ROUTE_ERROR',
427
- message,
428
- },
429
- 500
430
- );
431
- });
432
-
433
- interface SyncRequestEventsTable {
434
- event_id: number;
435
- partition_id: string;
436
- request_id: string | null;
437
- trace_id: string | null;
438
- span_id: string | null;
439
- event_type: string;
440
- sync_path: string;
441
- transport_path: string;
442
- actor_id: string;
443
- client_id: string;
444
- status_code: number;
445
- outcome: string;
446
- response_status: string;
447
- error_code: string | null;
448
- duration_ms: number;
449
- commit_seq: number | null;
450
- operation_count: number | null;
451
- row_count: number | null;
452
- subscription_count: number | null;
453
- scopes_summary: unknown | null;
454
- tables: unknown;
455
- error_message: string | null;
456
- payload_ref: string | null;
457
- created_at: string;
458
- }
459
-
460
- interface SyncRequestPayloadsTable {
461
- payload_ref: string;
462
- partition_id: string;
463
- request_payload: unknown;
464
- response_payload: unknown | null;
465
- created_at: string;
466
- }
467
-
468
- interface SyncApiKeysTable {
469
- key_id: string;
470
- key_hash: string;
471
- key_prefix: string;
472
- name: string;
473
- key_type: string;
474
- scope_keys: unknown | null;
475
- actor_id: string | null;
476
- created_at: string;
477
- expires_at: string | null;
478
- last_used_at: string | null;
479
- revoked_at: string | null;
480
- }
481
-
482
- interface SyncOperationEventsTable {
483
- operation_id: Generated<number>;
484
- operation_type: string;
485
- console_user_id: string | null;
486
- partition_id: string | null;
487
- target_client_id: string | null;
488
- request_payload: unknown | null;
489
- result_payload: unknown | null;
490
- created_at: Generated<string>;
491
- }
492
-
493
- type SyncOperationEventRow = Selectable<SyncOperationEventsTable>;
494
-
495
- interface ConsoleDb extends SyncCoreDb {
496
- sync_request_events: SyncRequestEventsTable;
497
- sync_request_payloads: SyncRequestPayloadsTable;
498
- sync_operation_events: SyncOperationEventsTable;
499
- sync_api_keys: SyncApiKeysTable;
500
- }
501
-
502
- const db = options.db as Pick<
503
- Kysely<ConsoleDb>,
504
- 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
505
- >;
506
- const metricsAggregationMode = options.metrics?.aggregationMode ?? 'auto';
507
- const rawFallbackMaxEvents = Math.max(
508
- 1,
509
- options.metrics?.rawFallbackMaxEvents ?? 5000
510
- );
511
- const requestEventsMaxAgeMs = readNonNegativeInteger(
512
- options.maintenance?.requestEventsMaxAgeMs,
513
- DEFAULT_REQUEST_EVENTS_MAX_AGE_MS
514
- );
515
- const requestEventsMaxRows = readNonNegativeInteger(
516
- options.maintenance?.requestEventsMaxRows,
517
- DEFAULT_REQUEST_EVENTS_MAX_ROWS
518
- );
519
- const operationEventsMaxAgeMs = readNonNegativeInteger(
520
- options.maintenance?.operationEventsMaxAgeMs,
521
- DEFAULT_OPERATION_EVENTS_MAX_AGE_MS
522
- );
523
- const operationEventsMaxRows = readNonNegativeInteger(
524
- options.maintenance?.operationEventsMaxRows,
525
- DEFAULT_OPERATION_EVENTS_MAX_ROWS
526
- );
527
- const autoEventsPruneIntervalMs = readNonNegativeInteger(
528
- options.maintenance?.autoPruneIntervalMs,
529
- DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS
530
- );
531
- let lastEventsPruneRunAt = 0;
532
-
533
- // Ensure console schema exists before handlers query console tables.
534
- const consoleSchemaReadyPromise = (
535
- options.consoleSchemaReady ??
536
- options.dialect.ensureConsoleSchema?.(options.db) ??
537
- Promise.resolve()
538
- ).catch((err) => {
539
- console.error('[console] Failed to ensure console schema:', err);
540
- throw err;
541
- });
542
-
543
- // CORS configuration
544
- const corsOrigins = options.corsOrigins ?? [
545
- 'http://localhost:5173',
546
- 'https://console.sync.dev',
547
- ];
548
- const allowWildcardCors = corsOrigins === '*';
549
-
550
- routes.use(
551
- '*',
552
- cors({
553
- origin: allowWildcardCors ? '*' : corsOrigins,
554
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
555
- allowHeaders: [
556
- 'Content-Type',
557
- 'Authorization',
558
- 'X-Syncular-Transport-Path',
559
- 'Baggage',
560
- 'Sentry-Trace',
561
- 'Traceparent',
562
- 'Tracestate',
563
- ],
564
- exposeHeaders: ['X-Total-Count'],
565
- credentials: !allowWildcardCors,
566
- })
567
- );
568
-
569
- const ensureConsoleSchemaReady = async (
570
- c: Context
571
- ): Promise<Response | null> => {
572
- try {
573
- await consoleSchemaReadyPromise;
574
- return null;
575
- } catch {
576
- return c.json({ error: 'CONSOLE_SCHEMA_UNAVAILABLE' }, 503);
577
- }
578
- };
579
-
580
- routes.use('*', async (c, next) => {
581
- const readyError = await ensureConsoleSchemaReady(c);
582
- if (readyError) {
583
- return readyError;
584
- }
585
- await next();
586
- });
587
-
588
- routes.use('*', async (c, next) => {
589
- if (c.req.method !== 'OPTIONS') {
590
- triggerAutomaticEventsPrune();
591
- }
592
- await next();
593
- });
594
-
595
- // Auth middleware
596
- const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
597
- const auth = await options.authenticate(c);
598
- if (!auth) {
599
- return null;
600
- }
601
- return auth;
602
- };
603
-
604
- const requestEventSelectColumns = [
605
- 'event_id',
606
- 'partition_id',
607
- 'request_id',
608
- 'trace_id',
609
- 'span_id',
610
- 'event_type',
611
- 'sync_path',
612
- 'transport_path',
613
- 'actor_id',
614
- 'client_id',
615
- 'status_code',
616
- 'outcome',
617
- 'response_status',
618
- 'error_code',
619
- 'duration_ms',
620
- 'commit_seq',
621
- 'operation_count',
622
- 'row_count',
623
- 'subscription_count',
624
- 'scopes_summary',
625
- 'tables',
626
- 'error_message',
627
- 'payload_ref',
628
- 'created_at',
629
- ] as const;
630
-
631
- const mapRequestEvent = (
632
- row: SyncRequestEventsTable
633
- ): ConsoleRequestEvent => ({
634
- eventId: coerceNumber(row.event_id) ?? 0,
635
- partitionId: row.partition_id ?? 'default',
636
- requestId: row.request_id ?? '',
637
- traceId: row.trace_id ?? null,
638
- spanId: row.span_id ?? null,
639
- eventType: row.event_type === 'push' ? 'push' : 'pull',
640
- syncPath: row.sync_path === 'ws-push' ? 'ws-push' : 'http-combined',
641
- transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
642
- actorId: row.actor_id ?? '',
643
- clientId: row.client_id ?? '',
644
- statusCode: coerceNumber(row.status_code) ?? 0,
645
- outcome: row.outcome ?? '',
646
- responseStatus: row.response_status ?? 'unknown',
647
- errorCode: row.error_code ?? null,
648
- durationMs: coerceNumber(row.duration_ms) ?? 0,
649
- commitSeq: coerceNumber(row.commit_seq),
650
- operationCount: coerceNumber(row.operation_count),
651
- rowCount: coerceNumber(row.row_count),
652
- subscriptionCount: coerceNumber(row.subscription_count),
653
- scopesSummary: parseScopesSummary(row.scopes_summary),
654
- tables: options.dialect.dbToArray(row.tables),
655
- errorMessage: row.error_message ?? null,
656
- payloadRef: row.payload_ref ?? null,
657
- createdAt: row.created_at ?? '',
658
- });
659
-
660
- const operationEventSelectColumns = [
661
- 'operation_id',
662
- 'operation_type',
663
- 'console_user_id',
664
- 'partition_id',
665
- 'target_client_id',
666
- 'request_payload',
667
- 'result_payload',
668
- 'created_at',
669
- ] as const;
670
-
671
- const mapOperationEvent = (
672
- row: SyncOperationEventRow
673
- ): ConsoleOperationEvent => ({
674
- operationId: coerceNumber(row.operation_id) ?? 0,
675
- operationType:
676
- row.operation_type === 'prune' ||
677
- row.operation_type === 'compact' ||
678
- row.operation_type === 'notify_data_change' ||
679
- row.operation_type === 'evict_client'
680
- ? row.operation_type
681
- : 'prune',
682
- consoleUserId: row.console_user_id ?? null,
683
- partitionId: row.partition_id ?? null,
684
- targetClientId: row.target_client_id ?? null,
685
- requestPayload: parseJsonValue(row.request_payload),
686
- resultPayload: parseJsonValue(row.result_payload),
687
- createdAt: row.created_at ?? '',
688
- });
689
-
690
- type PruneEventsRunResult = {
691
- requestEventsDeleted: number;
692
- operationEventsDeleted: number;
693
- payloadSnapshotsDeleted: number;
694
- totalDeleted: number;
695
- };
696
-
697
- const deleteUnreferencedPayloadSnapshots = async (): Promise<number> => {
698
- const result = await db
699
- .deleteFrom('sync_request_payloads')
700
- .where(
701
- 'payload_ref',
702
- 'not in',
703
- db
704
- .selectFrom('sync_request_events')
705
- .select('payload_ref')
706
- .where('payload_ref', 'is not', null)
707
- )
708
- .executeTakeFirst();
709
- return Number(result?.numDeletedRows ?? 0);
710
- };
711
-
712
- const pruneRequestEventsByAge = async (): Promise<number> => {
713
- if (requestEventsMaxAgeMs <= 0) {
714
- return 0;
715
- }
716
-
717
- const cutoffDate = new Date(Date.now() - requestEventsMaxAgeMs);
718
- const result = await db
719
- .deleteFrom('sync_request_events')
720
- .where('created_at', '<', cutoffDate.toISOString())
721
- .executeTakeFirst();
722
-
723
- return Number(result?.numDeletedRows ?? 0);
724
- };
725
-
726
- const pruneRequestEventsByCount = async (): Promise<number> => {
727
- if (requestEventsMaxRows <= 0) {
728
- return 0;
729
- }
730
-
731
- const countRow = await db
732
- .selectFrom('sync_request_events')
733
- .select(({ fn }) => fn.countAll().as('total'))
734
- .executeTakeFirst();
735
-
736
- const total = coerceNumber(countRow?.total) ?? 0;
737
- if (total <= requestEventsMaxRows) {
738
- return 0;
739
- }
740
-
741
- const cutoffRow = await db
742
- .selectFrom('sync_request_events')
743
- .select(['event_id'])
744
- .orderBy('event_id', 'desc')
745
- .offset(requestEventsMaxRows)
746
- .limit(1)
747
- .executeTakeFirst();
748
-
749
- const cutoffEventId = coerceNumber(cutoffRow?.event_id);
750
- if (cutoffEventId === null) {
751
- return 0;
752
- }
753
-
754
- const result = await db
755
- .deleteFrom('sync_request_events')
756
- .where('event_id', '<=', cutoffEventId)
757
- .executeTakeFirst();
758
- return Number(result?.numDeletedRows ?? 0);
759
- };
760
-
761
- const pruneOperationEventsByAge = async (): Promise<number> => {
762
- if (operationEventsMaxAgeMs <= 0) {
763
- return 0;
764
- }
765
-
766
- const cutoffDate = new Date(Date.now() - operationEventsMaxAgeMs);
767
- const result = await db
768
- .deleteFrom('sync_operation_events')
769
- .where('created_at', '<', cutoffDate.toISOString())
770
- .executeTakeFirst();
771
- return Number(result?.numDeletedRows ?? 0);
772
- };
773
-
774
- const pruneOperationEventsByCount = async (): Promise<number> => {
775
- if (operationEventsMaxRows <= 0) {
776
- return 0;
777
- }
778
-
779
- const countRow = await db
780
- .selectFrom('sync_operation_events')
781
- .select(({ fn }) => fn.countAll().as('total'))
782
- .executeTakeFirst();
783
- const total = coerceNumber(countRow?.total) ?? 0;
784
- if (total <= operationEventsMaxRows) {
785
- return 0;
786
- }
787
-
788
- const cutoffRow = await db
789
- .selectFrom('sync_operation_events')
790
- .select(['operation_id'])
791
- .orderBy('operation_id', 'desc')
792
- .offset(operationEventsMaxRows)
793
- .limit(1)
794
- .executeTakeFirst();
795
-
796
- const cutoffOperationId = coerceNumber(cutoffRow?.operation_id);
797
- if (cutoffOperationId === null) {
798
- return 0;
799
- }
800
-
801
- const result = await db
802
- .deleteFrom('sync_operation_events')
803
- .where('operation_id', '<=', cutoffOperationId)
804
- .executeTakeFirst();
805
- return Number(result?.numDeletedRows ?? 0);
806
- };
807
-
808
- const pruneConsoleEvents = async (): Promise<PruneEventsRunResult> => {
809
- const requestEventsDeletedByAge = await pruneRequestEventsByAge();
810
- const requestEventsDeletedByCount = await pruneRequestEventsByCount();
811
- const requestEventsDeleted =
812
- requestEventsDeletedByAge + requestEventsDeletedByCount;
813
-
814
- const operationEventsDeletedByAge = await pruneOperationEventsByAge();
815
- const operationEventsDeletedByCount = await pruneOperationEventsByCount();
816
- const operationEventsDeleted =
817
- operationEventsDeletedByAge + operationEventsDeletedByCount;
818
-
819
- const payloadSnapshotsDeleted = await deleteUnreferencedPayloadSnapshots();
820
- const totalDeleted = requestEventsDeleted + operationEventsDeleted;
821
-
822
- return {
823
- requestEventsDeleted,
824
- operationEventsDeleted,
825
- payloadSnapshotsDeleted,
826
- totalDeleted,
827
- };
828
- };
829
-
830
- let eventsPrunePromise: Promise<PruneEventsRunResult> | null = null;
831
-
832
- const runEventsPrune = async (): Promise<PruneEventsRunResult> => {
833
- if (eventsPrunePromise) {
834
- return eventsPrunePromise;
835
- }
836
-
837
- let pending: Promise<PruneEventsRunResult>;
838
- pending = pruneConsoleEvents()
839
- .then((result) => {
840
- lastEventsPruneRunAt = Date.now();
841
- return result;
842
- })
843
- .finally(() => {
844
- if (eventsPrunePromise === pending) {
845
- eventsPrunePromise = null;
846
- }
847
- });
848
-
849
- eventsPrunePromise = pending;
850
- return pending;
851
- };
852
-
853
- const triggerAutomaticEventsPrune = (): void => {
854
- if (autoEventsPruneIntervalMs <= 0) {
855
- return;
856
- }
857
- if (eventsPrunePromise) {
858
- return;
859
- }
860
- if (Date.now() - lastEventsPruneRunAt < autoEventsPruneIntervalMs) {
861
- return;
862
- }
863
-
864
- void runEventsPrune()
865
- .then((result) => {
866
- if (result.totalDeleted <= 0 && result.payloadSnapshotsDeleted <= 0) {
867
- return;
868
- }
869
-
870
- logSyncEvent({
871
- event: 'console.prune_events_auto',
872
- deletedCount: result.totalDeleted,
873
- requestEventsDeleted: result.requestEventsDeleted,
874
- operationEventsDeleted: result.operationEventsDeleted,
875
- payloadDeletedCount: result.payloadSnapshotsDeleted,
876
- });
877
- })
878
- .catch((error) => {
879
- logSyncEvent({
880
- event: 'console.prune_events_auto_failed',
881
- error: error instanceof Error ? error.message : String(error),
882
- });
883
- });
884
- };
885
-
886
- const recordOperationEvent = async (event: {
887
- operationType: ConsoleOperationType;
888
- consoleUserId?: string;
889
- partitionId?: string | null;
890
- targetClientId?: string | null;
891
- requestPayload?: unknown;
892
- resultPayload?: unknown;
893
- }) => {
894
- await db
895
- .insertInto('sync_operation_events')
896
- .values({
897
- operation_type: event.operationType,
898
- console_user_id: event.consoleUserId ?? null,
899
- partition_id: event.partitionId ?? null,
900
- target_client_id: event.targetClientId ?? null,
901
- request_payload:
902
- event.requestPayload === undefined
903
- ? null
904
- : JSON.stringify(event.requestPayload),
905
- result_payload:
906
- event.resultPayload === undefined
907
- ? null
908
- : JSON.stringify(event.resultPayload),
909
- })
910
- .execute();
911
- };
912
-
913
- const shouldUseRawMetrics = async (
914
- startIso: string,
915
- partitionId?: string
916
- ): Promise<boolean> => {
917
- if (metricsAggregationMode === 'raw') {
918
- return true;
919
- }
920
- if (metricsAggregationMode === 'aggregated') {
921
- return false;
922
- }
923
-
924
- let countQuery = db
925
- .selectFrom('sync_request_events')
926
- .select(({ fn }) => fn.countAll().as('total'))
927
- .where('created_at', '>=', startIso);
928
-
929
- if (partitionId) {
930
- countQuery = countQuery.where('partition_id', '=', partitionId);
931
- }
932
-
933
- const countRow = await countQuery.executeTakeFirst();
934
- const total = coerceNumber(countRow?.total) ?? 0;
935
- return total <= rawFallbackMaxEvents;
936
- };
937
-
938
- // -------------------------------------------------------------------------
939
- // GET /stats
940
- // -------------------------------------------------------------------------
941
-
942
- routes.get(
943
- '/stats',
944
- describeRoute({
945
- tags: ['console'],
946
- summary: 'Get sync statistics',
947
- responses: {
948
- 200: {
949
- description: 'Sync statistics',
950
- content: {
951
- 'application/json': { schema: resolver(SyncStatsSchema) },
952
- },
953
- },
954
- 401: {
955
- description: 'Unauthenticated',
956
- content: {
957
- 'application/json': { schema: resolver(ErrorResponseSchema) },
958
- },
959
- },
960
- },
961
- }),
962
- zValidator('query', ConsolePartitionQuerySchema),
963
- async (c) => {
964
- const auth = await requireAuth(c);
965
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
966
- const { partitionId } = c.req.valid('query');
967
-
968
- const stats: SyncStats = await readSyncStats(options.db, {
969
- partitionId,
970
- });
971
-
972
- logSyncEvent({
973
- event: 'console.stats',
974
- consoleUserId: auth.consoleUserId,
975
- });
976
-
977
- return c.json(stats, 200);
978
- }
979
- );
980
-
981
- // -------------------------------------------------------------------------
982
- // GET /stats/timeseries
983
- // -------------------------------------------------------------------------
984
-
985
- routes.get(
986
- '/stats/timeseries',
987
- describeRoute({
988
- tags: ['console'],
989
- summary: 'Get time-series statistics',
990
- responses: {
991
- 200: {
992
- description: 'Time-series statistics',
993
- content: {
994
- 'application/json': {
995
- schema: resolver(TimeseriesStatsResponseSchema),
996
- },
997
- },
998
- },
999
- 401: {
1000
- description: 'Unauthenticated',
1001
- content: {
1002
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1003
- },
1004
- },
1005
- },
1006
- }),
1007
- zValidator('query', TimeseriesQuerySchema),
1008
- async (c) => {
1009
- const auth = await requireAuth(c);
1010
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1011
-
1012
- const { interval, range, partitionId } = c.req.valid('query');
1013
-
1014
- const rangeMs = rangeToMs(range);
1015
- const startTime = new Date(Date.now() - rangeMs);
1016
- const startIso = startTime.toISOString();
1017
- const intervalMs = intervalToMs(interval);
1018
- const bucketMap = createTimeseriesBucketMap({
1019
- startTime,
1020
- rangeMs,
1021
- intervalMs,
1022
- });
1023
- const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
1024
-
1025
- if (useRawMetrics) {
1026
- let eventsQuery = db
1027
- .selectFrom('sync_request_events')
1028
- .select(['event_type', 'duration_ms', 'outcome', 'created_at'])
1029
- .where('created_at', '>=', startIso);
1030
-
1031
- if (partitionId) {
1032
- eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
1033
- }
1034
-
1035
- const events = await eventsQuery.orderBy('created_at', 'asc').execute();
1036
-
1037
- for (const event of events) {
1038
- const eventTime = parseDate(event.created_at);
1039
- if (eventTime === null) continue;
1040
- const bucketIndex = Math.floor(
1041
- (eventTime - startTime.getTime()) / intervalMs
1042
- );
1043
- const bucketTime = new Date(
1044
- startTime.getTime() + bucketIndex * intervalMs
1045
- ).toISOString();
1046
-
1047
- let bucket = bucketMap.get(bucketTime);
1048
- if (!bucket) {
1049
- bucket = createEmptyTimeseriesAccumulator();
1050
- bucketMap.set(bucketTime, bucket);
1051
- }
1052
-
1053
- if (event.event_type === 'push') {
1054
- bucket.pushCount++;
1055
- } else if (event.event_type === 'pull') {
1056
- bucket.pullCount++;
1057
- }
1058
-
1059
- if (event.outcome === 'error') {
1060
- bucket.errorCount++;
1061
- }
1062
-
1063
- const durationMs = coerceNumber(event.duration_ms);
1064
- if (durationMs !== null) {
1065
- bucket.totalLatency += durationMs;
1066
- bucket.eventCount++;
1067
- }
1068
- }
1069
- } else {
1070
- const partitionFilter = partitionId
1071
- ? sql`and partition_id = ${partitionId}`
1072
- : sql``;
1073
-
1074
- if (options.dialect.family === 'sqlite') {
1075
- const bucketFormat = intervalToSqliteBucketFormat(interval);
1076
- const rowsResult = await sql<{
1077
- bucket: unknown;
1078
- push_count: unknown;
1079
- pull_count: unknown;
1080
- error_count: unknown;
1081
- avg_latency_ms: unknown;
1082
- }>`
1083
- select
1084
- strftime(${bucketFormat}, created_at) as bucket,
1085
- sum(case when event_type = 'push' then 1 else 0 end) as push_count,
1086
- sum(case when event_type = 'pull' then 1 else 0 end) as pull_count,
1087
- sum(case when outcome = 'error' then 1 else 0 end) as error_count,
1088
- avg(duration_ms) as avg_latency_ms
1089
- from ${sql.table('sync_request_events')}
1090
- where created_at >= ${startIso}
1091
- ${partitionFilter}
1092
- group by 1
1093
- order by 1 asc
1094
- `.execute(options.db);
1095
-
1096
- for (const row of rowsResult.rows) {
1097
- const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
1098
- if (!bucketTimestamp) continue;
1099
-
1100
- let bucket = bucketMap.get(bucketTimestamp);
1101
- if (!bucket) {
1102
- bucket = createEmptyTimeseriesAccumulator();
1103
- bucketMap.set(bucketTimestamp, bucket);
1104
- }
1105
-
1106
- const pushCount = coerceNumber(row.push_count) ?? 0;
1107
- const pullCount = coerceNumber(row.pull_count) ?? 0;
1108
- const errorCount = coerceNumber(row.error_count) ?? 0;
1109
- const avgLatencyMs = coerceNumber(row.avg_latency_ms);
1110
- const rowEventCount = pushCount + pullCount;
1111
-
1112
- bucket.pushCount += pushCount;
1113
- bucket.pullCount += pullCount;
1114
- bucket.errorCount += errorCount;
1115
- if (avgLatencyMs !== null && rowEventCount > 0) {
1116
- bucket.totalLatency += avgLatencyMs * rowEventCount;
1117
- bucket.eventCount += rowEventCount;
1118
- }
1119
- }
1120
- } else {
1121
- const rowsResult = await sql<{
1122
- bucket: unknown;
1123
- push_count: unknown;
1124
- pull_count: unknown;
1125
- error_count: unknown;
1126
- avg_latency_ms: unknown;
1127
- }>`
1128
- select
1129
- date_trunc(${interval}, created_at::timestamptz) as bucket,
1130
- count(*) filter (where event_type = 'push') as push_count,
1131
- count(*) filter (where event_type = 'pull') as pull_count,
1132
- count(*) filter (where outcome = 'error') as error_count,
1133
- avg(duration_ms) as avg_latency_ms
1134
- from ${sql.table('sync_request_events')}
1135
- where created_at >= ${startIso}
1136
- ${partitionFilter}
1137
- group by 1
1138
- order by 1 asc
1139
- `.execute(options.db);
1140
-
1141
- for (const row of rowsResult.rows) {
1142
- const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
1143
- if (!bucketTimestamp) continue;
1144
-
1145
- let bucket = bucketMap.get(bucketTimestamp);
1146
- if (!bucket) {
1147
- bucket = createEmptyTimeseriesAccumulator();
1148
- bucketMap.set(bucketTimestamp, bucket);
1149
- }
1150
-
1151
- const pushCount = coerceNumber(row.push_count) ?? 0;
1152
- const pullCount = coerceNumber(row.pull_count) ?? 0;
1153
- const errorCount = coerceNumber(row.error_count) ?? 0;
1154
- const avgLatencyMs = coerceNumber(row.avg_latency_ms);
1155
- const rowEventCount = pushCount + pullCount;
1156
-
1157
- bucket.pushCount += pushCount;
1158
- bucket.pullCount += pullCount;
1159
- bucket.errorCount += errorCount;
1160
- if (avgLatencyMs !== null && rowEventCount > 0) {
1161
- bucket.totalLatency += avgLatencyMs * rowEventCount;
1162
- bucket.eventCount += rowEventCount;
1163
- }
1164
- }
1165
- }
1166
- }
1167
-
1168
- const buckets: TimeseriesBucket[] = finalizeTimeseriesBuckets(bucketMap);
1169
-
1170
- const response: TimeseriesStatsResponse = {
1171
- buckets,
1172
- interval,
1173
- range,
1174
- };
1175
-
1176
- return c.json(response, 200);
1177
- }
1178
- );
1179
-
1180
- // -------------------------------------------------------------------------
1181
- // GET /stats/latency
1182
- // -------------------------------------------------------------------------
1183
-
1184
- routes.get(
1185
- '/stats/latency',
1186
- describeRoute({
1187
- tags: ['console'],
1188
- summary: 'Get latency percentiles',
1189
- responses: {
1190
- 200: {
1191
- description: 'Latency percentiles',
1192
- content: {
1193
- 'application/json': {
1194
- schema: resolver(LatencyStatsResponseSchema),
1195
- },
1196
- },
1197
- },
1198
- 401: {
1199
- description: 'Unauthenticated',
1200
- content: {
1201
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1202
- },
1203
- },
1204
- },
1205
- }),
1206
- zValidator('query', LatencyQuerySchema),
1207
- async (c) => {
1208
- const auth = await requireAuth(c);
1209
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1210
-
1211
- const { range, partitionId } = c.req.valid('query');
1212
-
1213
- const rangeMs = rangeToMs(range);
1214
- const startTime = new Date(Date.now() - rangeMs);
1215
- const startIso = startTime.toISOString();
1216
- const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
1217
-
1218
- if (!useRawMetrics && options.dialect.family !== 'sqlite') {
1219
- const partitionFilter = partitionId
1220
- ? sql`and partition_id = ${partitionId}`
1221
- : sql``;
1222
- const rowsResult = await sql<{
1223
- event_type: unknown;
1224
- p50: unknown;
1225
- p90: unknown;
1226
- p99: unknown;
1227
- }>`
1228
- select
1229
- event_type,
1230
- percentile_disc(0.5) within group (order by duration_ms) as p50,
1231
- percentile_disc(0.9) within group (order by duration_ms) as p90,
1232
- percentile_disc(0.99) within group (order by duration_ms) as p99
1233
- from ${sql.table('sync_request_events')}
1234
- where created_at >= ${startIso}
1235
- ${partitionFilter}
1236
- group by event_type
1237
- `.execute(options.db);
1238
-
1239
- const push: LatencyPercentiles = { p50: 0, p90: 0, p99: 0 };
1240
- const pull: LatencyPercentiles = { p50: 0, p90: 0, p99: 0 };
1241
-
1242
- for (const row of rowsResult.rows) {
1243
- const eventType = row.event_type === 'push' ? 'push' : 'pull';
1244
- const target = eventType === 'push' ? push : pull;
1245
- target.p50 = coerceNumber(row.p50) ?? 0;
1246
- target.p90 = coerceNumber(row.p90) ?? 0;
1247
- target.p99 = coerceNumber(row.p99) ?? 0;
1248
- }
1249
-
1250
- const aggregatedResponse: LatencyStatsResponse = {
1251
- push,
1252
- pull,
1253
- range,
1254
- };
1255
- return c.json(aggregatedResponse, 200);
1256
- }
1257
-
1258
- // Raw fallback path (default for local/dev and SQLite)
1259
- let eventsQuery = db
1260
- .selectFrom('sync_request_events')
1261
- .select(['event_type', 'duration_ms'])
1262
- .where('created_at', '>=', startIso);
1263
-
1264
- if (partitionId) {
1265
- eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
1266
- }
1267
-
1268
- const events = await eventsQuery.execute();
1269
-
1270
- const pushLatencies: number[] = [];
1271
- const pullLatencies: number[] = [];
1272
-
1273
- for (const event of events) {
1274
- const durationMs = coerceNumber(event.duration_ms);
1275
- if (durationMs !== null) {
1276
- if (event.event_type === 'push') {
1277
- pushLatencies.push(durationMs);
1278
- } else if (event.event_type === 'pull') {
1279
- pullLatencies.push(durationMs);
1280
- }
1281
- }
1282
- }
1283
-
1284
- const response: LatencyStatsResponse = {
1285
- push: calculatePercentiles(pushLatencies),
1286
- pull: calculatePercentiles(pullLatencies),
1287
- range,
1288
- };
1289
-
1290
- return c.json(response, 200);
1291
- }
1292
- );
1293
-
1294
- // -------------------------------------------------------------------------
1295
- // GET /timeline
1296
- // -------------------------------------------------------------------------
1297
-
1298
- routes.get(
1299
- '/timeline',
1300
- describeRoute({
1301
- tags: ['console'],
1302
- summary: 'List timeline items',
1303
- responses: {
1304
- 200: {
1305
- description: 'Paginated merged timeline',
1306
- content: {
1307
- 'application/json': {
1308
- schema: resolver(
1309
- ConsolePaginatedResponseSchema(ConsoleTimelineItemSchema)
1310
- ),
1311
- },
1312
- },
1313
- },
1314
- 401: {
1315
- description: 'Unauthenticated',
1316
- content: {
1317
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1318
- },
1319
- },
1320
- },
1321
- }),
1322
- zValidator('query', ConsoleTimelineQuerySchema),
1323
- async (c) => {
1324
- const auth = await requireAuth(c);
1325
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1326
-
1327
- const {
1328
- limit,
1329
- offset,
1330
- view,
1331
- partitionId,
1332
- eventType,
1333
- actorId,
1334
- clientId,
1335
- requestId,
1336
- traceId,
1337
- table,
1338
- outcome,
1339
- search,
1340
- from,
1341
- to,
1342
- } = c.req.valid('query');
1343
-
1344
- const items: ConsoleTimelineItem[] = [];
1345
- const normalizedSearchTerm = search?.trim().toLowerCase() || null;
1346
- const normalizedTable = table?.trim() || null;
1347
-
1348
- if (
1349
- view !== 'events' &&
1350
- !eventType &&
1351
- !outcome &&
1352
- !requestId &&
1353
- !traceId
1354
- ) {
1355
- let commitsQuery = db
1356
- .selectFrom('sync_commits')
1357
- .select([
1358
- 'commit_seq',
1359
- 'actor_id',
1360
- 'client_id',
1361
- 'client_commit_id',
1362
- 'created_at',
1363
- 'change_count',
1364
- 'affected_tables',
1365
- ]);
1366
-
1367
- if (partitionId) {
1368
- commitsQuery = commitsQuery.where('partition_id', '=', partitionId);
1369
- }
1370
- if (actorId) {
1371
- commitsQuery = commitsQuery.where('actor_id', '=', actorId);
1372
- }
1373
- if (clientId) {
1374
- commitsQuery = commitsQuery.where('client_id', '=', clientId);
1375
- }
1376
- if (from) {
1377
- commitsQuery = commitsQuery.where('created_at', '>=', from);
1378
- }
1379
- if (to) {
1380
- commitsQuery = commitsQuery.where('created_at', '<=', to);
1381
- }
1382
-
1383
- const commitRows = await commitsQuery.execute();
1384
- for (const row of commitRows) {
1385
- const commit: ConsoleCommitListItem = {
1386
- commitSeq: coerceNumber(row.commit_seq) ?? 0,
1387
- actorId: row.actor_id ?? '',
1388
- clientId: row.client_id ?? '',
1389
- clientCommitId: row.client_commit_id ?? '',
1390
- createdAt: row.created_at ?? '',
1391
- changeCount: coerceNumber(row.change_count) ?? 0,
1392
- affectedTables: options.dialect.dbToArray(row.affected_tables),
1393
- };
1394
-
1395
- items.push({
1396
- type: 'commit',
1397
- timestamp: commit.createdAt,
1398
- commit,
1399
- event: null,
1400
- });
1401
- }
1402
- }
1403
-
1404
- if (view !== 'commits') {
1405
- let eventsQuery = db
1406
- .selectFrom('sync_request_events')
1407
- .select(requestEventSelectColumns);
1408
-
1409
- if (partitionId) {
1410
- eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
1411
- }
1412
- if (eventType) {
1413
- eventsQuery = eventsQuery.where('event_type', '=', eventType);
1414
- }
1415
- if (actorId) {
1416
- eventsQuery = eventsQuery.where('actor_id', '=', actorId);
1417
- }
1418
- if (clientId) {
1419
- eventsQuery = eventsQuery.where('client_id', '=', clientId);
1420
- }
1421
- if (requestId) {
1422
- eventsQuery = eventsQuery.where('request_id', '=', requestId);
1423
- }
1424
- if (traceId) {
1425
- eventsQuery = eventsQuery.where('trace_id', '=', traceId);
1426
- }
1427
- if (outcome) {
1428
- eventsQuery = eventsQuery.where('outcome', '=', outcome);
1429
- }
1430
- if (from) {
1431
- eventsQuery = eventsQuery.where('created_at', '>=', from);
1432
- }
1433
- if (to) {
1434
- eventsQuery = eventsQuery.where('created_at', '<=', to);
1435
- }
1436
-
1437
- const eventRows = await eventsQuery.execute();
1438
- for (const row of eventRows) {
1439
- const event = mapRequestEvent(row);
1440
-
1441
- items.push({
1442
- type: 'event',
1443
- timestamp: event.createdAt,
1444
- commit: null,
1445
- event,
1446
- });
1447
- }
1448
- }
1449
-
1450
- const filteredItems = items.filter((item) => {
1451
- if (item.type === 'commit') {
1452
- const commit = item.commit;
1453
- if (!commit) return false;
1454
-
1455
- if (
1456
- normalizedTable &&
1457
- !(commit.affectedTables ?? []).includes(normalizedTable)
1458
- ) {
1459
- return false;
1460
- }
1461
-
1462
- if (!normalizedSearchTerm) return true;
1463
-
1464
- const searchableCommitFields = [
1465
- String(commit.commitSeq),
1466
- commit.actorId,
1467
- commit.clientId,
1468
- commit.clientCommitId,
1469
- ...(commit.affectedTables ?? []),
1470
- ];
1471
-
1472
- return searchableCommitFields.some((field) =>
1473
- includesSearchTerm(field, normalizedSearchTerm)
1474
- );
1475
- }
1476
-
1477
- const event = item.event;
1478
- if (!event) return false;
1479
-
1480
- if (
1481
- normalizedTable &&
1482
- !(event.tables ?? []).includes(normalizedTable)
1483
- ) {
1484
- return false;
1485
- }
1486
-
1487
- if (!normalizedSearchTerm) return true;
1488
-
1489
- const searchableEventFields = [
1490
- String(event.eventId),
1491
- event.requestId,
1492
- event.traceId ?? '',
1493
- event.actorId,
1494
- event.clientId,
1495
- event.outcome,
1496
- event.responseStatus,
1497
- event.errorCode ?? '',
1498
- event.errorMessage ?? '',
1499
- ...(event.tables ?? []),
1500
- ];
1501
-
1502
- return searchableEventFields.some((field) =>
1503
- includesSearchTerm(field, normalizedSearchTerm)
1504
- );
1505
- });
1506
-
1507
- filteredItems.sort(
1508
- (a, b) => (parseDate(b.timestamp) ?? 0) - (parseDate(a.timestamp) ?? 0)
1509
- );
1510
-
1511
- const total = filteredItems.length;
1512
- const pagedItems = filteredItems.slice(offset, offset + limit);
1513
-
1514
- const response: ConsolePaginatedResponse<ConsoleTimelineItem> = {
1515
- items: pagedItems,
1516
- total,
1517
- offset,
1518
- limit,
1519
- };
1520
-
1521
- c.header('X-Total-Count', String(total));
1522
- return c.json(response, 200);
1523
- }
1524
- );
1525
-
1526
- // -------------------------------------------------------------------------
1527
- // GET /commits
1528
- // -------------------------------------------------------------------------
1529
-
1530
- routes.get(
1531
- '/commits',
1532
- describeRoute({
1533
- tags: ['console'],
1534
- summary: 'List commits',
1535
- responses: {
1536
- 200: {
1537
- description: 'Paginated commit list',
1538
- content: {
1539
- 'application/json': {
1540
- schema: resolver(
1541
- ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)
1542
- ),
1543
- },
1544
- },
1545
- },
1546
- 401: {
1547
- description: 'Unauthenticated',
1548
- content: {
1549
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1550
- },
1551
- },
1552
- },
1553
- }),
1554
- zValidator('query', ConsolePartitionedPaginationQuerySchema),
1555
- async (c) => {
1556
- const auth = await requireAuth(c);
1557
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1558
-
1559
- const { limit, offset, partitionId } = c.req.valid('query');
1560
-
1561
- let query = db
1562
- .selectFrom('sync_commits')
1563
- .select([
1564
- 'commit_seq',
1565
- 'actor_id',
1566
- 'client_id',
1567
- 'client_commit_id',
1568
- 'created_at',
1569
- 'change_count',
1570
- 'affected_tables',
1571
- ]);
1572
-
1573
- let countQuery = db
1574
- .selectFrom('sync_commits')
1575
- .select(({ fn }) => fn.countAll().as('total'));
1576
-
1577
- if (partitionId) {
1578
- query = query.where('partition_id', '=', partitionId);
1579
- countQuery = countQuery.where('partition_id', '=', partitionId);
1580
- }
1581
-
1582
- const [rows, countRow] = await Promise.all([
1583
- query
1584
- .orderBy('commit_seq', 'desc')
1585
- .limit(limit)
1586
- .offset(offset)
1587
- .execute(),
1588
- countQuery.executeTakeFirst(),
1589
- ]);
1590
-
1591
- const items: ConsoleCommitListItem[] = rows.map((row) => ({
1592
- commitSeq: coerceNumber(row.commit_seq) ?? 0,
1593
- actorId: row.actor_id ?? '',
1594
- clientId: row.client_id ?? '',
1595
- clientCommitId: row.client_commit_id ?? '',
1596
- createdAt: row.created_at ?? '',
1597
- changeCount: coerceNumber(row.change_count) ?? 0,
1598
- affectedTables: options.dialect.dbToArray(row.affected_tables),
1599
- }));
1600
-
1601
- const total = coerceNumber(countRow?.total) ?? 0;
1602
-
1603
- const response: ConsolePaginatedResponse<ConsoleCommitListItem> = {
1604
- items,
1605
- total,
1606
- offset,
1607
- limit,
1608
- };
1609
-
1610
- c.header('X-Total-Count', String(total));
1611
- return c.json(response, 200);
1612
- }
1613
- );
1614
-
1615
- // -------------------------------------------------------------------------
1616
- // GET /commits/:seq
1617
- // -------------------------------------------------------------------------
1618
-
1619
- routes.get(
1620
- '/commits/:seq',
1621
- describeRoute({
1622
- tags: ['console'],
1623
- summary: 'Get commit details',
1624
- responses: {
1625
- 200: {
1626
- description: 'Commit with changes',
1627
- content: {
1628
- 'application/json': { schema: resolver(ConsoleCommitDetailSchema) },
1629
- },
1630
- },
1631
- 400: {
1632
- description: 'Invalid request',
1633
- content: {
1634
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1635
- },
1636
- },
1637
- 401: {
1638
- description: 'Unauthenticated',
1639
- content: {
1640
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1641
- },
1642
- },
1643
- 404: {
1644
- description: 'Not found',
1645
- content: {
1646
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1647
- },
1648
- },
1649
- },
1650
- }),
1651
- zValidator('param', commitSeqParamSchema),
1652
- zValidator('query', commitDetailQuerySchema),
1653
- async (c) => {
1654
- const auth = await requireAuth(c);
1655
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1656
-
1657
- const { seq } = c.req.valid('param');
1658
- const { partitionId } = c.req.valid('query');
1659
-
1660
- let commitQuery = db
1661
- .selectFrom('sync_commits')
1662
- .select([
1663
- 'commit_seq',
1664
- 'actor_id',
1665
- 'client_id',
1666
- 'client_commit_id',
1667
- 'created_at',
1668
- 'change_count',
1669
- 'affected_tables',
1670
- ])
1671
- .where('commit_seq', '=', seq);
1672
-
1673
- if (partitionId) {
1674
- commitQuery = commitQuery.where('partition_id', '=', partitionId);
1675
- }
1676
-
1677
- const commitRow = await commitQuery.executeTakeFirst();
1678
-
1679
- if (!commitRow) {
1680
- return c.json({ error: 'NOT_FOUND' }, 404);
1681
- }
1682
-
1683
- let changesQuery = db
1684
- .selectFrom('sync_changes')
1685
- .select([
1686
- 'change_id',
1687
- 'table',
1688
- 'row_id',
1689
- 'op',
1690
- 'row_json',
1691
- 'row_version',
1692
- 'scopes',
1693
- ])
1694
- .where('commit_seq', '=', seq);
1695
-
1696
- if (partitionId) {
1697
- changesQuery = changesQuery.where('partition_id', '=', partitionId);
1698
- }
1699
-
1700
- const changeRows = await changesQuery
1701
- .orderBy('change_id', 'asc')
1702
- .execute();
1703
-
1704
- const changes: ConsoleChange[] = changeRows.map((row) => ({
1705
- changeId: coerceNumber(row.change_id) ?? 0,
1706
- table: row.table ?? '',
1707
- rowId: row.row_id ?? '',
1708
- op: row.op === 'delete' ? 'delete' : 'upsert',
1709
- rowJson:
1710
- typeof row.row_json === 'string'
1711
- ? (() => {
1712
- try {
1713
- return JSON.parse(row.row_json);
1714
- } catch {
1715
- return row.row_json;
1716
- }
1717
- })()
1718
- : row.row_json,
1719
- rowVersion: coerceNumber(row.row_version),
1720
- scopes:
1721
- typeof row.scopes === 'string'
1722
- ? JSON.parse(row.scopes || '{}')
1723
- : (row.scopes ?? {}),
1724
- }));
1725
-
1726
- const commit: ConsoleCommitDetail = {
1727
- commitSeq: coerceNumber(commitRow.commit_seq) ?? 0,
1728
- actorId: commitRow.actor_id ?? '',
1729
- clientId: commitRow.client_id ?? '',
1730
- clientCommitId: commitRow.client_commit_id ?? '',
1731
- createdAt: commitRow.created_at ?? '',
1732
- changeCount: coerceNumber(commitRow.change_count) ?? 0,
1733
- affectedTables: Array.isArray(commitRow.affected_tables)
1734
- ? commitRow.affected_tables
1735
- : typeof commitRow.affected_tables === 'string'
1736
- ? JSON.parse(commitRow.affected_tables || '[]')
1737
- : [],
1738
- changes,
1739
- };
1740
-
1741
- return c.json(commit, 200);
1742
- }
1743
- );
1744
-
1745
- // -------------------------------------------------------------------------
1746
- // GET /clients
1747
- // -------------------------------------------------------------------------
1748
-
1749
- routes.get(
1750
- '/clients',
1751
- describeRoute({
1752
- tags: ['console'],
1753
- summary: 'List clients',
1754
- responses: {
1755
- 200: {
1756
- description: 'Paginated client list',
1757
- content: {
1758
- 'application/json': {
1759
- schema: resolver(
1760
- ConsolePaginatedResponseSchema(ConsoleClientSchema)
1761
- ),
1762
- },
1763
- },
1764
- },
1765
- 401: {
1766
- description: 'Unauthenticated',
1767
- content: {
1768
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1769
- },
1770
- },
1771
- },
1772
- }),
1773
- zValidator('query', ConsolePartitionedPaginationQuerySchema),
1774
- async (c) => {
1775
- const auth = await requireAuth(c);
1776
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1777
-
1778
- const { limit, offset, partitionId } = c.req.valid('query');
1779
-
1780
- let clientsQuery = db
1781
- .selectFrom('sync_client_cursors')
1782
- .select([
1783
- 'client_id',
1784
- 'actor_id',
1785
- 'cursor',
1786
- 'effective_scopes',
1787
- 'updated_at',
1788
- ]);
1789
- let countQuery = db
1790
- .selectFrom('sync_client_cursors')
1791
- .select(({ fn }) => fn.countAll().as('total'));
1792
- let maxCommitSeqQuery = db
1793
- .selectFrom('sync_commits')
1794
- .select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'));
1795
-
1796
- if (partitionId) {
1797
- clientsQuery = clientsQuery.where('partition_id', '=', partitionId);
1798
- countQuery = countQuery.where('partition_id', '=', partitionId);
1799
- maxCommitSeqQuery = maxCommitSeqQuery.where(
1800
- 'partition_id',
1801
- '=',
1802
- partitionId
1803
- );
1804
- }
1805
-
1806
- const [rows, countRow, maxCommitSeqRow] = await Promise.all([
1807
- clientsQuery
1808
- .orderBy('updated_at', 'desc')
1809
- .limit(limit)
1810
- .offset(offset)
1811
- .execute(),
1812
- countQuery.executeTakeFirst(),
1813
- maxCommitSeqQuery.executeTakeFirst(),
1814
- ]);
1815
-
1816
- const maxCommitSeq = coerceNumber(maxCommitSeqRow?.max_commit_seq) ?? 0;
1817
- const pagedClientIds = rows
1818
- .map((row) => row.client_id)
1819
- .filter((clientId): clientId is string => typeof clientId === 'string');
1820
-
1821
- const latestEventsByClientId = new Map<
1822
- string,
1823
- {
1824
- createdAt: string;
1825
- eventType: 'push' | 'pull';
1826
- outcome: string;
1827
- transportPath: 'direct' | 'relay';
1828
- }
1829
- >();
1830
-
1831
- if (pagedClientIds.length > 0) {
1832
- let recentEventsQuery = db
1833
- .selectFrom('sync_request_events')
1834
- .select([
1835
- 'client_id',
1836
- 'event_type',
1837
- 'outcome',
1838
- 'created_at',
1839
- 'transport_path',
1840
- ])
1841
- .where('client_id', 'in', pagedClientIds);
1842
-
1843
- if (partitionId) {
1844
- recentEventsQuery = recentEventsQuery.where(
1845
- 'partition_id',
1846
- '=',
1847
- partitionId
1848
- );
1849
- }
1850
-
1851
- const recentEventRows = await recentEventsQuery
1852
- .orderBy('created_at', 'desc')
1853
- .execute();
1854
-
1855
- for (const row of recentEventRows) {
1856
- const clientId = row.client_id;
1857
- if (!clientId || latestEventsByClientId.has(clientId)) {
1858
- continue;
1859
- }
1860
-
1861
- const eventType = row.event_type === 'push' ? 'push' : 'pull';
1862
-
1863
- latestEventsByClientId.set(clientId, {
1864
- createdAt: row.created_at ?? '',
1865
- eventType,
1866
- outcome: row.outcome ?? '',
1867
- transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
1868
- });
1869
- }
1870
- }
1871
-
1872
- const items: ConsoleClient[] = rows.map((row) => {
1873
- const clientId = row.client_id ?? '';
1874
- const cursor = coerceNumber(row.cursor) ?? 0;
1875
- const latestEvent = latestEventsByClientId.get(clientId);
1876
- const connectionCount =
1877
- options.wsConnectionManager?.getConnectionCount(clientId) ?? 0;
1878
- const connectionPath =
1879
- options.wsConnectionManager?.getClientTransportPath(clientId) ??
1880
- latestEvent?.transportPath ??
1881
- 'direct';
1882
-
1883
- return {
1884
- clientId,
1885
- actorId: row.actor_id ?? '',
1886
- cursor,
1887
- lagCommitCount: Math.max(0, maxCommitSeq - cursor),
1888
- connectionPath,
1889
- connectionMode: connectionCount > 0 ? 'realtime' : 'polling',
1890
- realtimeConnectionCount: connectionCount,
1891
- isRealtimeConnected: connectionCount > 0,
1892
- activityState: getClientActivityState({
1893
- connectionCount,
1894
- updatedAt: row.updated_at,
1895
- }),
1896
- lastRequestAt: latestEvent?.createdAt ?? null,
1897
- lastRequestType: latestEvent?.eventType ?? null,
1898
- lastRequestOutcome: latestEvent?.outcome ?? null,
1899
- effectiveScopes: options.dialect.dbToScopes(row.effective_scopes),
1900
- updatedAt: row.updated_at ?? '',
1901
- };
1902
- });
1903
-
1904
- const total = coerceNumber(countRow?.total) ?? 0;
1905
-
1906
- const response: ConsolePaginatedResponse<ConsoleClient> = {
1907
- items,
1908
- total,
1909
- offset,
1910
- limit,
1911
- };
1912
-
1913
- c.header('X-Total-Count', String(total));
1914
- return c.json(response, 200);
1915
- }
1916
- );
1917
-
1918
- // -------------------------------------------------------------------------
1919
- // GET /handlers
1920
- // -------------------------------------------------------------------------
1921
-
1922
- routes.get(
1923
- '/handlers',
1924
- describeRoute({
1925
- tags: ['console'],
1926
- summary: 'List registered handlers',
1927
- responses: {
1928
- 200: {
1929
- description: 'Handler list',
1930
- content: {
1931
- 'application/json': { schema: resolver(handlersResponseSchema) },
1932
- },
1933
- },
1934
- 401: {
1935
- description: 'Unauthenticated',
1936
- content: {
1937
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1938
- },
1939
- },
1940
- },
1941
- }),
1942
- async (c) => {
1943
- const auth = await requireAuth(c);
1944
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1945
-
1946
- const items: ConsoleHandler[] = options.handlers.map((handler) => ({
1947
- table: handler.table,
1948
- dependsOn: handler.dependsOn,
1949
- snapshotChunkTtlMs: handler.snapshotChunkTtlMs,
1950
- }));
1951
-
1952
- return c.json({ items }, 200);
1953
- }
1954
- );
1955
-
1956
- // -------------------------------------------------------------------------
1957
- // GET /operations - Operation audit log
1958
- // -------------------------------------------------------------------------
1959
-
1960
- routes.get(
1961
- '/operations',
1962
- describeRoute({
1963
- tags: ['console'],
1964
- summary: 'List operation audit events',
1965
- responses: {
1966
- 200: {
1967
- description: 'Paginated operation events',
1968
- content: {
1969
- 'application/json': {
1970
- schema: resolver(
1971
- ConsolePaginatedResponseSchema(ConsoleOperationEventSchema)
1972
- ),
1973
- },
1974
- },
1975
- },
1976
- 401: {
1977
- description: 'Unauthenticated',
1978
- content: {
1979
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1980
- },
1981
- },
1982
- },
1983
- }),
1984
- zValidator('query', ConsoleOperationsQuerySchema),
1985
- async (c) => {
1986
- const auth = await requireAuth(c);
1987
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1988
-
1989
- const { limit, offset, operationType, partitionId } =
1990
- c.req.valid('query');
1991
-
1992
- let query = db
1993
- .selectFrom('sync_operation_events')
1994
- .select(operationEventSelectColumns);
1995
-
1996
- let countQuery = db
1997
- .selectFrom('sync_operation_events')
1998
- .select(({ fn }) => fn.countAll().as('total'));
1999
-
2000
- if (operationType) {
2001
- query = query.where('operation_type', '=', operationType);
2002
- countQuery = countQuery.where('operation_type', '=', operationType);
2003
- }
2004
- if (partitionId) {
2005
- query = query.where('partition_id', '=', partitionId);
2006
- countQuery = countQuery.where('partition_id', '=', partitionId);
2007
- }
2008
-
2009
- const [rows, countRow] = await Promise.all([
2010
- query
2011
- .orderBy('created_at', 'desc')
2012
- .limit(limit)
2013
- .offset(offset)
2014
- .execute(),
2015
- countQuery.executeTakeFirst(),
2016
- ]);
2017
-
2018
- const items = rows.map((row) => mapOperationEvent(row));
2019
- const total = coerceNumber(countRow?.total) ?? 0;
2020
-
2021
- const response: ConsolePaginatedResponse<ConsoleOperationEvent> = {
2022
- items,
2023
- total,
2024
- offset,
2025
- limit,
2026
- };
2027
-
2028
- c.header('X-Total-Count', String(total));
2029
- return c.json(response, 200);
2030
- }
2031
- );
2032
-
2033
- // -------------------------------------------------------------------------
2034
- // POST /prune/preview
2035
- // -------------------------------------------------------------------------
2036
-
2037
- routes.post(
2038
- '/prune/preview',
2039
- describeRoute({
2040
- tags: ['console'],
2041
- summary: 'Preview pruning',
2042
- responses: {
2043
- 200: {
2044
- description: 'Prune preview',
2045
- content: {
2046
- 'application/json': { schema: resolver(ConsolePrunePreviewSchema) },
2047
- },
2048
- },
2049
- 401: {
2050
- description: 'Unauthenticated',
2051
- content: {
2052
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2053
- },
2054
- },
2055
- },
2056
- }),
2057
- async (c) => {
2058
- const auth = await requireAuth(c);
2059
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2060
-
2061
- const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
2062
- options.db,
2063
- options.prune
2064
- );
2065
-
2066
- // Count commits that would be deleted
2067
- const countRow = await db
2068
- .selectFrom('sync_commits')
2069
- .select(({ fn }) => fn.countAll().as('count'))
2070
- .where('commit_seq', '<=', watermarkCommitSeq)
2071
- .executeTakeFirst();
2072
-
2073
- const commitsToDelete = coerceNumber(countRow?.count) ?? 0;
2074
-
2075
- const preview: ConsolePrunePreview = {
2076
- watermarkCommitSeq,
2077
- commitsToDelete,
2078
- };
2079
-
2080
- return c.json(preview, 200);
2081
- }
2082
- );
2083
-
2084
- // -------------------------------------------------------------------------
2085
- // POST /prune
2086
- // -------------------------------------------------------------------------
2087
-
2088
- routes.post(
2089
- '/prune',
2090
- describeRoute({
2091
- tags: ['console'],
2092
- summary: 'Trigger pruning',
2093
- responses: {
2094
- 200: {
2095
- description: 'Prune result',
2096
- content: {
2097
- 'application/json': { schema: resolver(ConsolePruneResultSchema) },
2098
- },
2099
- },
2100
- 401: {
2101
- description: 'Unauthenticated',
2102
- content: {
2103
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2104
- },
2105
- },
2106
- },
2107
- }),
2108
- async (c) => {
2109
- const auth = await requireAuth(c);
2110
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2111
-
2112
- const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
2113
- options.db,
2114
- options.prune
2115
- );
2116
-
2117
- const deletedCommits = await pruneSync(options.db, {
2118
- watermarkCommitSeq,
2119
- keepNewestCommits: options.prune?.keepNewestCommits,
2120
- });
2121
-
2122
- logSyncEvent({
2123
- event: 'console.prune',
2124
- consoleUserId: auth.consoleUserId,
2125
- deletedCommits,
2126
- watermarkCommitSeq,
2127
- });
2128
- await recordOperationEvent({
2129
- operationType: 'prune',
2130
- consoleUserId: auth.consoleUserId,
2131
- requestPayload: {
2132
- watermarkCommitSeq,
2133
- keepNewestCommits: options.prune?.keepNewestCommits ?? null,
2134
- },
2135
- resultPayload: { deletedCommits, watermarkCommitSeq },
2136
- });
2137
-
2138
- const result: ConsolePruneResult = { deletedCommits };
2139
- return c.json(result, 200);
2140
- }
2141
- );
2142
-
2143
- // -------------------------------------------------------------------------
2144
- // POST /compact
2145
- // -------------------------------------------------------------------------
2146
-
2147
- routes.post(
2148
- '/compact',
2149
- describeRoute({
2150
- tags: ['console'],
2151
- summary: 'Trigger compaction',
2152
- responses: {
2153
- 200: {
2154
- description: 'Compact result',
2155
- content: {
2156
- 'application/json': {
2157
- schema: resolver(ConsoleCompactResultSchema),
2158
- },
2159
- },
2160
- },
2161
- 401: {
2162
- description: 'Unauthenticated',
2163
- content: {
2164
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2165
- },
2166
- },
2167
- },
2168
- }),
2169
- async (c) => {
2170
- const auth = await requireAuth(c);
2171
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2172
-
2173
- const fullHistoryHours = options.compact?.fullHistoryHours ?? 24 * 7;
2174
-
2175
- const deletedChanges = await compactChanges(options.db, {
2176
- dialect: options.dialect,
2177
- options: { fullHistoryHours },
2178
- });
2179
-
2180
- logSyncEvent({
2181
- event: 'console.compact',
2182
- consoleUserId: auth.consoleUserId,
2183
- deletedChanges,
2184
- fullHistoryHours,
2185
- });
2186
- await recordOperationEvent({
2187
- operationType: 'compact',
2188
- consoleUserId: auth.consoleUserId,
2189
- requestPayload: { fullHistoryHours },
2190
- resultPayload: { deletedChanges },
2191
- });
2192
-
2193
- const result: ConsoleCompactResult = { deletedChanges };
2194
- return c.json(result, 200);
2195
- }
2196
- );
2197
-
2198
- // -------------------------------------------------------------------------
2199
- // POST /notify-data-change
2200
- // -------------------------------------------------------------------------
2201
-
2202
- const NotifyDataChangeRequestSchema = z.object({
2203
- tables: z.array(z.string().min(1)).min(1),
2204
- partitionId: z.string().optional(),
2205
- });
2206
-
2207
- const NotifyDataChangeResponseSchema = z.object({
2208
- commitSeq: z.number(),
2209
- tables: z.array(z.string()),
2210
- deletedChunks: z.number(),
2211
- });
2212
-
2213
- routes.post(
2214
- '/notify-data-change',
2215
- describeRoute({
2216
- tags: ['console'],
2217
- summary: 'Notify external data change',
2218
- description:
2219
- 'Creates a synthetic commit to force re-bootstrap for affected tables. ' +
2220
- 'Use after pipeline imports or direct DB writes to notify connected clients.',
2221
- responses: {
2222
- 200: {
2223
- description: 'Notification result',
2224
- content: {
2225
- 'application/json': {
2226
- schema: resolver(NotifyDataChangeResponseSchema),
2227
- },
2228
- },
2229
- },
2230
- 400: {
2231
- description: 'Invalid request',
2232
- content: {
2233
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2234
- },
2235
- },
2236
- 401: {
2237
- description: 'Unauthenticated',
2238
- content: {
2239
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2240
- },
2241
- },
2242
- },
2243
- }),
2244
- zValidator('json', NotifyDataChangeRequestSchema),
2245
- async (c) => {
2246
- const auth = await requireAuth(c);
2247
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2248
-
2249
- const body = c.req.valid('json');
2250
-
2251
- const result = await notifyExternalDataChange({
2252
- db: options.db,
2253
- dialect: options.dialect,
2254
- tables: body.tables,
2255
- partitionId: body.partitionId,
2256
- });
2257
-
2258
- logSyncEvent({
2259
- event: 'console.notify_data_change',
2260
- consoleUserId: auth.consoleUserId,
2261
- tables: body.tables,
2262
- commitSeq: result.commitSeq,
2263
- deletedChunks: result.deletedChunks,
2264
- });
2265
- await recordOperationEvent({
2266
- operationType: 'notify_data_change',
2267
- consoleUserId: auth.consoleUserId,
2268
- partitionId: body.partitionId ?? null,
2269
- requestPayload: {
2270
- tables: body.tables,
2271
- partitionId: body.partitionId ?? null,
2272
- },
2273
- resultPayload: result,
2274
- });
2275
-
2276
- // Wake all WS clients so they pull immediately
2277
- if (options.wsConnectionManager) {
2278
- options.wsConnectionManager.notifyAllClients(result.commitSeq);
2279
- }
2280
-
2281
- return c.json(result, 200);
2282
- }
2283
- );
2284
-
2285
- // -------------------------------------------------------------------------
2286
- // DELETE /clients/:id
2287
- // -------------------------------------------------------------------------
2288
-
2289
- routes.delete(
2290
- '/clients/:id',
2291
- describeRoute({
2292
- tags: ['console'],
2293
- summary: 'Evict client',
2294
- responses: {
2295
- 200: {
2296
- description: 'Evict result',
2297
- content: {
2298
- 'application/json': { schema: resolver(ConsoleEvictResultSchema) },
2299
- },
2300
- },
2301
- 400: {
2302
- description: 'Invalid request',
2303
- content: {
2304
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2305
- },
2306
- },
2307
- 401: {
2308
- description: 'Unauthenticated',
2309
- content: {
2310
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2311
- },
2312
- },
2313
- },
2314
- }),
2315
- zValidator('param', clientIdParamSchema),
2316
- zValidator('query', evictClientQuerySchema),
2317
- async (c) => {
2318
- const auth = await requireAuth(c);
2319
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2320
-
2321
- const { id: clientId } = c.req.valid('param');
2322
- const { partitionId } = c.req.valid('query');
2323
-
2324
- let deleteQuery = db
2325
- .deleteFrom('sync_client_cursors')
2326
- .where('client_id', '=', clientId);
2327
-
2328
- if (partitionId) {
2329
- deleteQuery = deleteQuery.where('partition_id', '=', partitionId);
2330
- }
2331
-
2332
- const res = await deleteQuery.executeTakeFirst();
2333
-
2334
- const evicted = Number(res?.numDeletedRows ?? 0) > 0;
2335
-
2336
- logSyncEvent({
2337
- event: 'console.evict_client',
2338
- consoleUserId: auth.consoleUserId,
2339
- clientId,
2340
- evicted,
2341
- });
2342
- await recordOperationEvent({
2343
- operationType: 'evict_client',
2344
- consoleUserId: auth.consoleUserId,
2345
- partitionId: partitionId ?? null,
2346
- targetClientId: clientId,
2347
- requestPayload: { clientId, partitionId: partitionId ?? null },
2348
- resultPayload: { evicted },
2349
- });
2350
-
2351
- const result: ConsoleEvictResult = { evicted };
2352
- return c.json(result, 200);
2353
- }
2354
- );
2355
-
2356
- // -------------------------------------------------------------------------
2357
- // GET /events - Paginated request events list
2358
- // -------------------------------------------------------------------------
2359
-
2360
- routes.get(
2361
- '/events',
2362
- describeRoute({
2363
- tags: ['console'],
2364
- summary: 'List request events',
2365
- responses: {
2366
- 200: {
2367
- description: 'Paginated event list',
2368
- content: {
2369
- 'application/json': {
2370
- schema: resolver(
2371
- ConsolePaginatedResponseSchema(ConsoleRequestEventSchema)
2372
- ),
2373
- },
2374
- },
2375
- },
2376
- 401: {
2377
- description: 'Unauthenticated',
2378
- content: {
2379
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2380
- },
2381
- },
2382
- },
2383
- }),
2384
- zValidator('query', eventsQuerySchema),
2385
- async (c) => {
2386
- const auth = await requireAuth(c);
2387
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2388
-
2389
- const {
2390
- limit,
2391
- offset,
2392
- partitionId,
2393
- eventType,
2394
- actorId,
2395
- clientId,
2396
- requestId,
2397
- traceId,
2398
- outcome,
2399
- } = c.req.valid('query');
2400
-
2401
- let query = db
2402
- .selectFrom('sync_request_events')
2403
- .select(requestEventSelectColumns);
2404
-
2405
- let countQuery = db
2406
- .selectFrom('sync_request_events')
2407
- .select(({ fn }) => fn.countAll().as('total'));
2408
-
2409
- if (partitionId) {
2410
- query = query.where('partition_id', '=', partitionId);
2411
- countQuery = countQuery.where('partition_id', '=', partitionId);
2412
- }
2413
- if (eventType) {
2414
- query = query.where('event_type', '=', eventType);
2415
- countQuery = countQuery.where('event_type', '=', eventType);
2416
- }
2417
- if (actorId) {
2418
- query = query.where('actor_id', '=', actorId);
2419
- countQuery = countQuery.where('actor_id', '=', actorId);
2420
- }
2421
- if (clientId) {
2422
- query = query.where('client_id', '=', clientId);
2423
- countQuery = countQuery.where('client_id', '=', clientId);
2424
- }
2425
- if (requestId) {
2426
- query = query.where('request_id', '=', requestId);
2427
- countQuery = countQuery.where('request_id', '=', requestId);
2428
- }
2429
- if (traceId) {
2430
- query = query.where('trace_id', '=', traceId);
2431
- countQuery = countQuery.where('trace_id', '=', traceId);
2432
- }
2433
- if (outcome) {
2434
- query = query.where('outcome', '=', outcome);
2435
- countQuery = countQuery.where('outcome', '=', outcome);
2436
- }
2437
-
2438
- const [rows, countRow] = await Promise.all([
2439
- query
2440
- .orderBy('created_at', 'desc')
2441
- .limit(limit)
2442
- .offset(offset)
2443
- .execute(),
2444
- countQuery.executeTakeFirst(),
2445
- ]);
2446
-
2447
- const items: ConsoleRequestEvent[] = rows.map((row) =>
2448
- mapRequestEvent(row)
2449
- );
2450
-
2451
- const total = coerceNumber(countRow?.total) ?? 0;
2452
-
2453
- const response: ConsolePaginatedResponse<ConsoleRequestEvent> = {
2454
- items,
2455
- total,
2456
- offset,
2457
- limit,
2458
- };
2459
-
2460
- c.header('X-Total-Count', String(total));
2461
- return c.json(response, 200);
2462
- }
2463
- );
2464
-
2465
- // -------------------------------------------------------------------------
2466
- // GET /events/live - WebSocket for live activity feed
2467
- // NOTE: Must be defined BEFORE /events/:id to avoid route conflict
2468
- // -------------------------------------------------------------------------
2469
-
2470
- if (
2471
- options.eventEmitter &&
2472
- options.websocket?.enabled &&
2473
- options.websocket?.upgradeWebSocket
2474
- ) {
2475
- const emitter = options.eventEmitter;
2476
- const upgradeWebSocket = options.websocket.upgradeWebSocket;
2477
- const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
2478
-
2479
- type WebSocketLike = {
2480
- send: (data: string) => void;
2481
- close: (code?: number, reason?: string) => void;
2482
- };
2483
-
2484
- const wsState = new WeakMap<
2485
- WebSocketLike,
2486
- {
2487
- listener: ConsoleEventListener | null;
2488
- heartbeatInterval: ReturnType<typeof setInterval> | null;
2489
- authTimeout: ReturnType<typeof setTimeout> | null;
2490
- isAuthenticated: boolean;
2491
- }
2492
- >();
2493
-
2494
- const closeUnauthenticated = (ws: WebSocketLike) => {
2495
- try {
2496
- ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
2497
- } catch {
2498
- // ignore send errors
2499
- }
2500
- ws.close(4001, 'Unauthenticated');
2501
- };
2502
-
2503
- const cleanup = (ws: WebSocketLike) => {
2504
- const state = wsState.get(ws);
2505
- if (!state) return;
2506
- if (state.listener) {
2507
- emitter.removeListener(state.listener);
2508
- }
2509
- if (state.heartbeatInterval) {
2510
- clearInterval(state.heartbeatInterval);
2511
- }
2512
- if (state.authTimeout) {
2513
- clearTimeout(state.authTimeout);
2514
- }
2515
- wsState.delete(ws);
2516
- };
2517
-
2518
- routes.get(
2519
- '/events/live',
2520
- upgradeWebSocket(async (c) => {
2521
- const authHeader = c.req.header('Authorization');
2522
- const partitionId = c.req.query('partitionId')?.trim() || undefined;
2523
- const replaySince = c.req.query('since');
2524
- const replayLimitRaw = c.req.query('replayLimit');
2525
- const replayLimitNumber = replayLimitRaw
2526
- ? Number.parseInt(replayLimitRaw, 10)
2527
- : Number.NaN;
2528
- const replayLimit = Number.isFinite(replayLimitNumber)
2529
- ? Math.max(1, Math.min(500, replayLimitNumber))
2530
- : 100;
2531
- const mockContext = {
2532
- req: {
2533
- header: (name: string) =>
2534
- name === 'Authorization' ? authHeader : undefined,
2535
- query: () => undefined,
2536
- },
2537
- } as unknown as Context;
2538
-
2539
- const initialAuth = await options.authenticate(mockContext);
2540
-
2541
- const authenticateWithBearer = async (token: string) => {
2542
- const trimmedToken = token.trim();
2543
- if (!trimmedToken) return null;
2544
- const authContext = {
2545
- req: {
2546
- header: (name: string) =>
2547
- name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2548
- query: () => undefined,
2549
- },
2550
- } as unknown as Context;
2551
- return options.authenticate(authContext);
2552
- };
2553
-
2554
- return {
2555
- onOpen(_event, ws) {
2556
- const state = {
2557
- listener: null,
2558
- heartbeatInterval: null,
2559
- authTimeout: null,
2560
- isAuthenticated: false,
2561
- } as {
2562
- listener: ConsoleEventListener | null;
2563
- heartbeatInterval: ReturnType<typeof setInterval> | null;
2564
- authTimeout: ReturnType<typeof setTimeout> | null;
2565
- isAuthenticated: boolean;
2566
- };
2567
- wsState.set(ws, state);
2568
-
2569
- const startAuthenticatedSession = () => {
2570
- if (state.isAuthenticated) return;
2571
- state.isAuthenticated = true;
2572
- if (state.authTimeout) {
2573
- clearTimeout(state.authTimeout);
2574
- state.authTimeout = null;
2575
- }
2576
-
2577
- const listener: ConsoleEventListener = (event) => {
2578
- if (partitionId) {
2579
- const eventPartitionId = event.data.partitionId;
2580
- if (
2581
- typeof eventPartitionId !== 'string' ||
2582
- eventPartitionId !== partitionId
2583
- ) {
2584
- return;
2585
- }
2586
- }
2587
- try {
2588
- ws.send(JSON.stringify(event));
2589
- } catch {
2590
- // Connection closed
2591
- }
2592
- };
2593
-
2594
- emitter.addListener(listener);
2595
- state.listener = listener;
2596
-
2597
- ws.send(
2598
- JSON.stringify({
2599
- type: 'connected',
2600
- timestamp: new Date().toISOString(),
2601
- })
2602
- );
2603
-
2604
- const replayEvents = emitter.replay({
2605
- since: replaySince,
2606
- limit: replayLimit,
2607
- partitionId,
2608
- });
2609
- for (const replayEvent of replayEvents) {
2610
- try {
2611
- ws.send(JSON.stringify(replayEvent));
2612
- } catch {
2613
- // Connection closed
2614
- break;
2615
- }
2616
- }
2617
-
2618
- const heartbeatInterval = setInterval(() => {
2619
- try {
2620
- ws.send(
2621
- JSON.stringify({
2622
- type: 'heartbeat',
2623
- timestamp: new Date().toISOString(),
2624
- })
2625
- );
2626
- } catch {
2627
- clearInterval(heartbeatInterval);
2628
- }
2629
- }, heartbeatIntervalMs);
2630
- state.heartbeatInterval = heartbeatInterval;
2631
- };
2632
-
2633
- if (initialAuth) {
2634
- startAuthenticatedSession();
2635
- return;
2636
- }
2637
-
2638
- state.authTimeout = setTimeout(() => {
2639
- const current = wsState.get(ws);
2640
- if (!current || current.isAuthenticated) {
2641
- return;
2642
- }
2643
- closeUnauthenticated(ws);
2644
- cleanup(ws);
2645
- }, 5_000);
2646
- },
2647
- async onMessage(event, ws) {
2648
- const state = wsState.get(ws);
2649
- if (!state || state.isAuthenticated) {
2650
- return;
2651
- }
2652
-
2653
- if (typeof event.data !== 'string') {
2654
- closeUnauthenticated(ws);
2655
- cleanup(ws);
2656
- return;
2657
- }
2658
-
2659
- let token = '';
2660
- try {
2661
- const parsed = JSON.parse(event.data) as {
2662
- type?: unknown;
2663
- token?: unknown;
2664
- };
2665
- if (
2666
- parsed.type === 'auth' &&
2667
- typeof parsed.token === 'string' &&
2668
- parsed.token.trim().length > 0
2669
- ) {
2670
- token = parsed.token;
2671
- }
2672
- } catch {
2673
- // Ignore parse errors and close as unauthenticated below.
2674
- }
2675
-
2676
- if (!token) {
2677
- closeUnauthenticated(ws);
2678
- cleanup(ws);
2679
- return;
2680
- }
2681
-
2682
- const auth = await authenticateWithBearer(token);
2683
- const currentState = wsState.get(ws);
2684
- if (!currentState || currentState.isAuthenticated) {
2685
- return;
2686
- }
2687
- if (!auth) {
2688
- closeUnauthenticated(ws);
2689
- cleanup(ws);
2690
- return;
2691
- }
2692
-
2693
- currentState.isAuthenticated = true;
2694
- if (currentState.authTimeout) {
2695
- clearTimeout(currentState.authTimeout);
2696
- currentState.authTimeout = null;
2697
- }
2698
-
2699
- const listener: ConsoleEventListener = (liveEvent) => {
2700
- if (partitionId) {
2701
- const eventPartitionId = liveEvent.data.partitionId;
2702
- if (
2703
- typeof eventPartitionId !== 'string' ||
2704
- eventPartitionId !== partitionId
2705
- ) {
2706
- return;
2707
- }
2708
- }
2709
- try {
2710
- ws.send(JSON.stringify(liveEvent));
2711
- } catch {
2712
- // Connection closed
2713
- }
2714
- };
2715
-
2716
- emitter.addListener(listener);
2717
- currentState.listener = listener;
2718
-
2719
- ws.send(
2720
- JSON.stringify({
2721
- type: 'connected',
2722
- timestamp: new Date().toISOString(),
2723
- })
2724
- );
2725
-
2726
- const replayEvents = emitter.replay({
2727
- since: replaySince,
2728
- limit: replayLimit,
2729
- partitionId,
2730
- });
2731
- for (const replayEvent of replayEvents) {
2732
- try {
2733
- ws.send(JSON.stringify(replayEvent));
2734
- } catch {
2735
- // Connection closed
2736
- break;
2737
- }
2738
- }
2739
-
2740
- const heartbeatInterval = setInterval(() => {
2741
- try {
2742
- ws.send(
2743
- JSON.stringify({
2744
- type: 'heartbeat',
2745
- timestamp: new Date().toISOString(),
2746
- })
2747
- );
2748
- } catch {
2749
- clearInterval(heartbeatInterval);
2750
- }
2751
- }, heartbeatIntervalMs);
2752
- currentState.heartbeatInterval = heartbeatInterval;
2753
- },
2754
- onClose(_event, ws) {
2755
- cleanup(ws);
2756
- },
2757
- onError(_event, ws) {
2758
- cleanup(ws);
2759
- },
2760
- };
2761
- })
2762
- );
2763
- }
2764
-
2765
- // -------------------------------------------------------------------------
2766
- // GET /events/:id - Single event detail
2767
- // -------------------------------------------------------------------------
2768
-
2769
- routes.get(
2770
- '/events/:id',
2771
- describeRoute({
2772
- tags: ['console'],
2773
- summary: 'Get event details',
2774
- responses: {
2775
- 200: {
2776
- description: 'Event details',
2777
- content: {
2778
- 'application/json': {
2779
- schema: resolver(ConsoleRequestEventSchema),
2780
- },
2781
- },
2782
- },
2783
- 400: {
2784
- description: 'Invalid request',
2785
- content: {
2786
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2787
- },
2788
- },
2789
- 401: {
2790
- description: 'Unauthenticated',
2791
- content: {
2792
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2793
- },
2794
- },
2795
- 404: {
2796
- description: 'Not found',
2797
- content: {
2798
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2799
- },
2800
- },
2801
- },
2802
- }),
2803
- zValidator('param', eventIdParamSchema),
2804
- zValidator('query', eventDetailQuerySchema),
2805
- async (c) => {
2806
- const auth = await requireAuth(c);
2807
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2808
-
2809
- const { id: eventId } = c.req.valid('param');
2810
- const { partitionId } = c.req.valid('query');
2811
-
2812
- let eventQuery = db
2813
- .selectFrom('sync_request_events')
2814
- .select(requestEventSelectColumns)
2815
- .where('event_id', '=', eventId);
2816
-
2817
- if (partitionId) {
2818
- eventQuery = eventQuery.where('partition_id', '=', partitionId);
2819
- }
2820
-
2821
- const row = await eventQuery.executeTakeFirst();
2822
-
2823
- if (!row) {
2824
- return c.json({ error: 'NOT_FOUND' }, 404);
2825
- }
2826
-
2827
- return c.json(mapRequestEvent(row), 200);
2828
- }
2829
- );
2830
-
2831
- // -------------------------------------------------------------------------
2832
- // GET /events/:id/payload - payload snapshot detail (if retained)
2833
- // -------------------------------------------------------------------------
2834
-
2835
- routes.get(
2836
- '/events/:id/payload',
2837
- describeRoute({
2838
- tags: ['console'],
2839
- summary: 'Get event payload snapshot',
2840
- responses: {
2841
- 200: {
2842
- description: 'Payload snapshot details',
2843
- content: {
2844
- 'application/json': {
2845
- schema: resolver(ConsoleRequestPayloadSchema),
2846
- },
2847
- },
2848
- },
2849
- 400: {
2850
- description: 'Invalid request',
2851
- content: {
2852
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2853
- },
2854
- },
2855
- 401: {
2856
- description: 'Unauthenticated',
2857
- content: {
2858
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2859
- },
2860
- },
2861
- 404: {
2862
- description: 'Not found',
2863
- content: {
2864
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2865
- },
2866
- },
2867
- },
2868
- }),
2869
- zValidator('param', eventIdParamSchema),
2870
- zValidator('query', eventDetailQuerySchema),
2871
- async (c) => {
2872
- const auth = await requireAuth(c);
2873
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2874
-
2875
- const { id: eventId } = c.req.valid('param');
2876
- const { partitionId } = c.req.valid('query');
2877
-
2878
- let eventQuery = db
2879
- .selectFrom('sync_request_events')
2880
- .select(['payload_ref', 'partition_id'])
2881
- .where('event_id', '=', eventId);
2882
-
2883
- if (partitionId) {
2884
- eventQuery = eventQuery.where('partition_id', '=', partitionId);
2885
- }
2886
-
2887
- const eventRow = await eventQuery.executeTakeFirst();
2888
-
2889
- if (!eventRow) {
2890
- return c.json({ error: 'NOT_FOUND' }, 404);
2891
- }
2892
-
2893
- const payloadRef = eventRow.payload_ref;
2894
- if (!payloadRef) {
2895
- return c.json(
2896
- { error: 'NOT_FOUND', message: 'No payload snapshot recorded' },
2897
- 404
2898
- );
2899
- }
2900
-
2901
- const payloadRow = await db
2902
- .selectFrom('sync_request_payloads')
2903
- .select([
2904
- 'payload_ref',
2905
- 'partition_id',
2906
- 'request_payload',
2907
- 'response_payload',
2908
- 'created_at',
2909
- ])
2910
- .where('payload_ref', '=', payloadRef)
2911
- .where('partition_id', '=', eventRow.partition_id)
2912
- .executeTakeFirst();
2913
-
2914
- if (!payloadRow) {
2915
- return c.json(
2916
- { error: 'NOT_FOUND', message: 'Payload snapshot not available' },
2917
- 404
2918
- );
2919
- }
2920
-
2921
- const payload: ConsoleRequestPayload = {
2922
- payloadRef: payloadRow.payload_ref,
2923
- partitionId: payloadRow.partition_id,
2924
- requestPayload: parseJsonValue(payloadRow.request_payload),
2925
- responsePayload: parseJsonValue(payloadRow.response_payload),
2926
- createdAt: payloadRow.created_at,
2927
- };
2928
-
2929
- return c.json(payload, 200);
2930
- }
2931
- );
2932
-
2933
- // -------------------------------------------------------------------------
2934
- // DELETE /events - Clear all events
2935
- // -------------------------------------------------------------------------
2936
-
2937
- routes.delete(
2938
- '/events',
2939
- describeRoute({
2940
- tags: ['console'],
2941
- summary: 'Clear all events',
2942
- responses: {
2943
- 200: {
2944
- description: 'Clear result',
2945
- content: {
2946
- 'application/json': {
2947
- schema: resolver(ConsoleClearEventsResultSchema),
2948
- },
2949
- },
2950
- },
2951
- 401: {
2952
- description: 'Unauthenticated',
2953
- content: {
2954
- 'application/json': { schema: resolver(ErrorResponseSchema) },
2955
- },
2956
- },
2957
- },
2958
- }),
2959
- async (c) => {
2960
- const auth = await requireAuth(c);
2961
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2962
-
2963
- const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
2964
-
2965
- const deletedCount = Number(res?.numDeletedRows ?? 0);
2966
- const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
2967
-
2968
- logSyncEvent({
2969
- event: 'console.clear_events',
2970
- consoleUserId: auth.consoleUserId,
2971
- deletedCount,
2972
- payloadDeletedCount,
2973
- });
2974
-
2975
- const result: ConsoleClearEventsResult = { deletedCount };
2976
- return c.json(result, 200);
2977
- }
2978
- );
2979
-
2980
- // -------------------------------------------------------------------------
2981
- // POST /events/prune - Prune old events
2982
- // -------------------------------------------------------------------------
2983
-
2984
- routes.post(
2985
- '/events/prune',
2986
- describeRoute({
2987
- tags: ['console'],
2988
- summary: 'Prune old events',
2989
- responses: {
2990
- 200: {
2991
- description: 'Prune result',
2992
- content: {
2993
- 'application/json': {
2994
- schema: resolver(ConsolePruneEventsResultSchema),
2995
- },
2996
- },
2997
- },
2998
- 401: {
2999
- description: 'Unauthenticated',
3000
- content: {
3001
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3002
- },
3003
- },
3004
- },
3005
- }),
3006
- async (c) => {
3007
- const auth = await requireAuth(c);
3008
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3009
-
3010
- const pruneResult = await runEventsPrune();
3011
- const deletedCount = pruneResult.totalDeleted;
3012
-
3013
- logSyncEvent({
3014
- event: 'console.prune_events',
3015
- consoleUserId: auth.consoleUserId,
3016
- deletedCount,
3017
- requestEventsDeleted: pruneResult.requestEventsDeleted,
3018
- operationEventsDeleted: pruneResult.operationEventsDeleted,
3019
- payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
3020
- });
3021
-
3022
- const result: ConsolePruneEventsResult = { deletedCount };
3023
- return c.json(result, 200);
3024
- }
3025
- );
3026
-
3027
- // -------------------------------------------------------------------------
3028
- // GET /api-keys - List all API keys
3029
- // -------------------------------------------------------------------------
3030
-
3031
- routes.get(
3032
- '/api-keys',
3033
- describeRoute({
3034
- tags: ['console'],
3035
- summary: 'List API keys',
3036
- responses: {
3037
- 200: {
3038
- description: 'Paginated API key list',
3039
- content: {
3040
- 'application/json': {
3041
- schema: resolver(
3042
- ConsolePaginatedResponseSchema(ConsoleApiKeySchema)
3043
- ),
3044
- },
3045
- },
3046
- },
3047
- 401: {
3048
- description: 'Unauthenticated',
3049
- content: {
3050
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3051
- },
3052
- },
3053
- },
3054
- }),
3055
- zValidator('query', apiKeysQuerySchema),
3056
- async (c) => {
3057
- const auth = await requireAuth(c);
3058
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3059
-
3060
- const {
3061
- limit,
3062
- offset,
3063
- type: keyType,
3064
- status,
3065
- expiresWithinDays,
3066
- } = c.req.valid('query');
3067
-
3068
- let query = db
3069
- .selectFrom('sync_api_keys')
3070
- .select([
3071
- 'key_id',
3072
- 'key_prefix',
3073
- 'name',
3074
- 'key_type',
3075
- 'scope_keys',
3076
- 'actor_id',
3077
- 'created_at',
3078
- 'expires_at',
3079
- 'last_used_at',
3080
- 'revoked_at',
3081
- ]);
3082
-
3083
- let countQuery = db
3084
- .selectFrom('sync_api_keys')
3085
- .select(({ fn }) => fn.countAll().as('total'));
3086
-
3087
- if (keyType) {
3088
- query = query.where('key_type', '=', keyType);
3089
- countQuery = countQuery.where('key_type', '=', keyType);
3090
- }
3091
-
3092
- const now = new Date();
3093
- const nowIso = now.toISOString();
3094
- const expiringThresholdIso = new Date(
3095
- now.getTime() + (expiresWithinDays ?? 14) * 24 * 60 * 60 * 1000
3096
- ).toISOString();
3097
-
3098
- if (status === 'active') {
3099
- query = query
3100
- .where('revoked_at', 'is', null)
3101
- .where((eb) =>
3102
- eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)])
3103
- );
3104
- countQuery = countQuery
3105
- .where('revoked_at', 'is', null)
3106
- .where((eb) =>
3107
- eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)])
3108
- );
3109
- } else if (status === 'revoked') {
3110
- query = query.where('revoked_at', 'is not', null);
3111
- countQuery = countQuery.where('revoked_at', 'is not', null);
3112
- } else if (status === 'expiring') {
3113
- query = query
3114
- .where('revoked_at', 'is', null)
3115
- .where('expires_at', '>', nowIso)
3116
- .where('expires_at', '<=', expiringThresholdIso);
3117
- countQuery = countQuery
3118
- .where('revoked_at', 'is', null)
3119
- .where('expires_at', '>', nowIso)
3120
- .where('expires_at', '<=', expiringThresholdIso);
3121
- }
3122
-
3123
- const [rows, countRow] = await Promise.all([
3124
- query
3125
- .orderBy('created_at', 'desc')
3126
- .limit(limit)
3127
- .offset(offset)
3128
- .execute(),
3129
- countQuery.executeTakeFirst(),
3130
- ]);
3131
-
3132
- const items: ConsoleApiKey[] = rows.map((row) => ({
3133
- keyId: row.key_id ?? '',
3134
- keyPrefix: row.key_prefix ?? '',
3135
- name: row.name ?? '',
3136
- keyType: row.key_type as ApiKeyType,
3137
- scopeKeys: options.dialect.dbToArray(row.scope_keys),
3138
- actorId: row.actor_id ?? null,
3139
- createdAt: row.created_at ?? '',
3140
- expiresAt: row.expires_at ?? null,
3141
- lastUsedAt: row.last_used_at ?? null,
3142
- revokedAt: row.revoked_at ?? null,
3143
- }));
3144
-
3145
- const totalCount = coerceNumber(countRow?.total) ?? 0;
3146
-
3147
- const response: ConsolePaginatedResponse<ConsoleApiKey> = {
3148
- items,
3149
- total: totalCount,
3150
- offset,
3151
- limit,
3152
- };
3153
-
3154
- c.header('X-Total-Count', String(totalCount));
3155
- return c.json(response, 200);
3156
- }
3157
- );
3158
-
3159
- // -------------------------------------------------------------------------
3160
- // POST /api-keys - Create new API key
3161
- // -------------------------------------------------------------------------
3162
-
3163
- routes.post(
3164
- '/api-keys',
3165
- describeRoute({
3166
- tags: ['console'],
3167
- summary: 'Create API key',
3168
- responses: {
3169
- 201: {
3170
- description: 'Created API key',
3171
- content: {
3172
- 'application/json': {
3173
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
3174
- },
3175
- },
3176
- },
3177
- 400: {
3178
- description: 'Invalid request',
3179
- content: {
3180
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3181
- },
3182
- },
3183
- 401: {
3184
- description: 'Unauthenticated',
3185
- content: {
3186
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3187
- },
3188
- },
3189
- },
3190
- }),
3191
- zValidator('json', ConsoleApiKeyCreateRequestSchema),
3192
- async (c) => {
3193
- const auth = await requireAuth(c);
3194
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3195
-
3196
- const body = c.req.valid('json');
3197
-
3198
- // Generate key components
3199
- const keyId = generateKeyId();
3200
- const secretKey = generateSecretKey(body.keyType);
3201
- const keyHash = await hashApiKey(secretKey);
3202
- const keyPrefix = secretKey.slice(0, 12);
3203
-
3204
- // Calculate expiry
3205
- let expiresAt: string | null = null;
3206
- if (body.expiresInDays && body.expiresInDays > 0) {
3207
- expiresAt = new Date(
3208
- Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000
3209
- ).toISOString();
3210
- }
3211
-
3212
- const scopeKeys = body.scopeKeys ?? [];
3213
- const now = new Date().toISOString();
3214
-
3215
- // Insert into database
3216
- await db
3217
- .insertInto('sync_api_keys')
3218
- .values({
3219
- key_id: keyId,
3220
- key_hash: keyHash,
3221
- key_prefix: keyPrefix,
3222
- name: body.name,
3223
- key_type: body.keyType,
3224
- scope_keys: options.dialect.arrayToDb(scopeKeys),
3225
- actor_id: body.actorId ?? null,
3226
- created_at: now,
3227
- expires_at: expiresAt,
3228
- last_used_at: null,
3229
- revoked_at: null,
3230
- })
3231
- .execute();
3232
-
3233
- logSyncEvent({
3234
- event: 'console.create_api_key',
3235
- consoleUserId: auth.consoleUserId,
3236
- keyId,
3237
- keyType: body.keyType,
3238
- });
3239
-
3240
- const key: ConsoleApiKey = {
3241
- keyId,
3242
- keyPrefix,
3243
- name: body.name,
3244
- keyType: body.keyType,
3245
- scopeKeys,
3246
- actorId: body.actorId ?? null,
3247
- createdAt: now,
3248
- expiresAt,
3249
- lastUsedAt: null,
3250
- revokedAt: null,
3251
- };
3252
-
3253
- const response: ConsoleApiKeyCreateResponse = {
3254
- key,
3255
- secretKey,
3256
- };
3257
-
3258
- return c.json(response, 201);
3259
- }
3260
- );
3261
-
3262
- // -------------------------------------------------------------------------
3263
- // GET /api-keys/:id - Get single API key
3264
- // -------------------------------------------------------------------------
3265
-
3266
- routes.get(
3267
- '/api-keys/:id',
3268
- describeRoute({
3269
- tags: ['console'],
3270
- summary: 'Get API key',
3271
- responses: {
3272
- 200: {
3273
- description: 'API key details',
3274
- content: {
3275
- 'application/json': { schema: resolver(ConsoleApiKeySchema) },
3276
- },
3277
- },
3278
- 401: {
3279
- description: 'Unauthenticated',
3280
- content: {
3281
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3282
- },
3283
- },
3284
- 404: {
3285
- description: 'Not found',
3286
- content: {
3287
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3288
- },
3289
- },
3290
- },
3291
- }),
3292
- zValidator('param', apiKeyIdParamSchema),
3293
- async (c) => {
3294
- const auth = await requireAuth(c);
3295
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3296
-
3297
- const { id: keyId } = c.req.valid('param');
3298
-
3299
- const row = await db
3300
- .selectFrom('sync_api_keys')
3301
- .select([
3302
- 'key_id',
3303
- 'key_prefix',
3304
- 'name',
3305
- 'key_type',
3306
- 'scope_keys',
3307
- 'actor_id',
3308
- 'created_at',
3309
- 'expires_at',
3310
- 'last_used_at',
3311
- 'revoked_at',
3312
- ])
3313
- .where('key_id', '=', keyId)
3314
- .executeTakeFirst();
3315
-
3316
- if (!row) {
3317
- return c.json({ error: 'NOT_FOUND' }, 404);
3318
- }
3319
-
3320
- const key: ConsoleApiKey = {
3321
- keyId: row.key_id ?? '',
3322
- keyPrefix: row.key_prefix ?? '',
3323
- name: row.name ?? '',
3324
- keyType: row.key_type as ApiKeyType,
3325
- scopeKeys: options.dialect.dbToArray(row.scope_keys),
3326
- actorId: row.actor_id ?? null,
3327
- createdAt: row.created_at ?? '',
3328
- expiresAt: row.expires_at ?? null,
3329
- lastUsedAt: row.last_used_at ?? null,
3330
- revokedAt: row.revoked_at ?? null,
3331
- };
3332
-
3333
- return c.json(key, 200);
3334
- }
3335
- );
3336
-
3337
- // -------------------------------------------------------------------------
3338
- // DELETE /api-keys/:id - Revoke API key (soft delete)
3339
- // -------------------------------------------------------------------------
3340
-
3341
- routes.delete(
3342
- '/api-keys/:id',
3343
- describeRoute({
3344
- tags: ['console'],
3345
- summary: 'Revoke API key',
3346
- responses: {
3347
- 200: {
3348
- description: 'Revoke result',
3349
- content: {
3350
- 'application/json': {
3351
- schema: resolver(ConsoleApiKeyRevokeResponseSchema),
3352
- },
3353
- },
3354
- },
3355
- 401: {
3356
- description: 'Unauthenticated',
3357
- content: {
3358
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3359
- },
3360
- },
3361
- },
3362
- }),
3363
- zValidator('param', apiKeyIdParamSchema),
3364
- async (c) => {
3365
- const auth = await requireAuth(c);
3366
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3367
-
3368
- const { id: keyId } = c.req.valid('param');
3369
- const now = new Date().toISOString();
3370
-
3371
- const res = await db
3372
- .updateTable('sync_api_keys')
3373
- .set({ revoked_at: now })
3374
- .where('key_id', '=', keyId)
3375
- .where('revoked_at', 'is', null)
3376
- .executeTakeFirst();
3377
-
3378
- const revoked = Number(res?.numUpdatedRows ?? 0) > 0;
3379
-
3380
- logSyncEvent({
3381
- event: 'console.revoke_api_key',
3382
- consoleUserId: auth.consoleUserId,
3383
- keyId,
3384
- revoked,
3385
- });
3386
-
3387
- return c.json({ revoked }, 200);
3388
- }
3389
- );
3390
-
3391
- // -------------------------------------------------------------------------
3392
- // POST /api-keys/bulk-revoke - Revoke multiple API keys
3393
- // -------------------------------------------------------------------------
3394
-
3395
- routes.post(
3396
- '/api-keys/bulk-revoke',
3397
- describeRoute({
3398
- tags: ['console'],
3399
- summary: 'Bulk revoke API keys',
3400
- responses: {
3401
- 200: {
3402
- description: 'Bulk revoke result',
3403
- content: {
3404
- 'application/json': {
3405
- schema: resolver(ConsoleApiKeyBulkRevokeResponseSchema),
3406
- },
3407
- },
3408
- },
3409
- 400: {
3410
- description: 'Invalid request',
3411
- content: {
3412
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3413
- },
3414
- },
3415
- 401: {
3416
- description: 'Unauthenticated',
3417
- content: {
3418
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3419
- },
3420
- },
3421
- },
3422
- }),
3423
- zValidator('json', ConsoleApiKeyBulkRevokeRequestSchema),
3424
- async (c) => {
3425
- const auth = await requireAuth(c);
3426
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3427
-
3428
- const body = c.req.valid('json');
3429
- const keyIds = [...new Set(body.keyIds.map((keyId) => keyId.trim()))]
3430
- .filter((keyId) => keyId.length > 0)
3431
- .slice(0, 200);
3432
-
3433
- if (keyIds.length === 0) {
3434
- return c.json(
3435
- { error: 'INVALID_REQUEST', message: 'No API key IDs provided' },
3436
- 400
3437
- );
3438
- }
3439
-
3440
- const now = new Date().toISOString();
3441
- const existingRows = await db
3442
- .selectFrom('sync_api_keys')
3443
- .select(['key_id', 'revoked_at'])
3444
- .where('key_id', 'in', keyIds)
3445
- .execute();
3446
-
3447
- const existingById = new Map(
3448
- existingRows.map((row) => [row.key_id, row.revoked_at])
3449
- );
3450
-
3451
- const notFoundKeyIds: string[] = [];
3452
- const alreadyRevokedKeyIds: string[] = [];
3453
- const revokeCandidateKeyIds: string[] = [];
3454
-
3455
- for (const keyId of keyIds) {
3456
- const revokedAt = existingById.get(keyId);
3457
- if (revokedAt === undefined) {
3458
- notFoundKeyIds.push(keyId);
3459
- } else if (revokedAt !== null) {
3460
- alreadyRevokedKeyIds.push(keyId);
3461
- } else {
3462
- revokeCandidateKeyIds.push(keyId);
3463
- }
3464
- }
3465
-
3466
- let revokedCount = 0;
3467
- if (revokeCandidateKeyIds.length > 0) {
3468
- const updateResult = await db
3469
- .updateTable('sync_api_keys')
3470
- .set({ revoked_at: now })
3471
- .where('key_id', 'in', revokeCandidateKeyIds)
3472
- .where('revoked_at', 'is', null)
3473
- .executeTakeFirst();
3474
-
3475
- revokedCount = Number(updateResult?.numUpdatedRows ?? 0);
3476
- }
3477
-
3478
- const response: ConsoleApiKeyBulkRevokeResponse = {
3479
- requestedCount: keyIds.length,
3480
- revokedCount,
3481
- alreadyRevokedCount: alreadyRevokedKeyIds.length,
3482
- notFoundCount: notFoundKeyIds.length,
3483
- revokedKeyIds: revokeCandidateKeyIds,
3484
- alreadyRevokedKeyIds,
3485
- notFoundKeyIds,
3486
- };
3487
-
3488
- logSyncEvent({
3489
- event: 'console.bulk_revoke_api_keys',
3490
- consoleUserId: auth.consoleUserId,
3491
- requestedCount: response.requestedCount,
3492
- revokedCount: response.revokedCount,
3493
- alreadyRevokedCount: response.alreadyRevokedCount,
3494
- notFoundCount: response.notFoundCount,
3495
- });
3496
-
3497
- return c.json(response, 200);
3498
- }
3499
- );
3500
-
3501
- // -------------------------------------------------------------------------
3502
- // POST /api-keys/:id/rotate/stage - Stage rotate API key (keep old active)
3503
- // -------------------------------------------------------------------------
3504
-
3505
- routes.post(
3506
- '/api-keys/:id/rotate/stage',
3507
- describeRoute({
3508
- tags: ['console'],
3509
- summary: 'Stage rotate API key',
3510
- responses: {
3511
- 200: {
3512
- description: 'Staged API key replacement',
3513
- content: {
3514
- 'application/json': {
3515
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
3516
- },
3517
- },
3518
- },
3519
- 401: {
3520
- description: 'Unauthenticated',
3521
- content: {
3522
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3523
- },
3524
- },
3525
- 404: {
3526
- description: 'Not found',
3527
- content: {
3528
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3529
- },
3530
- },
3531
- },
3532
- }),
3533
- zValidator('param', apiKeyIdParamSchema),
3534
- async (c) => {
3535
- const auth = await requireAuth(c);
3536
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3537
-
3538
- const { id: keyId } = c.req.valid('param');
3539
- const now = new Date().toISOString();
3540
-
3541
- const existingRow = await db
3542
- .selectFrom('sync_api_keys')
3543
- .select([
3544
- 'name',
3545
- 'key_type',
3546
- 'scope_keys',
3547
- 'actor_id',
3548
- 'expires_at',
3549
- 'revoked_at',
3550
- ])
3551
- .where('key_id', '=', keyId)
3552
- .where('revoked_at', 'is', null)
3553
- .executeTakeFirst();
3554
-
3555
- if (!existingRow) {
3556
- return c.json({ error: 'NOT_FOUND' }, 404);
3557
- }
3558
-
3559
- const newKeyId = generateKeyId();
3560
- const keyType = existingRow.key_type as ApiKeyType;
3561
- const secretKey = generateSecretKey(keyType);
3562
- const keyHash = await hashApiKey(secretKey);
3563
- const keyPrefix = secretKey.slice(0, 12);
3564
- const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
3565
-
3566
- await db
3567
- .insertInto('sync_api_keys')
3568
- .values({
3569
- key_id: newKeyId,
3570
- key_hash: keyHash,
3571
- key_prefix: keyPrefix,
3572
- name: existingRow.name,
3573
- key_type: keyType,
3574
- scope_keys: options.dialect.arrayToDb(scopeKeys),
3575
- actor_id: existingRow.actor_id ?? null,
3576
- created_at: now,
3577
- expires_at: existingRow.expires_at,
3578
- last_used_at: null,
3579
- revoked_at: null,
3580
- })
3581
- .execute();
3582
-
3583
- logSyncEvent({
3584
- event: 'console.stage_rotate_api_key',
3585
- consoleUserId: auth.consoleUserId,
3586
- oldKeyId: keyId,
3587
- newKeyId,
3588
- });
3589
-
3590
- const key: ConsoleApiKey = {
3591
- keyId: newKeyId,
3592
- keyPrefix,
3593
- name: existingRow.name,
3594
- keyType,
3595
- scopeKeys,
3596
- actorId: existingRow.actor_id ?? null,
3597
- createdAt: now,
3598
- expiresAt: existingRow.expires_at ?? null,
3599
- lastUsedAt: null,
3600
- revokedAt: null,
3601
- };
3602
-
3603
- const response: ConsoleApiKeyCreateResponse = {
3604
- key,
3605
- secretKey,
3606
- };
3607
-
3608
- return c.json(response, 200);
3609
- }
3610
- );
3611
-
3612
- // -------------------------------------------------------------------------
3613
- // POST /api-keys/:id/rotate - Rotate API key
3614
- // -------------------------------------------------------------------------
3615
-
3616
- routes.post(
3617
- '/api-keys/:id/rotate',
3618
- describeRoute({
3619
- tags: ['console'],
3620
- summary: 'Rotate API key',
3621
- responses: {
3622
- 200: {
3623
- description: 'Rotated API key',
3624
- content: {
3625
- 'application/json': {
3626
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
3627
- },
3628
- },
3629
- },
3630
- 401: {
3631
- description: 'Unauthenticated',
3632
- content: {
3633
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3634
- },
3635
- },
3636
- 404: {
3637
- description: 'Not found',
3638
- content: {
3639
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3640
- },
3641
- },
3642
- },
3643
- }),
3644
- zValidator('param', apiKeyIdParamSchema),
3645
- async (c) => {
3646
- const auth = await requireAuth(c);
3647
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3648
-
3649
- const { id: keyId } = c.req.valid('param');
3650
- const now = new Date().toISOString();
3651
-
3652
- // Get existing key
3653
- const existingRow = await db
3654
- .selectFrom('sync_api_keys')
3655
- .select([
3656
- 'key_id',
3657
- 'name',
3658
- 'key_type',
3659
- 'scope_keys',
3660
- 'actor_id',
3661
- 'expires_at',
3662
- ])
3663
- .where('key_id', '=', keyId)
3664
- .where('revoked_at', 'is', null)
3665
- .executeTakeFirst();
3666
-
3667
- if (!existingRow) {
3668
- return c.json({ error: 'NOT_FOUND' }, 404);
3669
- }
3670
-
3671
- // Revoke old key
3672
- await db
3673
- .updateTable('sync_api_keys')
3674
- .set({ revoked_at: now })
3675
- .where('key_id', '=', keyId)
3676
- .execute();
3677
-
3678
- // Create new key with same properties
3679
- const newKeyId = generateKeyId();
3680
- const keyType = existingRow.key_type as ApiKeyType;
3681
- const secretKey = generateSecretKey(keyType);
3682
- const keyHash = await hashApiKey(secretKey);
3683
- const keyPrefix = secretKey.slice(0, 12);
3684
-
3685
- const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
3686
-
3687
- await db
3688
- .insertInto('sync_api_keys')
3689
- .values({
3690
- key_id: newKeyId,
3691
- key_hash: keyHash,
3692
- key_prefix: keyPrefix,
3693
- name: existingRow.name,
3694
- key_type: keyType,
3695
- scope_keys: options.dialect.arrayToDb(scopeKeys),
3696
- actor_id: existingRow.actor_id ?? null,
3697
- created_at: now,
3698
- expires_at: existingRow.expires_at,
3699
- last_used_at: null,
3700
- revoked_at: null,
3701
- })
3702
- .execute();
3703
-
3704
- logSyncEvent({
3705
- event: 'console.rotate_api_key',
3706
- consoleUserId: auth.consoleUserId,
3707
- oldKeyId: keyId,
3708
- newKeyId,
3709
- });
3710
-
3711
- const key: ConsoleApiKey = {
3712
- keyId: newKeyId,
3713
- keyPrefix,
3714
- name: existingRow.name,
3715
- keyType,
3716
- scopeKeys,
3717
- actorId: existingRow.actor_id ?? null,
3718
- createdAt: now,
3719
- expiresAt: existingRow.expires_at ?? null,
3720
- lastUsedAt: null,
3721
- revokedAt: null,
3722
- };
3723
-
3724
- const response: ConsoleApiKeyCreateResponse = {
3725
- key,
3726
- secretKey,
3727
- };
3728
-
3729
- return c.json(response, 200);
3730
- }
3731
- );
3732
-
3733
- // -----------------------------------------------------------------------
3734
- // Storage endpoints
3735
- // -----------------------------------------------------------------------
3736
- const bucket = options.blobBucket;
3737
-
3738
- routes.get(
3739
- '/storage',
3740
- describeRoute({
3741
- tags: ['console'],
3742
- summary: 'List storage items',
3743
- responses: {
3744
- 200: {
3745
- description: 'Paginated list of storage items',
3746
- content: {
3747
- 'application/json': {
3748
- schema: resolver(ConsoleBlobListResponseSchema),
3749
- },
3750
- },
3751
- },
3752
- 401: {
3753
- description: 'Unauthenticated',
3754
- content: {
3755
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3756
- },
3757
- },
3758
- },
3759
- }),
3760
- zValidator('query', ConsoleBlobListQuerySchema),
3761
- async (c) => {
3762
- const auth = await requireAuth(c);
3763
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3764
-
3765
- if (!bucket) {
3766
- return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
3767
- }
3768
-
3769
- const { prefix, cursor, limit } = c.req.valid('query');
3770
- const listed = await bucket.list({
3771
- prefix: prefix || undefined,
3772
- cursor: cursor || undefined,
3773
- limit,
3774
- });
3775
-
3776
- return c.json(
3777
- {
3778
- items: listed.objects.map((obj) => ({
3779
- key: obj.key,
3780
- size: obj.size,
3781
- uploaded: obj.uploaded.toISOString(),
3782
- httpMetadata: obj.httpMetadata?.contentType
3783
- ? { contentType: obj.httpMetadata.contentType }
3784
- : undefined,
3785
- })),
3786
- truncated: listed.truncated,
3787
- cursor: listed.cursor ?? null,
3788
- },
3789
- 200
3790
- );
3791
- }
3792
- );
3793
-
3794
- routes.get(
3795
- '/storage/:key{.+}/download',
3796
- describeRoute({
3797
- tags: ['console'],
3798
- summary: 'Download a storage item',
3799
- responses: {
3800
- 200: { description: 'Storage item contents' },
3801
- 401: {
3802
- description: 'Unauthenticated',
3803
- content: {
3804
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3805
- },
3806
- },
3807
- 404: {
3808
- description: 'Blob not found',
3809
- content: {
3810
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3811
- },
3812
- },
3813
- },
3814
- }),
3815
- async (c) => {
3816
- const auth = await requireAuth(c);
3817
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3818
-
3819
- if (!bucket) {
3820
- return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
3821
- }
3822
-
3823
- const key = decodeURIComponent(c.req.param('key'));
3824
- const object = await bucket.get(key);
3825
- if (!object) {
3826
- return c.json({ error: 'BLOB_NOT_FOUND' }, 404);
3827
- }
3828
-
3829
- const headers = new Headers();
3830
- headers.set('Content-Length', String(object.size));
3831
- headers.set(
3832
- 'Content-Type',
3833
- object.httpMetadata?.contentType ?? 'application/octet-stream'
3834
- );
3835
- const filename = key.split('/').pop() || key;
3836
- headers.set(
3837
- 'Content-Disposition',
3838
- `attachment; filename="${filename.replace(/"/g, '\\"')}"`
3839
- );
3840
-
3841
- return new Response(object.body as ReadableStream, {
3842
- status: 200,
3843
- headers,
3844
- });
3845
- }
3846
- );
3847
-
3848
- routes.delete(
3849
- '/storage/:key{.+}',
3850
- describeRoute({
3851
- tags: ['console'],
3852
- summary: 'Delete a storage item',
3853
- responses: {
3854
- 200: {
3855
- description: 'Storage item deleted',
3856
- content: {
3857
- 'application/json': {
3858
- schema: resolver(ConsoleBlobDeleteResponseSchema),
3859
- },
3860
- },
3861
- },
3862
- 401: {
3863
- description: 'Unauthenticated',
3864
- content: {
3865
- 'application/json': { schema: resolver(ErrorResponseSchema) },
3866
- },
3867
- },
3868
- },
3869
- }),
3870
- async (c) => {
3871
- const auth = await requireAuth(c);
3872
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
3873
-
3874
- if (!bucket) {
3875
- return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
3876
- }
3877
-
3878
- const key = decodeURIComponent(c.req.param('key'));
3879
- await bucket.delete(key);
3880
- return c.json({ deleted: true }, 200);
3881
- }
3882
- );
3883
-
3884
- return routes;
3885
- }
3886
-
3887
- // ===========================================================================
3888
- // API Key Utilities
3889
- // ===========================================================================
3890
-
3891
- function generateKeyId(): string {
3892
- const bytes = new Uint8Array(16);
3893
- crypto.getRandomValues(bytes);
3894
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
3895
- }
3896
-
3897
- function generateSecretKey(keyType: ApiKeyType): string {
3898
- const bytes = new Uint8Array(24);
3899
- crypto.getRandomValues(bytes);
3900
- const random = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(
3901
- ''
3902
- );
3903
- return `sk_${keyType}_${random}`;
3904
- }
3905
-
3906
- async function hashApiKey(secretKey: string): Promise<string> {
3907
- const encoder = new TextEncoder();
3908
- const data = encoder.encode(secretKey);
3909
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
3910
- const hashArray = new Uint8Array(hashBuffer);
3911
- return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('');
3912
- }
3913
-
3914
- /**
3915
- * Creates a simple token-based authenticator for local development.
3916
- * The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
3917
- */
3918
- export function createTokenAuthenticator(
3919
- token?: string
3920
- ): (c: Context) => Promise<ConsoleAuthResult | null> {
3921
- const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
3922
-
3923
- return async (c: Context) => {
3924
- if (!expectedToken) return null;
3925
-
3926
- const authHeader = c.req.header('Authorization')?.trim();
3927
- if (authHeader?.startsWith('Bearer ')) {
3928
- const bearerToken = authHeader.slice(7).trim();
3929
- if (bearerToken === expectedToken) {
3930
- return { consoleUserId: 'token' };
3931
- }
3932
- }
3933
-
3934
- return null;
3935
- };
3936
- }