@geekmidas/studio 2.0.1 → 10.0.0-alpha.0
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/package.json +8 -5
- package/CHANGELOG.md +0 -60
- package/src/Studio.ts +0 -367
- package/src/__tests__/Studio.spec.ts +0 -447
- package/src/data/DataBrowser.ts +0 -170
- package/src/data/__tests__/DataBrowser.integration.spec.ts +0 -418
- package/src/data/__tests__/filtering.integration.spec.ts +0 -741
- package/src/data/__tests__/introspection.integration.spec.ts +0 -352
- package/src/data/__tests__/pagination.spec.ts +0 -123
- package/src/data/filtering.ts +0 -191
- package/src/data/index.ts +0 -1
- package/src/data/introspection.ts +0 -220
- package/src/data/pagination.ts +0 -33
- package/src/index.ts +0 -29
- package/src/server/__tests__/hono.integration.spec.ts +0 -619
- package/src/server/hono.ts +0 -427
- package/src/types.ts +0 -278
- package/src/ui-assets.ts +0 -37
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -13
- package/ui/CHANGELOG.md +0 -26
- package/ui/index.html +0 -12
- package/ui/node_modules/.bin/tsc +0 -21
- package/ui/node_modules/.bin/tsserver +0 -21
- package/ui/node_modules/.bin/vite +0 -21
- package/ui/package.json +0 -30
- package/ui/src/App.tsx +0 -100
- package/ui/src/api.ts +0 -213
- package/ui/src/components/FilterPanel.tsx +0 -213
- package/ui/src/components/NavRail.tsx +0 -183
- package/ui/src/components/RowDetail.tsx +0 -119
- package/ui/src/components/StudioHeader.tsx +0 -109
- package/ui/src/components/TableList.tsx +0 -58
- package/ui/src/components/TableView.tsx +0 -564
- package/ui/src/main.tsx +0 -10
- package/ui/src/pages/DashboardPage.tsx +0 -500
- package/ui/src/pages/DatabasePage.tsx +0 -226
- package/ui/src/pages/EndpointDetailsPage.tsx +0 -288
- package/ui/src/pages/ExceptionsPage.tsx +0 -268
- package/ui/src/pages/LogsPage.tsx +0 -228
- package/ui/src/pages/MonitoringPage.tsx +0 -46
- package/ui/src/pages/PerformancePage.tsx +0 -307
- package/ui/src/pages/RequestsPage.tsx +0 -379
- package/ui/src/providers/StudioProvider.tsx +0 -194
- package/ui/src/styles.css +0 -105
- package/ui/src/types.ts +0 -174
- package/ui/src/vite-env.d.ts +0 -1
- package/ui/tsconfig.json +0 -21
- package/ui/tsconfig.tsbuildinfo +0 -1
- package/ui/vite.config.ts +0 -12
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
import { InMemoryStorage } from '@geekmidas/telescope';
|
|
2
|
-
import {
|
|
3
|
-
CamelCasePlugin,
|
|
4
|
-
type Generated,
|
|
5
|
-
Kysely,
|
|
6
|
-
PostgresDialect,
|
|
7
|
-
} from 'kysely';
|
|
8
|
-
import pg from 'pg';
|
|
9
|
-
import {
|
|
10
|
-
afterAll,
|
|
11
|
-
afterEach,
|
|
12
|
-
beforeAll,
|
|
13
|
-
beforeEach,
|
|
14
|
-
describe,
|
|
15
|
-
expect,
|
|
16
|
-
it,
|
|
17
|
-
vi,
|
|
18
|
-
} from 'vitest';
|
|
19
|
-
import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
|
|
20
|
-
import { Studio } from '../Studio';
|
|
21
|
-
import { Direction } from '../types';
|
|
22
|
-
|
|
23
|
-
interface TestDatabase {
|
|
24
|
-
studioTestUsers: {
|
|
25
|
-
id: Generated<number>;
|
|
26
|
-
name: string;
|
|
27
|
-
email: string;
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe('Studio', () => {
|
|
32
|
-
let db: Kysely<TestDatabase>;
|
|
33
|
-
let studio: Studio<TestDatabase>;
|
|
34
|
-
let storage: InMemoryStorage;
|
|
35
|
-
|
|
36
|
-
beforeAll(async () => {
|
|
37
|
-
db = new Kysely<TestDatabase>({
|
|
38
|
-
dialect: new PostgresDialect({
|
|
39
|
-
pool: new pg.Pool({
|
|
40
|
-
...TEST_DATABASE_CONFIG,
|
|
41
|
-
database: 'postgres',
|
|
42
|
-
}),
|
|
43
|
-
}),
|
|
44
|
-
plugins: [new CamelCasePlugin()],
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Create test table
|
|
48
|
-
await db.schema
|
|
49
|
-
.createTable('studio_test_users')
|
|
50
|
-
.ifNotExists()
|
|
51
|
-
.addColumn('id', 'serial', (col) => col.primaryKey())
|
|
52
|
-
.addColumn('name', 'varchar(255)', (col) => col.notNull())
|
|
53
|
-
.addColumn('email', 'varchar(255)', (col) => col.notNull())
|
|
54
|
-
.execute();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
beforeEach(() => {
|
|
58
|
-
storage = new InMemoryStorage();
|
|
59
|
-
studio = new Studio({
|
|
60
|
-
monitoring: {
|
|
61
|
-
storage,
|
|
62
|
-
},
|
|
63
|
-
data: {
|
|
64
|
-
db,
|
|
65
|
-
cursor: { field: 'id', direction: Direction.Desc },
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
afterEach(async () => {
|
|
71
|
-
studio.destroy();
|
|
72
|
-
await db.deleteFrom('studioTestUsers').execute();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
afterAll(async () => {
|
|
76
|
-
await db.schema.dropTable('studio_test_users').ifExists().execute();
|
|
77
|
-
await db.destroy();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('configuration', () => {
|
|
81
|
-
it('should expose path property with default value', () => {
|
|
82
|
-
expect(studio.path).toBe('/__studio');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('should allow custom path', () => {
|
|
86
|
-
const customStudio = new Studio({
|
|
87
|
-
monitoring: { storage },
|
|
88
|
-
data: { db, cursor: { field: 'id', direction: Direction.Desc } },
|
|
89
|
-
path: '/custom-studio',
|
|
90
|
-
});
|
|
91
|
-
expect(customStudio.path).toBe('/custom-studio');
|
|
92
|
-
customStudio.destroy();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('should expose enabled property', () => {
|
|
96
|
-
expect(studio.enabled).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('should allow disabling studio', () => {
|
|
100
|
-
const disabledStudio = new Studio({
|
|
101
|
-
monitoring: { storage },
|
|
102
|
-
data: { db, cursor: { field: 'id', direction: Direction.Desc } },
|
|
103
|
-
enabled: false,
|
|
104
|
-
});
|
|
105
|
-
expect(disabledStudio.enabled).toBe(false);
|
|
106
|
-
disabledStudio.destroy();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('should expose recordBody property', () => {
|
|
110
|
-
expect(studio.recordBody).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should expose maxBodySize property', () => {
|
|
114
|
-
expect(studio.maxBodySize).toBe(64 * 1024); // 64KB default
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('should expose data browser instance', () => {
|
|
118
|
-
expect(studio.data).toBeDefined();
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('monitoring - recording', () => {
|
|
123
|
-
it('should record requests', async () => {
|
|
124
|
-
const requestId = await studio.recordRequest({
|
|
125
|
-
method: 'GET',
|
|
126
|
-
path: '/api/users',
|
|
127
|
-
status: 200,
|
|
128
|
-
duration: 100,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
expect(requestId).toBeDefined();
|
|
132
|
-
expect(typeof requestId).toBe('string');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should record log entries in batch', async () => {
|
|
136
|
-
await studio.log([
|
|
137
|
-
{ level: 'info', message: 'Test log 1' },
|
|
138
|
-
{ level: 'error', message: 'Test log 2' },
|
|
139
|
-
]);
|
|
140
|
-
|
|
141
|
-
const logs = await studio.getLogs();
|
|
142
|
-
expect(logs.length).toBeGreaterThanOrEqual(2);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('should log debug messages', async () => {
|
|
146
|
-
await studio.debug('Debug message', { foo: 'bar' });
|
|
147
|
-
|
|
148
|
-
const logs = await studio.getLogs({ level: 'debug' });
|
|
149
|
-
expect(logs.some((l) => l.message === 'Debug message')).toBe(true);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('should log info messages', async () => {
|
|
153
|
-
await studio.info('Info message', { key: 'value' });
|
|
154
|
-
|
|
155
|
-
const logs = await studio.getLogs({ level: 'info' });
|
|
156
|
-
expect(logs.some((l) => l.message === 'Info message')).toBe(true);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('should log warning messages', async () => {
|
|
160
|
-
await studio.warn('Warning message');
|
|
161
|
-
|
|
162
|
-
const logs = await studio.getLogs({ level: 'warn' });
|
|
163
|
-
expect(logs.some((l) => l.message === 'Warning message')).toBe(true);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should log error messages', async () => {
|
|
167
|
-
await studio.error('Error message');
|
|
168
|
-
|
|
169
|
-
const logs = await studio.getLogs({ level: 'error' });
|
|
170
|
-
expect(logs.some((l) => l.message === 'Error message')).toBe(true);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should record exceptions', async () => {
|
|
174
|
-
const error = new Error('Test error');
|
|
175
|
-
await studio.exception(error);
|
|
176
|
-
|
|
177
|
-
const exceptions = await studio.getExceptions();
|
|
178
|
-
expect(exceptions.some((e) => e.message === 'Test error')).toBe(true);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('should record exceptions with request ID', async () => {
|
|
182
|
-
const requestId = await studio.recordRequest({
|
|
183
|
-
method: 'GET',
|
|
184
|
-
path: '/api/users',
|
|
185
|
-
status: 500,
|
|
186
|
-
duration: 50,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
await studio.exception(new Error('Request error'), requestId);
|
|
190
|
-
|
|
191
|
-
const exceptions = await studio.getExceptions();
|
|
192
|
-
expect(exceptions.some((e) => e.requestId === requestId)).toBe(true);
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('monitoring - querying', () => {
|
|
197
|
-
beforeEach(async () => {
|
|
198
|
-
// Record some test data
|
|
199
|
-
await studio.recordRequest({
|
|
200
|
-
method: 'GET',
|
|
201
|
-
path: '/api/users',
|
|
202
|
-
status: 200,
|
|
203
|
-
duration: 100,
|
|
204
|
-
});
|
|
205
|
-
await studio.recordRequest({
|
|
206
|
-
method: 'POST',
|
|
207
|
-
path: '/api/users',
|
|
208
|
-
status: 201,
|
|
209
|
-
duration: 150,
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('should get all requests', async () => {
|
|
214
|
-
const requests = await studio.getRequests();
|
|
215
|
-
expect(requests.length).toBeGreaterThanOrEqual(2);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('should get requests with limit', async () => {
|
|
219
|
-
const requests = await studio.getRequests({ limit: 1 });
|
|
220
|
-
expect(requests.length).toBe(1);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('should get a single request by ID', async () => {
|
|
224
|
-
const requestId = await studio.recordRequest({
|
|
225
|
-
method: 'DELETE',
|
|
226
|
-
path: '/api/users/123',
|
|
227
|
-
status: 204,
|
|
228
|
-
duration: 50,
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
const request = await studio.getRequest(requestId);
|
|
232
|
-
expect(request).toBeDefined();
|
|
233
|
-
expect(request?.method).toBe('DELETE');
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('should return null for non-existent request', async () => {
|
|
237
|
-
const request = await studio.getRequest('non-existent-id');
|
|
238
|
-
expect(request).toBeNull();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('should get exceptions', async () => {
|
|
242
|
-
await studio.exception(new Error('Test exception'));
|
|
243
|
-
const exceptions = await studio.getExceptions();
|
|
244
|
-
expect(exceptions.length).toBeGreaterThanOrEqual(1);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('should get a single exception by ID', async () => {
|
|
248
|
-
await studio.exception(new Error('Another exception'));
|
|
249
|
-
const exceptions = await studio.getExceptions();
|
|
250
|
-
const first = exceptions[0];
|
|
251
|
-
|
|
252
|
-
const exception = await studio.getException(first.id);
|
|
253
|
-
expect(exception).toBeDefined();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('should return null for non-existent exception', async () => {
|
|
257
|
-
const exception = await studio.getException('non-existent-id');
|
|
258
|
-
expect(exception).toBeNull();
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('should get logs', async () => {
|
|
262
|
-
await studio.info('Log entry');
|
|
263
|
-
const logs = await studio.getLogs();
|
|
264
|
-
expect(logs.length).toBeGreaterThanOrEqual(1);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('should get storage stats', async () => {
|
|
268
|
-
const stats = await studio.getStats();
|
|
269
|
-
expect(stats).toBeDefined();
|
|
270
|
-
expect(typeof stats.requests).toBe('number');
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
describe('metrics', () => {
|
|
275
|
-
it('should get aggregated metrics', () => {
|
|
276
|
-
const metrics = studio.getMetrics();
|
|
277
|
-
expect(metrics).toBeDefined();
|
|
278
|
-
expect(typeof metrics.totalRequests).toBe('number');
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('should get endpoint metrics', () => {
|
|
282
|
-
const endpoints = studio.getEndpointMetrics();
|
|
283
|
-
expect(Array.isArray(endpoints)).toBe(true);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('should get status distribution', () => {
|
|
287
|
-
const distribution = studio.getStatusDistribution();
|
|
288
|
-
expect(distribution).toBeDefined();
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it('should reset metrics', () => {
|
|
292
|
-
// Just ensure it doesn't throw
|
|
293
|
-
studio.resetMetrics();
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
describe('path ignoring', () => {
|
|
298
|
-
it('should ignore studio paths', () => {
|
|
299
|
-
expect(studio.shouldIgnore('/__studio')).toBe(true);
|
|
300
|
-
expect(studio.shouldIgnore('/__studio/api/requests')).toBe(true);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('should not ignore other paths', () => {
|
|
304
|
-
expect(studio.shouldIgnore('/api/users')).toBe(false);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('should respect custom ignore patterns', () => {
|
|
308
|
-
const studioWithPatterns = new Studio({
|
|
309
|
-
monitoring: {
|
|
310
|
-
storage,
|
|
311
|
-
ignorePatterns: ['/health', '/metrics'],
|
|
312
|
-
},
|
|
313
|
-
data: {
|
|
314
|
-
db,
|
|
315
|
-
cursor: { field: 'id', direction: Direction.Desc },
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
expect(studioWithPatterns.shouldIgnore('/health')).toBe(true);
|
|
320
|
-
expect(studioWithPatterns.shouldIgnore('/api/users')).toBe(false);
|
|
321
|
-
|
|
322
|
-
studioWithPatterns.destroy();
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
describe('pruning', () => {
|
|
327
|
-
it('should prune old entries', async () => {
|
|
328
|
-
// Record some data
|
|
329
|
-
await studio.recordRequest({
|
|
330
|
-
method: 'GET',
|
|
331
|
-
path: '/old-request',
|
|
332
|
-
status: 200,
|
|
333
|
-
duration: 100,
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// Prune entries older than now (should remove all)
|
|
337
|
-
const futureDate = new Date(Date.now() + 1000);
|
|
338
|
-
const pruned = await studio.prune(futureDate);
|
|
339
|
-
|
|
340
|
-
expect(typeof pruned).toBe('number');
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
describe('WebSocket management', () => {
|
|
345
|
-
it('should add WebSocket clients', () => {
|
|
346
|
-
const mockWs = {
|
|
347
|
-
send: vi.fn(),
|
|
348
|
-
readyState: 1, // WebSocket.OPEN
|
|
349
|
-
} as any;
|
|
350
|
-
|
|
351
|
-
// Should not throw
|
|
352
|
-
studio.addWsClient(mockWs);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it('should remove WebSocket clients', () => {
|
|
356
|
-
const mockWs = {
|
|
357
|
-
send: vi.fn(),
|
|
358
|
-
readyState: 1,
|
|
359
|
-
} as any;
|
|
360
|
-
|
|
361
|
-
studio.addWsClient(mockWs);
|
|
362
|
-
studio.removeWsClient(mockWs);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('should broadcast events to connected clients', () => {
|
|
366
|
-
const mockWs = {
|
|
367
|
-
send: vi.fn(),
|
|
368
|
-
readyState: 1,
|
|
369
|
-
} as any;
|
|
370
|
-
|
|
371
|
-
studio.addWsClient(mockWs);
|
|
372
|
-
|
|
373
|
-
studio.broadcast({
|
|
374
|
-
type: 'request',
|
|
375
|
-
payload: { id: '123' },
|
|
376
|
-
timestamp: Date.now(),
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
expect(mockWs.send).toHaveBeenCalled();
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it('should remove clients that fail to receive', () => {
|
|
383
|
-
const mockWs = {
|
|
384
|
-
send: vi.fn().mockImplementation(() => {
|
|
385
|
-
throw new Error('Connection closed');
|
|
386
|
-
}),
|
|
387
|
-
readyState: 1,
|
|
388
|
-
} as any;
|
|
389
|
-
|
|
390
|
-
studio.addWsClient(mockWs);
|
|
391
|
-
|
|
392
|
-
// Broadcast should not throw
|
|
393
|
-
studio.broadcast({
|
|
394
|
-
type: 'log',
|
|
395
|
-
payload: { message: 'test' },
|
|
396
|
-
timestamp: Date.now(),
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// Client should be removed after failed send
|
|
400
|
-
// (internal state, hard to verify directly)
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
describe('destroy', () => {
|
|
405
|
-
it('should clean up resources', () => {
|
|
406
|
-
const s = new Studio({
|
|
407
|
-
monitoring: { storage },
|
|
408
|
-
data: {
|
|
409
|
-
db,
|
|
410
|
-
cursor: { field: 'id', direction: Direction.Desc },
|
|
411
|
-
},
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
// Add a WebSocket client
|
|
415
|
-
const mockWs = { send: vi.fn() } as any;
|
|
416
|
-
s.addWsClient(mockWs);
|
|
417
|
-
|
|
418
|
-
// Should not throw
|
|
419
|
-
s.destroy();
|
|
420
|
-
});
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
describe('data browser integration', () => {
|
|
424
|
-
it('should access the underlying database through data browser', async () => {
|
|
425
|
-
// Insert test data
|
|
426
|
-
await db
|
|
427
|
-
.insertInto('studioTestUsers')
|
|
428
|
-
.values({ name: 'Test User', email: 'test@example.com' })
|
|
429
|
-
.execute();
|
|
430
|
-
|
|
431
|
-
// Query through data browser
|
|
432
|
-
const result = await studio.data.query({
|
|
433
|
-
table: 'studio_test_users',
|
|
434
|
-
pageSize: 10,
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
expect(result.rows.length).toBeGreaterThanOrEqual(1);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it('should get schema through data browser', async () => {
|
|
441
|
-
const schema = await studio.data.getSchema();
|
|
442
|
-
|
|
443
|
-
const tableNames = schema.tables.map((t) => t.name);
|
|
444
|
-
expect(tableNames).toContain('studio_test_users');
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
});
|
package/src/data/DataBrowser.ts
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import type { Kysely, SelectQueryBuilder } from 'kysely';
|
|
2
|
-
import {
|
|
3
|
-
type CursorConfig,
|
|
4
|
-
type DataBrowserOptions,
|
|
5
|
-
Direction,
|
|
6
|
-
type QueryOptions,
|
|
7
|
-
type QueryResult,
|
|
8
|
-
type SchemaInfo,
|
|
9
|
-
type TableInfo,
|
|
10
|
-
} from '../types';
|
|
11
|
-
import { applyFilters, applySorting } from './filtering';
|
|
12
|
-
import { introspectSchema } from './introspection';
|
|
13
|
-
import { decodeCursor, encodeCursor } from './pagination';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Database browser for introspecting and querying PostgreSQL databases.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```typescript
|
|
20
|
-
* const browser = new DataBrowser({
|
|
21
|
-
* db: kyselyInstance,
|
|
22
|
-
* cursor: { field: 'id', direction: Direction.Desc },
|
|
23
|
-
* });
|
|
24
|
-
*
|
|
25
|
-
* const schema = await browser.getSchema();
|
|
26
|
-
* const result = await browser.query({ table: 'users', pageSize: 20 });
|
|
27
|
-
* ```
|
|
28
|
-
*/
|
|
29
|
-
export class DataBrowser<DB = unknown> {
|
|
30
|
-
private db: Kysely<DB>;
|
|
31
|
-
private options: Required<DataBrowserOptions<DB>>;
|
|
32
|
-
private schemaCache: SchemaInfo | null = null;
|
|
33
|
-
private schemaCacheExpiry = 0;
|
|
34
|
-
private readonly CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
35
|
-
|
|
36
|
-
constructor(options: Required<DataBrowserOptions<DB>>) {
|
|
37
|
-
this.db = options.db;
|
|
38
|
-
this.options = options;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ============================================
|
|
42
|
-
// Schema Introspection
|
|
43
|
-
// ============================================
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Get the database schema information.
|
|
47
|
-
* Results are cached for 1 minute.
|
|
48
|
-
*
|
|
49
|
-
* @param forceRefresh - Force a refresh of the cache
|
|
50
|
-
*/
|
|
51
|
-
async getSchema(forceRefresh = false): Promise<SchemaInfo> {
|
|
52
|
-
const now = Date.now();
|
|
53
|
-
|
|
54
|
-
if (!forceRefresh && this.schemaCache && now < this.schemaCacheExpiry) {
|
|
55
|
-
return this.schemaCache;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const schema = await introspectSchema(this.db, this.options.excludeTables);
|
|
59
|
-
|
|
60
|
-
this.schemaCache = schema;
|
|
61
|
-
this.schemaCacheExpiry = now + this.CACHE_TTL_MS;
|
|
62
|
-
|
|
63
|
-
return schema;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Get information about a specific table.
|
|
68
|
-
*/
|
|
69
|
-
async getTableInfo(tableName: string): Promise<TableInfo | null> {
|
|
70
|
-
const schema = await this.getSchema();
|
|
71
|
-
return schema.tables.find((t) => t.name === tableName) ?? null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ============================================
|
|
75
|
-
// Data Querying
|
|
76
|
-
// ============================================
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Query table data with pagination, filtering, and sorting.
|
|
80
|
-
*/
|
|
81
|
-
async query(options: QueryOptions): Promise<QueryResult> {
|
|
82
|
-
const tableInfo = await this.getTableInfo(options.table);
|
|
83
|
-
|
|
84
|
-
if (!tableInfo) {
|
|
85
|
-
throw new Error(`Table '${options.table}' not found`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const cursorConfig = this.getCursorConfig(options.table);
|
|
89
|
-
const pageSize = Math.min(
|
|
90
|
-
options.pageSize ?? this.options.defaultPageSize,
|
|
91
|
-
100,
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
// Build base query selecting all columns
|
|
95
|
-
let query = this.db
|
|
96
|
-
.selectFrom(options.table as any)
|
|
97
|
-
.selectAll() as SelectQueryBuilder<any, any, any>;
|
|
98
|
-
|
|
99
|
-
// Apply filters if provided
|
|
100
|
-
if (options.filters && options.filters.length > 0) {
|
|
101
|
-
query = applyFilters(query, options.filters, tableInfo);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Apply sorting if provided, otherwise use cursor field
|
|
105
|
-
if (options.sort && options.sort.length > 0) {
|
|
106
|
-
query = applySorting(query, options.sort, tableInfo);
|
|
107
|
-
} else {
|
|
108
|
-
query = query.orderBy(cursorConfig.field as any, cursorConfig.direction);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Handle cursor-based pagination
|
|
112
|
-
if (options.cursor) {
|
|
113
|
-
const cursorValue = decodeCursor(options.cursor);
|
|
114
|
-
const operator = cursorConfig.direction === Direction.Asc ? '>' : '<';
|
|
115
|
-
query = query.where(cursorConfig.field as any, operator, cursorValue);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Fetch one extra row to determine if there are more results
|
|
119
|
-
const rows = await query.limit(pageSize + 1).execute();
|
|
120
|
-
|
|
121
|
-
const hasMore = rows.length > pageSize;
|
|
122
|
-
const resultRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
123
|
-
|
|
124
|
-
// Generate cursors
|
|
125
|
-
let nextCursor: string | null = null;
|
|
126
|
-
let prevCursor: string | null = null;
|
|
127
|
-
|
|
128
|
-
if (hasMore && resultRows.length > 0) {
|
|
129
|
-
const lastRow = resultRows[resultRows.length - 1];
|
|
130
|
-
if (lastRow) {
|
|
131
|
-
nextCursor = encodeCursor(lastRow[cursorConfig.field]);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// For prev cursor, we need to know if there are previous results
|
|
136
|
-
// This would require a separate query, so we'll only set it if there's an input cursor
|
|
137
|
-
if (options.cursor && resultRows.length > 0) {
|
|
138
|
-
const firstRow = resultRows[0];
|
|
139
|
-
if (firstRow) {
|
|
140
|
-
prevCursor = encodeCursor(firstRow[cursorConfig.field]);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
rows: resultRows,
|
|
146
|
-
hasMore,
|
|
147
|
-
nextCursor,
|
|
148
|
-
prevCursor,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ============================================
|
|
153
|
-
// Configuration Access
|
|
154
|
-
// ============================================
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Get the cursor configuration for a table.
|
|
158
|
-
* Returns the table-specific config if defined, otherwise the default.
|
|
159
|
-
*/
|
|
160
|
-
getCursorConfig(tableName: string): CursorConfig {
|
|
161
|
-
return this.options.tableCursors[tableName] ?? this.options.cursor;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Get the underlying Kysely database instance.
|
|
166
|
-
*/
|
|
167
|
-
get database(): Kysely<DB> {
|
|
168
|
-
return this.db;
|
|
169
|
-
}
|
|
170
|
-
}
|