@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.
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +176 -37
- package/dist/console/routes.js.map +1 -1
- package/dist/console/types.d.ts +33 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +1 -0
- package/dist/create-server.js.map +1 -1
- package/dist/routes.d.ts +16 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +77 -55
- package/dist/routes.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/console-routes.test.ts +92 -9
- package/src/__tests__/create-server.test.ts +147 -4
- package/src/console/routes.ts +233 -45
- package/src/console/types.ts +34 -0
- package/src/create-server.ts +1 -0
- package/src/routes.ts +107 -55
|
@@ -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
|
|
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(
|
|
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: {
|
|
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 () => {});
|
package/src/console/routes.ts
CHANGED
|
@@ -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
|
-
|
|
2783
|
-
const
|
|
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
|
-
|
|
3017
|
+
requestEventsDeleted: pruneResult.requestEventsDeleted,
|
|
3018
|
+
operationEventsDeleted: pruneResult.operationEventsDeleted,
|
|
3019
|
+
payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
|
|
2832
3020
|
});
|
|
2833
3021
|
|
|
2834
3022
|
const result: ConsolePruneEventsResult = { deletedCount };
|
package/src/console/types.ts
CHANGED
|
@@ -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
|
|
package/src/create-server.ts
CHANGED
|
@@ -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: {
|