@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.
- package/dist/console/routes.d.ts +23 -2
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +1129 -219
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schemas.d.ts +153 -0
- package/dist/console/schemas.d.ts.map +1 -1
- package/dist/console/schemas.js +80 -0
- package/dist/console/schemas.js.map +1 -1
- package/dist/create-server.d.ts +5 -0
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +17 -7
- package/dist/create-server.js.map +1 -1
- package/dist/routes.d.ts +11 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +514 -10
- package/dist/routes.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/console-routes.test.ts +1468 -0
- package/src/__tests__/create-server.test.ts +75 -0
- package/src/console/routes.ts +1471 -241
- package/src/console/schemas.ts +106 -0
- package/src/create-server.ts +28 -10
- package/src/routes.ts +616 -17
package/dist/console/routes.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
const intervalMs =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
const
|
|
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', '>=',
|
|
318
|
-
|
|
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 /
|
|
694
|
+
// GET /timeline
|
|
357
695
|
// -------------------------------------------------------------------------
|
|
358
|
-
routes.get('/
|
|
696
|
+
routes.get('/timeline', describeRoute({
|
|
359
697
|
tags: ['console'],
|
|
360
|
-
summary: 'List
|
|
698
|
+
summary: 'List timeline items',
|
|
361
699
|
responses: {
|
|
362
700
|
200: {
|
|
363
|
-
description: 'Paginated
|
|
701
|
+
description: 'Paginated merged timeline',
|
|
364
702
|
content: {
|
|
365
703
|
'application/json': {
|
|
366
|
-
schema: resolver(ConsolePaginatedResponseSchema(
|
|
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',
|
|
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
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
1509
|
+
const { partitionId } = c.req.valid('query');
|
|
1510
|
+
let deleteQuery = db
|
|
887
1511
|
.deleteFrom('sync_client_cursors')
|
|
888
|
-
.where('client_id', '=', clientId)
|
|
889
|
-
|
|
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
|
|
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
|
-
'
|
|
1117
|
-
'
|
|
1118
|
-
'
|
|
1119
|
-
'
|
|
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('
|
|
1838
|
+
.where('payload_ref', '=', payloadRef)
|
|
1839
|
+
.where('partition_id', '=', eventRow.partition_id)
|
|
1132
1840
|
.executeTakeFirst();
|
|
1133
|
-
if (!
|
|
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
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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(
|
|
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({
|