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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/console/gateway.d.ts +3 -1
  2. package/dist/console/gateway.d.ts.map +1 -1
  3. package/dist/console/gateway.js +218 -41
  4. package/dist/console/gateway.js.map +1 -1
  5. package/dist/console/index.d.ts +1 -0
  6. package/dist/console/index.d.ts.map +1 -1
  7. package/dist/console/index.js +1 -0
  8. package/dist/console/index.js.map +1 -1
  9. package/dist/console/routes.d.ts +3 -97
  10. package/dist/console/routes.d.ts.map +1 -1
  11. package/dist/console/routes.js +507 -80
  12. package/dist/console/routes.js.map +1 -1
  13. package/dist/console/schemas.d.ts +29 -0
  14. package/dist/console/schemas.d.ts.map +1 -1
  15. package/dist/console/schemas.js +22 -0
  16. package/dist/console/schemas.js.map +1 -1
  17. package/dist/console/types.d.ts +175 -0
  18. package/dist/console/types.d.ts.map +1 -0
  19. package/dist/console/types.js +2 -0
  20. package/dist/console/types.js.map +1 -0
  21. package/dist/create-server.d.ts +17 -34
  22. package/dist/create-server.d.ts.map +1 -1
  23. package/dist/create-server.js +26 -26
  24. package/dist/create-server.js.map +1 -1
  25. package/dist/proxy/connection-manager.d.ts +3 -3
  26. package/dist/proxy/connection-manager.d.ts.map +1 -1
  27. package/dist/proxy/routes.d.ts +4 -4
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +1 -1
  30. package/dist/routes.d.ts +33 -9
  31. package/dist/routes.d.ts.map +1 -1
  32. package/dist/routes.js +153 -70
  33. package/dist/routes.js.map +1 -1
  34. package/package.json +21 -7
  35. package/src/__tests__/blob-routes.test.ts +424 -0
  36. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  37. package/src/__tests__/console-routes.test.ts +161 -7
  38. package/src/__tests__/console-ui.test.ts +114 -0
  39. package/src/__tests__/create-server.test.ts +233 -10
  40. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  41. package/src/__tests__/realtime-bridge.test.ts +6 -2
  42. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  43. package/src/console/gateway.ts +277 -53
  44. package/src/console/index.ts +1 -0
  45. package/src/console/routes.ts +654 -198
  46. package/src/console/schemas.ts +29 -0
  47. package/src/console/types.ts +185 -0
  48. package/src/create-server.ts +56 -53
  49. package/src/proxy/connection-manager.ts +3 -3
  50. package/src/proxy/routes.ts +4 -4
  51. 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,11 +545,12 @@ 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
555
  allowHeaders: [
590
556
  'Content-Type',
@@ -596,10 +562,36 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
596
562
  'Tracestate',
597
563
  ],
598
564
  exposeHeaders: ['X-Total-Count'],
599
- credentials: true,
565
+ credentials: !allowWildcardCors,
600
566
  })
601
567
  );
602
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
+
603
595
  // Auth middleware
604
596
  const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
605
597
  const auth = await options.authenticate(c);
@@ -695,6 +687,202 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
695
687
  createdAt: row.created_at ?? '',
696
688
  });
697
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
+
698
886
  const recordOperationEvent = async (event: {
699
887
  operationType: ConsoleOperationType;
700
888
  consoleUserId?: string;
@@ -883,7 +1071,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
883
1071
  ? sql`and partition_id = ${partitionId}`
884
1072
  : sql``;
885
1073
 
886
- if (options.dialect.name === 'sqlite') {
1074
+ if (options.dialect.family === 'sqlite') {
887
1075
  const bucketFormat = intervalToSqliteBucketFormat(interval);
888
1076
  const rowsResult = await sql<{
889
1077
  bucket: unknown;
@@ -1027,7 +1215,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
1027
1215
  const startIso = startTime.toISOString();
1028
1216
  const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
1029
1217
 
1030
- if (!useRawMetrics && options.dialect.name !== 'sqlite') {
1218
+ if (!useRawMetrics && options.dialect.family !== 'sqlite') {
1031
1219
  const partitionFilter = partitionId
1032
1220
  ? sql`and partition_id = ${partitionId}`
1033
1221
  : sql``;
@@ -2296,16 +2484,40 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2296
2484
  const wsState = new WeakMap<
2297
2485
  WebSocketLike,
2298
2486
  {
2299
- listener: ConsoleEventListener;
2300
- heartbeatInterval: ReturnType<typeof setInterval>;
2487
+ listener: ConsoleEventListener | null;
2488
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
2489
+ authTimeout: ReturnType<typeof setTimeout> | null;
2490
+ isAuthenticated: boolean;
2301
2491
  }
2302
2492
  >();
2303
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
+
2304
2518
  routes.get(
2305
2519
  '/events/live',
2306
2520
  upgradeWebSocket(async (c) => {
2307
- // Auth check via query param (WebSocket doesn't support headers easily)
2308
- const token = c.req.query('token');
2309
2521
  const authHeader = c.req.header('Authorization');
2310
2522
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
2311
2523
  const replaySince = c.req.query('since');
@@ -2320,25 +2532,173 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2320
2532
  req: {
2321
2533
  header: (name: string) =>
2322
2534
  name === 'Authorization' ? authHeader : undefined,
2323
- query: (name: string) => (name === 'token' ? token : undefined),
2535
+ query: () => undefined,
2324
2536
  },
2325
- } as Context;
2537
+ } as unknown as Context;
2326
2538
 
2327
- 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
+ };
2328
2553
 
2329
2554
  return {
2330
2555
  onOpen(_event, ws) {
2331
- 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
+
2332
2597
  ws.send(
2333
- JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
2598
+ JSON.stringify({
2599
+ type: 'connected',
2600
+ timestamp: new Date().toISOString(),
2601
+ })
2334
2602
  );
2335
- 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();
2336
2635
  return;
2337
2636
  }
2338
2637
 
2339
- const listener: ConsoleEventListener = (event) => {
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) => {
2340
2700
  if (partitionId) {
2341
- const eventPartitionId = event.data.partitionId;
2701
+ const eventPartitionId = liveEvent.data.partitionId;
2342
2702
  if (
2343
2703
  typeof eventPartitionId !== 'string' ||
2344
2704
  eventPartitionId !== partitionId
@@ -2347,15 +2707,15 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2347
2707
  }
2348
2708
  }
2349
2709
  try {
2350
- ws.send(JSON.stringify(event));
2710
+ ws.send(JSON.stringify(liveEvent));
2351
2711
  } catch {
2352
2712
  // Connection closed
2353
2713
  }
2354
2714
  };
2355
2715
 
2356
2716
  emitter.addListener(listener);
2717
+ currentState.listener = listener;
2357
2718
 
2358
- // Send connected message
2359
2719
  ws.send(
2360
2720
  JSON.stringify({
2361
2721
  type: 'connected',
@@ -2377,7 +2737,6 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2377
2737
  }
2378
2738
  }
2379
2739
 
2380
- // Start heartbeat
2381
2740
  const heartbeatInterval = setInterval(() => {
2382
2741
  try {
2383
2742
  ws.send(
@@ -2390,22 +2749,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2390
2749
  clearInterval(heartbeatInterval);
2391
2750
  }
2392
2751
  }, heartbeatIntervalMs);
2393
-
2394
- wsState.set(ws, { listener, heartbeatInterval });
2752
+ currentState.heartbeatInterval = heartbeatInterval;
2395
2753
  },
2396
2754
  onClose(_event, ws) {
2397
- const state = wsState.get(ws);
2398
- if (!state) return;
2399
- emitter.removeListener(state.listener);
2400
- clearInterval(state.heartbeatInterval);
2401
- wsState.delete(ws);
2755
+ cleanup(ws);
2402
2756
  },
2403
2757
  onError(_event, ws) {
2404
- const state = wsState.get(ws);
2405
- if (!state) return;
2406
- emitter.removeListener(state.listener);
2407
- clearInterval(state.heartbeatInterval);
2408
- wsState.delete(ws);
2758
+ cleanup(ws);
2409
2759
  },
2410
2760
  };
2411
2761
  })
@@ -2613,11 +2963,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2613
2963
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
2614
2964
 
2615
2965
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2966
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
2616
2967
 
2617
2968
  logSyncEvent({
2618
2969
  event: 'console.clear_events',
2619
2970
  consoleUserId: auth.consoleUserId,
2620
2971
  deletedCount,
2972
+ payloadDeletedCount,
2621
2973
  });
2622
2974
 
2623
2975
  const result: ConsoleClearEventsResult = { deletedCount };
@@ -2655,53 +3007,16 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
2655
3007
  const auth = await requireAuth(c);
2656
3008
  if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2657
3009
 
2658
- // Prune events older than 7 days or keep max 10000 events
2659
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2660
-
2661
- // Delete by date first
2662
- const resByDate = await db
2663
- .deleteFrom('sync_request_events')
2664
- .where('created_at', '<', cutoffDate.toISOString())
2665
- .executeTakeFirst();
2666
-
2667
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
2668
-
2669
- // Then delete oldest if we still have more than 10000 events
2670
- const countRow = await db
2671
- .selectFrom('sync_request_events')
2672
- .select(({ fn }) => fn.countAll().as('total'))
2673
- .executeTakeFirst();
2674
-
2675
- const total = coerceNumber(countRow?.total) ?? 0;
2676
- const maxEvents = 10000;
2677
-
2678
- if (total > maxEvents) {
2679
- // Find event_id cutoff to keep only newest maxEvents
2680
- const cutoffRow = await db
2681
- .selectFrom('sync_request_events')
2682
- .select(['event_id'])
2683
- .orderBy('event_id', 'desc')
2684
- .offset(maxEvents)
2685
- .limit(1)
2686
- .executeTakeFirst();
2687
-
2688
- if (cutoffRow) {
2689
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
2690
- if (cutoffEventId !== null) {
2691
- const resByCount = await db
2692
- .deleteFrom('sync_request_events')
2693
- .where('event_id', '<=', cutoffEventId)
2694
- .executeTakeFirst();
2695
-
2696
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
2697
- }
2698
- }
2699
- }
3010
+ const pruneResult = await runEventsPrune();
3011
+ const deletedCount = pruneResult.totalDeleted;
2700
3012
 
2701
3013
  logSyncEvent({
2702
3014
  event: 'console.prune_events',
2703
3015
  consoleUserId: auth.consoleUserId,
2704
3016
  deletedCount,
3017
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
3018
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
3019
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
2705
3020
  });
2706
3021
 
2707
3022
  const result: ConsolePruneEventsResult = { deletedCount };
@@ -3415,6 +3730,157 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
3415
3730
  }
3416
3731
  );
3417
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
+
3418
3884
  return routes;
3419
3885
  }
3420
3886
 
@@ -3452,29 +3918,19 @@ async function hashApiKey(secretKey: string): Promise<string> {
3452
3918
  export function createTokenAuthenticator(
3453
3919
  token?: string
3454
3920
  ): (c: Context) => Promise<ConsoleAuthResult | null> {
3455
- const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
3921
+ const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
3456
3922
 
3457
3923
  return async (c: Context) => {
3458
- if (!expectedToken) {
3459
- // No token configured, allow all requests (not recommended for production)
3460
- return { consoleUserId: 'anonymous' };
3461
- }
3924
+ if (!expectedToken) return null;
3462
3925
 
3463
- // Check Authorization header
3464
- const authHeader = c.req.header('Authorization');
3926
+ const authHeader = c.req.header('Authorization')?.trim();
3465
3927
  if (authHeader?.startsWith('Bearer ')) {
3466
- const bearerToken = authHeader.slice(7);
3928
+ const bearerToken = authHeader.slice(7).trim();
3467
3929
  if (bearerToken === expectedToken) {
3468
3930
  return { consoleUserId: 'token' };
3469
3931
  }
3470
3932
  }
3471
3933
 
3472
- // Check query parameter
3473
- const queryToken = c.req.query('token');
3474
- if (queryToken === expectedToken) {
3475
- return { consoleUserId: 'token' };
3476
- }
3477
-
3478
3934
  return null;
3479
3935
  };
3480
3936
  }