@geekmidas/telescope 0.0.1

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.
Files changed (103) hide show
  1. package/README.md +521 -0
  2. package/dist/Telescope-B3Wd82yk.cjs +602 -0
  3. package/dist/Telescope-B3Wd82yk.cjs.map +1 -0
  4. package/dist/Telescope-C5dyDYYB.d.cts +133 -0
  5. package/dist/Telescope-D-uoZB6b.mjs +596 -0
  6. package/dist/Telescope-D-uoZB6b.mjs.map +1 -0
  7. package/dist/Telescope-DyIWgh9-.d.mts +133 -0
  8. package/dist/Telescope.cjs +3 -0
  9. package/dist/Telescope.d.cts +3 -0
  10. package/dist/Telescope.d.mts +3 -0
  11. package/dist/Telescope.mjs +3 -0
  12. package/dist/chunk-CUT6urMc.cjs +30 -0
  13. package/dist/index.cjs +5 -0
  14. package/dist/index.d.cts +4 -0
  15. package/dist/index.d.mts +4 -0
  16. package/dist/index.mjs +4 -0
  17. package/dist/logger/console.cjs +161 -0
  18. package/dist/logger/console.cjs.map +1 -0
  19. package/dist/logger/console.d.cts +109 -0
  20. package/dist/logger/console.d.mts +109 -0
  21. package/dist/logger/console.mjs +159 -0
  22. package/dist/logger/console.mjs.map +1 -0
  23. package/dist/logger/pino.cjs +118 -0
  24. package/dist/logger/pino.cjs.map +1 -0
  25. package/dist/logger/pino.d.cts +89 -0
  26. package/dist/logger/pino.d.mts +89 -0
  27. package/dist/logger/pino.mjs +116 -0
  28. package/dist/logger/pino.mjs.map +1 -0
  29. package/dist/memory-9-B9WACq.cjs +110 -0
  30. package/dist/memory-9-B9WACq.cjs.map +1 -0
  31. package/dist/memory-Cm0eevCS.d.mts +38 -0
  32. package/dist/memory-DiP1a-pp.d.cts +38 -0
  33. package/dist/memory-SdN5vtG9.mjs +104 -0
  34. package/dist/memory-SdN5vtG9.mjs.map +1 -0
  35. package/dist/server/hono.cjs +180 -0
  36. package/dist/server/hono.cjs.map +1 -0
  37. package/dist/server/hono.d.cts +26 -0
  38. package/dist/server/hono.d.mts +26 -0
  39. package/dist/server/hono.mjs +176 -0
  40. package/dist/server/hono.mjs.map +1 -0
  41. package/dist/storage/kysely.cjs +336 -0
  42. package/dist/storage/kysely.cjs.map +1 -0
  43. package/dist/storage/kysely.d.cts +161 -0
  44. package/dist/storage/kysely.d.mts +161 -0
  45. package/dist/storage/kysely.mjs +334 -0
  46. package/dist/storage/kysely.mjs.map +1 -0
  47. package/dist/storage/memory.cjs +3 -0
  48. package/dist/storage/memory.d.cts +3 -0
  49. package/dist/storage/memory.d.mts +3 -0
  50. package/dist/storage/memory.mjs +3 -0
  51. package/dist/types-BGDhFv4R.d.cts +170 -0
  52. package/dist/types-CZbzz8kx.d.mts +170 -0
  53. package/dist/types.cjs +0 -0
  54. package/dist/types.d.cts +2 -0
  55. package/dist/types.d.mts +2 -0
  56. package/dist/types.mjs +0 -0
  57. package/dist/ui-assets-D6-8TAr_.mjs +30 -0
  58. package/dist/ui-assets-D6-8TAr_.mjs.map +1 -0
  59. package/dist/ui-assets-ulevVble.cjs +48 -0
  60. package/dist/ui-assets-ulevVble.cjs.map +1 -0
  61. package/dist/ui-assets.cjs +5 -0
  62. package/dist/ui-assets.d.cts +12 -0
  63. package/dist/ui-assets.d.mts +12 -0
  64. package/dist/ui-assets.mjs +3 -0
  65. package/package.json +83 -0
  66. package/scripts/embed-ui.ts +90 -0
  67. package/src/Telescope.ts +714 -0
  68. package/src/__tests__/Telescope.spec.ts +356 -0
  69. package/src/index.ts +23 -0
  70. package/src/logger/__tests__/console.spec.ts +266 -0
  71. package/src/logger/__tests__/pino.spec.ts +217 -0
  72. package/src/logger/console.ts +230 -0
  73. package/src/logger/pino.ts +191 -0
  74. package/src/server/__tests__/hono.spec.ts +340 -0
  75. package/src/server/hono.ts +247 -0
  76. package/src/storage/__tests__/kysely.spec.ts +715 -0
  77. package/src/storage/__tests__/memory.spec.ts +411 -0
  78. package/src/storage/kysely.ts +572 -0
  79. package/src/storage/memory.ts +168 -0
  80. package/src/types.ts +188 -0
  81. package/src/ui-assets.ts +40 -0
  82. package/ui/index.html +12 -0
  83. package/ui/node_modules/.bin/browserslist +21 -0
  84. package/ui/node_modules/.bin/jiti +21 -0
  85. package/ui/node_modules/.bin/terser +21 -0
  86. package/ui/node_modules/.bin/tsc +21 -0
  87. package/ui/node_modules/.bin/tsserver +21 -0
  88. package/ui/node_modules/.bin/tsx +21 -0
  89. package/ui/node_modules/.bin/vite +21 -0
  90. package/ui/package.json +24 -0
  91. package/ui/src/App.tsx +342 -0
  92. package/ui/src/api.ts +75 -0
  93. package/ui/src/components/ExceptionDetail.tsx +100 -0
  94. package/ui/src/components/LogDetail.tsx +91 -0
  95. package/ui/src/components/RequestDetail.tsx +143 -0
  96. package/ui/src/main.tsx +10 -0
  97. package/ui/src/styles.css +10 -0
  98. package/ui/src/types.ts +63 -0
  99. package/ui/src/vite-env.d.ts +1 -0
  100. package/ui/src/vite-plugin-gkm-config.ts +54 -0
  101. package/ui/tsconfig.json +20 -0
  102. package/ui/tsconfig.tsbuildinfo +14 -0
  103. package/ui/vite.config.ts +13 -0
@@ -0,0 +1,715 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { ExceptionEntry, LogEntry, RequestEntry } from '../../types';
3
+ import {
4
+ KyselyStorage,
5
+ type TelescopeExceptionTable,
6
+ type TelescopeLogTable,
7
+ type TelescopeRequestTable,
8
+ getTelescopeMigration,
9
+ } from '../kysely';
10
+
11
+ // Helper to create mock query builder
12
+ function createMockQueryBuilder() {
13
+ const builder: Record<string, any> = {};
14
+
15
+ // Chain methods return the builder itself
16
+ builder.selectFrom = vi.fn().mockReturnValue(builder);
17
+ builder.insertInto = vi.fn().mockReturnValue(builder);
18
+ builder.deleteFrom = vi.fn().mockReturnValue(builder);
19
+ builder.selectAll = vi.fn().mockReturnValue(builder);
20
+ builder.select = vi.fn().mockReturnValue(builder);
21
+ builder.values = vi.fn().mockReturnValue(builder);
22
+ builder.where = vi.fn().mockReturnValue(builder);
23
+ builder.orderBy = vi.fn().mockReturnValue(builder);
24
+ builder.limit = vi.fn().mockReturnValue(builder);
25
+ builder.offset = vi.fn().mockReturnValue(builder);
26
+ builder.or = vi.fn().mockReturnValue(builder);
27
+
28
+ // Terminal methods
29
+ builder.execute = vi.fn().mockResolvedValue([]);
30
+ builder.executeTakeFirst = vi.fn().mockResolvedValue(undefined);
31
+
32
+ return builder;
33
+ }
34
+
35
+ describe('KyselyStorage', () => {
36
+ let mockDb: ReturnType<typeof createMockQueryBuilder>;
37
+ let storage: KyselyStorage<any>;
38
+
39
+ beforeEach(() => {
40
+ mockDb = createMockQueryBuilder();
41
+ storage = new KyselyStorage({ db: mockDb as any });
42
+ });
43
+
44
+ // ============================================
45
+ // Requests
46
+ // ============================================
47
+
48
+ describe('saveRequest', () => {
49
+ it('should insert a request into the database', async () => {
50
+ const entry: RequestEntry = {
51
+ id: 'req-123',
52
+ method: 'GET',
53
+ path: '/api/users',
54
+ url: 'http://localhost/api/users',
55
+ headers: { 'content-type': 'application/json' },
56
+ query: { page: '1' },
57
+ status: 200,
58
+ responseHeaders: { 'content-type': 'application/json' },
59
+ responseBody: { users: [] },
60
+ duration: 50,
61
+ timestamp: new Date('2024-01-01T00:00:00Z'),
62
+ ip: '127.0.0.1',
63
+ userId: 'user-1',
64
+ tags: ['api'],
65
+ };
66
+
67
+ await storage.saveRequest(entry);
68
+
69
+ expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_requests');
70
+ expect(mockDb.values).toHaveBeenCalledWith({
71
+ id: 'req-123',
72
+ method: 'GET',
73
+ path: '/api/users',
74
+ url: 'http://localhost/api/users',
75
+ headers: { 'content-type': 'application/json' },
76
+ query: { page: '1' },
77
+ body: null,
78
+ status: 200,
79
+ response_headers: { 'content-type': 'application/json' },
80
+ response_body: { users: [] },
81
+ duration: 50,
82
+ timestamp: entry.timestamp,
83
+ ip: '127.0.0.1',
84
+ user_id: 'user-1',
85
+ tags: ['api'],
86
+ });
87
+ expect(mockDb.execute).toHaveBeenCalled();
88
+ });
89
+
90
+ it('should handle optional fields as null', async () => {
91
+ const entry: RequestEntry = {
92
+ id: 'req-456',
93
+ method: 'POST',
94
+ path: '/api/test',
95
+ url: 'http://localhost/api/test',
96
+ headers: {},
97
+ status: 201,
98
+ responseHeaders: {},
99
+ duration: 10,
100
+ timestamp: new Date(),
101
+ };
102
+
103
+ await storage.saveRequest(entry);
104
+
105
+ expect(mockDb.values).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ body: null,
108
+ query: null,
109
+ response_body: null,
110
+ ip: null,
111
+ user_id: null,
112
+ tags: null,
113
+ }),
114
+ );
115
+ });
116
+ });
117
+
118
+ describe('saveRequests', () => {
119
+ it('should batch insert multiple requests', async () => {
120
+ const entries: RequestEntry[] = [
121
+ {
122
+ id: 'req-1',
123
+ method: 'GET',
124
+ path: '/1',
125
+ url: 'http://localhost/1',
126
+ headers: {},
127
+ status: 200,
128
+ responseHeaders: {},
129
+ duration: 10,
130
+ timestamp: new Date(),
131
+ },
132
+ {
133
+ id: 'req-2',
134
+ method: 'GET',
135
+ path: '/2',
136
+ url: 'http://localhost/2',
137
+ headers: {},
138
+ status: 200,
139
+ responseHeaders: {},
140
+ duration: 20,
141
+ timestamp: new Date(),
142
+ },
143
+ ];
144
+
145
+ await storage.saveRequests(entries);
146
+
147
+ expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_requests');
148
+ expect(mockDb.values).toHaveBeenCalledWith(
149
+ expect.arrayContaining([
150
+ expect.objectContaining({ id: 'req-1' }),
151
+ expect.objectContaining({ id: 'req-2' }),
152
+ ]),
153
+ );
154
+ });
155
+
156
+ it('should not insert when entries array is empty', async () => {
157
+ await storage.saveRequests([]);
158
+
159
+ expect(mockDb.insertInto).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ describe('getRequests', () => {
164
+ it('should query requests with default limit', async () => {
165
+ const mockRows: TelescopeRequestTable[] = [
166
+ {
167
+ id: 'req-1',
168
+ method: 'GET',
169
+ path: '/api/test',
170
+ url: 'http://localhost/api/test',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: null,
173
+ query: null,
174
+ status: 200,
175
+ response_headers: {},
176
+ response_body: null,
177
+ duration: 10,
178
+ timestamp: new Date('2024-01-01'),
179
+ ip: null,
180
+ user_id: null,
181
+ tags: null,
182
+ },
183
+ ];
184
+
185
+ mockDb.execute.mockResolvedValueOnce(mockRows);
186
+
187
+ const result = await storage.getRequests();
188
+
189
+ expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_requests');
190
+ expect(mockDb.selectAll).toHaveBeenCalled();
191
+ expect(mockDb.orderBy).toHaveBeenCalledWith('timestamp', 'desc');
192
+ expect(mockDb.limit).toHaveBeenCalledWith(50);
193
+
194
+ expect(result).toHaveLength(1);
195
+ expect(result[0].id).toBe('req-1');
196
+ expect(result[0].headers).toEqual({ 'content-type': 'application/json' });
197
+ });
198
+
199
+ it('should apply query options', async () => {
200
+ mockDb.execute.mockResolvedValueOnce([]);
201
+
202
+ await storage.getRequests({
203
+ limit: 10,
204
+ offset: 5,
205
+ after: new Date('2024-01-01'),
206
+ before: new Date('2024-12-31'),
207
+ });
208
+
209
+ expect(mockDb.where).toHaveBeenCalledWith(
210
+ 'timestamp',
211
+ '>=',
212
+ expect.any(Date),
213
+ );
214
+ expect(mockDb.where).toHaveBeenCalledWith(
215
+ 'timestamp',
216
+ '<=',
217
+ expect.any(Date),
218
+ );
219
+ expect(mockDb.limit).toHaveBeenCalledWith(10);
220
+ expect(mockDb.offset).toHaveBeenCalledWith(5);
221
+ });
222
+ });
223
+
224
+ describe('getRequest', () => {
225
+ it('should return a single request by ID', async () => {
226
+ const mockRow: TelescopeRequestTable = {
227
+ id: 'req-123',
228
+ method: 'GET',
229
+ path: '/api/test',
230
+ url: 'http://localhost/api/test',
231
+ headers: {},
232
+ body: null,
233
+ query: null,
234
+ status: 200,
235
+ response_headers: {},
236
+ response_body: null,
237
+ duration: 10,
238
+ timestamp: new Date('2024-01-01'),
239
+ ip: '127.0.0.1',
240
+ user_id: 'user-1',
241
+ tags: ['test'],
242
+ };
243
+
244
+ mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
245
+
246
+ const result = await storage.getRequest('req-123');
247
+
248
+ expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_requests');
249
+ expect(mockDb.where).toHaveBeenCalledWith('id', '=', 'req-123');
250
+ expect(result).not.toBeNull();
251
+ expect(result?.id).toBe('req-123');
252
+ expect(result?.ip).toBe('127.0.0.1');
253
+ expect(result?.userId).toBe('user-1');
254
+ expect(result?.tags).toEqual(['test']);
255
+ });
256
+
257
+ it('should return null when request not found', async () => {
258
+ mockDb.executeTakeFirst.mockResolvedValueOnce(undefined);
259
+
260
+ const result = await storage.getRequest('non-existent');
261
+
262
+ expect(result).toBeNull();
263
+ });
264
+ });
265
+
266
+ // ============================================
267
+ // Exceptions
268
+ // ============================================
269
+
270
+ describe('saveException', () => {
271
+ it('should insert an exception into the database', async () => {
272
+ const entry: ExceptionEntry = {
273
+ id: 'exc-123',
274
+ name: 'Error',
275
+ message: 'Something went wrong',
276
+ stack: [{ file: 'test.ts', line: 10, column: 5, function: 'test' }],
277
+ source: { file: 'test.ts', line: 10, code: 'throw new Error()' },
278
+ requestId: 'req-1',
279
+ timestamp: new Date('2024-01-01'),
280
+ handled: false,
281
+ tags: ['critical'],
282
+ };
283
+
284
+ await storage.saveException(entry);
285
+
286
+ expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_exceptions');
287
+ expect(mockDb.values).toHaveBeenCalledWith({
288
+ id: 'exc-123',
289
+ name: 'Error',
290
+ message: 'Something went wrong',
291
+ stack: entry.stack,
292
+ source: entry.source,
293
+ request_id: 'req-1',
294
+ timestamp: entry.timestamp,
295
+ handled: false,
296
+ tags: ['critical'],
297
+ });
298
+ });
299
+ });
300
+
301
+ describe('saveExceptions', () => {
302
+ it('should batch insert multiple exceptions', async () => {
303
+ const entries: ExceptionEntry[] = [
304
+ {
305
+ id: 'exc-1',
306
+ name: 'Error',
307
+ message: 'First error',
308
+ stack: [],
309
+ timestamp: new Date(),
310
+ handled: true,
311
+ },
312
+ {
313
+ id: 'exc-2',
314
+ name: 'TypeError',
315
+ message: 'Second error',
316
+ stack: [],
317
+ timestamp: new Date(),
318
+ handled: false,
319
+ },
320
+ ];
321
+
322
+ await storage.saveExceptions(entries);
323
+
324
+ expect(mockDb.values).toHaveBeenCalledWith(
325
+ expect.arrayContaining([
326
+ expect.objectContaining({ id: 'exc-1', name: 'Error' }),
327
+ expect.objectContaining({ id: 'exc-2', name: 'TypeError' }),
328
+ ]),
329
+ );
330
+ });
331
+
332
+ it('should not insert when entries array is empty', async () => {
333
+ await storage.saveExceptions([]);
334
+
335
+ expect(mockDb.insertInto).not.toHaveBeenCalled();
336
+ });
337
+ });
338
+
339
+ describe('getExceptions', () => {
340
+ it('should query exceptions with default limit', async () => {
341
+ const mockRows: TelescopeExceptionTable[] = [
342
+ {
343
+ id: 'exc-1',
344
+ name: 'Error',
345
+ message: 'Test error',
346
+ stack: [{ file: 'test.ts', line: 1 }],
347
+ source: null,
348
+ request_id: 'req-1',
349
+ timestamp: new Date(),
350
+ handled: false,
351
+ tags: null,
352
+ },
353
+ ];
354
+
355
+ mockDb.execute.mockResolvedValueOnce(mockRows);
356
+
357
+ const result = await storage.getExceptions();
358
+
359
+ expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_exceptions');
360
+ expect(result).toHaveLength(1);
361
+ expect(result[0].name).toBe('Error');
362
+ expect(result[0].requestId).toBe('req-1');
363
+ });
364
+ });
365
+
366
+ describe('getException', () => {
367
+ it('should return a single exception by ID', async () => {
368
+ const mockRow: TelescopeExceptionTable = {
369
+ id: 'exc-123',
370
+ name: 'Error',
371
+ message: 'Test',
372
+ stack: [],
373
+ source: null,
374
+ request_id: null,
375
+ timestamp: new Date(),
376
+ handled: true,
377
+ tags: null,
378
+ };
379
+
380
+ mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
381
+
382
+ const result = await storage.getException('exc-123');
383
+
384
+ expect(result).not.toBeNull();
385
+ expect(result?.handled).toBe(true);
386
+ });
387
+
388
+ it('should return null when exception not found', async () => {
389
+ mockDb.executeTakeFirst.mockResolvedValueOnce(undefined);
390
+
391
+ const result = await storage.getException('non-existent');
392
+
393
+ expect(result).toBeNull();
394
+ });
395
+ });
396
+
397
+ // ============================================
398
+ // Logs
399
+ // ============================================
400
+
401
+ describe('saveLog', () => {
402
+ it('should insert a log into the database', async () => {
403
+ const entry: LogEntry = {
404
+ id: 'log-123',
405
+ level: 'info',
406
+ message: 'User logged in',
407
+ context: { userId: '123' },
408
+ requestId: 'req-1',
409
+ timestamp: new Date('2024-01-01'),
410
+ };
411
+
412
+ await storage.saveLog(entry);
413
+
414
+ expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_logs');
415
+ expect(mockDb.values).toHaveBeenCalledWith({
416
+ id: 'log-123',
417
+ level: 'info',
418
+ message: 'User logged in',
419
+ context: { userId: '123' },
420
+ request_id: 'req-1',
421
+ timestamp: entry.timestamp,
422
+ });
423
+ });
424
+ });
425
+
426
+ describe('saveLogs', () => {
427
+ it('should batch insert multiple logs', async () => {
428
+ const entries: LogEntry[] = [
429
+ {
430
+ id: 'log-1',
431
+ level: 'debug',
432
+ message: 'Debug message',
433
+ timestamp: new Date(),
434
+ },
435
+ {
436
+ id: 'log-2',
437
+ level: 'error',
438
+ message: 'Error message',
439
+ timestamp: new Date(),
440
+ },
441
+ ];
442
+
443
+ await storage.saveLogs(entries);
444
+
445
+ expect(mockDb.values).toHaveBeenCalledWith(
446
+ expect.arrayContaining([
447
+ expect.objectContaining({ id: 'log-1', level: 'debug' }),
448
+ expect.objectContaining({ id: 'log-2', level: 'error' }),
449
+ ]),
450
+ );
451
+ });
452
+
453
+ it('should not insert when entries array is empty', async () => {
454
+ await storage.saveLogs([]);
455
+
456
+ expect(mockDb.insertInto).not.toHaveBeenCalled();
457
+ });
458
+ });
459
+
460
+ describe('getLogs', () => {
461
+ it('should query logs with default limit', async () => {
462
+ const mockRows: TelescopeLogTable[] = [
463
+ {
464
+ id: 'log-1',
465
+ level: 'info',
466
+ message: 'Test message',
467
+ context: { key: 'value' },
468
+ request_id: null,
469
+ timestamp: new Date(),
470
+ },
471
+ ];
472
+
473
+ mockDb.execute.mockResolvedValueOnce(mockRows);
474
+
475
+ const result = await storage.getLogs();
476
+
477
+ expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_logs');
478
+ expect(result).toHaveLength(1);
479
+ expect(result[0].level).toBe('info');
480
+ expect(result[0].context).toEqual({ key: 'value' });
481
+ });
482
+ });
483
+
484
+ // ============================================
485
+ // Prune
486
+ // ============================================
487
+
488
+ describe('prune', () => {
489
+ it('should delete old entries from all tables', async () => {
490
+ const olderThan = new Date('2024-01-01');
491
+
492
+ mockDb.executeTakeFirst
493
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(5) })
494
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(2) })
495
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(10) });
496
+
497
+ const deleted = await storage.prune(olderThan);
498
+
499
+ expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_requests');
500
+ expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_exceptions');
501
+ expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_logs');
502
+ expect(mockDb.where).toHaveBeenCalledWith('timestamp', '<', olderThan);
503
+ expect(deleted).toBe(17);
504
+ });
505
+
506
+ it('should return 0 when no entries deleted', async () => {
507
+ mockDb.executeTakeFirst
508
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(0) })
509
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(0) })
510
+ .mockResolvedValueOnce({ numDeletedRows: BigInt(0) });
511
+
512
+ const deleted = await storage.prune(new Date());
513
+
514
+ expect(deleted).toBe(0);
515
+ });
516
+ });
517
+
518
+ // ============================================
519
+ // Stats
520
+ // ============================================
521
+
522
+ describe('getStats', () => {
523
+ it('should aggregate statistics from all tables', async () => {
524
+ const now = new Date();
525
+ const earlier = new Date(now.getTime() - 3600000);
526
+
527
+ mockDb.executeTakeFirst
528
+ .mockResolvedValueOnce({
529
+ count: BigInt(100),
530
+ oldest: earlier,
531
+ newest: now,
532
+ })
533
+ .mockResolvedValueOnce({
534
+ count: BigInt(5),
535
+ oldest: earlier,
536
+ newest: now,
537
+ })
538
+ .mockResolvedValueOnce({
539
+ count: BigInt(50),
540
+ oldest: earlier,
541
+ newest: now,
542
+ });
543
+
544
+ const stats = await storage.getStats();
545
+
546
+ expect(stats.requests).toBe(100);
547
+ expect(stats.exceptions).toBe(5);
548
+ expect(stats.logs).toBe(50);
549
+ expect(stats.oldestEntry).toEqual(earlier);
550
+ expect(stats.newestEntry).toEqual(now);
551
+ });
552
+
553
+ it('should handle empty tables', async () => {
554
+ mockDb.executeTakeFirst
555
+ .mockResolvedValueOnce({ count: BigInt(0), oldest: null, newest: null })
556
+ .mockResolvedValueOnce({ count: BigInt(0), oldest: null, newest: null })
557
+ .mockResolvedValueOnce({
558
+ count: BigInt(0),
559
+ oldest: null,
560
+ newest: null,
561
+ });
562
+
563
+ const stats = await storage.getStats();
564
+
565
+ expect(stats.requests).toBe(0);
566
+ expect(stats.exceptions).toBe(0);
567
+ expect(stats.logs).toBe(0);
568
+ expect(stats.oldestEntry).toBeUndefined();
569
+ expect(stats.newestEntry).toBeUndefined();
570
+ });
571
+ });
572
+
573
+ // ============================================
574
+ // Table Prefix
575
+ // ============================================
576
+
577
+ describe('table prefix', () => {
578
+ it('should use custom table prefix', async () => {
579
+ const customStorage = new KyselyStorage({
580
+ db: mockDb as any,
581
+ tablePrefix: 'custom',
582
+ });
583
+
584
+ await customStorage.saveRequest({
585
+ id: 'req-1',
586
+ method: 'GET',
587
+ path: '/test',
588
+ url: 'http://localhost/test',
589
+ headers: {},
590
+ status: 200,
591
+ responseHeaders: {},
592
+ duration: 10,
593
+ timestamp: new Date(),
594
+ });
595
+
596
+ expect(mockDb.insertInto).toHaveBeenCalledWith('custom_requests');
597
+ });
598
+ });
599
+
600
+ // ============================================
601
+ // JSON Parsing
602
+ // ============================================
603
+
604
+ describe('JSON parsing', () => {
605
+ it('should parse JSON strings from database', async () => {
606
+ const mockRow: TelescopeRequestTable = {
607
+ id: 'req-1',
608
+ method: 'GET',
609
+ path: '/test',
610
+ url: 'http://localhost/test',
611
+ headers: '{"content-type":"application/json"}' as unknown as unknown,
612
+ body: null,
613
+ query: '{"page":"1"}' as unknown as unknown,
614
+ status: 200,
615
+ response_headers: '{}' as unknown as unknown,
616
+ response_body: null,
617
+ duration: 10,
618
+ timestamp: new Date(),
619
+ ip: null,
620
+ user_id: null,
621
+ tags: '["api"]' as unknown as unknown,
622
+ };
623
+
624
+ mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
625
+
626
+ const result = await storage.getRequest('req-1');
627
+
628
+ expect(result?.headers).toEqual({ 'content-type': 'application/json' });
629
+ expect(result?.query).toEqual({ page: '1' });
630
+ expect(result?.tags).toEqual(['api']);
631
+ });
632
+
633
+ it('should handle already-parsed JSON objects from JSONB columns', async () => {
634
+ const mockRow: TelescopeRequestTable = {
635
+ id: 'req-1',
636
+ method: 'GET',
637
+ path: '/test',
638
+ url: 'http://localhost/test',
639
+ headers: { 'content-type': 'application/json' },
640
+ body: null,
641
+ query: { page: '1' },
642
+ status: 200,
643
+ response_headers: {},
644
+ response_body: null,
645
+ duration: 10,
646
+ timestamp: new Date(),
647
+ ip: null,
648
+ user_id: null,
649
+ tags: ['api'],
650
+ };
651
+
652
+ mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
653
+
654
+ const result = await storage.getRequest('req-1');
655
+
656
+ expect(result?.headers).toEqual({ 'content-type': 'application/json' });
657
+ expect(result?.query).toEqual({ page: '1' });
658
+ expect(result?.tags).toEqual(['api']);
659
+ });
660
+ });
661
+ });
662
+
663
+ describe('getTelescopeMigration', () => {
664
+ it('should return up and down migration SQL', () => {
665
+ const migration = getTelescopeMigration();
666
+
667
+ expect(migration.up).toContain(
668
+ 'CREATE TABLE IF NOT EXISTS telescope_requests',
669
+ );
670
+ expect(migration.up).toContain(
671
+ 'CREATE TABLE IF NOT EXISTS telescope_exceptions',
672
+ );
673
+ expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS telescope_logs');
674
+ expect(migration.up).toContain('CREATE INDEX IF NOT EXISTS');
675
+ expect(migration.down).toContain('DROP TABLE IF EXISTS telescope_logs');
676
+ expect(migration.down).toContain(
677
+ 'DROP TABLE IF EXISTS telescope_exceptions',
678
+ );
679
+ expect(migration.down).toContain('DROP TABLE IF EXISTS telescope_requests');
680
+ });
681
+
682
+ it('should support custom table prefix', () => {
683
+ const migration = getTelescopeMigration('debug');
684
+
685
+ expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS debug_requests');
686
+ expect(migration.up).toContain(
687
+ 'CREATE TABLE IF NOT EXISTS debug_exceptions',
688
+ );
689
+ expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS debug_logs');
690
+ expect(migration.up).toContain('idx_debug_requests_timestamp');
691
+ expect(migration.down).toContain('DROP TABLE IF EXISTS debug_logs');
692
+ });
693
+
694
+ it('should include proper PostgreSQL column types', () => {
695
+ const migration = getTelescopeMigration();
696
+
697
+ expect(migration.up).toContain('JSONB');
698
+ expect(migration.up).toContain('TIMESTAMPTZ');
699
+ expect(migration.up).toContain('DOUBLE PRECISION');
700
+ expect(migration.up).toContain('BOOLEAN');
701
+ });
702
+
703
+ it('should include indexes for common queries', () => {
704
+ const migration = getTelescopeMigration();
705
+
706
+ expect(migration.up).toContain('idx_telescope_requests_timestamp');
707
+ expect(migration.up).toContain('idx_telescope_requests_path');
708
+ expect(migration.up).toContain('idx_telescope_requests_status');
709
+ expect(migration.up).toContain('idx_telescope_exceptions_timestamp');
710
+ expect(migration.up).toContain('idx_telescope_exceptions_request_id');
711
+ expect(migration.up).toContain('idx_telescope_logs_timestamp');
712
+ expect(migration.up).toContain('idx_telescope_logs_level');
713
+ expect(migration.up).toContain('idx_telescope_logs_request_id');
714
+ });
715
+ });