@syncular/server-hono 0.0.4-25 → 0.0.6-100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +6 -1
  2. package/dist/console/gateway.d.ts +3 -1
  3. package/dist/console/gateway.d.ts.map +1 -1
  4. package/dist/console/gateway.js +227 -42
  5. package/dist/console/gateway.js.map +1 -1
  6. package/dist/console/index.d.ts +2 -0
  7. package/dist/console/index.d.ts.map +1 -1
  8. package/dist/console/index.js +2 -0
  9. package/dist/console/index.js.map +1 -1
  10. package/dist/console/routes.d.ts +3 -97
  11. package/dist/console/routes.d.ts.map +1 -1
  12. package/dist/console/routes.js +516 -81
  13. package/dist/console/routes.js.map +1 -1
  14. package/dist/console/schemas.d.ts +29 -0
  15. package/dist/console/schemas.d.ts.map +1 -1
  16. package/dist/console/schemas.js +22 -0
  17. package/dist/console/schemas.js.map +1 -1
  18. package/dist/console/types.d.ts +175 -0
  19. package/dist/console/types.d.ts.map +1 -0
  20. package/dist/console/types.js +2 -0
  21. package/dist/console/types.js.map +1 -0
  22. package/dist/console/ui.d.ts +38 -0
  23. package/dist/console/ui.d.ts.map +1 -0
  24. package/dist/console/ui.js +43 -0
  25. package/dist/console/ui.js.map +1 -0
  26. package/dist/create-server.d.ts +17 -34
  27. package/dist/create-server.d.ts.map +1 -1
  28. package/dist/create-server.js +26 -26
  29. package/dist/create-server.js.map +1 -1
  30. package/dist/proxy/connection-manager.d.ts +3 -3
  31. package/dist/proxy/connection-manager.d.ts.map +1 -1
  32. package/dist/proxy/routes.d.ts +4 -4
  33. package/dist/proxy/routes.d.ts.map +1 -1
  34. package/dist/proxy/routes.js +1 -1
  35. package/dist/routes.d.ts +33 -9
  36. package/dist/routes.d.ts.map +1 -1
  37. package/dist/routes.js +153 -70
  38. package/dist/routes.js.map +1 -1
  39. package/package.json +21 -6
  40. package/src/__tests__/blob-routes.test.ts +424 -0
  41. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  42. package/src/__tests__/console-routes.test.ts +161 -7
  43. package/src/__tests__/console-ui.test.ts +114 -0
  44. package/src/__tests__/create-server.test.ts +233 -10
  45. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  46. package/src/__tests__/realtime-bridge.test.ts +6 -2
  47. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  48. package/src/console/gateway.ts +286 -54
  49. package/src/console/index.ts +2 -0
  50. package/src/console/routes.ts +663 -199
  51. package/src/console/schemas.ts +29 -0
  52. package/src/console/types.ts +185 -0
  53. package/src/console/ui.ts +100 -0
  54. package/src/create-server.ts +56 -53
  55. package/src/proxy/connection-manager.ts +3 -3
  56. package/src/proxy/routes.ts +4 -4
  57. package/src/routes.ts +225 -96
@@ -16,11 +16,7 @@
16
16
  */
17
17
 
18
18
  import { logSyncEvent } from '@syncular/core';
19
- import type {
20
- ServerSyncDialect,
21
- ServerTableHandler,
22
- SyncCoreDb,
23
- } from '@syncular/server';
19
+ import type { SqlFamily, SyncCoreDb, SyncServerAuth } from '@syncular/server';
24
20
  import {
25
21
  compactChanges,
26
22
  computePruneWatermarkCommitSeq,
@@ -31,11 +27,9 @@ import {
31
27
  import type { Context } from 'hono';
32
28
  import { Hono } from 'hono';
33
29
  import { cors } from 'hono/cors';
34
- import type { UpgradeWebSocket } from 'hono/ws';
35
30
  import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
36
31
  import { type Generated, type Kysely, type Selectable, sql } from 'kysely';
37
32
  import { z } from 'zod';
38
- import type { WebSocketConnectionManager } from '../ws';
39
33
  import {
40
34
  type ApiKeyType,
41
35
  ApiKeyTypeSchema,
@@ -48,6 +42,9 @@ import {
48
42
  ConsoleApiKeyCreateResponseSchema,
49
43
  ConsoleApiKeyRevokeResponseSchema,
50
44
  ConsoleApiKeySchema,
45
+ ConsoleBlobDeleteResponseSchema,
46
+ ConsoleBlobListQuerySchema,
47
+ ConsoleBlobListResponseSchema,
51
48
  type ConsoleChange,
52
49
  type ConsoleClearEventsResult,
53
50
  ConsoleClearEventsResultSchema,
@@ -97,36 +94,12 @@ import {
97
94
  type TimeseriesStatsResponse,
98
95
  TimeseriesStatsResponseSchema,
99
96
  } from './schemas';
100
-
101
- export interface ConsoleAuthResult {
102
- /** Identifier for the console user (for audit logging). */
103
- consoleUserId?: string;
104
- }
105
-
106
- /**
107
- * Listener for console live events (SSE streaming).
108
- */
109
- export type ConsoleEventListener = (event: LiveEvent) => void;
110
-
111
- /**
112
- * Console event emitter for broadcasting live events.
113
- */
114
- export interface ConsoleEventEmitter {
115
- /** Add a listener for live events */
116
- addListener(listener: ConsoleEventListener): void;
117
- /** Remove a listener */
118
- removeListener(listener: ConsoleEventListener): void;
119
- /** Emit an event to all listeners */
120
- emit(event: LiveEvent): void;
121
- /**
122
- * Replay recent events, optionally constrained by timestamp, partition, and max count.
123
- */
124
- replay(options?: {
125
- since?: string;
126
- limit?: number;
127
- partitionId?: string;
128
- }): LiveEvent[];
129
- }
97
+ import type {
98
+ ConsoleAuthResult,
99
+ ConsoleEventEmitter,
100
+ ConsoleEventListener,
101
+ CreateConsoleRoutesOptions,
102
+ } from './types';
130
103
 
131
104
  /**
132
105
  * Create a simple console event emitter for broadcasting live events.
@@ -193,73 +166,6 @@ export function createConsoleEventEmitter(options?: {
193
166
  };
194
167
  }
195
168
 
196
- export interface CreateConsoleRoutesOptions<
197
- DB extends SyncCoreDb = SyncCoreDb,
198
- > {
199
- db: Kysely<DB>;
200
- dialect: ServerSyncDialect;
201
- handlers: ServerTableHandler<DB>[];
202
- /**
203
- * Authentication function for console requests.
204
- * Return null to reject the request.
205
- */
206
- authenticate: (c: Context) => Promise<ConsoleAuthResult | null>;
207
- /**
208
- * CORS origins to allow. Defaults to ['http://localhost:5173', 'https://console.sync.dev'].
209
- * Set to '*' to allow all origins (not recommended for production).
210
- */
211
- corsOrigins?: string[] | '*';
212
- /**
213
- * Compaction options (required for /compact endpoint).
214
- */
215
- compact?: {
216
- fullHistoryHours?: number;
217
- };
218
- /**
219
- * Pruning options.
220
- */
221
- prune?: {
222
- activeWindowMs?: number;
223
- fallbackMaxAgeMs?: number;
224
- keepNewestCommits?: number;
225
- };
226
- /**
227
- * Event emitter for live console events.
228
- * If provided along with websocket config, enables the /events/live WebSocket endpoint.
229
- */
230
- eventEmitter?: ConsoleEventEmitter;
231
- /**
232
- * Shared sync WebSocket connection manager.
233
- * When provided, `/clients` includes realtime connection state per client.
234
- */
235
- wsConnectionManager?: WebSocketConnectionManager;
236
- /**
237
- * WebSocket configuration for live events streaming.
238
- */
239
- websocket?: {
240
- enabled?: boolean;
241
- /**
242
- * Runtime-provided WebSocket upgrader (e.g. from `hono/bun`'s `createBunWebSocket()`).
243
- */
244
- upgradeWebSocket?: UpgradeWebSocket;
245
- /**
246
- * Heartbeat interval in milliseconds. Default: 30000
247
- */
248
- heartbeatIntervalMs?: number;
249
- };
250
- /**
251
- * Metrics query strategy for timeseries/latency endpoints.
252
- * - raw: in-memory processing from raw event rows
253
- * - aggregated: DB-level aggregation where supported (raw fallback for unsupported paths)
254
- * - auto: use raw for small windows, aggregated for larger windows
255
- */
256
- metrics?: {
257
- aggregationMode?: 'auto' | 'raw' | 'aggregated';
258
- /** Max events for using raw mode when aggregationMode is 'auto'. */
259
- rawFallbackMaxEvents?: number;
260
- };
261
- }
262
-
263
169
  function coerceNumber(value: unknown): number | null {
264
170
  if (value === null || value === undefined) return null;
265
171
  if (typeof value === 'number') return Number.isFinite(value) ? value : null;
@@ -485,11 +391,45 @@ const handlersResponseSchema = z.object({
485
391
  items: z.array(ConsoleHandlerSchema),
486
392
  });
487
393
 
488
- export function createConsoleRoutes<DB extends SyncCoreDb>(
489
- options: CreateConsoleRoutesOptions<DB>
490
- ): Hono {
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 {
491
418
  const routes = new Hono();
492
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
+
493
433
  interface SyncRequestEventsTable {
494
434
  event_id: number;
495
435
  partition_id: string;
@@ -568,11 +508,36 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
568
508
  1,
569
509
  options.metrics?.rawFallbackMaxEvents ?? 5000
570
510
  );
571
-
572
- // Ensure console schema exists (creates sync_request_events table if needed)
573
- // Run asynchronously - will be ready before first request typically
574
- options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
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) => {
575
539
  console.error('[console] Failed to ensure console schema:', err);
540
+ throw err;
576
541
  });
577
542
 
578
543
  // CORS configuration
@@ -580,18 +545,53 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
580
545
  'http://localhost:5173',
581
546
  'https://console.sync.dev',
582
547
  ];
548
+ const allowWildcardCors = corsOrigins === '*';
583
549
 
584
550
  routes.use(
585
551
  '*',
586
552
  cors({
587
- origin: corsOrigins === '*' ? '*' : corsOrigins,
553
+ origin: allowWildcardCors ? '*' : corsOrigins,
588
554
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
589
- allowHeaders: ['Content-Type', 'Authorization'],
555
+ allowHeaders: [
556
+ 'Content-Type',
557
+ 'Authorization',
558
+ 'X-Syncular-Transport-Path',
559
+ 'Baggage',
560
+ 'Sentry-Trace',
561
+ 'Traceparent',
562
+ 'Tracestate',
563
+ ],
590
564
  exposeHeaders: ['X-Total-Count'],
591
- credentials: true,
565
+ credentials: !allowWildcardCors,
592
566
  })
593
567
  );
594
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
595
  // Auth middleware
596
596
  const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
597
597
  const auth = await options.authenticate(c);
@@ -687,6 +687,202 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
687
687
  createdAt: row.created_at ?? '',
688
688
  });
689
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
+
690
886
  const recordOperationEvent = async (event: {
691
887
  operationType: ConsoleOperationType;
692
888
  consoleUserId?: string;
@@ -875,7 +1071,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
875
1071
  ? sql`and partition_id = ${partitionId}`
876
1072
  : sql``;
877
1073
 
878
- if (options.dialect.name === 'sqlite') {
1074
+ if (options.dialect.family === 'sqlite') {
879
1075
  const bucketFormat = intervalToSqliteBucketFormat(interval);
880
1076
  const rowsResult = await sql<{
881
1077
  bucket: unknown;
@@ -1019,7 +1215,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
1019
1215
  const startIso = startTime.toISOString();
1020
1216
  const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
1021
1217
 
1022
- if (!useRawMetrics && options.dialect.name !== 'sqlite') {
1218
+ if (!useRawMetrics && options.dialect.family !== 'sqlite') {
1023
1219
  const partitionFilter = partitionId
1024
1220
  ? sql`and partition_id = ${partitionId}`
1025
1221
  : sql``;
@@ -2288,16 +2484,40 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2288
2484
  const wsState = new WeakMap<
2289
2485
  WebSocketLike,
2290
2486
  {
2291
- listener: ConsoleEventListener;
2292
- heartbeatInterval: ReturnType<typeof setInterval>;
2487
+ listener: ConsoleEventListener | null;
2488
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
2489
+ authTimeout: ReturnType<typeof setTimeout> | null;
2490
+ isAuthenticated: boolean;
2293
2491
  }
2294
2492
  >();
2295
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
+
2296
2518
  routes.get(
2297
2519
  '/events/live',
2298
2520
  upgradeWebSocket(async (c) => {
2299
- // Auth check via query param (WebSocket doesn't support headers easily)
2300
- const token = c.req.query('token');
2301
2521
  const authHeader = c.req.header('Authorization');
2302
2522
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
2303
2523
  const replaySince = c.req.query('since');
@@ -2312,25 +2532,173 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2312
2532
  req: {
2313
2533
  header: (name: string) =>
2314
2534
  name === 'Authorization' ? authHeader : undefined,
2315
- query: (name: string) => (name === 'token' ? token : undefined),
2535
+ query: () => undefined,
2316
2536
  },
2317
- } as Context;
2537
+ } as unknown as Context;
2318
2538
 
2319
- const auth = await options.authenticate(mockContext);
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
+ };
2320
2553
 
2321
2554
  return {
2322
2555
  onOpen(_event, ws) {
2323
- if (!auth) {
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
+
2324
2597
  ws.send(
2325
- JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
2598
+ JSON.stringify({
2599
+ type: 'connected',
2600
+ timestamp: new Date().toISOString(),
2601
+ })
2326
2602
  );
2327
- ws.close(4001, 'Unauthenticated');
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);
2328
2690
  return;
2329
2691
  }
2330
2692
 
2331
- const listener: ConsoleEventListener = (event) => {
2693
+ currentState.isAuthenticated = true;
2694
+ if (currentState.authTimeout) {
2695
+ clearTimeout(currentState.authTimeout);
2696
+ currentState.authTimeout = null;
2697
+ }
2698
+
2699
+ const listener: ConsoleEventListener = (liveEvent) => {
2332
2700
  if (partitionId) {
2333
- const eventPartitionId = event.data.partitionId;
2701
+ const eventPartitionId = liveEvent.data.partitionId;
2334
2702
  if (
2335
2703
  typeof eventPartitionId !== 'string' ||
2336
2704
  eventPartitionId !== partitionId
@@ -2339,15 +2707,15 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2339
2707
  }
2340
2708
  }
2341
2709
  try {
2342
- ws.send(JSON.stringify(event));
2710
+ ws.send(JSON.stringify(liveEvent));
2343
2711
  } catch {
2344
2712
  // Connection closed
2345
2713
  }
2346
2714
  };
2347
2715
 
2348
2716
  emitter.addListener(listener);
2717
+ currentState.listener = listener;
2349
2718
 
2350
- // Send connected message
2351
2719
  ws.send(
2352
2720
  JSON.stringify({
2353
2721
  type: 'connected',
@@ -2369,7 +2737,6 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2369
2737
  }
2370
2738
  }
2371
2739
 
2372
- // Start heartbeat
2373
2740
  const heartbeatInterval = setInterval(() => {
2374
2741
  try {
2375
2742
  ws.send(
@@ -2382,22 +2749,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2382
2749
  clearInterval(heartbeatInterval);
2383
2750
  }
2384
2751
  }, heartbeatIntervalMs);
2385
-
2386
- wsState.set(ws, { listener, heartbeatInterval });
2752
+ currentState.heartbeatInterval = heartbeatInterval;
2387
2753
  },
2388
2754
  onClose(_event, ws) {
2389
- const state = wsState.get(ws);
2390
- if (!state) return;
2391
- emitter.removeListener(state.listener);
2392
- clearInterval(state.heartbeatInterval);
2393
- wsState.delete(ws);
2755
+ cleanup(ws);
2394
2756
  },
2395
2757
  onError(_event, ws) {
2396
- const state = wsState.get(ws);
2397
- if (!state) return;
2398
- emitter.removeListener(state.listener);
2399
- clearInterval(state.heartbeatInterval);
2400
- wsState.delete(ws);
2758
+ cleanup(ws);
2401
2759
  },
2402
2760
  };
2403
2761
  })
@@ -2605,11 +2963,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2605
2963
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
2606
2964
 
2607
2965
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2966
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
2608
2967
 
2609
2968
  logSyncEvent({
2610
2969
  event: 'console.clear_events',
2611
2970
  consoleUserId: auth.consoleUserId,
2612
2971
  deletedCount,
2972
+ payloadDeletedCount,
2613
2973
  });
2614
2974
 
2615
2975
  const result: ConsoleClearEventsResult = { deletedCount };
@@ -2647,53 +3007,16 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2647
3007
  const auth = await requireAuth(c);
2648
3008
  if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2649
3009
 
2650
- // Prune events older than 7 days or keep max 10000 events
2651
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2652
-
2653
- // Delete by date first
2654
- const resByDate = await db
2655
- .deleteFrom('sync_request_events')
2656
- .where('created_at', '<', cutoffDate.toISOString())
2657
- .executeTakeFirst();
2658
-
2659
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
2660
-
2661
- // Then delete oldest if we still have more than 10000 events
2662
- const countRow = await db
2663
- .selectFrom('sync_request_events')
2664
- .select(({ fn }) => fn.countAll().as('total'))
2665
- .executeTakeFirst();
2666
-
2667
- const total = coerceNumber(countRow?.total) ?? 0;
2668
- const maxEvents = 10000;
2669
-
2670
- if (total > maxEvents) {
2671
- // Find event_id cutoff to keep only newest maxEvents
2672
- const cutoffRow = await db
2673
- .selectFrom('sync_request_events')
2674
- .select(['event_id'])
2675
- .orderBy('event_id', 'desc')
2676
- .offset(maxEvents)
2677
- .limit(1)
2678
- .executeTakeFirst();
2679
-
2680
- if (cutoffRow) {
2681
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
2682
- if (cutoffEventId !== null) {
2683
- const resByCount = await db
2684
- .deleteFrom('sync_request_events')
2685
- .where('event_id', '<=', cutoffEventId)
2686
- .executeTakeFirst();
2687
-
2688
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
2689
- }
2690
- }
2691
- }
3010
+ const pruneResult = await runEventsPrune();
3011
+ const deletedCount = pruneResult.totalDeleted;
2692
3012
 
2693
3013
  logSyncEvent({
2694
3014
  event: 'console.prune_events',
2695
3015
  consoleUserId: auth.consoleUserId,
2696
3016
  deletedCount,
3017
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
3018
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
3019
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
2697
3020
  });
2698
3021
 
2699
3022
  const result: ConsolePruneEventsResult = { deletedCount };
@@ -3407,6 +3730,157 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
3407
3730
  }
3408
3731
  );
3409
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
+
3410
3884
  return routes;
3411
3885
  }
3412
3886
 
@@ -3444,29 +3918,19 @@ async function hashApiKey(secretKey: string): Promise<string> {
3444
3918
  export function createTokenAuthenticator(
3445
3919
  token?: string
3446
3920
  ): (c: Context) => Promise<ConsoleAuthResult | null> {
3447
- const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
3921
+ const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
3448
3922
 
3449
3923
  return async (c: Context) => {
3450
- if (!expectedToken) {
3451
- // No token configured, allow all requests (not recommended for production)
3452
- return { consoleUserId: 'anonymous' };
3453
- }
3924
+ if (!expectedToken) return null;
3454
3925
 
3455
- // Check Authorization header
3456
- const authHeader = c.req.header('Authorization');
3926
+ const authHeader = c.req.header('Authorization')?.trim();
3457
3927
  if (authHeader?.startsWith('Bearer ')) {
3458
- const bearerToken = authHeader.slice(7);
3928
+ const bearerToken = authHeader.slice(7).trim();
3459
3929
  if (bearerToken === expectedToken) {
3460
3930
  return { consoleUserId: 'token' };
3461
3931
  }
3462
3932
  }
3463
3933
 
3464
- // Check query parameter
3465
- const queryToken = c.req.query('token');
3466
- if (queryToken === expectedToken) {
3467
- return { consoleUserId: 'token' };
3468
- }
3469
-
3470
3934
  return null;
3471
3935
  };
3472
3936
  }