@syncular/server-hono 0.0.6-93 → 0.0.6-96

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.
@@ -391,6 +391,25 @@ const handlersResponseSchema = z.object({
391
391
  items: z.array(ConsoleHandlerSchema),
392
392
  });
393
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
+
394
413
  export function createConsoleRoutes<
395
414
  DB extends SyncCoreDb,
396
415
  Auth extends SyncServerAuth,
@@ -489,11 +508,36 @@ export function createConsoleRoutes<
489
508
  1,
490
509
  options.metrics?.rawFallbackMaxEvents ?? 5000
491
510
  );
492
-
493
- // Ensure console schema exists (creates sync_request_events table if needed)
494
- // Run asynchronously - will be ready before first request typically
495
- 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) => {
496
539
  console.error('[console] Failed to ensure console schema:', err);
540
+ throw err;
497
541
  });
498
542
 
499
543
  // CORS configuration
@@ -501,11 +545,12 @@ export function createConsoleRoutes<
501
545
  'http://localhost:5173',
502
546
  'https://console.sync.dev',
503
547
  ];
548
+ const allowWildcardCors = corsOrigins === '*';
504
549
 
505
550
  routes.use(
506
551
  '*',
507
552
  cors({
508
- origin: corsOrigins === '*' ? '*' : corsOrigins,
553
+ origin: allowWildcardCors ? '*' : corsOrigins,
509
554
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
510
555
  allowHeaders: [
511
556
  'Content-Type',
@@ -517,10 +562,36 @@ export function createConsoleRoutes<
517
562
  'Tracestate',
518
563
  ],
519
564
  exposeHeaders: ['X-Total-Count'],
520
- credentials: true,
565
+ credentials: !allowWildcardCors,
521
566
  })
522
567
  );
523
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
+
524
595
  // Auth middleware
525
596
  const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
526
597
  const auth = await options.authenticate(c);
@@ -616,6 +687,202 @@ export function createConsoleRoutes<
616
687
  createdAt: row.created_at ?? '',
617
688
  });
618
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
+
619
886
  const recordOperationEvent = async (event: {
620
887
  operationType: ConsoleOperationType;
621
888
  consoleUserId?: string;
@@ -2696,11 +2963,13 @@ export function createConsoleRoutes<
2696
2963
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
2697
2964
 
2698
2965
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2966
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
2699
2967
 
2700
2968
  logSyncEvent({
2701
2969
  event: 'console.clear_events',
2702
2970
  consoleUserId: auth.consoleUserId,
2703
2971
  deletedCount,
2972
+ payloadDeletedCount,
2704
2973
  });
2705
2974
 
2706
2975
  const result: ConsoleClearEventsResult = { deletedCount };
@@ -2738,53 +3007,16 @@ export function createConsoleRoutes<
2738
3007
  const auth = await requireAuth(c);
2739
3008
  if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2740
3009
 
2741
- // Prune events older than 7 days or keep max 10000 events
2742
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2743
-
2744
- // Delete by date first
2745
- const resByDate = await db
2746
- .deleteFrom('sync_request_events')
2747
- .where('created_at', '<', cutoffDate.toISOString())
2748
- .executeTakeFirst();
2749
-
2750
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
2751
-
2752
- // Then delete oldest if we still have more than 10000 events
2753
- const countRow = await db
2754
- .selectFrom('sync_request_events')
2755
- .select(({ fn }) => fn.countAll().as('total'))
2756
- .executeTakeFirst();
2757
-
2758
- const total = coerceNumber(countRow?.total) ?? 0;
2759
- const maxEvents = 10000;
2760
-
2761
- if (total > maxEvents) {
2762
- // Find event_id cutoff to keep only newest maxEvents
2763
- const cutoffRow = await db
2764
- .selectFrom('sync_request_events')
2765
- .select(['event_id'])
2766
- .orderBy('event_id', 'desc')
2767
- .offset(maxEvents)
2768
- .limit(1)
2769
- .executeTakeFirst();
2770
-
2771
- if (cutoffRow) {
2772
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
2773
- if (cutoffEventId !== null) {
2774
- const resByCount = await db
2775
- .deleteFrom('sync_request_events')
2776
- .where('event_id', '<=', cutoffEventId)
2777
- .executeTakeFirst();
2778
-
2779
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
2780
- }
2781
- }
2782
- }
3010
+ const pruneResult = await runEventsPrune();
3011
+ const deletedCount = pruneResult.totalDeleted;
2783
3012
 
2784
3013
  logSyncEvent({
2785
3014
  event: 'console.prune_events',
2786
3015
  consoleUserId: auth.consoleUserId,
2787
3016
  deletedCount,
3017
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
3018
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
3019
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
2788
3020
  });
2789
3021
 
2790
3022
  const result: ConsolePruneEventsResult = { deletedCount };
@@ -53,6 +53,39 @@ export interface ConsoleMetricsOptions {
53
53
  rawFallbackMaxEvents?: number;
54
54
  }
55
55
 
56
+ export interface ConsoleMaintenanceOptions {
57
+ /**
58
+ * Minimum interval between automatic event-prune runs.
59
+ * Set to 0 to disable automatic pruning.
60
+ * Default: 5 minutes.
61
+ */
62
+ autoPruneIntervalMs?: number;
63
+ /**
64
+ * Max age for request events before pruning.
65
+ * Set to 0 to disable age-based pruning.
66
+ * Default: 7 days.
67
+ */
68
+ requestEventsMaxAgeMs?: number;
69
+ /**
70
+ * Max number of request events to retain.
71
+ * Set to 0 to disable count-based pruning.
72
+ * Default: 10000.
73
+ */
74
+ requestEventsMaxRows?: number;
75
+ /**
76
+ * Max age for operation audit events before pruning.
77
+ * Set to 0 to disable age-based pruning.
78
+ * Default: 30 days.
79
+ */
80
+ operationEventsMaxAgeMs?: number;
81
+ /**
82
+ * Max number of operation audit events to retain.
83
+ * Set to 0 to disable count-based pruning.
84
+ * Default: 5000.
85
+ */
86
+ operationEventsMaxRows?: number;
87
+ }
88
+
56
89
  export interface ConsoleBlobObject {
57
90
  key: string;
58
91
  size: number;
@@ -89,6 +122,7 @@ export interface ConsoleSharedOptions {
89
122
  */
90
123
  corsOrigins?: string[] | '*';
91
124
  metrics?: ConsoleMetricsOptions;
125
+ maintenance?: ConsoleMaintenanceOptions;
92
126
  blobBucket?: ConsoleBlobBucket;
93
127
  }
94
128
 
@@ -143,4 +177,9 @@ export interface CreateConsoleRoutesOptions<
143
177
  */
144
178
  heartbeatIntervalMs?: number;
145
179
  };
180
+ /**
181
+ * Optional console schema readiness promise.
182
+ * When provided, routes wait for this promise before querying console tables.
183
+ */
184
+ consoleSchemaReady?: Promise<void>;
146
185
  }
@@ -138,6 +138,10 @@ export function createSyncServer<
138
138
  const consoleEventEmitter = isConsoleEnabled
139
139
  ? createConsoleEventEmitter()
140
140
  : undefined;
141
+ const consoleSchemaReady =
142
+ isConsoleEnabled && dialect.ensureConsoleSchema
143
+ ? dialect.ensureConsoleSchema(db)
144
+ : undefined;
141
145
 
142
146
  // Create sync routes
143
147
  const syncRoutes = createSyncRoutes({
@@ -149,6 +153,7 @@ export function createSyncServer<
149
153
  chunkStorage,
150
154
  scopeCache,
151
155
  consoleLiveEmitter: consoleEventEmitter,
156
+ consoleSchemaReady,
152
157
  sync: {
153
158
  ...routes,
154
159
  websocket: upgradeWebSocket
@@ -179,10 +184,12 @@ export function createSyncServer<
179
184
  dialect,
180
185
  handlers: sync.handlers,
181
186
  authenticate: createTokenAuthenticator(consoleToken),
182
- corsOrigins: resolvedConsoleConfig.corsOrigins ?? '*',
187
+ corsOrigins: resolvedConsoleConfig.corsOrigins,
183
188
  eventEmitter: consoleEventEmitter,
189
+ consoleSchemaReady,
184
190
  wsConnectionManager: getSyncWebSocketConnectionManager(syncRoutes),
185
191
  metrics: resolvedConsoleConfig.metrics,
192
+ maintenance: resolvedConsoleConfig.maintenance,
186
193
  blobBucket: resolvedConsoleConfig.blobBucket,
187
194
  ...(upgradeWebSocket && {
188
195
  websocket: {