@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.
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/console/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAQ9E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAqE5B,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EAEnB,0BAA0B,EAC3B,MAAM,SAAS,CAAC;AAEjB;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,mBAAmB,CA0DtB;AAmOD,wBAAgB,mBAAmB,CACjC,EAAE,SAAS,UAAU,EACrB,IAAI,SAAS,cAAc,EAC3B,CAAC,SAAS,SAAS,GAAG,SAAS,EAC/B,OAAO,EAAE,0BAA0B,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAurGxD;AA6BD;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,CAAC,EAAE,MAAM,GACb,CAAC,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAgBnD"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/console/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAQ9E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAqE5B,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EAEnB,0BAA0B,EAC3B,MAAM,SAAS,CAAC;AAEjB;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,mBAAmB,CA0DtB;AAsPD,wBAAgB,mBAAmB,CACjC,EAAE,SAAS,UAAU,EACrB,IAAI,SAAS,cAAc,EAC3B,CAAC,SAAS,SAAS,GAAG,SAAS,EAC/B,OAAO,EAAE,0BAA0B,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CA44GxD;AA6BD;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,CAAC,EAAE,MAAM,GACb,CAAC,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAgBnD"}
@@ -262,6 +262,20 @@ const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
262
262
  const handlersResponseSchema = z.object({
263
263
  items: z.array(ConsoleHandlerSchema),
264
264
  });
265
+ const DEFAULT_REQUEST_EVENTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
266
+ const DEFAULT_REQUEST_EVENTS_MAX_ROWS = 10_000;
267
+ const DEFAULT_OPERATION_EVENTS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
268
+ const DEFAULT_OPERATION_EVENTS_MAX_ROWS = 5_000;
269
+ const DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
270
+ function readNonNegativeInteger(value, fallback) {
271
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
272
+ return fallback;
273
+ }
274
+ if (value < 0) {
275
+ return fallback;
276
+ }
277
+ return Math.floor(value);
278
+ }
265
279
  export function createConsoleRoutes(options) {
266
280
  const routes = new Hono();
267
281
  routes.onError((error, context) => {
@@ -275,18 +289,27 @@ export function createConsoleRoutes(options) {
275
289
  const db = options.db;
276
290
  const metricsAggregationMode = options.metrics?.aggregationMode ?? 'auto';
277
291
  const rawFallbackMaxEvents = Math.max(1, options.metrics?.rawFallbackMaxEvents ?? 5000);
278
- // Ensure console schema exists (creates sync_request_events table if needed)
279
- // Run asynchronously - will be ready before first request typically
280
- options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
292
+ const requestEventsMaxAgeMs = readNonNegativeInteger(options.maintenance?.requestEventsMaxAgeMs, DEFAULT_REQUEST_EVENTS_MAX_AGE_MS);
293
+ const requestEventsMaxRows = readNonNegativeInteger(options.maintenance?.requestEventsMaxRows, DEFAULT_REQUEST_EVENTS_MAX_ROWS);
294
+ const operationEventsMaxAgeMs = readNonNegativeInteger(options.maintenance?.operationEventsMaxAgeMs, DEFAULT_OPERATION_EVENTS_MAX_AGE_MS);
295
+ const operationEventsMaxRows = readNonNegativeInteger(options.maintenance?.operationEventsMaxRows, DEFAULT_OPERATION_EVENTS_MAX_ROWS);
296
+ const autoEventsPruneIntervalMs = readNonNegativeInteger(options.maintenance?.autoPruneIntervalMs, DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS);
297
+ let lastEventsPruneRunAt = 0;
298
+ // Ensure console schema exists before handlers query console tables.
299
+ const consoleSchemaReadyPromise = (options.consoleSchemaReady ??
300
+ options.dialect.ensureConsoleSchema?.(options.db) ??
301
+ Promise.resolve()).catch((err) => {
281
302
  console.error('[console] Failed to ensure console schema:', err);
303
+ throw err;
282
304
  });
283
305
  // CORS configuration
284
306
  const corsOrigins = options.corsOrigins ?? [
285
307
  'http://localhost:5173',
286
308
  'https://console.sync.dev',
287
309
  ];
310
+ const allowWildcardCors = corsOrigins === '*';
288
311
  routes.use('*', cors({
289
- origin: corsOrigins === '*' ? '*' : corsOrigins,
312
+ origin: allowWildcardCors ? '*' : corsOrigins,
290
313
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
291
314
  allowHeaders: [
292
315
  'Content-Type',
@@ -298,8 +321,30 @@ export function createConsoleRoutes(options) {
298
321
  'Tracestate',
299
322
  ],
300
323
  exposeHeaders: ['X-Total-Count'],
301
- credentials: true,
324
+ credentials: !allowWildcardCors,
302
325
  }));
326
+ const ensureConsoleSchemaReady = async (c) => {
327
+ try {
328
+ await consoleSchemaReadyPromise;
329
+ return null;
330
+ }
331
+ catch {
332
+ return c.json({ error: 'CONSOLE_SCHEMA_UNAVAILABLE' }, 503);
333
+ }
334
+ };
335
+ routes.use('*', async (c, next) => {
336
+ const readyError = await ensureConsoleSchemaReady(c);
337
+ if (readyError) {
338
+ return readyError;
339
+ }
340
+ await next();
341
+ });
342
+ routes.use('*', async (c, next) => {
343
+ if (c.req.method !== 'OPTIONS') {
344
+ triggerAutomaticEventsPrune();
345
+ }
346
+ await next();
347
+ });
303
348
  // Auth middleware
304
349
  const requireAuth = async (c) => {
305
350
  const auth = await options.authenticate(c);
@@ -385,6 +430,161 @@ export function createConsoleRoutes(options) {
385
430
  resultPayload: parseJsonValue(row.result_payload),
386
431
  createdAt: row.created_at ?? '',
387
432
  });
433
+ const deleteUnreferencedPayloadSnapshots = async () => {
434
+ const result = await db
435
+ .deleteFrom('sync_request_payloads')
436
+ .where('payload_ref', 'not in', db
437
+ .selectFrom('sync_request_events')
438
+ .select('payload_ref')
439
+ .where('payload_ref', 'is not', null))
440
+ .executeTakeFirst();
441
+ return Number(result?.numDeletedRows ?? 0);
442
+ };
443
+ const pruneRequestEventsByAge = async () => {
444
+ if (requestEventsMaxAgeMs <= 0) {
445
+ return 0;
446
+ }
447
+ const cutoffDate = new Date(Date.now() - requestEventsMaxAgeMs);
448
+ const result = await db
449
+ .deleteFrom('sync_request_events')
450
+ .where('created_at', '<', cutoffDate.toISOString())
451
+ .executeTakeFirst();
452
+ return Number(result?.numDeletedRows ?? 0);
453
+ };
454
+ const pruneRequestEventsByCount = async () => {
455
+ if (requestEventsMaxRows <= 0) {
456
+ return 0;
457
+ }
458
+ const countRow = await db
459
+ .selectFrom('sync_request_events')
460
+ .select(({ fn }) => fn.countAll().as('total'))
461
+ .executeTakeFirst();
462
+ const total = coerceNumber(countRow?.total) ?? 0;
463
+ if (total <= requestEventsMaxRows) {
464
+ return 0;
465
+ }
466
+ const cutoffRow = await db
467
+ .selectFrom('sync_request_events')
468
+ .select(['event_id'])
469
+ .orderBy('event_id', 'desc')
470
+ .offset(requestEventsMaxRows)
471
+ .limit(1)
472
+ .executeTakeFirst();
473
+ const cutoffEventId = coerceNumber(cutoffRow?.event_id);
474
+ if (cutoffEventId === null) {
475
+ return 0;
476
+ }
477
+ const result = await db
478
+ .deleteFrom('sync_request_events')
479
+ .where('event_id', '<=', cutoffEventId)
480
+ .executeTakeFirst();
481
+ return Number(result?.numDeletedRows ?? 0);
482
+ };
483
+ const pruneOperationEventsByAge = async () => {
484
+ if (operationEventsMaxAgeMs <= 0) {
485
+ return 0;
486
+ }
487
+ const cutoffDate = new Date(Date.now() - operationEventsMaxAgeMs);
488
+ const result = await db
489
+ .deleteFrom('sync_operation_events')
490
+ .where('created_at', '<', cutoffDate.toISOString())
491
+ .executeTakeFirst();
492
+ return Number(result?.numDeletedRows ?? 0);
493
+ };
494
+ const pruneOperationEventsByCount = async () => {
495
+ if (operationEventsMaxRows <= 0) {
496
+ return 0;
497
+ }
498
+ const countRow = await db
499
+ .selectFrom('sync_operation_events')
500
+ .select(({ fn }) => fn.countAll().as('total'))
501
+ .executeTakeFirst();
502
+ const total = coerceNumber(countRow?.total) ?? 0;
503
+ if (total <= operationEventsMaxRows) {
504
+ return 0;
505
+ }
506
+ const cutoffRow = await db
507
+ .selectFrom('sync_operation_events')
508
+ .select(['operation_id'])
509
+ .orderBy('operation_id', 'desc')
510
+ .offset(operationEventsMaxRows)
511
+ .limit(1)
512
+ .executeTakeFirst();
513
+ const cutoffOperationId = coerceNumber(cutoffRow?.operation_id);
514
+ if (cutoffOperationId === null) {
515
+ return 0;
516
+ }
517
+ const result = await db
518
+ .deleteFrom('sync_operation_events')
519
+ .where('operation_id', '<=', cutoffOperationId)
520
+ .executeTakeFirst();
521
+ return Number(result?.numDeletedRows ?? 0);
522
+ };
523
+ const pruneConsoleEvents = async () => {
524
+ const requestEventsDeletedByAge = await pruneRequestEventsByAge();
525
+ const requestEventsDeletedByCount = await pruneRequestEventsByCount();
526
+ const requestEventsDeleted = requestEventsDeletedByAge + requestEventsDeletedByCount;
527
+ const operationEventsDeletedByAge = await pruneOperationEventsByAge();
528
+ const operationEventsDeletedByCount = await pruneOperationEventsByCount();
529
+ const operationEventsDeleted = operationEventsDeletedByAge + operationEventsDeletedByCount;
530
+ const payloadSnapshotsDeleted = await deleteUnreferencedPayloadSnapshots();
531
+ const totalDeleted = requestEventsDeleted + operationEventsDeleted;
532
+ return {
533
+ requestEventsDeleted,
534
+ operationEventsDeleted,
535
+ payloadSnapshotsDeleted,
536
+ totalDeleted,
537
+ };
538
+ };
539
+ let eventsPrunePromise = null;
540
+ const runEventsPrune = async () => {
541
+ if (eventsPrunePromise) {
542
+ return eventsPrunePromise;
543
+ }
544
+ let pending;
545
+ pending = pruneConsoleEvents()
546
+ .then((result) => {
547
+ lastEventsPruneRunAt = Date.now();
548
+ return result;
549
+ })
550
+ .finally(() => {
551
+ if (eventsPrunePromise === pending) {
552
+ eventsPrunePromise = null;
553
+ }
554
+ });
555
+ eventsPrunePromise = pending;
556
+ return pending;
557
+ };
558
+ const triggerAutomaticEventsPrune = () => {
559
+ if (autoEventsPruneIntervalMs <= 0) {
560
+ return;
561
+ }
562
+ if (eventsPrunePromise) {
563
+ return;
564
+ }
565
+ if (Date.now() - lastEventsPruneRunAt < autoEventsPruneIntervalMs) {
566
+ return;
567
+ }
568
+ void runEventsPrune()
569
+ .then((result) => {
570
+ if (result.totalDeleted <= 0 && result.payloadSnapshotsDeleted <= 0) {
571
+ return;
572
+ }
573
+ logSyncEvent({
574
+ event: 'console.prune_events_auto',
575
+ deletedCount: result.totalDeleted,
576
+ requestEventsDeleted: result.requestEventsDeleted,
577
+ operationEventsDeleted: result.operationEventsDeleted,
578
+ payloadDeletedCount: result.payloadSnapshotsDeleted,
579
+ });
580
+ })
581
+ .catch((error) => {
582
+ logSyncEvent({
583
+ event: 'console.prune_events_auto_failed',
584
+ error: error instanceof Error ? error.message : String(error),
585
+ });
586
+ });
587
+ };
388
588
  const recordOperationEvent = async (event) => {
389
589
  await db
390
590
  .insertInto('sync_operation_events')
@@ -2030,10 +2230,12 @@ export function createConsoleRoutes(options) {
2030
2230
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
2031
2231
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
2032
2232
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2233
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
2033
2234
  logSyncEvent({
2034
2235
  event: 'console.clear_events',
2035
2236
  consoleUserId: auth.consoleUserId,
2036
2237
  deletedCount,
2238
+ payloadDeletedCount,
2037
2239
  });
2038
2240
  const result = { deletedCount };
2039
2241
  return c.json(result, 200);
@@ -2064,45 +2266,15 @@ export function createConsoleRoutes(options) {
2064
2266
  const auth = await requireAuth(c);
2065
2267
  if (!auth)
2066
2268
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
2067
- // Prune events older than 7 days or keep max 10000 events
2068
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2069
- // Delete by date first
2070
- const resByDate = await db
2071
- .deleteFrom('sync_request_events')
2072
- .where('created_at', '<', cutoffDate.toISOString())
2073
- .executeTakeFirst();
2074
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
2075
- // Then delete oldest if we still have more than 10000 events
2076
- const countRow = await db
2077
- .selectFrom('sync_request_events')
2078
- .select(({ fn }) => fn.countAll().as('total'))
2079
- .executeTakeFirst();
2080
- const total = coerceNumber(countRow?.total) ?? 0;
2081
- const maxEvents = 10000;
2082
- if (total > maxEvents) {
2083
- // Find event_id cutoff to keep only newest maxEvents
2084
- const cutoffRow = await db
2085
- .selectFrom('sync_request_events')
2086
- .select(['event_id'])
2087
- .orderBy('event_id', 'desc')
2088
- .offset(maxEvents)
2089
- .limit(1)
2090
- .executeTakeFirst();
2091
- if (cutoffRow) {
2092
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
2093
- if (cutoffEventId !== null) {
2094
- const resByCount = await db
2095
- .deleteFrom('sync_request_events')
2096
- .where('event_id', '<=', cutoffEventId)
2097
- .executeTakeFirst();
2098
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
2099
- }
2100
- }
2101
- }
2269
+ const pruneResult = await runEventsPrune();
2270
+ const deletedCount = pruneResult.totalDeleted;
2102
2271
  logSyncEvent({
2103
2272
  event: 'console.prune_events',
2104
2273
  consoleUserId: auth.consoleUserId,
2105
2274
  deletedCount,
2275
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
2276
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
2277
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
2106
2278
  });
2107
2279
  const result = { deletedCount };
2108
2280
  return c.json(result, 200);