@syncular/server-hono 0.0.2-137 → 0.0.2-139

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.
@@ -19,13 +19,16 @@ import { compactChanges, computePruneWatermarkCommitSeq, notifyExternalDataChang
19
19
  import { Hono } from 'hono';
20
20
  import { cors } from 'hono/cors';
21
21
  import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
22
+ import { sql } from 'kysely';
22
23
  import { z } from 'zod';
23
- import { ApiKeyTypeSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas.js';
24
+ import { ApiKeyTypeSchema, ConsoleApiKeyBulkRevokeRequestSchema, ConsoleApiKeyBulkRevokeResponseSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsoleOperationEventSchema, ConsoleOperationsQuerySchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePartitionedPaginationQuerySchema, ConsolePartitionQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, ConsoleRequestPayloadSchema, ConsoleTimelineItemSchema, ConsoleTimelineQuerySchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas.js';
24
25
  /**
25
26
  * Create a simple console event emitter for broadcasting live events.
26
27
  */
27
- export function createConsoleEventEmitter() {
28
+ export function createConsoleEventEmitter(options) {
28
29
  const listeners = new Set();
30
+ const history = [];
31
+ const maxHistory = Math.max(1, options?.maxHistory ?? 500);
29
32
  return {
30
33
  addListener(listener) {
31
34
  listeners.add(listener);
@@ -34,6 +37,10 @@ export function createConsoleEventEmitter() {
34
37
  listeners.delete(listener);
35
38
  },
36
39
  emit(event) {
40
+ history.push(event);
41
+ if (history.length > maxHistory) {
42
+ history.splice(0, history.length - maxHistory);
43
+ }
37
44
  for (const listener of listeners) {
38
45
  try {
39
46
  listener(event);
@@ -43,6 +50,32 @@ export function createConsoleEventEmitter() {
43
50
  }
44
51
  }
45
52
  },
53
+ replay(replayOptions) {
54
+ const sinceMs = replayOptions?.since
55
+ ? Date.parse(replayOptions.since)
56
+ : Number.NaN;
57
+ const hasSince = Number.isFinite(sinceMs);
58
+ const normalizedPartitionId = replayOptions?.partitionId?.trim();
59
+ const hasPartitionFilter = Boolean(normalizedPartitionId);
60
+ const filteredByTime = hasSince
61
+ ? history.filter((event) => {
62
+ const eventMs = Date.parse(event.timestamp);
63
+ return Number.isFinite(eventMs) && eventMs > sinceMs;
64
+ })
65
+ : history;
66
+ const filtered = hasPartitionFilter
67
+ ? filteredByTime.filter((event) => {
68
+ const eventPartitionId = event.data.partitionId;
69
+ return (typeof eventPartitionId === 'string' &&
70
+ eventPartitionId === normalizedPartitionId);
71
+ })
72
+ : filteredByTime;
73
+ const normalizedLimit = replayOptions?.limit && replayOptions.limit > 0
74
+ ? Math.floor(replayOptions.limit)
75
+ : 100;
76
+ const limited = filtered.slice(-normalizedLimit);
77
+ return [...limited];
78
+ },
46
79
  };
47
80
  }
48
81
  function coerceNumber(value) {
@@ -64,6 +97,42 @@ function parseDate(value) {
64
97
  const parsed = Date.parse(value);
65
98
  return Number.isFinite(parsed) ? parsed : null;
66
99
  }
100
+ function includesSearchTerm(value, searchTerm) {
101
+ if (!searchTerm)
102
+ return true;
103
+ if (!value)
104
+ return false;
105
+ return value.toLowerCase().includes(searchTerm);
106
+ }
107
+ function parseJsonValue(value) {
108
+ if (value === null || value === undefined)
109
+ return null;
110
+ if (typeof value !== 'string')
111
+ return value;
112
+ try {
113
+ return JSON.parse(value);
114
+ }
115
+ catch {
116
+ return value;
117
+ }
118
+ }
119
+ function parseScopesSummary(value) {
120
+ const parsed = parseJsonValue(value);
121
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
122
+ return null;
123
+ }
124
+ const summary = {};
125
+ for (const [key, entry] of Object.entries(parsed)) {
126
+ if (typeof entry === 'string') {
127
+ summary[key] = entry;
128
+ continue;
129
+ }
130
+ if (!Array.isArray(entry))
131
+ continue;
132
+ summary[key] = entry.filter((value) => typeof value === 'string');
133
+ }
134
+ return Object.keys(summary).length > 0 ? summary : null;
135
+ }
67
136
  function getClientActivityState(args) {
68
137
  if (args.connectionCount > 0) {
69
138
  return 'active';
@@ -81,6 +150,87 @@ function getClientActivityState(args) {
81
150
  }
82
151
  return 'stale';
83
152
  }
153
+ function rangeToMs(range) {
154
+ if (range === '1h')
155
+ return 60 * 60 * 1000;
156
+ if (range === '6h')
157
+ return 6 * 60 * 60 * 1000;
158
+ if (range === '24h')
159
+ return 24 * 60 * 60 * 1000;
160
+ if (range === '7d')
161
+ return 7 * 24 * 60 * 60 * 1000;
162
+ return 30 * 24 * 60 * 60 * 1000;
163
+ }
164
+ function intervalToMs(interval) {
165
+ if (interval === 'minute')
166
+ return 60 * 1000;
167
+ if (interval === 'hour')
168
+ return 60 * 60 * 1000;
169
+ return 24 * 60 * 60 * 1000;
170
+ }
171
+ function intervalToSqliteBucketFormat(interval) {
172
+ if (interval === 'minute')
173
+ return '%Y-%m-%dT%H:%M:00.000Z';
174
+ if (interval === 'hour')
175
+ return '%Y-%m-%dT%H:00:00.000Z';
176
+ return '%Y-%m-%dT00:00:00.000Z';
177
+ }
178
+ function createEmptyTimeseriesAccumulator() {
179
+ return {
180
+ pushCount: 0,
181
+ pullCount: 0,
182
+ errorCount: 0,
183
+ totalLatency: 0,
184
+ eventCount: 0,
185
+ };
186
+ }
187
+ function createTimeseriesBucketMap(args) {
188
+ const map = new Map();
189
+ const bucketCount = Math.ceil(args.rangeMs / args.intervalMs);
190
+ for (let i = 0; i < bucketCount; i++) {
191
+ const bucketTimestamp = new Date(args.startTime.getTime() + i * args.intervalMs).toISOString();
192
+ map.set(bucketTimestamp, createEmptyTimeseriesAccumulator());
193
+ }
194
+ return map;
195
+ }
196
+ function normalizeBucketTimestamp(value) {
197
+ if (value instanceof Date) {
198
+ return value.toISOString();
199
+ }
200
+ if (typeof value !== 'string') {
201
+ return null;
202
+ }
203
+ const parsed = Date.parse(value);
204
+ if (!Number.isFinite(parsed))
205
+ return null;
206
+ return new Date(parsed).toISOString();
207
+ }
208
+ function finalizeTimeseriesBuckets(bucketMap) {
209
+ return Array.from(bucketMap.entries())
210
+ .sort(([a], [b]) => a.localeCompare(b))
211
+ .map(([timestamp, data]) => ({
212
+ timestamp,
213
+ pushCount: data.pushCount,
214
+ pullCount: data.pullCount,
215
+ errorCount: data.errorCount,
216
+ avgLatencyMs: data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
217
+ }));
218
+ }
219
+ function calculatePercentiles(latencies) {
220
+ if (latencies.length === 0) {
221
+ return { p50: 0, p90: 0, p99: 0 };
222
+ }
223
+ const sorted = [...latencies].sort((a, b) => a - b);
224
+ const getPercentile = (p) => {
225
+ const index = Math.ceil((p / 100) * sorted.length) - 1;
226
+ return sorted[Math.max(0, index)] ?? 0;
227
+ };
228
+ return {
229
+ p50: getPercentile(50),
230
+ p90: getPercentile(90),
231
+ p99: getPercentile(99),
232
+ };
233
+ }
84
234
  // ============================================================================
85
235
  // Route Schemas
86
236
  // ============================================================================
@@ -92,14 +242,22 @@ const commitSeqParamSchema = z.object({ seq: z.coerce.number().int() });
92
242
  const clientIdParamSchema = z.object({ id: z.string().min(1) });
93
243
  const eventIdParamSchema = z.object({ id: z.coerce.number().int() });
94
244
  const apiKeyIdParamSchema = z.object({ id: z.string().min(1) });
95
- const eventsQuerySchema = ConsolePaginationQuerySchema.extend({
245
+ const eventsQuerySchema = ConsolePartitionedPaginationQuerySchema.extend({
96
246
  eventType: z.enum(['push', 'pull']).optional(),
97
247
  actorId: z.string().optional(),
98
248
  clientId: z.string().optional(),
249
+ requestId: z.string().optional(),
250
+ traceId: z.string().optional(),
99
251
  outcome: z.string().optional(),
100
252
  });
253
+ const commitDetailQuerySchema = ConsolePartitionQuerySchema;
254
+ const eventDetailQuerySchema = ConsolePartitionQuerySchema;
255
+ const evictClientQuerySchema = ConsolePartitionQuerySchema;
256
+ const apiKeyStatusSchema = z.enum(['active', 'revoked', 'expiring']);
101
257
  const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
102
258
  type: ApiKeyTypeSchema.optional(),
259
+ status: apiKeyStatusSchema.optional(),
260
+ expiresWithinDays: z.coerce.number().int().min(1).max(365).optional(),
103
261
  });
104
262
  const handlersResponseSchema = z.object({
105
263
  items: z.array(ConsoleHandlerSchema),
@@ -107,6 +265,8 @@ const handlersResponseSchema = z.object({
107
265
  export function createConsoleRoutes(options) {
108
266
  const routes = new Hono();
109
267
  const db = options.db;
268
+ const metricsAggregationMode = options.metrics?.aggregationMode ?? 'auto';
269
+ const rawFallbackMaxEvents = Math.max(1, options.metrics?.rawFallbackMaxEvents ?? 5000);
110
270
  // Ensure console schema exists (creates sync_request_events table if needed)
111
271
  // Run asynchronously - will be ready before first request typically
112
272
  options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
@@ -132,6 +292,118 @@ export function createConsoleRoutes(options) {
132
292
  }
133
293
  return auth;
134
294
  };
295
+ const requestEventSelectColumns = [
296
+ 'event_id',
297
+ 'partition_id',
298
+ 'request_id',
299
+ 'trace_id',
300
+ 'span_id',
301
+ 'event_type',
302
+ 'sync_path',
303
+ 'transport_path',
304
+ 'actor_id',
305
+ 'client_id',
306
+ 'status_code',
307
+ 'outcome',
308
+ 'response_status',
309
+ 'error_code',
310
+ 'duration_ms',
311
+ 'commit_seq',
312
+ 'operation_count',
313
+ 'row_count',
314
+ 'subscription_count',
315
+ 'scopes_summary',
316
+ 'tables',
317
+ 'error_message',
318
+ 'payload_ref',
319
+ 'created_at',
320
+ ];
321
+ const mapRequestEvent = (row) => ({
322
+ eventId: coerceNumber(row.event_id) ?? 0,
323
+ partitionId: row.partition_id ?? 'default',
324
+ requestId: row.request_id ?? '',
325
+ traceId: row.trace_id ?? null,
326
+ spanId: row.span_id ?? null,
327
+ eventType: row.event_type === 'push' ? 'push' : 'pull',
328
+ syncPath: row.sync_path === 'ws-push' ? 'ws-push' : 'http-combined',
329
+ transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
330
+ actorId: row.actor_id ?? '',
331
+ clientId: row.client_id ?? '',
332
+ statusCode: coerceNumber(row.status_code) ?? 0,
333
+ outcome: row.outcome ?? '',
334
+ responseStatus: row.response_status ?? 'unknown',
335
+ errorCode: row.error_code ?? null,
336
+ durationMs: coerceNumber(row.duration_ms) ?? 0,
337
+ commitSeq: coerceNumber(row.commit_seq),
338
+ operationCount: coerceNumber(row.operation_count),
339
+ rowCount: coerceNumber(row.row_count),
340
+ subscriptionCount: coerceNumber(row.subscription_count),
341
+ scopesSummary: parseScopesSummary(row.scopes_summary),
342
+ tables: options.dialect.dbToArray(row.tables),
343
+ errorMessage: row.error_message ?? null,
344
+ payloadRef: row.payload_ref ?? null,
345
+ createdAt: row.created_at ?? '',
346
+ });
347
+ const operationEventSelectColumns = [
348
+ 'operation_id',
349
+ 'operation_type',
350
+ 'console_user_id',
351
+ 'partition_id',
352
+ 'target_client_id',
353
+ 'request_payload',
354
+ 'result_payload',
355
+ 'created_at',
356
+ ];
357
+ const mapOperationEvent = (row) => ({
358
+ operationId: coerceNumber(row.operation_id) ?? 0,
359
+ operationType: row.operation_type === 'prune' ||
360
+ row.operation_type === 'compact' ||
361
+ row.operation_type === 'notify_data_change' ||
362
+ row.operation_type === 'evict_client'
363
+ ? row.operation_type
364
+ : 'prune',
365
+ consoleUserId: row.console_user_id ?? null,
366
+ partitionId: row.partition_id ?? null,
367
+ targetClientId: row.target_client_id ?? null,
368
+ requestPayload: parseJsonValue(row.request_payload),
369
+ resultPayload: parseJsonValue(row.result_payload),
370
+ createdAt: row.created_at ?? '',
371
+ });
372
+ const recordOperationEvent = async (event) => {
373
+ await db
374
+ .insertInto('sync_operation_events')
375
+ .values({
376
+ operation_type: event.operationType,
377
+ console_user_id: event.consoleUserId ?? null,
378
+ partition_id: event.partitionId ?? null,
379
+ target_client_id: event.targetClientId ?? null,
380
+ request_payload: event.requestPayload === undefined
381
+ ? null
382
+ : JSON.stringify(event.requestPayload),
383
+ result_payload: event.resultPayload === undefined
384
+ ? null
385
+ : JSON.stringify(event.resultPayload),
386
+ })
387
+ .execute();
388
+ };
389
+ const shouldUseRawMetrics = async (startIso, partitionId) => {
390
+ if (metricsAggregationMode === 'raw') {
391
+ return true;
392
+ }
393
+ if (metricsAggregationMode === 'aggregated') {
394
+ return false;
395
+ }
396
+ let countQuery = db
397
+ .selectFrom('sync_request_events')
398
+ .select(({ fn }) => fn.countAll().as('total'))
399
+ .where('created_at', '>=', startIso);
400
+ if (partitionId) {
401
+ countQuery = countQuery.where('partition_id', '=', partitionId);
402
+ }
403
+ const countRow = await countQuery.executeTakeFirst();
404
+ const total = coerceNumber(countRow?.total) ?? 0;
405
+ return total <= rawFallbackMaxEvents;
406
+ };
135
407
  // -------------------------------------------------------------------------
136
408
  // GET /stats
137
409
  // -------------------------------------------------------------------------
@@ -152,11 +424,14 @@ export function createConsoleRoutes(options) {
152
424
  },
153
425
  },
154
426
  },
155
- }), async (c) => {
427
+ }), zValidator('query', ConsolePartitionQuerySchema), async (c) => {
156
428
  const auth = await requireAuth(c);
157
429
  if (!auth)
158
430
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
159
- const stats = await readSyncStats(options.db);
431
+ const { partitionId } = c.req.valid('query');
432
+ const stats = await readSyncStats(options.db, {
433
+ partitionId,
434
+ });
160
435
  logSyncEvent({
161
436
  event: 'console.stats',
162
437
  consoleUserId: auth.consoleUserId,
@@ -189,84 +464,134 @@ export function createConsoleRoutes(options) {
189
464
  const auth = await requireAuth(c);
190
465
  if (!auth)
191
466
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
192
- const { interval, range } = c.req.valid('query');
193
- // Calculate the time range
194
- const rangeMs = {
195
- '1h': 60 * 60 * 1000,
196
- '6h': 6 * 60 * 60 * 1000,
197
- '24h': 24 * 60 * 60 * 1000,
198
- '7d': 7 * 24 * 60 * 60 * 1000,
199
- '30d': 30 * 24 * 60 * 60 * 1000,
200
- }[range];
467
+ const { interval, range, partitionId } = c.req.valid('query');
468
+ const rangeMs = rangeToMs(range);
201
469
  const startTime = new Date(Date.now() - rangeMs);
202
- // Get interval in milliseconds for bucket size
203
- const intervalMs = {
204
- minute: 60 * 1000,
205
- hour: 60 * 60 * 1000,
206
- day: 24 * 60 * 60 * 1000,
207
- }[interval];
208
- // Query events within the time range
209
- const events = await db
210
- .selectFrom('sync_request_events')
211
- .select(['event_type', 'duration_ms', 'outcome', 'created_at'])
212
- .where('created_at', '>=', startTime.toISOString())
213
- .orderBy('created_at', 'asc')
214
- .execute();
215
- // Build buckets
216
- const bucketMap = new Map();
217
- // Initialize buckets for the entire range
218
- const bucketCount = Math.ceil(rangeMs / intervalMs);
219
- for (let i = 0; i < bucketCount; i++) {
220
- const bucketTime = new Date(startTime.getTime() + i * intervalMs).toISOString();
221
- bucketMap.set(bucketTime, {
222
- pushCount: 0,
223
- pullCount: 0,
224
- errorCount: 0,
225
- totalLatency: 0,
226
- eventCount: 0,
227
- });
228
- }
229
- // Populate buckets with event data
230
- for (const event of events) {
231
- const eventTime = new Date(event.created_at).getTime();
232
- const bucketIndex = Math.floor((eventTime - startTime.getTime()) / intervalMs);
233
- const bucketTime = new Date(startTime.getTime() + bucketIndex * intervalMs).toISOString();
234
- let bucket = bucketMap.get(bucketTime);
235
- if (!bucket) {
236
- bucket = {
237
- pushCount: 0,
238
- pullCount: 0,
239
- errorCount: 0,
240
- totalLatency: 0,
241
- eventCount: 0,
242
- };
243
- bucketMap.set(bucketTime, bucket);
244
- }
245
- if (event.event_type === 'push') {
246
- bucket.pushCount++;
470
+ const startIso = startTime.toISOString();
471
+ const intervalMs = intervalToMs(interval);
472
+ const bucketMap = createTimeseriesBucketMap({
473
+ startTime,
474
+ rangeMs,
475
+ intervalMs,
476
+ });
477
+ const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
478
+ if (useRawMetrics) {
479
+ let eventsQuery = db
480
+ .selectFrom('sync_request_events')
481
+ .select(['event_type', 'duration_ms', 'outcome', 'created_at'])
482
+ .where('created_at', '>=', startIso);
483
+ if (partitionId) {
484
+ eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
247
485
  }
248
- else if (event.event_type === 'pull') {
249
- bucket.pullCount++;
486
+ const events = await eventsQuery.orderBy('created_at', 'asc').execute();
487
+ for (const event of events) {
488
+ const eventTime = parseDate(event.created_at);
489
+ if (eventTime === null)
490
+ continue;
491
+ const bucketIndex = Math.floor((eventTime - startTime.getTime()) / intervalMs);
492
+ const bucketTime = new Date(startTime.getTime() + bucketIndex * intervalMs).toISOString();
493
+ let bucket = bucketMap.get(bucketTime);
494
+ if (!bucket) {
495
+ bucket = createEmptyTimeseriesAccumulator();
496
+ bucketMap.set(bucketTime, bucket);
497
+ }
498
+ if (event.event_type === 'push') {
499
+ bucket.pushCount++;
500
+ }
501
+ else if (event.event_type === 'pull') {
502
+ bucket.pullCount++;
503
+ }
504
+ if (event.outcome === 'error') {
505
+ bucket.errorCount++;
506
+ }
507
+ const durationMs = coerceNumber(event.duration_ms);
508
+ if (durationMs !== null) {
509
+ bucket.totalLatency += durationMs;
510
+ bucket.eventCount++;
511
+ }
250
512
  }
251
- if (event.outcome === 'error') {
252
- bucket.errorCount++;
513
+ }
514
+ else {
515
+ const partitionFilter = partitionId
516
+ ? sql `and partition_id = ${partitionId}`
517
+ : sql ``;
518
+ if (options.dialect.name === 'sqlite') {
519
+ const bucketFormat = intervalToSqliteBucketFormat(interval);
520
+ const rowsResult = await sql `
521
+ select
522
+ strftime(${bucketFormat}, created_at) as bucket,
523
+ sum(case when event_type = 'push' then 1 else 0 end) as push_count,
524
+ sum(case when event_type = 'pull' then 1 else 0 end) as pull_count,
525
+ sum(case when outcome = 'error' then 1 else 0 end) as error_count,
526
+ avg(duration_ms) as avg_latency_ms
527
+ from ${sql.table('sync_request_events')}
528
+ where created_at >= ${startIso}
529
+ ${partitionFilter}
530
+ group by 1
531
+ order by 1 asc
532
+ `.execute(options.db);
533
+ for (const row of rowsResult.rows) {
534
+ const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
535
+ if (!bucketTimestamp)
536
+ continue;
537
+ let bucket = bucketMap.get(bucketTimestamp);
538
+ if (!bucket) {
539
+ bucket = createEmptyTimeseriesAccumulator();
540
+ bucketMap.set(bucketTimestamp, bucket);
541
+ }
542
+ const pushCount = coerceNumber(row.push_count) ?? 0;
543
+ const pullCount = coerceNumber(row.pull_count) ?? 0;
544
+ const errorCount = coerceNumber(row.error_count) ?? 0;
545
+ const avgLatencyMs = coerceNumber(row.avg_latency_ms);
546
+ const rowEventCount = pushCount + pullCount;
547
+ bucket.pushCount += pushCount;
548
+ bucket.pullCount += pullCount;
549
+ bucket.errorCount += errorCount;
550
+ if (avgLatencyMs !== null && rowEventCount > 0) {
551
+ bucket.totalLatency += avgLatencyMs * rowEventCount;
552
+ bucket.eventCount += rowEventCount;
553
+ }
554
+ }
253
555
  }
254
- const durationMs = coerceNumber(event.duration_ms);
255
- if (durationMs !== null) {
256
- bucket.totalLatency += durationMs;
257
- bucket.eventCount++;
556
+ else {
557
+ const rowsResult = await sql `
558
+ select
559
+ date_trunc(${interval}, created_at::timestamptz) as bucket,
560
+ count(*) filter (where event_type = 'push') as push_count,
561
+ count(*) filter (where event_type = 'pull') as pull_count,
562
+ count(*) filter (where outcome = 'error') as error_count,
563
+ avg(duration_ms) as avg_latency_ms
564
+ from ${sql.table('sync_request_events')}
565
+ where created_at >= ${startIso}
566
+ ${partitionFilter}
567
+ group by 1
568
+ order by 1 asc
569
+ `.execute(options.db);
570
+ for (const row of rowsResult.rows) {
571
+ const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
572
+ if (!bucketTimestamp)
573
+ continue;
574
+ let bucket = bucketMap.get(bucketTimestamp);
575
+ if (!bucket) {
576
+ bucket = createEmptyTimeseriesAccumulator();
577
+ bucketMap.set(bucketTimestamp, bucket);
578
+ }
579
+ const pushCount = coerceNumber(row.push_count) ?? 0;
580
+ const pullCount = coerceNumber(row.pull_count) ?? 0;
581
+ const errorCount = coerceNumber(row.error_count) ?? 0;
582
+ const avgLatencyMs = coerceNumber(row.avg_latency_ms);
583
+ const rowEventCount = pushCount + pullCount;
584
+ bucket.pushCount += pushCount;
585
+ bucket.pullCount += pullCount;
586
+ bucket.errorCount += errorCount;
587
+ if (avgLatencyMs !== null && rowEventCount > 0) {
588
+ bucket.totalLatency += avgLatencyMs * rowEventCount;
589
+ bucket.eventCount += rowEventCount;
590
+ }
591
+ }
258
592
  }
259
593
  }
260
- // Convert to array and calculate averages
261
- const buckets = Array.from(bucketMap.entries())
262
- .sort(([a], [b]) => a.localeCompare(b))
263
- .map(([timestamp, data]) => ({
264
- timestamp,
265
- pushCount: data.pushCount,
266
- pullCount: data.pullCount,
267
- errorCount: data.errorCount,
268
- avgLatencyMs: data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
269
- }));
594
+ const buckets = finalizeTimeseriesBuckets(bucketMap);
270
595
  const response = {
271
596
  buckets,
272
597
  interval,
@@ -300,22 +625,51 @@ export function createConsoleRoutes(options) {
300
625
  const auth = await requireAuth(c);
301
626
  if (!auth)
302
627
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
303
- const { range } = c.req.valid('query');
304
- // Calculate the time range
305
- const rangeMs = {
306
- '1h': 60 * 60 * 1000,
307
- '6h': 6 * 60 * 60 * 1000,
308
- '24h': 24 * 60 * 60 * 1000,
309
- '7d': 7 * 24 * 60 * 60 * 1000,
310
- '30d': 30 * 24 * 60 * 60 * 1000,
311
- }[range];
628
+ const { range, partitionId } = c.req.valid('query');
629
+ const rangeMs = rangeToMs(range);
312
630
  const startTime = new Date(Date.now() - rangeMs);
313
- // Get all latencies for push and pull events
314
- const events = await db
631
+ const startIso = startTime.toISOString();
632
+ const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
633
+ if (!useRawMetrics && options.dialect.name !== 'sqlite') {
634
+ const partitionFilter = partitionId
635
+ ? sql `and partition_id = ${partitionId}`
636
+ : sql ``;
637
+ const rowsResult = await sql `
638
+ select
639
+ event_type,
640
+ percentile_disc(0.5) within group (order by duration_ms) as p50,
641
+ percentile_disc(0.9) within group (order by duration_ms) as p90,
642
+ percentile_disc(0.99) within group (order by duration_ms) as p99
643
+ from ${sql.table('sync_request_events')}
644
+ where created_at >= ${startIso}
645
+ ${partitionFilter}
646
+ group by event_type
647
+ `.execute(options.db);
648
+ const push = { p50: 0, p90: 0, p99: 0 };
649
+ const pull = { p50: 0, p90: 0, p99: 0 };
650
+ for (const row of rowsResult.rows) {
651
+ const eventType = row.event_type === 'push' ? 'push' : 'pull';
652
+ const target = eventType === 'push' ? push : pull;
653
+ target.p50 = coerceNumber(row.p50) ?? 0;
654
+ target.p90 = coerceNumber(row.p90) ?? 0;
655
+ target.p99 = coerceNumber(row.p99) ?? 0;
656
+ }
657
+ const aggregatedResponse = {
658
+ push,
659
+ pull,
660
+ range,
661
+ };
662
+ return c.json(aggregatedResponse, 200);
663
+ }
664
+ // Raw fallback path (default for local/dev and SQLite)
665
+ let eventsQuery = db
315
666
  .selectFrom('sync_request_events')
316
667
  .select(['event_type', 'duration_ms'])
317
- .where('created_at', '>=', startTime.toISOString())
318
- .execute();
668
+ .where('created_at', '>=', startIso);
669
+ if (partitionId) {
670
+ eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
671
+ }
672
+ const events = await eventsQuery.execute();
319
673
  const pushLatencies = [];
320
674
  const pullLatencies = [];
321
675
  for (const event of events) {
@@ -329,22 +683,6 @@ export function createConsoleRoutes(options) {
329
683
  }
330
684
  }
331
685
  }
332
- // Calculate percentiles
333
- const calculatePercentiles = (latencies) => {
334
- if (latencies.length === 0) {
335
- return { p50: 0, p90: 0, p99: 0 };
336
- }
337
- const sorted = [...latencies].sort((a, b) => a - b);
338
- const getPercentile = (p) => {
339
- const index = Math.ceil((p / 100) * sorted.length) - 1;
340
- return sorted[Math.max(0, index)] ?? 0;
341
- };
342
- return {
343
- p50: getPercentile(50),
344
- p90: getPercentile(90),
345
- p99: getPercentile(99),
346
- };
347
- };
348
686
  const response = {
349
687
  push: calculatePercentiles(pushLatencies),
350
688
  pull: calculatePercentiles(pullLatencies),
@@ -353,17 +691,17 @@ export function createConsoleRoutes(options) {
353
691
  return c.json(response, 200);
354
692
  });
355
693
  // -------------------------------------------------------------------------
356
- // GET /commits
694
+ // GET /timeline
357
695
  // -------------------------------------------------------------------------
358
- routes.get('/commits', describeRoute({
696
+ routes.get('/timeline', describeRoute({
359
697
  tags: ['console'],
360
- summary: 'List commits',
698
+ summary: 'List timeline items',
361
699
  responses: {
362
700
  200: {
363
- description: 'Paginated commit list',
701
+ description: 'Paginated merged timeline',
364
702
  content: {
365
703
  'application/json': {
366
- schema: resolver(ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)),
704
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleTimelineItemSchema)),
367
705
  },
368
706
  },
369
707
  },
@@ -374,13 +712,20 @@ export function createConsoleRoutes(options) {
374
712
  },
375
713
  },
376
714
  },
377
- }), zValidator('query', ConsolePaginationQuerySchema), async (c) => {
715
+ }), zValidator('query', ConsoleTimelineQuerySchema), async (c) => {
378
716
  const auth = await requireAuth(c);
379
717
  if (!auth)
380
718
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
381
- const { limit, offset } = c.req.valid('query');
382
- const [rows, countRow] = await Promise.all([
383
- db
719
+ const { limit, offset, view, partitionId, eventType, actorId, clientId, requestId, traceId, table, outcome, search, from, to, } = c.req.valid('query');
720
+ const items = [];
721
+ const normalizedSearchTerm = search?.trim().toLowerCase() || null;
722
+ const normalizedTable = table?.trim() || null;
723
+ if (view !== 'events' &&
724
+ !eventType &&
725
+ !outcome &&
726
+ !requestId &&
727
+ !traceId) {
728
+ let commitsQuery = db
384
729
  .selectFrom('sync_commits')
385
730
  .select([
386
731
  'commit_seq',
@@ -390,15 +735,190 @@ export function createConsoleRoutes(options) {
390
735
  'created_at',
391
736
  'change_count',
392
737
  'affected_tables',
393
- ])
738
+ ]);
739
+ if (partitionId) {
740
+ commitsQuery = commitsQuery.where('partition_id', '=', partitionId);
741
+ }
742
+ if (actorId) {
743
+ commitsQuery = commitsQuery.where('actor_id', '=', actorId);
744
+ }
745
+ if (clientId) {
746
+ commitsQuery = commitsQuery.where('client_id', '=', clientId);
747
+ }
748
+ if (from) {
749
+ commitsQuery = commitsQuery.where('created_at', '>=', from);
750
+ }
751
+ if (to) {
752
+ commitsQuery = commitsQuery.where('created_at', '<=', to);
753
+ }
754
+ const commitRows = await commitsQuery.execute();
755
+ for (const row of commitRows) {
756
+ const commit = {
757
+ commitSeq: coerceNumber(row.commit_seq) ?? 0,
758
+ actorId: row.actor_id ?? '',
759
+ clientId: row.client_id ?? '',
760
+ clientCommitId: row.client_commit_id ?? '',
761
+ createdAt: row.created_at ?? '',
762
+ changeCount: coerceNumber(row.change_count) ?? 0,
763
+ affectedTables: options.dialect.dbToArray(row.affected_tables),
764
+ };
765
+ items.push({
766
+ type: 'commit',
767
+ timestamp: commit.createdAt,
768
+ commit,
769
+ event: null,
770
+ });
771
+ }
772
+ }
773
+ if (view !== 'commits') {
774
+ let eventsQuery = db
775
+ .selectFrom('sync_request_events')
776
+ .select(requestEventSelectColumns);
777
+ if (partitionId) {
778
+ eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
779
+ }
780
+ if (eventType) {
781
+ eventsQuery = eventsQuery.where('event_type', '=', eventType);
782
+ }
783
+ if (actorId) {
784
+ eventsQuery = eventsQuery.where('actor_id', '=', actorId);
785
+ }
786
+ if (clientId) {
787
+ eventsQuery = eventsQuery.where('client_id', '=', clientId);
788
+ }
789
+ if (requestId) {
790
+ eventsQuery = eventsQuery.where('request_id', '=', requestId);
791
+ }
792
+ if (traceId) {
793
+ eventsQuery = eventsQuery.where('trace_id', '=', traceId);
794
+ }
795
+ if (outcome) {
796
+ eventsQuery = eventsQuery.where('outcome', '=', outcome);
797
+ }
798
+ if (from) {
799
+ eventsQuery = eventsQuery.where('created_at', '>=', from);
800
+ }
801
+ if (to) {
802
+ eventsQuery = eventsQuery.where('created_at', '<=', to);
803
+ }
804
+ const eventRows = await eventsQuery.execute();
805
+ for (const row of eventRows) {
806
+ const event = mapRequestEvent(row);
807
+ items.push({
808
+ type: 'event',
809
+ timestamp: event.createdAt,
810
+ commit: null,
811
+ event,
812
+ });
813
+ }
814
+ }
815
+ const filteredItems = items.filter((item) => {
816
+ if (item.type === 'commit') {
817
+ const commit = item.commit;
818
+ if (!commit)
819
+ return false;
820
+ if (normalizedTable &&
821
+ !(commit.affectedTables ?? []).includes(normalizedTable)) {
822
+ return false;
823
+ }
824
+ if (!normalizedSearchTerm)
825
+ return true;
826
+ const searchableCommitFields = [
827
+ String(commit.commitSeq),
828
+ commit.actorId,
829
+ commit.clientId,
830
+ commit.clientCommitId,
831
+ ...(commit.affectedTables ?? []),
832
+ ];
833
+ return searchableCommitFields.some((field) => includesSearchTerm(field, normalizedSearchTerm));
834
+ }
835
+ const event = item.event;
836
+ if (!event)
837
+ return false;
838
+ if (normalizedTable &&
839
+ !(event.tables ?? []).includes(normalizedTable)) {
840
+ return false;
841
+ }
842
+ if (!normalizedSearchTerm)
843
+ return true;
844
+ const searchableEventFields = [
845
+ String(event.eventId),
846
+ event.requestId,
847
+ event.traceId ?? '',
848
+ event.actorId,
849
+ event.clientId,
850
+ event.outcome,
851
+ event.responseStatus,
852
+ event.errorCode ?? '',
853
+ event.errorMessage ?? '',
854
+ ...(event.tables ?? []),
855
+ ];
856
+ return searchableEventFields.some((field) => includesSearchTerm(field, normalizedSearchTerm));
857
+ });
858
+ filteredItems.sort((a, b) => (parseDate(b.timestamp) ?? 0) - (parseDate(a.timestamp) ?? 0));
859
+ const total = filteredItems.length;
860
+ const pagedItems = filteredItems.slice(offset, offset + limit);
861
+ const response = {
862
+ items: pagedItems,
863
+ total,
864
+ offset,
865
+ limit,
866
+ };
867
+ c.header('X-Total-Count', String(total));
868
+ return c.json(response, 200);
869
+ });
870
+ // -------------------------------------------------------------------------
871
+ // GET /commits
872
+ // -------------------------------------------------------------------------
873
+ routes.get('/commits', describeRoute({
874
+ tags: ['console'],
875
+ summary: 'List commits',
876
+ responses: {
877
+ 200: {
878
+ description: 'Paginated commit list',
879
+ content: {
880
+ 'application/json': {
881
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)),
882
+ },
883
+ },
884
+ },
885
+ 401: {
886
+ description: 'Unauthenticated',
887
+ content: {
888
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
889
+ },
890
+ },
891
+ },
892
+ }), zValidator('query', ConsolePartitionedPaginationQuerySchema), async (c) => {
893
+ const auth = await requireAuth(c);
894
+ if (!auth)
895
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
896
+ const { limit, offset, partitionId } = c.req.valid('query');
897
+ let query = db
898
+ .selectFrom('sync_commits')
899
+ .select([
900
+ 'commit_seq',
901
+ 'actor_id',
902
+ 'client_id',
903
+ 'client_commit_id',
904
+ 'created_at',
905
+ 'change_count',
906
+ 'affected_tables',
907
+ ]);
908
+ let countQuery = db
909
+ .selectFrom('sync_commits')
910
+ .select(({ fn }) => fn.countAll().as('total'));
911
+ if (partitionId) {
912
+ query = query.where('partition_id', '=', partitionId);
913
+ countQuery = countQuery.where('partition_id', '=', partitionId);
914
+ }
915
+ const [rows, countRow] = await Promise.all([
916
+ query
394
917
  .orderBy('commit_seq', 'desc')
395
918
  .limit(limit)
396
919
  .offset(offset)
397
920
  .execute(),
398
- db
399
- .selectFrom('sync_commits')
400
- .select(({ fn }) => fn.countAll().as('total'))
401
- .executeTakeFirst(),
921
+ countQuery.executeTakeFirst(),
402
922
  ]);
403
923
  const items = rows.map((row) => ({
404
924
  commitSeq: coerceNumber(row.commit_seq) ?? 0,
@@ -451,12 +971,13 @@ export function createConsoleRoutes(options) {
451
971
  },
452
972
  },
453
973
  },
454
- }), zValidator('param', commitSeqParamSchema), async (c) => {
974
+ }), zValidator('param', commitSeqParamSchema), zValidator('query', commitDetailQuerySchema), async (c) => {
455
975
  const auth = await requireAuth(c);
456
976
  if (!auth)
457
977
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
458
978
  const { seq } = c.req.valid('param');
459
- const commitRow = await db
979
+ const { partitionId } = c.req.valid('query');
980
+ let commitQuery = db
460
981
  .selectFrom('sync_commits')
461
982
  .select([
462
983
  'commit_seq',
@@ -467,12 +988,15 @@ export function createConsoleRoutes(options) {
467
988
  'change_count',
468
989
  'affected_tables',
469
990
  ])
470
- .where('commit_seq', '=', seq)
471
- .executeTakeFirst();
991
+ .where('commit_seq', '=', seq);
992
+ if (partitionId) {
993
+ commitQuery = commitQuery.where('partition_id', '=', partitionId);
994
+ }
995
+ const commitRow = await commitQuery.executeTakeFirst();
472
996
  if (!commitRow) {
473
997
  return c.json({ error: 'NOT_FOUND' }, 404);
474
998
  }
475
- const changeRows = await db
999
+ let changesQuery = db
476
1000
  .selectFrom('sync_changes')
477
1001
  .select([
478
1002
  'change_id',
@@ -483,7 +1007,11 @@ export function createConsoleRoutes(options) {
483
1007
  'row_version',
484
1008
  'scopes',
485
1009
  ])
486
- .where('commit_seq', '=', seq)
1010
+ .where('commit_seq', '=', seq);
1011
+ if (partitionId) {
1012
+ changesQuery = changesQuery.where('partition_id', '=', partitionId);
1013
+ }
1014
+ const changeRows = await changesQuery
487
1015
  .orderBy('change_id', 'asc')
488
1016
  .execute();
489
1017
  const changes = changeRows.map((row) => ({
@@ -544,33 +1072,39 @@ export function createConsoleRoutes(options) {
544
1072
  },
545
1073
  },
546
1074
  },
547
- }), zValidator('query', ConsolePaginationQuerySchema), async (c) => {
1075
+ }), zValidator('query', ConsolePartitionedPaginationQuerySchema), async (c) => {
548
1076
  const auth = await requireAuth(c);
549
1077
  if (!auth)
550
1078
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
551
- const { limit, offset } = c.req.valid('query');
1079
+ const { limit, offset, partitionId } = c.req.valid('query');
1080
+ let clientsQuery = db
1081
+ .selectFrom('sync_client_cursors')
1082
+ .select([
1083
+ 'client_id',
1084
+ 'actor_id',
1085
+ 'cursor',
1086
+ 'effective_scopes',
1087
+ 'updated_at',
1088
+ ]);
1089
+ let countQuery = db
1090
+ .selectFrom('sync_client_cursors')
1091
+ .select(({ fn }) => fn.countAll().as('total'));
1092
+ let maxCommitSeqQuery = db
1093
+ .selectFrom('sync_commits')
1094
+ .select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'));
1095
+ if (partitionId) {
1096
+ clientsQuery = clientsQuery.where('partition_id', '=', partitionId);
1097
+ countQuery = countQuery.where('partition_id', '=', partitionId);
1098
+ maxCommitSeqQuery = maxCommitSeqQuery.where('partition_id', '=', partitionId);
1099
+ }
552
1100
  const [rows, countRow, maxCommitSeqRow] = await Promise.all([
553
- db
554
- .selectFrom('sync_client_cursors')
555
- .select([
556
- 'client_id',
557
- 'actor_id',
558
- 'cursor',
559
- 'effective_scopes',
560
- 'updated_at',
561
- ])
1101
+ clientsQuery
562
1102
  .orderBy('updated_at', 'desc')
563
1103
  .limit(limit)
564
1104
  .offset(offset)
565
1105
  .execute(),
566
- db
567
- .selectFrom('sync_client_cursors')
568
- .select(({ fn }) => fn.countAll().as('total'))
569
- .executeTakeFirst(),
570
- db
571
- .selectFrom('sync_commits')
572
- .select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'))
573
- .executeTakeFirst(),
1106
+ countQuery.executeTakeFirst(),
1107
+ maxCommitSeqQuery.executeTakeFirst(),
574
1108
  ]);
575
1109
  const maxCommitSeq = coerceNumber(maxCommitSeqRow?.max_commit_seq) ?? 0;
576
1110
  const pagedClientIds = rows
@@ -578,7 +1112,7 @@ export function createConsoleRoutes(options) {
578
1112
  .filter((clientId) => typeof clientId === 'string');
579
1113
  const latestEventsByClientId = new Map();
580
1114
  if (pagedClientIds.length > 0) {
581
- const recentEventRows = await db
1115
+ let recentEventsQuery = db
582
1116
  .selectFrom('sync_request_events')
583
1117
  .select([
584
1118
  'client_id',
@@ -587,7 +1121,11 @@ export function createConsoleRoutes(options) {
587
1121
  'created_at',
588
1122
  'transport_path',
589
1123
  ])
590
- .where('client_id', 'in', pagedClientIds)
1124
+ .where('client_id', 'in', pagedClientIds);
1125
+ if (partitionId) {
1126
+ recentEventsQuery = recentEventsQuery.where('partition_id', '=', partitionId);
1127
+ }
1128
+ const recentEventRows = await recentEventsQuery
591
1129
  .orderBy('created_at', 'desc')
592
1130
  .execute();
593
1131
  for (const row of recentEventRows) {
@@ -674,6 +1212,66 @@ export function createConsoleRoutes(options) {
674
1212
  return c.json({ items }, 200);
675
1213
  });
676
1214
  // -------------------------------------------------------------------------
1215
+ // GET /operations - Operation audit log
1216
+ // -------------------------------------------------------------------------
1217
+ routes.get('/operations', describeRoute({
1218
+ tags: ['console'],
1219
+ summary: 'List operation audit events',
1220
+ responses: {
1221
+ 200: {
1222
+ description: 'Paginated operation events',
1223
+ content: {
1224
+ 'application/json': {
1225
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleOperationEventSchema)),
1226
+ },
1227
+ },
1228
+ },
1229
+ 401: {
1230
+ description: 'Unauthenticated',
1231
+ content: {
1232
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1233
+ },
1234
+ },
1235
+ },
1236
+ }), zValidator('query', ConsoleOperationsQuerySchema), async (c) => {
1237
+ const auth = await requireAuth(c);
1238
+ if (!auth)
1239
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1240
+ const { limit, offset, operationType, partitionId } = c.req.valid('query');
1241
+ let query = db
1242
+ .selectFrom('sync_operation_events')
1243
+ .select(operationEventSelectColumns);
1244
+ let countQuery = db
1245
+ .selectFrom('sync_operation_events')
1246
+ .select(({ fn }) => fn.countAll().as('total'));
1247
+ if (operationType) {
1248
+ query = query.where('operation_type', '=', operationType);
1249
+ countQuery = countQuery.where('operation_type', '=', operationType);
1250
+ }
1251
+ if (partitionId) {
1252
+ query = query.where('partition_id', '=', partitionId);
1253
+ countQuery = countQuery.where('partition_id', '=', partitionId);
1254
+ }
1255
+ const [rows, countRow] = await Promise.all([
1256
+ query
1257
+ .orderBy('created_at', 'desc')
1258
+ .limit(limit)
1259
+ .offset(offset)
1260
+ .execute(),
1261
+ countQuery.executeTakeFirst(),
1262
+ ]);
1263
+ const items = rows.map((row) => mapOperationEvent(row));
1264
+ const total = coerceNumber(countRow?.total) ?? 0;
1265
+ const response = {
1266
+ items,
1267
+ total,
1268
+ offset,
1269
+ limit,
1270
+ };
1271
+ c.header('X-Total-Count', String(total));
1272
+ return c.json(response, 200);
1273
+ });
1274
+ // -------------------------------------------------------------------------
677
1275
  // POST /prune/preview
678
1276
  // -------------------------------------------------------------------------
679
1277
  routes.post('/prune/preview', describeRoute({
@@ -746,6 +1344,15 @@ export function createConsoleRoutes(options) {
746
1344
  deletedCommits,
747
1345
  watermarkCommitSeq,
748
1346
  });
1347
+ await recordOperationEvent({
1348
+ operationType: 'prune',
1349
+ consoleUserId: auth.consoleUserId,
1350
+ requestPayload: {
1351
+ watermarkCommitSeq,
1352
+ keepNewestCommits: options.prune?.keepNewestCommits ?? null,
1353
+ },
1354
+ resultPayload: { deletedCommits, watermarkCommitSeq },
1355
+ });
749
1356
  const result = { deletedCommits };
750
1357
  return c.json(result, 200);
751
1358
  });
@@ -786,6 +1393,12 @@ export function createConsoleRoutes(options) {
786
1393
  deletedChanges,
787
1394
  fullHistoryHours,
788
1395
  });
1396
+ await recordOperationEvent({
1397
+ operationType: 'compact',
1398
+ consoleUserId: auth.consoleUserId,
1399
+ requestPayload: { fullHistoryHours },
1400
+ resultPayload: { deletedChanges },
1401
+ });
789
1402
  const result = { deletedChanges };
790
1403
  return c.json(result, 200);
791
1404
  });
@@ -846,6 +1459,16 @@ export function createConsoleRoutes(options) {
846
1459
  commitSeq: result.commitSeq,
847
1460
  deletedChunks: result.deletedChunks,
848
1461
  });
1462
+ await recordOperationEvent({
1463
+ operationType: 'notify_data_change',
1464
+ consoleUserId: auth.consoleUserId,
1465
+ partitionId: body.partitionId ?? null,
1466
+ requestPayload: {
1467
+ tables: body.tables,
1468
+ partitionId: body.partitionId ?? null,
1469
+ },
1470
+ resultPayload: result,
1471
+ });
849
1472
  // Wake all WS clients so they pull immediately
850
1473
  if (options.wsConnectionManager) {
851
1474
  options.wsConnectionManager.notifyAllClients(result.commitSeq);
@@ -878,15 +1501,19 @@ export function createConsoleRoutes(options) {
878
1501
  },
879
1502
  },
880
1503
  },
881
- }), zValidator('param', clientIdParamSchema), async (c) => {
1504
+ }), zValidator('param', clientIdParamSchema), zValidator('query', evictClientQuerySchema), async (c) => {
882
1505
  const auth = await requireAuth(c);
883
1506
  if (!auth)
884
1507
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
885
1508
  const { id: clientId } = c.req.valid('param');
886
- const res = await db
1509
+ const { partitionId } = c.req.valid('query');
1510
+ let deleteQuery = db
887
1511
  .deleteFrom('sync_client_cursors')
888
- .where('client_id', '=', clientId)
889
- .executeTakeFirst();
1512
+ .where('client_id', '=', clientId);
1513
+ if (partitionId) {
1514
+ deleteQuery = deleteQuery.where('partition_id', '=', partitionId);
1515
+ }
1516
+ const res = await deleteQuery.executeTakeFirst();
890
1517
  const evicted = Number(res?.numDeletedRows ?? 0) > 0;
891
1518
  logSyncEvent({
892
1519
  event: 'console.evict_client',
@@ -894,6 +1521,14 @@ export function createConsoleRoutes(options) {
894
1521
  clientId,
895
1522
  evicted,
896
1523
  });
1524
+ await recordOperationEvent({
1525
+ operationType: 'evict_client',
1526
+ consoleUserId: auth.consoleUserId,
1527
+ partitionId: partitionId ?? null,
1528
+ targetClientId: clientId,
1529
+ requestPayload: { clientId, partitionId: partitionId ?? null },
1530
+ resultPayload: { evicted },
1531
+ });
897
1532
  const result = { evicted };
898
1533
  return c.json(result, 200);
899
1534
  });
@@ -923,28 +1558,17 @@ export function createConsoleRoutes(options) {
923
1558
  const auth = await requireAuth(c);
924
1559
  if (!auth)
925
1560
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
926
- const { limit, offset, eventType, actorId, clientId, outcome } = c.req.valid('query');
1561
+ const { limit, offset, partitionId, eventType, actorId, clientId, requestId, traceId, outcome, } = c.req.valid('query');
927
1562
  let query = db
928
1563
  .selectFrom('sync_request_events')
929
- .select([
930
- 'event_id',
931
- 'event_type',
932
- 'transport_path',
933
- 'actor_id',
934
- 'client_id',
935
- 'status_code',
936
- 'outcome',
937
- 'duration_ms',
938
- 'commit_seq',
939
- 'operation_count',
940
- 'row_count',
941
- 'tables',
942
- 'error_message',
943
- 'created_at',
944
- ]);
1564
+ .select(requestEventSelectColumns);
945
1565
  let countQuery = db
946
1566
  .selectFrom('sync_request_events')
947
1567
  .select(({ fn }) => fn.countAll().as('total'));
1568
+ if (partitionId) {
1569
+ query = query.where('partition_id', '=', partitionId);
1570
+ countQuery = countQuery.where('partition_id', '=', partitionId);
1571
+ }
948
1572
  if (eventType) {
949
1573
  query = query.where('event_type', '=', eventType);
950
1574
  countQuery = countQuery.where('event_type', '=', eventType);
@@ -957,6 +1581,14 @@ export function createConsoleRoutes(options) {
957
1581
  query = query.where('client_id', '=', clientId);
958
1582
  countQuery = countQuery.where('client_id', '=', clientId);
959
1583
  }
1584
+ if (requestId) {
1585
+ query = query.where('request_id', '=', requestId);
1586
+ countQuery = countQuery.where('request_id', '=', requestId);
1587
+ }
1588
+ if (traceId) {
1589
+ query = query.where('trace_id', '=', traceId);
1590
+ countQuery = countQuery.where('trace_id', '=', traceId);
1591
+ }
960
1592
  if (outcome) {
961
1593
  query = query.where('outcome', '=', outcome);
962
1594
  countQuery = countQuery.where('outcome', '=', outcome);
@@ -969,22 +1601,7 @@ export function createConsoleRoutes(options) {
969
1601
  .execute(),
970
1602
  countQuery.executeTakeFirst(),
971
1603
  ]);
972
- const items = rows.map((row) => ({
973
- eventId: coerceNumber(row.event_id) ?? 0,
974
- eventType: row.event_type,
975
- transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
976
- actorId: row.actor_id ?? '',
977
- clientId: row.client_id ?? '',
978
- statusCode: coerceNumber(row.status_code) ?? 0,
979
- outcome: row.outcome ?? '',
980
- durationMs: coerceNumber(row.duration_ms) ?? 0,
981
- commitSeq: coerceNumber(row.commit_seq),
982
- operationCount: coerceNumber(row.operation_count),
983
- rowCount: coerceNumber(row.row_count),
984
- tables: options.dialect.dbToArray(row.tables),
985
- errorMessage: row.error_message ?? null,
986
- createdAt: row.created_at ?? '',
987
- }));
1604
+ const items = rows.map((row) => mapRequestEvent(row));
988
1605
  const total = coerceNumber(countRow?.total) ?? 0;
989
1606
  const response = {
990
1607
  items,
@@ -1010,6 +1627,15 @@ export function createConsoleRoutes(options) {
1010
1627
  // Auth check via query param (WebSocket doesn't support headers easily)
1011
1628
  const token = c.req.query('token');
1012
1629
  const authHeader = c.req.header('Authorization');
1630
+ const partitionId = c.req.query('partitionId')?.trim() || undefined;
1631
+ const replaySince = c.req.query('since');
1632
+ const replayLimitRaw = c.req.query('replayLimit');
1633
+ const replayLimitNumber = replayLimitRaw
1634
+ ? Number.parseInt(replayLimitRaw, 10)
1635
+ : Number.NaN;
1636
+ const replayLimit = Number.isFinite(replayLimitNumber)
1637
+ ? Math.max(1, Math.min(500, replayLimitNumber))
1638
+ : 100;
1013
1639
  const mockContext = {
1014
1640
  req: {
1015
1641
  header: (name) => name === 'Authorization' ? authHeader : undefined,
@@ -1025,6 +1651,13 @@ export function createConsoleRoutes(options) {
1025
1651
  return;
1026
1652
  }
1027
1653
  const listener = (event) => {
1654
+ if (partitionId) {
1655
+ const eventPartitionId = event.data.partitionId;
1656
+ if (typeof eventPartitionId !== 'string' ||
1657
+ eventPartitionId !== partitionId) {
1658
+ return;
1659
+ }
1660
+ }
1028
1661
  try {
1029
1662
  ws.send(JSON.stringify(event));
1030
1663
  }
@@ -1038,6 +1671,20 @@ export function createConsoleRoutes(options) {
1038
1671
  type: 'connected',
1039
1672
  timestamp: new Date().toISOString(),
1040
1673
  }));
1674
+ const replayEvents = emitter.replay({
1675
+ since: replaySince,
1676
+ limit: replayLimit,
1677
+ partitionId,
1678
+ });
1679
+ for (const replayEvent of replayEvents) {
1680
+ try {
1681
+ ws.send(JSON.stringify(replayEvent));
1682
+ }
1683
+ catch {
1684
+ // Connection closed
1685
+ break;
1686
+ }
1687
+ }
1041
1688
  // Start heartbeat
1042
1689
  const heartbeatInterval = setInterval(() => {
1043
1690
  try {
@@ -1105,51 +1752,103 @@ export function createConsoleRoutes(options) {
1105
1752
  },
1106
1753
  },
1107
1754
  },
1108
- }), zValidator('param', eventIdParamSchema), async (c) => {
1755
+ }), zValidator('param', eventIdParamSchema), zValidator('query', eventDetailQuerySchema), async (c) => {
1109
1756
  const auth = await requireAuth(c);
1110
1757
  if (!auth)
1111
1758
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1112
1759
  const { id: eventId } = c.req.valid('param');
1113
- const row = await db
1760
+ const { partitionId } = c.req.valid('query');
1761
+ let eventQuery = db
1762
+ .selectFrom('sync_request_events')
1763
+ .select(requestEventSelectColumns)
1764
+ .where('event_id', '=', eventId);
1765
+ if (partitionId) {
1766
+ eventQuery = eventQuery.where('partition_id', '=', partitionId);
1767
+ }
1768
+ const row = await eventQuery.executeTakeFirst();
1769
+ if (!row) {
1770
+ return c.json({ error: 'NOT_FOUND' }, 404);
1771
+ }
1772
+ return c.json(mapRequestEvent(row), 200);
1773
+ });
1774
+ // -------------------------------------------------------------------------
1775
+ // GET /events/:id/payload - payload snapshot detail (if retained)
1776
+ // -------------------------------------------------------------------------
1777
+ routes.get('/events/:id/payload', describeRoute({
1778
+ tags: ['console'],
1779
+ summary: 'Get event payload snapshot',
1780
+ responses: {
1781
+ 200: {
1782
+ description: 'Payload snapshot details',
1783
+ content: {
1784
+ 'application/json': {
1785
+ schema: resolver(ConsoleRequestPayloadSchema),
1786
+ },
1787
+ },
1788
+ },
1789
+ 400: {
1790
+ description: 'Invalid request',
1791
+ content: {
1792
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1793
+ },
1794
+ },
1795
+ 401: {
1796
+ description: 'Unauthenticated',
1797
+ content: {
1798
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1799
+ },
1800
+ },
1801
+ 404: {
1802
+ description: 'Not found',
1803
+ content: {
1804
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1805
+ },
1806
+ },
1807
+ },
1808
+ }), zValidator('param', eventIdParamSchema), zValidator('query', eventDetailQuerySchema), async (c) => {
1809
+ const auth = await requireAuth(c);
1810
+ if (!auth)
1811
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1812
+ const { id: eventId } = c.req.valid('param');
1813
+ const { partitionId } = c.req.valid('query');
1814
+ let eventQuery = db
1114
1815
  .selectFrom('sync_request_events')
1816
+ .select(['payload_ref', 'partition_id'])
1817
+ .where('event_id', '=', eventId);
1818
+ if (partitionId) {
1819
+ eventQuery = eventQuery.where('partition_id', '=', partitionId);
1820
+ }
1821
+ const eventRow = await eventQuery.executeTakeFirst();
1822
+ if (!eventRow) {
1823
+ return c.json({ error: 'NOT_FOUND' }, 404);
1824
+ }
1825
+ const payloadRef = eventRow.payload_ref;
1826
+ if (!payloadRef) {
1827
+ return c.json({ error: 'NOT_FOUND', message: 'No payload snapshot recorded' }, 404);
1828
+ }
1829
+ const payloadRow = await db
1830
+ .selectFrom('sync_request_payloads')
1115
1831
  .select([
1116
- 'event_id',
1117
- 'event_type',
1118
- 'transport_path',
1119
- 'actor_id',
1120
- 'client_id',
1121
- 'status_code',
1122
- 'outcome',
1123
- 'duration_ms',
1124
- 'commit_seq',
1125
- 'operation_count',
1126
- 'row_count',
1127
- 'tables',
1128
- 'error_message',
1832
+ 'payload_ref',
1833
+ 'partition_id',
1834
+ 'request_payload',
1835
+ 'response_payload',
1129
1836
  'created_at',
1130
1837
  ])
1131
- .where('event_id', '=', eventId)
1838
+ .where('payload_ref', '=', payloadRef)
1839
+ .where('partition_id', '=', eventRow.partition_id)
1132
1840
  .executeTakeFirst();
1133
- if (!row) {
1134
- return c.json({ error: 'NOT_FOUND' }, 404);
1841
+ if (!payloadRow) {
1842
+ return c.json({ error: 'NOT_FOUND', message: 'Payload snapshot not available' }, 404);
1135
1843
  }
1136
- const event = {
1137
- eventId: coerceNumber(row.event_id) ?? 0,
1138
- eventType: row.event_type,
1139
- transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
1140
- actorId: row.actor_id ?? '',
1141
- clientId: row.client_id ?? '',
1142
- statusCode: coerceNumber(row.status_code) ?? 0,
1143
- outcome: row.outcome ?? '',
1144
- durationMs: coerceNumber(row.duration_ms) ?? 0,
1145
- commitSeq: coerceNumber(row.commit_seq),
1146
- operationCount: coerceNumber(row.operation_count),
1147
- rowCount: coerceNumber(row.row_count),
1148
- tables: options.dialect.dbToArray(row.tables),
1149
- errorMessage: row.error_message ?? null,
1150
- createdAt: row.created_at ?? '',
1844
+ const payload = {
1845
+ payloadRef: payloadRow.payload_ref,
1846
+ partitionId: payloadRow.partition_id,
1847
+ requestPayload: parseJsonValue(payloadRow.request_payload),
1848
+ responsePayload: parseJsonValue(payloadRow.response_payload),
1849
+ createdAt: payloadRow.created_at,
1151
1850
  };
1152
- return c.json(event, 200);
1851
+ return c.json(payload, 200);
1153
1852
  });
1154
1853
  // -------------------------------------------------------------------------
1155
1854
  // DELETE /events - Clear all events
@@ -1282,7 +1981,7 @@ export function createConsoleRoutes(options) {
1282
1981
  const auth = await requireAuth(c);
1283
1982
  if (!auth)
1284
1983
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1285
- const { limit, offset, type: keyType } = c.req.valid('query');
1984
+ const { limit, offset, type: keyType, status, expiresWithinDays, } = c.req.valid('query');
1286
1985
  let query = db
1287
1986
  .selectFrom('sync_api_keys')
1288
1987
  .select([
@@ -1304,6 +2003,31 @@ export function createConsoleRoutes(options) {
1304
2003
  query = query.where('key_type', '=', keyType);
1305
2004
  countQuery = countQuery.where('key_type', '=', keyType);
1306
2005
  }
2006
+ const now = new Date();
2007
+ const nowIso = now.toISOString();
2008
+ const expiringThresholdIso = new Date(now.getTime() + (expiresWithinDays ?? 14) * 24 * 60 * 60 * 1000).toISOString();
2009
+ if (status === 'active') {
2010
+ query = query
2011
+ .where('revoked_at', 'is', null)
2012
+ .where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)]));
2013
+ countQuery = countQuery
2014
+ .where('revoked_at', 'is', null)
2015
+ .where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)]));
2016
+ }
2017
+ else if (status === 'revoked') {
2018
+ query = query.where('revoked_at', 'is not', null);
2019
+ countQuery = countQuery.where('revoked_at', 'is not', null);
2020
+ }
2021
+ else if (status === 'expiring') {
2022
+ query = query
2023
+ .where('revoked_at', 'is', null)
2024
+ .where('expires_at', '>', nowIso)
2025
+ .where('expires_at', '<=', expiringThresholdIso);
2026
+ countQuery = countQuery
2027
+ .where('revoked_at', 'is', null)
2028
+ .where('expires_at', '>', nowIso)
2029
+ .where('expires_at', '<=', expiringThresholdIso);
2030
+ }
1307
2031
  const [rows, countRow] = await Promise.all([
1308
2032
  query
1309
2033
  .orderBy('created_at', 'desc')
@@ -1528,6 +2252,192 @@ export function createConsoleRoutes(options) {
1528
2252
  return c.json({ revoked }, 200);
1529
2253
  });
1530
2254
  // -------------------------------------------------------------------------
2255
+ // POST /api-keys/bulk-revoke - Revoke multiple API keys
2256
+ // -------------------------------------------------------------------------
2257
+ routes.post('/api-keys/bulk-revoke', describeRoute({
2258
+ tags: ['console'],
2259
+ summary: 'Bulk revoke API keys',
2260
+ responses: {
2261
+ 200: {
2262
+ description: 'Bulk revoke result',
2263
+ content: {
2264
+ 'application/json': {
2265
+ schema: resolver(ConsoleApiKeyBulkRevokeResponseSchema),
2266
+ },
2267
+ },
2268
+ },
2269
+ 400: {
2270
+ description: 'Invalid request',
2271
+ content: {
2272
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2273
+ },
2274
+ },
2275
+ 401: {
2276
+ description: 'Unauthenticated',
2277
+ content: {
2278
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2279
+ },
2280
+ },
2281
+ },
2282
+ }), zValidator('json', ConsoleApiKeyBulkRevokeRequestSchema), async (c) => {
2283
+ const auth = await requireAuth(c);
2284
+ if (!auth)
2285
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
2286
+ const body = c.req.valid('json');
2287
+ const keyIds = [...new Set(body.keyIds.map((keyId) => keyId.trim()))]
2288
+ .filter((keyId) => keyId.length > 0)
2289
+ .slice(0, 200);
2290
+ if (keyIds.length === 0) {
2291
+ return c.json({ error: 'INVALID_REQUEST', message: 'No API key IDs provided' }, 400);
2292
+ }
2293
+ const now = new Date().toISOString();
2294
+ const existingRows = await db
2295
+ .selectFrom('sync_api_keys')
2296
+ .select(['key_id', 'revoked_at'])
2297
+ .where('key_id', 'in', keyIds)
2298
+ .execute();
2299
+ const existingById = new Map(existingRows.map((row) => [row.key_id, row.revoked_at]));
2300
+ const notFoundKeyIds = [];
2301
+ const alreadyRevokedKeyIds = [];
2302
+ const revokeCandidateKeyIds = [];
2303
+ for (const keyId of keyIds) {
2304
+ const revokedAt = existingById.get(keyId);
2305
+ if (revokedAt === undefined) {
2306
+ notFoundKeyIds.push(keyId);
2307
+ }
2308
+ else if (revokedAt !== null) {
2309
+ alreadyRevokedKeyIds.push(keyId);
2310
+ }
2311
+ else {
2312
+ revokeCandidateKeyIds.push(keyId);
2313
+ }
2314
+ }
2315
+ let revokedCount = 0;
2316
+ if (revokeCandidateKeyIds.length > 0) {
2317
+ const updateResult = await db
2318
+ .updateTable('sync_api_keys')
2319
+ .set({ revoked_at: now })
2320
+ .where('key_id', 'in', revokeCandidateKeyIds)
2321
+ .where('revoked_at', 'is', null)
2322
+ .executeTakeFirst();
2323
+ revokedCount = Number(updateResult?.numUpdatedRows ?? 0);
2324
+ }
2325
+ const response = {
2326
+ requestedCount: keyIds.length,
2327
+ revokedCount,
2328
+ alreadyRevokedCount: alreadyRevokedKeyIds.length,
2329
+ notFoundCount: notFoundKeyIds.length,
2330
+ revokedKeyIds: revokeCandidateKeyIds,
2331
+ alreadyRevokedKeyIds,
2332
+ notFoundKeyIds,
2333
+ };
2334
+ logSyncEvent({
2335
+ event: 'console.bulk_revoke_api_keys',
2336
+ consoleUserId: auth.consoleUserId,
2337
+ requestedCount: response.requestedCount,
2338
+ revokedCount: response.revokedCount,
2339
+ alreadyRevokedCount: response.alreadyRevokedCount,
2340
+ notFoundCount: response.notFoundCount,
2341
+ });
2342
+ return c.json(response, 200);
2343
+ });
2344
+ // -------------------------------------------------------------------------
2345
+ // POST /api-keys/:id/rotate/stage - Stage rotate API key (keep old active)
2346
+ // -------------------------------------------------------------------------
2347
+ routes.post('/api-keys/:id/rotate/stage', describeRoute({
2348
+ tags: ['console'],
2349
+ summary: 'Stage rotate API key',
2350
+ responses: {
2351
+ 200: {
2352
+ description: 'Staged API key replacement',
2353
+ content: {
2354
+ 'application/json': {
2355
+ schema: resolver(ConsoleApiKeyCreateResponseSchema),
2356
+ },
2357
+ },
2358
+ },
2359
+ 401: {
2360
+ description: 'Unauthenticated',
2361
+ content: {
2362
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2363
+ },
2364
+ },
2365
+ 404: {
2366
+ description: 'Not found',
2367
+ content: {
2368
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2369
+ },
2370
+ },
2371
+ },
2372
+ }), zValidator('param', apiKeyIdParamSchema), async (c) => {
2373
+ const auth = await requireAuth(c);
2374
+ if (!auth)
2375
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
2376
+ const { id: keyId } = c.req.valid('param');
2377
+ const now = new Date().toISOString();
2378
+ const existingRow = await db
2379
+ .selectFrom('sync_api_keys')
2380
+ .select([
2381
+ 'name',
2382
+ 'key_type',
2383
+ 'scope_keys',
2384
+ 'actor_id',
2385
+ 'expires_at',
2386
+ 'revoked_at',
2387
+ ])
2388
+ .where('key_id', '=', keyId)
2389
+ .where('revoked_at', 'is', null)
2390
+ .executeTakeFirst();
2391
+ if (!existingRow) {
2392
+ return c.json({ error: 'NOT_FOUND' }, 404);
2393
+ }
2394
+ const newKeyId = generateKeyId();
2395
+ const keyType = existingRow.key_type;
2396
+ const secretKey = generateSecretKey(keyType);
2397
+ const keyHash = await hashApiKey(secretKey);
2398
+ const keyPrefix = secretKey.slice(0, 12);
2399
+ const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
2400
+ await db
2401
+ .insertInto('sync_api_keys')
2402
+ .values({
2403
+ key_id: newKeyId,
2404
+ key_hash: keyHash,
2405
+ key_prefix: keyPrefix,
2406
+ name: existingRow.name,
2407
+ key_type: keyType,
2408
+ scope_keys: options.dialect.arrayToDb(scopeKeys),
2409
+ actor_id: existingRow.actor_id ?? null,
2410
+ created_at: now,
2411
+ expires_at: existingRow.expires_at,
2412
+ last_used_at: null,
2413
+ revoked_at: null,
2414
+ })
2415
+ .execute();
2416
+ logSyncEvent({
2417
+ event: 'console.stage_rotate_api_key',
2418
+ consoleUserId: auth.consoleUserId,
2419
+ oldKeyId: keyId,
2420
+ newKeyId,
2421
+ });
2422
+ const key = {
2423
+ keyId: newKeyId,
2424
+ keyPrefix,
2425
+ name: existingRow.name,
2426
+ keyType,
2427
+ scopeKeys,
2428
+ actorId: existingRow.actor_id ?? null,
2429
+ createdAt: now,
2430
+ expiresAt: existingRow.expires_at ?? null,
2431
+ lastUsedAt: null,
2432
+ revokedAt: null,
2433
+ };
2434
+ const response = {
2435
+ key,
2436
+ secretKey,
2437
+ };
2438
+ return c.json(response, 200);
2439
+ });
2440
+ // -------------------------------------------------------------------------
1531
2441
  // POST /api-keys/:id/rotate - Rotate API key
1532
2442
  // -------------------------------------------------------------------------
1533
2443
  routes.post('/api-keys/:id/rotate', describeRoute({