@syncular/server-hono 0.0.6-95 → 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.
@@ -9,7 +9,7 @@ import {
9
9
  import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
10
10
  import { Hono } from 'hono';
11
11
  import { defineWebSocketHelper } from 'hono/ws';
12
- import type { Kysely } from 'kysely';
12
+ import { type Kysely, sql } from 'kysely';
13
13
  import { createSyncServer } from '../create-server';
14
14
  import { getSyncWebSocketConnectionManager } from '../routes';
15
15
  import type { WebSocketConnection } from '../ws';
@@ -100,10 +100,16 @@ describe('createSyncServer console configuration', () => {
100
100
  };
101
101
  }
102
102
 
103
- function createPushRequest(): Request {
103
+ function createPushRequest(args?: {
104
+ requestId?: string;
105
+ title?: string;
106
+ }): Request {
104
107
  return new Request('http://localhost/sync', {
105
108
  method: 'POST',
106
- headers: { 'content-type': 'application/json' },
109
+ headers: {
110
+ 'content-type': 'application/json',
111
+ ...(args?.requestId ? { 'x-request-id': args.requestId } : {}),
112
+ },
107
113
  body: JSON.stringify({
108
114
  clientId: 'client-1',
109
115
  push: {
@@ -117,7 +123,7 @@ describe('createSyncServer console configuration', () => {
117
123
  payload: {
118
124
  id: 'task-1',
119
125
  user_id: 'u1',
120
- title: 'Task 1',
126
+ title: args?.title ?? 'Task 1',
121
127
  server_version: 0,
122
128
  },
123
129
  },
@@ -127,6 +133,62 @@ describe('createSyncServer console configuration', () => {
127
133
  });
128
134
  }
129
135
 
136
+ function parseSnapshotValue(value: unknown): unknown {
137
+ if (typeof value !== 'string') {
138
+ return value;
139
+ }
140
+ try {
141
+ return JSON.parse(value);
142
+ } catch {
143
+ return value;
144
+ }
145
+ }
146
+
147
+ async function waitForRequestEventRow(requestId: string): Promise<{
148
+ payload_ref: string | null;
149
+ }> {
150
+ for (let attempt = 0; attempt < 40; attempt++) {
151
+ const result = await sql<{ payload_ref: string | null }>`
152
+ SELECT payload_ref
153
+ FROM sync_request_events
154
+ WHERE request_id = ${requestId}
155
+ ORDER BY event_id DESC
156
+ LIMIT 1
157
+ `.execute(db);
158
+
159
+ const row = result.rows[0];
160
+ if (row) {
161
+ return row;
162
+ }
163
+
164
+ await new Promise((resolve) => setTimeout(resolve, 25));
165
+ }
166
+
167
+ throw new Error(`Timed out waiting for request event: ${requestId}`);
168
+ }
169
+
170
+ async function waitForRequestPayloadSnapshot(
171
+ payloadRef: string
172
+ ): Promise<unknown> {
173
+ for (let attempt = 0; attempt < 40; attempt++) {
174
+ const result = await sql<{ request_payload: unknown | null }>`
175
+ SELECT request_payload
176
+ FROM sync_request_payloads
177
+ WHERE payload_ref = ${payloadRef}
178
+ LIMIT 1
179
+ `.execute(db);
180
+
181
+ const row = result.rows[0];
182
+ if (row && row.request_payload !== null) {
183
+ return parseSnapshotValue(row.request_payload);
184
+ }
185
+
186
+ await new Promise((resolve) => setTimeout(resolve, 25));
187
+ }
188
+
189
+ throw new Error(`Timed out waiting for payload snapshot: ${payloadRef}`);
190
+ }
191
+
130
192
  it('keeps console routes disabled when console config is omitted', () => {
131
193
  const server = createSyncServer(createOptions());
132
194
  expect(server.consoleRoutes).toBeUndefined();
@@ -270,6 +332,87 @@ describe('createSyncServer console configuration', () => {
270
332
  });
271
333
  });
272
334
 
335
+ it('allows disabling request payload snapshots for privacy-sensitive deployments', async () => {
336
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
337
+ const options = createOptions();
338
+ const server = createSyncServer({
339
+ ...options,
340
+ console: {},
341
+ routes: {
342
+ requestPayloadSnapshots: {
343
+ enabled: false,
344
+ },
345
+ },
346
+ });
347
+
348
+ const app = new Hono();
349
+ app.route('/sync', server.syncRoutes);
350
+
351
+ const requestId = 'req-no-payload-snapshot';
352
+ const response = await app.request(createPushRequest({ requestId }));
353
+ expect(response.status).toBe(200);
354
+
355
+ const eventRow = await waitForRequestEventRow(requestId);
356
+ expect(eventRow.payload_ref).toBeNull();
357
+
358
+ const payloadCountResult = await sql<{ total: number | string }>`
359
+ SELECT COUNT(*)::int AS total
360
+ FROM sync_request_payloads
361
+ `.execute(db);
362
+ const payloadCount = Number(payloadCountResult.rows[0]?.total ?? 0);
363
+ expect(payloadCount).toBe(0);
364
+ });
365
+
366
+ it('supports aggressively reducing stored payload snapshot size', async () => {
367
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
368
+ const options = createOptions();
369
+ const server = createSyncServer({
370
+ ...options,
371
+ console: {},
372
+ routes: {
373
+ requestPayloadSnapshots: {
374
+ maxBytes: 32,
375
+ },
376
+ },
377
+ });
378
+
379
+ const app = new Hono();
380
+ app.route('/sync', server.syncRoutes);
381
+
382
+ const requestId = 'req-small-payload-preview';
383
+ const response = await app.request(
384
+ createPushRequest({
385
+ requestId,
386
+ title: 'x'.repeat(1024),
387
+ })
388
+ );
389
+ expect(response.status).toBe(200);
390
+
391
+ const eventRow = await waitForRequestEventRow(requestId);
392
+ expect(typeof eventRow.payload_ref).toBe('string');
393
+ if (!eventRow.payload_ref) {
394
+ throw new Error('Expected payload_ref to be present.');
395
+ }
396
+
397
+ const storedPayload = await waitForRequestPayloadSnapshot(
398
+ eventRow.payload_ref
399
+ );
400
+ expect(typeof storedPayload).toBe('object');
401
+ expect(Array.isArray(storedPayload)).toBe(false);
402
+ if (!storedPayload || typeof storedPayload !== 'object') {
403
+ throw new Error('Expected stored payload snapshot to be an object.');
404
+ }
405
+
406
+ const truncated = Reflect.get(storedPayload, 'truncated');
407
+ const preview = Reflect.get(storedPayload, 'preview');
408
+
409
+ expect(truncated).toBe(true);
410
+ expect(typeof preview).toBe('string');
411
+ if (typeof preview === 'string') {
412
+ expect(preview.length).toBeLessThanOrEqual(32);
413
+ }
414
+ });
415
+
273
416
  it('forwards maxConnectionsTotal from factory to realtime route', async () => {
274
417
  const options = createOptions();
275
418
  const upgradeWebSocket = defineWebSocketHelper(async () => {});
@@ -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,6 +508,27 @@ export function createConsoleRoutes<
489
508
  1,
490
509
  options.metrics?.rawFallbackMaxEvents ?? 5000
491
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;
492
532
 
493
533
  // Ensure console schema exists before handlers query console tables.
494
534
  const consoleSchemaReadyPromise = (
@@ -545,6 +585,13 @@ export function createConsoleRoutes<
545
585
  await next();
546
586
  });
547
587
 
588
+ routes.use('*', async (c, next) => {
589
+ if (c.req.method !== 'OPTIONS') {
590
+ triggerAutomaticEventsPrune();
591
+ }
592
+ await next();
593
+ });
594
+
548
595
  // Auth middleware
549
596
  const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
550
597
  const auth = await options.authenticate(c);
@@ -640,6 +687,13 @@ export function createConsoleRoutes<
640
687
  createdAt: row.created_at ?? '',
641
688
  });
642
689
 
690
+ type PruneEventsRunResult = {
691
+ requestEventsDeleted: number;
692
+ operationEventsDeleted: number;
693
+ payloadSnapshotsDeleted: number;
694
+ totalDeleted: number;
695
+ };
696
+
643
697
  const deleteUnreferencedPayloadSnapshots = async (): Promise<number> => {
644
698
  const result = await db
645
699
  .deleteFrom('sync_request_payloads')
@@ -655,6 +709,180 @@ export function createConsoleRoutes<
655
709
  return Number(result?.numDeletedRows ?? 0);
656
710
  };
657
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
+
658
886
  const recordOperationEvent = async (event: {
659
887
  operationType: ConsoleOperationType;
660
888
  consoleUserId?: string;
@@ -2779,56 +3007,16 @@ export function createConsoleRoutes<
2779
3007
  const auth = await requireAuth(c);
2780
3008
  if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
2781
3009
 
2782
- // Prune events older than 7 days or keep max 10000 events
2783
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2784
-
2785
- // Delete by date first
2786
- const resByDate = await db
2787
- .deleteFrom('sync_request_events')
2788
- .where('created_at', '<', cutoffDate.toISOString())
2789
- .executeTakeFirst();
2790
-
2791
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
2792
-
2793
- // Then delete oldest if we still have more than 10000 events
2794
- const countRow = await db
2795
- .selectFrom('sync_request_events')
2796
- .select(({ fn }) => fn.countAll().as('total'))
2797
- .executeTakeFirst();
2798
-
2799
- const total = coerceNumber(countRow?.total) ?? 0;
2800
- const maxEvents = 10000;
2801
-
2802
- if (total > maxEvents) {
2803
- // Find event_id cutoff to keep only newest maxEvents
2804
- const cutoffRow = await db
2805
- .selectFrom('sync_request_events')
2806
- .select(['event_id'])
2807
- .orderBy('event_id', 'desc')
2808
- .offset(maxEvents)
2809
- .limit(1)
2810
- .executeTakeFirst();
2811
-
2812
- if (cutoffRow) {
2813
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
2814
- if (cutoffEventId !== null) {
2815
- const resByCount = await db
2816
- .deleteFrom('sync_request_events')
2817
- .where('event_id', '<=', cutoffEventId)
2818
- .executeTakeFirst();
2819
-
2820
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
2821
- }
2822
- }
2823
- }
2824
-
2825
- const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
3010
+ const pruneResult = await runEventsPrune();
3011
+ const deletedCount = pruneResult.totalDeleted;
2826
3012
 
2827
3013
  logSyncEvent({
2828
3014
  event: 'console.prune_events',
2829
3015
  consoleUserId: auth.consoleUserId,
2830
3016
  deletedCount,
2831
- payloadDeletedCount,
3017
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
3018
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
3019
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
2832
3020
  });
2833
3021
 
2834
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
 
@@ -189,6 +189,7 @@ export function createSyncServer<
189
189
  consoleSchemaReady,
190
190
  wsConnectionManager: getSyncWebSocketConnectionManager(syncRoutes),
191
191
  metrics: resolvedConsoleConfig.metrics,
192
+ maintenance: resolvedConsoleConfig.maintenance,
192
193
  blobBucket: resolvedConsoleConfig.blobBucket,
193
194
  ...(upgradeWebSocket && {
194
195
  websocket: {