@geekmidas/studio 0.2.0 → 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
  3. package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
  4. package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
  5. package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
  6. package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
  7. package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
  8. package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
  9. package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
  10. package/dist/Studio-CYzz3wD2.d.cts +152 -0
  11. package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
  12. package/dist/Studio-D5yGscb8.d.mts +152 -0
  13. package/dist/Studio-D5yGscb8.d.mts.map +1 -0
  14. package/dist/data/index.cjs +1 -1
  15. package/dist/data/index.d.cts +1 -1
  16. package/dist/data/index.d.mts +1 -1
  17. package/dist/data/index.mjs +1 -1
  18. package/dist/index.cjs +33 -3
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +4 -131
  21. package/dist/index.d.mts +4 -131
  22. package/dist/index.mjs +33 -3
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/server/hono.cjs +168 -21
  25. package/dist/server/hono.cjs.map +1 -1
  26. package/dist/server/hono.d.cts +13 -2
  27. package/dist/server/hono.d.cts.map +1 -0
  28. package/dist/server/hono.d.mts +13 -2
  29. package/dist/server/hono.d.mts.map +1 -0
  30. package/dist/server/hono.mjs +168 -21
  31. package/dist/server/hono.mjs.map +1 -1
  32. package/dist/types-BZv87Ikv.mjs.map +1 -1
  33. package/dist/types-CMttUZYk.cjs.map +1 -1
  34. package/package.json +5 -5
  35. package/src/Studio.ts +341 -292
  36. package/src/__tests__/Studio.spec.ts +447 -0
  37. package/src/data/DataBrowser.ts +147 -143
  38. package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
  39. package/src/data/__tests__/filtering.integration.spec.ts +726 -726
  40. package/src/data/__tests__/introspection.integration.spec.ts +340 -340
  41. package/src/data/__tests__/pagination.spec.ts +123 -0
  42. package/src/data/filtering.ts +154 -154
  43. package/src/data/introspection.ts +141 -141
  44. package/src/data/pagination.ts +15 -15
  45. package/src/index.ts +22 -24
  46. package/src/server/__tests__/hono.integration.spec.ts +605 -347
  47. package/src/server/hono.ts +392 -190
  48. package/src/types.ts +138 -138
  49. package/src/ui-assets.ts +10 -13
  50. package/tsconfig.json +9 -0
  51. package/tsdown.config.ts +9 -9
  52. package/ui/CHANGELOG.md +12 -0
  53. package/ui/package.json +28 -22
  54. package/ui/src/App.tsx +95 -235
  55. package/ui/src/api.ts +184 -42
  56. package/ui/src/components/FilterPanel.tsx +198 -198
  57. package/ui/src/components/NavRail.tsx +183 -0
  58. package/ui/src/components/RowDetail.tsx +106 -106
  59. package/ui/src/components/StudioHeader.tsx +109 -0
  60. package/ui/src/components/TableList.tsx +49 -49
  61. package/ui/src/components/TableView.tsx +530 -485
  62. package/ui/src/main.tsx +3 -3
  63. package/ui/src/pages/DashboardPage.tsx +500 -0
  64. package/ui/src/pages/DatabasePage.tsx +226 -0
  65. package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
  66. package/ui/src/pages/ExceptionsPage.tsx +268 -0
  67. package/ui/src/pages/LogsPage.tsx +228 -0
  68. package/ui/src/pages/MonitoringPage.tsx +46 -0
  69. package/ui/src/pages/PerformancePage.tsx +307 -0
  70. package/ui/src/pages/RequestsPage.tsx +379 -0
  71. package/ui/src/providers/StudioProvider.tsx +194 -0
  72. package/ui/src/styles.css +53 -142
  73. package/ui/src/types.ts +154 -30
  74. package/ui/tsconfig.tsbuildinfo +1 -1
  75. package/ui/vite.config.ts +6 -6
  76. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
  77. package/dist/DataBrowser-c-Gs6PZB.cjs.map +0 -1
@@ -1,9 +1,9 @@
1
1
  import {
2
- CamelCasePlugin,
3
- type Generated,
4
- Kysely,
5
- PostgresDialect,
6
- sql,
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
7
7
  } from 'kysely';
8
8
  import pg from 'pg';
9
9
  import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@@ -11,342 +11,342 @@ import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
11
11
  import { introspectSchema, introspectTable } from '../introspection';
12
12
 
13
13
  interface TestDatabase {
14
- studioIntrospectUsers: {
15
- id: Generated<number>;
16
- name: string;
17
- email: string;
18
- isActive: boolean;
19
- metadata: Generated<Record<string, unknown> | null>;
20
- createdAt: Generated<Date>;
21
- updatedAt: Generated<Date>;
22
- };
23
- studioIntrospectPosts: {
24
- id: Generated<string>;
25
- userId: number;
26
- title: string;
27
- content: string | null;
28
- viewCount: number;
29
- publishedAt: Date | null;
30
- createdAt: Generated<Date>;
31
- };
32
- studioIntrospectTags: {
33
- id: Generated<number>;
34
- name: string;
35
- };
36
- studioIntrospectExcluded: {
37
- id: Generated<number>;
38
- value: string;
39
- };
14
+ studioIntrospectUsers: {
15
+ id: Generated<number>;
16
+ name: string;
17
+ email: string;
18
+ isActive: boolean;
19
+ metadata: Generated<Record<string, unknown> | null>;
20
+ createdAt: Generated<Date>;
21
+ updatedAt: Generated<Date>;
22
+ };
23
+ studioIntrospectPosts: {
24
+ id: Generated<string>;
25
+ userId: number;
26
+ title: string;
27
+ content: string | null;
28
+ viewCount: number;
29
+ publishedAt: Date | null;
30
+ createdAt: Generated<Date>;
31
+ };
32
+ studioIntrospectTags: {
33
+ id: Generated<number>;
34
+ name: string;
35
+ };
36
+ studioIntrospectExcluded: {
37
+ id: Generated<number>;
38
+ value: string;
39
+ };
40
40
  }
41
41
 
42
42
  describe('Schema Introspection Integration Tests', () => {
43
- let db: Kysely<TestDatabase>;
44
-
45
- beforeAll(async () => {
46
- db = new Kysely<TestDatabase>({
47
- dialect: new PostgresDialect({
48
- pool: new pg.Pool({
49
- ...TEST_DATABASE_CONFIG,
50
- database: 'postgres',
51
- }),
52
- }),
53
- plugins: [new CamelCasePlugin()],
54
- });
55
-
56
- // Create users table with various column types
57
- await db.schema
58
- .createTable('studio_introspect_users')
59
- .ifNotExists()
60
- .addColumn('id', 'serial', (col) => col.primaryKey())
61
- .addColumn('name', 'varchar(255)', (col) => col.notNull())
62
- .addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
63
- .addColumn('is_active', 'boolean', (col) => col.notNull().defaultTo(true))
64
- .addColumn('metadata', 'jsonb')
65
- .addColumn('created_at', 'timestamptz', (col) =>
66
- col.defaultTo(sql`now()`).notNull(),
67
- )
68
- .addColumn('updated_at', 'timestamptz', (col) =>
69
- col.defaultTo(sql`now()`).notNull(),
70
- )
71
- .execute();
72
-
73
- // Create posts table with foreign key
74
- await db.schema
75
- .createTable('studio_introspect_posts')
76
- .ifNotExists()
77
- .addColumn('id', 'uuid', (col) =>
78
- col.primaryKey().defaultTo(sql`gen_random_uuid()`),
79
- )
80
- .addColumn('user_id', 'integer', (col) =>
81
- col
82
- .notNull()
83
- .references('studio_introspect_users.id')
84
- .onDelete('cascade'),
85
- )
86
- .addColumn('title', 'varchar(500)', (col) => col.notNull())
87
- .addColumn('content', 'text')
88
- .addColumn('view_count', 'integer', (col) => col.notNull().defaultTo(0))
89
- .addColumn('published_at', 'timestamp')
90
- .addColumn('created_at', 'timestamptz', (col) =>
91
- col.defaultTo(sql`now()`).notNull(),
92
- )
93
- .execute();
94
-
95
- // Create simple tags table
96
- await db.schema
97
- .createTable('studio_introspect_tags')
98
- .ifNotExists()
99
- .addColumn('id', 'serial', (col) => col.primaryKey())
100
- .addColumn('name', 'varchar(100)', (col) => col.notNull())
101
- .execute();
102
-
103
- // Create excluded table (for exclusion testing)
104
- await db.schema
105
- .createTable('studio_introspect_excluded')
106
- .ifNotExists()
107
- .addColumn('id', 'serial', (col) => col.primaryKey())
108
- .addColumn('value', 'varchar(100)', (col) => col.notNull())
109
- .execute();
110
- });
111
-
112
- afterEach(async () => {
113
- // Clean up data after each test
114
- await db.deleteFrom('studioIntrospectPosts').execute();
115
- await db.deleteFrom('studioIntrospectUsers').execute();
116
- await db.deleteFrom('studioIntrospectTags').execute();
117
- await db.deleteFrom('studioIntrospectExcluded').execute();
118
- });
119
-
120
- afterAll(async () => {
121
- // Drop tables and close connection
122
- await db.schema.dropTable('studio_introspect_posts').ifExists().execute();
123
- await db.schema.dropTable('studio_introspect_users').ifExists().execute();
124
- await db.schema.dropTable('studio_introspect_tags').ifExists().execute();
125
- await db.schema
126
- .dropTable('studio_introspect_excluded')
127
- .ifExists()
128
- .execute();
129
- await db.destroy();
130
- });
131
-
132
- describe('introspectSchema', () => {
133
- it('should discover all tables in the public schema', async () => {
134
- const schema = await introspectSchema(db, []);
135
-
136
- // Find our test tables
137
- const tableNames = schema.tables.map((t) => t.name);
138
- expect(tableNames).toContain('studio_introspect_users');
139
- expect(tableNames).toContain('studio_introspect_posts');
140
- expect(tableNames).toContain('studio_introspect_tags');
141
- expect(tableNames).toContain('studio_introspect_excluded');
142
- expect(schema.updatedAt).toBeInstanceOf(Date);
143
- });
144
-
145
- it('should exclude specified tables', async () => {
146
- const schema = await introspectSchema(db, ['studio_introspect_excluded']);
147
-
148
- const tableNames = schema.tables.map((t) => t.name);
149
- expect(tableNames).toContain('studio_introspect_users');
150
- expect(tableNames).toContain('studio_introspect_posts');
151
- expect(tableNames).not.toContain('studio_introspect_excluded');
152
- });
153
-
154
- it('should exclude multiple tables', async () => {
155
- const schema = await introspectSchema(db, [
156
- 'studio_introspect_excluded',
157
- 'studio_introspect_tags',
158
- ]);
159
-
160
- const tableNames = schema.tables.map((t) => t.name);
161
- expect(tableNames).toContain('studio_introspect_users');
162
- expect(tableNames).toContain('studio_introspect_posts');
163
- expect(tableNames).not.toContain('studio_introspect_excluded');
164
- expect(tableNames).not.toContain('studio_introspect_tags');
165
- });
166
- });
167
-
168
- describe('introspectTable', () => {
169
- it('should return column information for users table', async () => {
170
- const tableInfo = await introspectTable(db, 'studio_introspect_users');
171
-
172
- expect(tableInfo.name).toBe('studio_introspect_users');
173
- expect(tableInfo.schema).toBe('public');
174
- expect(tableInfo.columns.length).toBe(7);
175
-
176
- // Find specific columns
177
- const idCol = tableInfo.columns.find((c) => c.name === 'id');
178
- const nameCol = tableInfo.columns.find((c) => c.name === 'name');
179
- const emailCol = tableInfo.columns.find((c) => c.name === 'email');
180
- const isActiveCol = tableInfo.columns.find((c) => c.name === 'is_active');
181
- const metadataCol = tableInfo.columns.find((c) => c.name === 'metadata');
182
- const createdAtCol = tableInfo.columns.find(
183
- (c) => c.name === 'created_at',
184
- );
185
-
186
- // Check id column (primary key, serial)
187
- expect(idCol).toBeDefined();
188
- expect(idCol!.type).toBe('number');
189
- expect(idCol!.rawType).toBe('int4');
190
- expect(idCol!.isPrimaryKey).toBe(true);
191
- expect(idCol!.nullable).toBe(false);
192
-
193
- // Check name column (varchar, not null)
194
- expect(nameCol).toBeDefined();
195
- expect(nameCol!.type).toBe('string');
196
- expect(nameCol!.rawType).toBe('varchar');
197
- expect(nameCol!.nullable).toBe(false);
198
-
199
- // Check email column (varchar, unique)
200
- expect(emailCol).toBeDefined();
201
- expect(emailCol!.type).toBe('string');
202
- expect(emailCol!.nullable).toBe(false);
203
-
204
- // Check is_active column (boolean with default)
205
- expect(isActiveCol).toBeDefined();
206
- expect(isActiveCol!.type).toBe('boolean');
207
- expect(isActiveCol!.rawType).toBe('bool');
208
- expect(isActiveCol!.nullable).toBe(false);
209
-
210
- // Check metadata column (jsonb, nullable)
211
- expect(metadataCol).toBeDefined();
212
- expect(metadataCol!.type).toBe('json');
213
- expect(metadataCol!.rawType).toBe('jsonb');
214
- expect(metadataCol!.nullable).toBe(true);
215
-
216
- // Check created_at column (timestamptz)
217
- expect(createdAtCol).toBeDefined();
218
- expect(createdAtCol!.type).toBe('datetime');
219
- expect(createdAtCol!.rawType).toBe('timestamptz');
220
- });
221
-
222
- it('should detect primary key', async () => {
223
- const tableInfo = await introspectTable(db, 'studio_introspect_users');
224
-
225
- expect(tableInfo.primaryKey).toEqual(['id']);
226
-
227
- const idColumn = tableInfo.columns.find((c) => c.name === 'id');
228
- expect(idColumn!.isPrimaryKey).toBe(true);
229
- });
230
-
231
- it('should detect foreign keys', async () => {
232
- const tableInfo = await introspectTable(db, 'studio_introspect_posts');
233
-
234
- const userIdCol = tableInfo.columns.find((c) => c.name === 'user_id');
235
- expect(userIdCol).toBeDefined();
236
- expect(userIdCol!.isForeignKey).toBe(true);
237
- expect(userIdCol!.foreignKeyTable).toBe('studio_introspect_users');
238
- expect(userIdCol!.foreignKeyColumn).toBe('id');
239
- });
240
-
241
- it('should detect uuid primary key', async () => {
242
- const tableInfo = await introspectTable(db, 'studio_introspect_posts');
243
-
244
- expect(tableInfo.primaryKey).toEqual(['id']);
245
-
246
- const idColumn = tableInfo.columns.find((c) => c.name === 'id');
247
- expect(idColumn).toBeDefined();
248
- expect(idColumn!.type).toBe('uuid');
249
- expect(idColumn!.rawType).toBe('uuid');
250
- expect(idColumn!.isPrimaryKey).toBe(true);
251
- });
252
-
253
- it('should map PostgreSQL types correctly', async () => {
254
- const usersTable = await introspectTable(db, 'studio_introspect_users');
255
- const postsTable = await introspectTable(db, 'studio_introspect_posts');
256
-
257
- // Integer types -> number
258
- const userIdCol = usersTable.columns.find((c) => c.name === 'id');
259
- expect(userIdCol!.type).toBe('number');
260
-
261
- // Boolean -> boolean
262
- const isActiveCol = usersTable.columns.find(
263
- (c) => c.name === 'is_active',
264
- );
265
- expect(isActiveCol!.type).toBe('boolean');
266
-
267
- // JSONB -> json
268
- const metadataCol = usersTable.columns.find((c) => c.name === 'metadata');
269
- expect(metadataCol!.type).toBe('json');
270
-
271
- // Timestamptz -> datetime
272
- const createdAtCol = usersTable.columns.find(
273
- (c) => c.name === 'created_at',
274
- );
275
- expect(createdAtCol!.type).toBe('datetime');
276
-
277
- // UUID -> uuid
278
- const postIdCol = postsTable.columns.find((c) => c.name === 'id');
279
- expect(postIdCol!.type).toBe('uuid');
280
-
281
- // Text -> string
282
- const contentCol = postsTable.columns.find((c) => c.name === 'content');
283
- expect(contentCol!.type).toBe('string');
284
- expect(contentCol!.rawType).toBe('text');
285
-
286
- // Timestamp (without tz) -> datetime
287
- const publishedCol = postsTable.columns.find(
288
- (c) => c.name === 'published_at',
289
- );
290
- expect(publishedCol!.type).toBe('datetime');
291
- });
292
-
293
- it('should detect nullable columns', async () => {
294
- const tableInfo = await introspectTable(db, 'studio_introspect_posts');
295
-
296
- const titleCol = tableInfo.columns.find((c) => c.name === 'title');
297
- const contentCol = tableInfo.columns.find((c) => c.name === 'content');
298
- const publishedAtCol = tableInfo.columns.find(
299
- (c) => c.name === 'published_at',
300
- );
301
-
302
- expect(titleCol!.nullable).toBe(false);
303
- expect(contentCol!.nullable).toBe(true);
304
- expect(publishedAtCol!.nullable).toBe(true);
305
- });
306
-
307
- it('should provide estimated row count when data exists', async () => {
308
- // Insert some data
309
- const user = await db
310
- .insertInto('studioIntrospectUsers')
311
- .values({
312
- name: 'Test User',
313
- email: 'test@example.com',
314
- })
315
- .returningAll()
316
- .executeTakeFirstOrThrow();
317
-
318
- await db
319
- .insertInto('studioIntrospectPosts')
320
- .values([
321
- { userId: user.id, title: 'Post 1' },
322
- { userId: user.id, title: 'Post 2' },
323
- { userId: user.id, title: 'Post 3' },
324
- ])
325
- .execute();
326
-
327
- // ANALYZE to update statistics
328
- await sql`ANALYZE studio_introspect_posts`.execute(db);
329
-
330
- const tableInfo = await introspectTable(db, 'studio_introspect_posts');
331
-
332
- // Row count estimate should exist (might not be exact)
333
- expect(tableInfo.estimatedRowCount).toBeDefined();
334
- expect(tableInfo.estimatedRowCount).toBeGreaterThan(0);
335
- });
336
-
337
- it('should return all columns in ordinal position order', async () => {
338
- const tableInfo = await introspectTable(db, 'studio_introspect_users');
339
-
340
- const columnNames = tableInfo.columns.map((c) => c.name);
341
- expect(columnNames).toEqual([
342
- 'id',
343
- 'name',
344
- 'email',
345
- 'is_active',
346
- 'metadata',
347
- 'created_at',
348
- 'updated_at',
349
- ]);
350
- });
351
- });
43
+ let db: Kysely<TestDatabase>;
44
+
45
+ beforeAll(async () => {
46
+ db = new Kysely<TestDatabase>({
47
+ dialect: new PostgresDialect({
48
+ pool: new pg.Pool({
49
+ ...TEST_DATABASE_CONFIG,
50
+ database: 'postgres',
51
+ }),
52
+ }),
53
+ plugins: [new CamelCasePlugin()],
54
+ });
55
+
56
+ // Create users table with various column types
57
+ await db.schema
58
+ .createTable('studio_introspect_users')
59
+ .ifNotExists()
60
+ .addColumn('id', 'serial', (col) => col.primaryKey())
61
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
62
+ .addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
63
+ .addColumn('is_active', 'boolean', (col) => col.notNull().defaultTo(true))
64
+ .addColumn('metadata', 'jsonb')
65
+ .addColumn('created_at', 'timestamptz', (col) =>
66
+ col.defaultTo(sql`now()`).notNull(),
67
+ )
68
+ .addColumn('updated_at', 'timestamptz', (col) =>
69
+ col.defaultTo(sql`now()`).notNull(),
70
+ )
71
+ .execute();
72
+
73
+ // Create posts table with foreign key
74
+ await db.schema
75
+ .createTable('studio_introspect_posts')
76
+ .ifNotExists()
77
+ .addColumn('id', 'uuid', (col) =>
78
+ col.primaryKey().defaultTo(sql`gen_random_uuid()`),
79
+ )
80
+ .addColumn('user_id', 'integer', (col) =>
81
+ col
82
+ .notNull()
83
+ .references('studio_introspect_users.id')
84
+ .onDelete('cascade'),
85
+ )
86
+ .addColumn('title', 'varchar(500)', (col) => col.notNull())
87
+ .addColumn('content', 'text')
88
+ .addColumn('view_count', 'integer', (col) => col.notNull().defaultTo(0))
89
+ .addColumn('published_at', 'timestamp')
90
+ .addColumn('created_at', 'timestamptz', (col) =>
91
+ col.defaultTo(sql`now()`).notNull(),
92
+ )
93
+ .execute();
94
+
95
+ // Create simple tags table
96
+ await db.schema
97
+ .createTable('studio_introspect_tags')
98
+ .ifNotExists()
99
+ .addColumn('id', 'serial', (col) => col.primaryKey())
100
+ .addColumn('name', 'varchar(100)', (col) => col.notNull())
101
+ .execute();
102
+
103
+ // Create excluded table (for exclusion testing)
104
+ await db.schema
105
+ .createTable('studio_introspect_excluded')
106
+ .ifNotExists()
107
+ .addColumn('id', 'serial', (col) => col.primaryKey())
108
+ .addColumn('value', 'varchar(100)', (col) => col.notNull())
109
+ .execute();
110
+ });
111
+
112
+ afterEach(async () => {
113
+ // Clean up data after each test
114
+ await db.deleteFrom('studioIntrospectPosts').execute();
115
+ await db.deleteFrom('studioIntrospectUsers').execute();
116
+ await db.deleteFrom('studioIntrospectTags').execute();
117
+ await db.deleteFrom('studioIntrospectExcluded').execute();
118
+ });
119
+
120
+ afterAll(async () => {
121
+ // Drop tables and close connection
122
+ await db.schema.dropTable('studio_introspect_posts').ifExists().execute();
123
+ await db.schema.dropTable('studio_introspect_users').ifExists().execute();
124
+ await db.schema.dropTable('studio_introspect_tags').ifExists().execute();
125
+ await db.schema
126
+ .dropTable('studio_introspect_excluded')
127
+ .ifExists()
128
+ .execute();
129
+ await db.destroy();
130
+ });
131
+
132
+ describe('introspectSchema', () => {
133
+ it('should discover all tables in the public schema', async () => {
134
+ const schema = await introspectSchema(db, []);
135
+
136
+ // Find our test tables
137
+ const tableNames = schema.tables.map((t) => t.name);
138
+ expect(tableNames).toContain('studio_introspect_users');
139
+ expect(tableNames).toContain('studio_introspect_posts');
140
+ expect(tableNames).toContain('studio_introspect_tags');
141
+ expect(tableNames).toContain('studio_introspect_excluded');
142
+ expect(schema.updatedAt).toBeInstanceOf(Date);
143
+ });
144
+
145
+ it('should exclude specified tables', async () => {
146
+ const schema = await introspectSchema(db, ['studio_introspect_excluded']);
147
+
148
+ const tableNames = schema.tables.map((t) => t.name);
149
+ expect(tableNames).toContain('studio_introspect_users');
150
+ expect(tableNames).toContain('studio_introspect_posts');
151
+ expect(tableNames).not.toContain('studio_introspect_excluded');
152
+ });
153
+
154
+ it('should exclude multiple tables', async () => {
155
+ const schema = await introspectSchema(db, [
156
+ 'studio_introspect_excluded',
157
+ 'studio_introspect_tags',
158
+ ]);
159
+
160
+ const tableNames = schema.tables.map((t) => t.name);
161
+ expect(tableNames).toContain('studio_introspect_users');
162
+ expect(tableNames).toContain('studio_introspect_posts');
163
+ expect(tableNames).not.toContain('studio_introspect_excluded');
164
+ expect(tableNames).not.toContain('studio_introspect_tags');
165
+ });
166
+ });
167
+
168
+ describe('introspectTable', () => {
169
+ it('should return column information for users table', async () => {
170
+ const tableInfo = await introspectTable(db, 'studio_introspect_users');
171
+
172
+ expect(tableInfo.name).toBe('studio_introspect_users');
173
+ expect(tableInfo.schema).toBe('public');
174
+ expect(tableInfo.columns.length).toBe(7);
175
+
176
+ // Find specific columns
177
+ const idCol = tableInfo.columns.find((c) => c.name === 'id');
178
+ const nameCol = tableInfo.columns.find((c) => c.name === 'name');
179
+ const emailCol = tableInfo.columns.find((c) => c.name === 'email');
180
+ const isActiveCol = tableInfo.columns.find((c) => c.name === 'is_active');
181
+ const metadataCol = tableInfo.columns.find((c) => c.name === 'metadata');
182
+ const createdAtCol = tableInfo.columns.find(
183
+ (c) => c.name === 'created_at',
184
+ );
185
+
186
+ // Check id column (primary key, serial)
187
+ expect(idCol).toBeDefined();
188
+ expect(idCol?.type).toBe('number');
189
+ expect(idCol?.rawType).toBe('int4');
190
+ expect(idCol?.isPrimaryKey).toBe(true);
191
+ expect(idCol?.nullable).toBe(false);
192
+
193
+ // Check name column (varchar, not null)
194
+ expect(nameCol).toBeDefined();
195
+ expect(nameCol?.type).toBe('string');
196
+ expect(nameCol?.rawType).toBe('varchar');
197
+ expect(nameCol?.nullable).toBe(false);
198
+
199
+ // Check email column (varchar, unique)
200
+ expect(emailCol).toBeDefined();
201
+ expect(emailCol?.type).toBe('string');
202
+ expect(emailCol?.nullable).toBe(false);
203
+
204
+ // Check is_active column (boolean with default)
205
+ expect(isActiveCol).toBeDefined();
206
+ expect(isActiveCol?.type).toBe('boolean');
207
+ expect(isActiveCol?.rawType).toBe('bool');
208
+ expect(isActiveCol?.nullable).toBe(false);
209
+
210
+ // Check metadata column (jsonb, nullable)
211
+ expect(metadataCol).toBeDefined();
212
+ expect(metadataCol?.type).toBe('json');
213
+ expect(metadataCol?.rawType).toBe('jsonb');
214
+ expect(metadataCol?.nullable).toBe(true);
215
+
216
+ // Check created_at column (timestamptz)
217
+ expect(createdAtCol).toBeDefined();
218
+ expect(createdAtCol?.type).toBe('datetime');
219
+ expect(createdAtCol?.rawType).toBe('timestamptz');
220
+ });
221
+
222
+ it('should detect primary key', async () => {
223
+ const tableInfo = await introspectTable(db, 'studio_introspect_users');
224
+
225
+ expect(tableInfo.primaryKey).toEqual(['id']);
226
+
227
+ const idColumn = tableInfo.columns.find((c) => c.name === 'id');
228
+ expect(idColumn?.isPrimaryKey).toBe(true);
229
+ });
230
+
231
+ it('should detect foreign keys', async () => {
232
+ const tableInfo = await introspectTable(db, 'studio_introspect_posts');
233
+
234
+ const userIdCol = tableInfo.columns.find((c) => c.name === 'user_id');
235
+ expect(userIdCol).toBeDefined();
236
+ expect(userIdCol?.isForeignKey).toBe(true);
237
+ expect(userIdCol?.foreignKeyTable).toBe('studio_introspect_users');
238
+ expect(userIdCol?.foreignKeyColumn).toBe('id');
239
+ });
240
+
241
+ it('should detect uuid primary key', async () => {
242
+ const tableInfo = await introspectTable(db, 'studio_introspect_posts');
243
+
244
+ expect(tableInfo.primaryKey).toEqual(['id']);
245
+
246
+ const idColumn = tableInfo.columns.find((c) => c.name === 'id');
247
+ expect(idColumn).toBeDefined();
248
+ expect(idColumn?.type).toBe('uuid');
249
+ expect(idColumn?.rawType).toBe('uuid');
250
+ expect(idColumn?.isPrimaryKey).toBe(true);
251
+ });
252
+
253
+ it('should map PostgreSQL types correctly', async () => {
254
+ const usersTable = await introspectTable(db, 'studio_introspect_users');
255
+ const postsTable = await introspectTable(db, 'studio_introspect_posts');
256
+
257
+ // Integer types -> number
258
+ const userIdCol = usersTable.columns.find((c) => c.name === 'id');
259
+ expect(userIdCol?.type).toBe('number');
260
+
261
+ // Boolean -> boolean
262
+ const isActiveCol = usersTable.columns.find(
263
+ (c) => c.name === 'is_active',
264
+ );
265
+ expect(isActiveCol?.type).toBe('boolean');
266
+
267
+ // JSONB -> json
268
+ const metadataCol = usersTable.columns.find((c) => c.name === 'metadata');
269
+ expect(metadataCol?.type).toBe('json');
270
+
271
+ // Timestamptz -> datetime
272
+ const createdAtCol = usersTable.columns.find(
273
+ (c) => c.name === 'created_at',
274
+ );
275
+ expect(createdAtCol?.type).toBe('datetime');
276
+
277
+ // UUID -> uuid
278
+ const postIdCol = postsTable.columns.find((c) => c.name === 'id');
279
+ expect(postIdCol?.type).toBe('uuid');
280
+
281
+ // Text -> string
282
+ const contentCol = postsTable.columns.find((c) => c.name === 'content');
283
+ expect(contentCol?.type).toBe('string');
284
+ expect(contentCol?.rawType).toBe('text');
285
+
286
+ // Timestamp (without tz) -> datetime
287
+ const publishedCol = postsTable.columns.find(
288
+ (c) => c.name === 'published_at',
289
+ );
290
+ expect(publishedCol?.type).toBe('datetime');
291
+ });
292
+
293
+ it('should detect nullable columns', async () => {
294
+ const tableInfo = await introspectTable(db, 'studio_introspect_posts');
295
+
296
+ const titleCol = tableInfo.columns.find((c) => c.name === 'title');
297
+ const contentCol = tableInfo.columns.find((c) => c.name === 'content');
298
+ const publishedAtCol = tableInfo.columns.find(
299
+ (c) => c.name === 'published_at',
300
+ );
301
+
302
+ expect(titleCol?.nullable).toBe(false);
303
+ expect(contentCol?.nullable).toBe(true);
304
+ expect(publishedAtCol?.nullable).toBe(true);
305
+ });
306
+
307
+ it('should provide estimated row count when data exists', async () => {
308
+ // Insert some data
309
+ const user = await db
310
+ .insertInto('studioIntrospectUsers')
311
+ .values({
312
+ name: 'Test User',
313
+ email: 'test@example.com',
314
+ })
315
+ .returningAll()
316
+ .executeTakeFirstOrThrow();
317
+
318
+ await db
319
+ .insertInto('studioIntrospectPosts')
320
+ .values([
321
+ { userId: user.id, title: 'Post 1' },
322
+ { userId: user.id, title: 'Post 2' },
323
+ { userId: user.id, title: 'Post 3' },
324
+ ])
325
+ .execute();
326
+
327
+ // ANALYZE to update statistics
328
+ await sql`ANALYZE studio_introspect_posts`.execute(db);
329
+
330
+ const tableInfo = await introspectTable(db, 'studio_introspect_posts');
331
+
332
+ // Row count estimate should exist (might not be exact)
333
+ expect(tableInfo.estimatedRowCount).toBeDefined();
334
+ expect(tableInfo.estimatedRowCount).toBeGreaterThan(0);
335
+ });
336
+
337
+ it('should return all columns in ordinal position order', async () => {
338
+ const tableInfo = await introspectTable(db, 'studio_introspect_users');
339
+
340
+ const columnNames = tableInfo.columns.map((c) => c.name);
341
+ expect(columnNames).toEqual([
342
+ 'id',
343
+ 'name',
344
+ 'email',
345
+ 'is_active',
346
+ 'metadata',
347
+ 'created_at',
348
+ 'updated_at',
349
+ ]);
350
+ });
351
+ });
352
352
  });