@geekmidas/studio 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 (64) hide show
  1. package/dist/DataBrowser-DQ3-ZxdV.mjs +427 -0
  2. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +1 -0
  3. package/dist/DataBrowser-SOcqmZb2.d.mts +267 -0
  4. package/dist/DataBrowser-c-Gs6PZB.cjs +432 -0
  5. package/dist/DataBrowser-c-Gs6PZB.cjs.map +1 -0
  6. package/dist/DataBrowser-hGwiTffZ.d.cts +267 -0
  7. package/dist/chunk-CUT6urMc.cjs +30 -0
  8. package/dist/data/index.cjs +4 -0
  9. package/dist/data/index.d.cts +2 -0
  10. package/dist/data/index.d.mts +2 -0
  11. package/dist/data/index.mjs +4 -0
  12. package/dist/index.cjs +239 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +132 -0
  15. package/dist/index.d.mts +132 -0
  16. package/dist/index.mjs +230 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/server/hono.cjs +192 -0
  19. package/dist/server/hono.cjs.map +1 -0
  20. package/dist/server/hono.d.cts +19 -0
  21. package/dist/server/hono.d.mts +19 -0
  22. package/dist/server/hono.mjs +191 -0
  23. package/dist/server/hono.mjs.map +1 -0
  24. package/dist/types-BZv87Ikv.mjs +31 -0
  25. package/dist/types-BZv87Ikv.mjs.map +1 -0
  26. package/dist/types-CMttUZYk.cjs +43 -0
  27. package/dist/types-CMttUZYk.cjs.map +1 -0
  28. package/package.json +54 -0
  29. package/src/Studio.ts +318 -0
  30. package/src/data/DataBrowser.ts +166 -0
  31. package/src/data/__tests__/DataBrowser.integration.spec.ts +418 -0
  32. package/src/data/__tests__/filtering.integration.spec.ts +741 -0
  33. package/src/data/__tests__/introspection.integration.spec.ts +352 -0
  34. package/src/data/filtering.ts +191 -0
  35. package/src/data/index.ts +1 -0
  36. package/src/data/introspection.ts +220 -0
  37. package/src/data/pagination.ts +33 -0
  38. package/src/index.ts +31 -0
  39. package/src/server/__tests__/hono.integration.spec.ts +361 -0
  40. package/src/server/hono.ts +225 -0
  41. package/src/types.ts +278 -0
  42. package/src/ui-assets.ts +40 -0
  43. package/tsdown.config.ts +13 -0
  44. package/ui/index.html +12 -0
  45. package/ui/node_modules/.bin/browserslist +21 -0
  46. package/ui/node_modules/.bin/jiti +21 -0
  47. package/ui/node_modules/.bin/terser +21 -0
  48. package/ui/node_modules/.bin/tsc +21 -0
  49. package/ui/node_modules/.bin/tsserver +21 -0
  50. package/ui/node_modules/.bin/tsx +21 -0
  51. package/ui/node_modules/.bin/vite +21 -0
  52. package/ui/package.json +24 -0
  53. package/ui/src/App.tsx +141 -0
  54. package/ui/src/api.ts +71 -0
  55. package/ui/src/components/RowDetail.tsx +113 -0
  56. package/ui/src/components/TableList.tsx +51 -0
  57. package/ui/src/components/TableView.tsx +219 -0
  58. package/ui/src/main.tsx +10 -0
  59. package/ui/src/styles.css +36 -0
  60. package/ui/src/types.ts +50 -0
  61. package/ui/src/vite-env.d.ts +1 -0
  62. package/ui/tsconfig.json +21 -0
  63. package/ui/tsconfig.tsbuildinfo +1 -0
  64. package/ui/vite.config.ts +12 -0
@@ -0,0 +1,220 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { ColumnInfo, ColumnType, SchemaInfo, TableInfo } from '../types';
3
+
4
+ /**
5
+ * Introspects the database schema to discover tables and columns.
6
+ * Uses PostgreSQL information_schema for metadata.
7
+ */
8
+ export async function introspectSchema<DB>(
9
+ db: Kysely<DB>,
10
+ excludeTables: string[],
11
+ ): Promise<SchemaInfo> {
12
+ // Query tables from information_schema
13
+ const excludePlaceholders =
14
+ excludeTables.length > 0
15
+ ? excludeTables.map((_, i) => `$${i + 1}`).join(', ')
16
+ : "''";
17
+
18
+ const tablesQuery = `
19
+ SELECT
20
+ table_name,
21
+ table_schema
22
+ FROM information_schema.tables
23
+ WHERE table_schema = 'public'
24
+ AND table_type = 'BASE TABLE'
25
+ ${excludeTables.length > 0 ? `AND table_name NOT IN (${excludePlaceholders})` : ''}
26
+ ORDER BY table_name
27
+ `;
28
+
29
+ const tablesResult = await db.executeQuery({
30
+ sql: tablesQuery,
31
+ parameters: excludeTables,
32
+ } as any);
33
+
34
+ const tables: TableInfo[] = [];
35
+
36
+ for (const row of tablesResult.rows as any[]) {
37
+ // Support both snake_case (raw) and camelCase (with CamelCasePlugin)
38
+ const tableName = row.table_name ?? row.tableName;
39
+ const tableSchema = row.table_schema ?? row.tableSchema;
40
+ const tableInfo = await introspectTable(db, tableName, tableSchema);
41
+ tables.push(tableInfo);
42
+ }
43
+
44
+ return {
45
+ tables,
46
+ updatedAt: new Date(),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Introspects a single table to get column information.
52
+ */
53
+ export async function introspectTable<DB>(
54
+ db: Kysely<DB>,
55
+ tableName: string,
56
+ schema = 'public',
57
+ ): Promise<TableInfo> {
58
+ // Query columns
59
+ const columnsQuery = `
60
+ SELECT
61
+ c.column_name,
62
+ c.data_type,
63
+ c.udt_name,
64
+ c.is_nullable,
65
+ c.column_default,
66
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key
67
+ FROM information_schema.columns c
68
+ LEFT JOIN (
69
+ SELECT ku.column_name
70
+ FROM information_schema.table_constraints tc
71
+ JOIN information_schema.key_column_usage ku
72
+ ON tc.constraint_name = ku.constraint_name
73
+ AND tc.table_schema = ku.table_schema
74
+ WHERE tc.table_name = $1
75
+ AND tc.table_schema = $2
76
+ AND tc.constraint_type = 'PRIMARY KEY'
77
+ ) pk ON c.column_name = pk.column_name
78
+ WHERE c.table_name = $1
79
+ AND c.table_schema = $2
80
+ ORDER BY c.ordinal_position
81
+ `;
82
+
83
+ const columnsResult = await db.executeQuery({
84
+ sql: columnsQuery,
85
+ parameters: [tableName, schema],
86
+ } as any);
87
+
88
+ // Query foreign keys
89
+ const fkQuery = `
90
+ SELECT
91
+ kcu.column_name,
92
+ ccu.table_name AS foreign_table,
93
+ ccu.column_name AS foreign_column
94
+ FROM information_schema.table_constraints tc
95
+ JOIN information_schema.key_column_usage kcu
96
+ ON tc.constraint_name = kcu.constraint_name
97
+ AND tc.table_schema = kcu.table_schema
98
+ JOIN information_schema.constraint_column_usage ccu
99
+ ON tc.constraint_name = ccu.constraint_name
100
+ AND tc.table_schema = ccu.table_schema
101
+ WHERE tc.table_name = $1
102
+ AND tc.table_schema = $2
103
+ AND tc.constraint_type = 'FOREIGN KEY'
104
+ `;
105
+
106
+ const fkResult = await db.executeQuery({
107
+ sql: fkQuery,
108
+ parameters: [tableName, schema],
109
+ } as any);
110
+
111
+ const foreignKeys = new Map<string, { table: string; column: string }>();
112
+ for (const row of fkResult.rows as any[]) {
113
+ // Support both snake_case (raw) and camelCase (with CamelCasePlugin)
114
+ const colName = row.column_name ?? row.columnName;
115
+ foreignKeys.set(colName, {
116
+ table: row.foreign_table ?? row.foreignTable,
117
+ column: row.foreign_column ?? row.foreignColumn,
118
+ });
119
+ }
120
+
121
+ const columns: ColumnInfo[] = (columnsResult.rows as any[]).map((row) => {
122
+ // Support both snake_case (raw) and camelCase (with CamelCasePlugin)
123
+ const colName = row.column_name ?? row.columnName;
124
+ const udtName = row.udt_name ?? row.udtName;
125
+ const isNullable = row.is_nullable ?? row.isNullable;
126
+ const isPrimaryKey = row.is_primary_key ?? row.isPrimaryKey;
127
+ const columnDefault = row.column_default ?? row.columnDefault;
128
+
129
+ const fk = foreignKeys.get(colName);
130
+ return {
131
+ name: colName,
132
+ type: mapPostgresType(udtName),
133
+ rawType: udtName,
134
+ nullable: isNullable === 'YES',
135
+ isPrimaryKey: isPrimaryKey,
136
+ isForeignKey: !!fk,
137
+ foreignKeyTable: fk?.table,
138
+ foreignKeyColumn: fk?.column,
139
+ defaultValue: columnDefault ?? undefined,
140
+ };
141
+ });
142
+
143
+ const primaryKey = columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
144
+
145
+ // Get estimated row count
146
+ const countQuery = `
147
+ SELECT reltuples::bigint AS estimate
148
+ FROM pg_class
149
+ WHERE relname = $1
150
+ `;
151
+
152
+ let estimatedRowCount: number | undefined;
153
+ try {
154
+ const countResult = await db.executeQuery({
155
+ sql: countQuery,
156
+ parameters: [tableName],
157
+ } as any);
158
+ if (countResult.rows.length > 0) {
159
+ const estimate = (countResult.rows[0] as any).estimate;
160
+ estimatedRowCount = estimate > 0 ? Number(estimate) : undefined;
161
+ }
162
+ } catch {
163
+ // Ignore errors, row count is optional
164
+ }
165
+
166
+ return {
167
+ name: tableName,
168
+ schema,
169
+ columns,
170
+ primaryKey,
171
+ estimatedRowCount,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Maps PostgreSQL types to generic column types.
177
+ */
178
+ function mapPostgresType(udtName: string): ColumnType {
179
+ const typeMap: Record<string, ColumnType> = {
180
+ // Strings
181
+ varchar: 'string',
182
+ char: 'string',
183
+ text: 'string',
184
+ name: 'string',
185
+ bpchar: 'string',
186
+
187
+ // Numbers
188
+ int2: 'number',
189
+ int4: 'number',
190
+ int8: 'number',
191
+ float4: 'number',
192
+ float8: 'number',
193
+ numeric: 'number',
194
+ money: 'number',
195
+ serial: 'number',
196
+ bigserial: 'number',
197
+
198
+ // Boolean
199
+ bool: 'boolean',
200
+
201
+ // Dates
202
+ date: 'date',
203
+ timestamp: 'datetime',
204
+ timestamptz: 'datetime',
205
+ time: 'datetime',
206
+ timetz: 'datetime',
207
+
208
+ // JSON
209
+ json: 'json',
210
+ jsonb: 'json',
211
+
212
+ // Binary
213
+ bytea: 'binary',
214
+
215
+ // UUID
216
+ uuid: 'uuid',
217
+ };
218
+
219
+ return typeMap[udtName] ?? 'unknown';
220
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cursor encoding/decoding utilities for pagination.
3
+ */
4
+
5
+ /**
6
+ * Encode a cursor value for safe URL transmission.
7
+ * Supports various types: string, number, Date, etc.
8
+ */
9
+ export function encodeCursor(value: unknown): string {
10
+ const payload = {
11
+ v: value instanceof Date ? value.toISOString() : value,
12
+ t: value instanceof Date ? 'date' : typeof value,
13
+ };
14
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
15
+ }
16
+
17
+ /**
18
+ * Decode a cursor string back to its original value.
19
+ */
20
+ export function decodeCursor(cursor: string): unknown {
21
+ try {
22
+ const json = Buffer.from(cursor, 'base64url').toString('utf-8');
23
+ const payload = JSON.parse(json);
24
+
25
+ if (payload.t === 'date') {
26
+ return new Date(payload.v);
27
+ }
28
+
29
+ return payload.v;
30
+ } catch {
31
+ throw new Error('Invalid cursor format');
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Core
2
+ export { Studio } from './Studio';
3
+
4
+ // Types
5
+ export {
6
+ Direction,
7
+ FilterOperator,
8
+ type CursorConfig,
9
+ type TableCursorConfig,
10
+ type MonitoringOptions,
11
+ type DataBrowserOptions,
12
+ type StudioOptions,
13
+ type NormalizedStudioOptions,
14
+ type ColumnType,
15
+ type ColumnInfo,
16
+ type TableInfo,
17
+ type SchemaInfo,
18
+ type FilterCondition,
19
+ type SortConfig,
20
+ type QueryOptions,
21
+ type QueryResult,
22
+ type StudioEventType,
23
+ type StudioEvent,
24
+ } from './types';
25
+
26
+ // Re-export Telescope storage types for convenience
27
+ // Users should import storage from @geekmidas/studio, not @geekmidas/telescope
28
+ export type { TelescopeStorage as MonitoringStorage } from '@geekmidas/telescope';
29
+
30
+ // Re-export InMemoryStorage as InMemoryMonitoringStorage
31
+ export { InMemoryStorage as InMemoryMonitoringStorage } from '@geekmidas/telescope/storage/memory';
@@ -0,0 +1,361 @@
1
+ import {
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
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
+ } from 'vitest';
18
+ import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
19
+ import { DataBrowser } from '../../data/DataBrowser';
20
+ import { Direction } from '../../types';
21
+ import { createStudioApp } from '../hono';
22
+
23
+ interface TestDatabase {
24
+ studioHonoProducts: {
25
+ id: Generated<number>;
26
+ name: string;
27
+ price: number;
28
+ category: string;
29
+ inStock: boolean;
30
+ createdAt: Generated<Date>;
31
+ };
32
+ }
33
+
34
+ // Minimal Studio-like object for testing the Hono adapter
35
+ interface MockStudio {
36
+ data: DataBrowser<TestDatabase>;
37
+ }
38
+
39
+ describe('Hono Server Adapter Integration Tests', () => {
40
+ let db: Kysely<TestDatabase>;
41
+ let mockStudio: MockStudio;
42
+ let app: ReturnType<typeof createStudioApp>;
43
+
44
+ beforeAll(async () => {
45
+ db = new Kysely<TestDatabase>({
46
+ dialect: new PostgresDialect({
47
+ pool: new pg.Pool({
48
+ ...TEST_DATABASE_CONFIG,
49
+ database: 'postgres',
50
+ }),
51
+ }),
52
+ plugins: [new CamelCasePlugin()],
53
+ });
54
+
55
+ // Create test table
56
+ await db.schema
57
+ .createTable('studio_hono_products')
58
+ .ifNotExists()
59
+ .addColumn('id', 'serial', (col) => col.primaryKey())
60
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
61
+ .addColumn('price', 'numeric(10, 2)', (col) => col.notNull())
62
+ .addColumn('category', 'varchar(100)', (col) => col.notNull())
63
+ .addColumn('in_stock', 'boolean', (col) => col.notNull().defaultTo(true))
64
+ .addColumn('created_at', 'timestamptz', (col) =>
65
+ col.defaultTo(sql`now()`).notNull(),
66
+ )
67
+ .execute();
68
+ });
69
+
70
+ beforeEach(async () => {
71
+ // Create DataBrowser directly for testing
72
+ const dataBrowser = new DataBrowser({
73
+ db,
74
+ cursor: { field: 'id', direction: Direction.Asc },
75
+ tableCursors: {},
76
+ excludeTables: [],
77
+ defaultPageSize: 50,
78
+ showBinaryColumns: false,
79
+ });
80
+
81
+ mockStudio = { data: dataBrowser };
82
+ app = createStudioApp(mockStudio as any);
83
+
84
+ // Insert test data
85
+ await db
86
+ .insertInto('studioHonoProducts')
87
+ .values([
88
+ {
89
+ name: 'Laptop',
90
+ price: 999.99,
91
+ category: 'electronics',
92
+ inStock: true,
93
+ },
94
+ {
95
+ name: 'Mouse',
96
+ price: 29.99,
97
+ category: 'electronics',
98
+ inStock: true,
99
+ },
100
+ {
101
+ name: 'Keyboard',
102
+ price: 79.99,
103
+ category: 'electronics',
104
+ inStock: false,
105
+ },
106
+ { name: 'Desk', price: 299.99, category: 'furniture', inStock: true },
107
+ { name: 'Chair', price: 199.99, category: 'furniture', inStock: true },
108
+ ])
109
+ .execute();
110
+ });
111
+
112
+ afterEach(async () => {
113
+ await db.deleteFrom('studioHonoProducts').execute();
114
+ });
115
+
116
+ afterAll(async () => {
117
+ await db.schema.dropTable('studio_hono_products').ifExists().execute();
118
+ await db.destroy();
119
+ });
120
+
121
+ describe('GET /api/schema', () => {
122
+ it('should return database schema', async () => {
123
+ const res = await app.request('/api/schema');
124
+
125
+ expect(res.status).toBe(200);
126
+ const data = await res.json();
127
+ expect(data.tables).toBeDefined();
128
+ expect(Array.isArray(data.tables)).toBe(true);
129
+ expect(data.updatedAt).toBeDefined();
130
+ });
131
+
132
+ it('should force refresh when requested', async () => {
133
+ const res1 = await app.request('/api/schema');
134
+ const data1 = await res1.json();
135
+
136
+ const res2 = await app.request('/api/schema?refresh=true');
137
+ const data2 = await res2.json();
138
+
139
+ // Both should have tables
140
+ expect(data1.tables).toBeDefined();
141
+ expect(data2.tables).toBeDefined();
142
+ });
143
+ });
144
+
145
+ describe('GET /api/tables', () => {
146
+ it('should return list of tables', async () => {
147
+ const res = await app.request('/api/tables');
148
+
149
+ expect(res.status).toBe(200);
150
+ const data = await res.json();
151
+ expect(data.tables).toBeDefined();
152
+
153
+ const productTable = data.tables.find(
154
+ (t: any) => t.name === 'studio_hono_products',
155
+ );
156
+ expect(productTable).toBeDefined();
157
+ expect(productTable.columnCount).toBeGreaterThan(0);
158
+ expect(productTable.primaryKey).toEqual(['id']);
159
+ });
160
+ });
161
+
162
+ describe('GET /api/tables/:name', () => {
163
+ it('should return table info for existing table', async () => {
164
+ const res = await app.request('/api/tables/studio_hono_products');
165
+
166
+ expect(res.status).toBe(200);
167
+ const data = await res.json();
168
+ expect(data.name).toBe('studio_hono_products');
169
+ expect(data.columns).toBeDefined();
170
+ expect(data.columns.length).toBeGreaterThan(0);
171
+ });
172
+
173
+ it('should return 404 for non-existent table', async () => {
174
+ const res = await app.request('/api/tables/non_existent_table');
175
+
176
+ expect(res.status).toBe(404);
177
+ const data = await res.json();
178
+ expect(data.error).toContain('not found');
179
+ });
180
+ });
181
+
182
+ describe('GET /api/tables/:name/rows', () => {
183
+ it('should return paginated rows', async () => {
184
+ const res = await app.request('/api/tables/studio_hono_products/rows');
185
+
186
+ expect(res.status).toBe(200);
187
+ const data = await res.json();
188
+ expect(data.rows).toBeDefined();
189
+ expect(data.rows.length).toBe(5);
190
+ expect(data.hasMore).toBe(false);
191
+ });
192
+
193
+ it('should respect pageSize parameter', async () => {
194
+ const res = await app.request(
195
+ '/api/tables/studio_hono_products/rows?pageSize=2',
196
+ );
197
+
198
+ expect(res.status).toBe(200);
199
+ const data = await res.json();
200
+ expect(data.rows.length).toBe(2);
201
+ expect(data.hasMore).toBe(true);
202
+ expect(data.nextCursor).toBeDefined();
203
+ });
204
+
205
+ it('should paginate using cursor', async () => {
206
+ const res1 = await app.request(
207
+ '/api/tables/studio_hono_products/rows?pageSize=2',
208
+ );
209
+ const data1 = await res1.json();
210
+
211
+ const res2 = await app.request(
212
+ `/api/tables/studio_hono_products/rows?pageSize=2&cursor=${data1.nextCursor}`,
213
+ );
214
+ const data2 = await res2.json();
215
+
216
+ expect(data2.rows.length).toBe(2);
217
+
218
+ // Rows should be different
219
+ const ids1 = data1.rows.map((r: any) => r.id);
220
+ const ids2 = data2.rows.map((r: any) => r.id);
221
+ expect(ids1.every((id: number) => !ids2.includes(id))).toBe(true);
222
+ });
223
+
224
+ it('should apply equality filter', async () => {
225
+ const res = await app.request(
226
+ '/api/tables/studio_hono_products/rows?filter[category][eq]=electronics',
227
+ );
228
+
229
+ expect(res.status).toBe(200);
230
+ const data = await res.json();
231
+ expect(data.rows.length).toBe(3);
232
+ data.rows.forEach((row: any) => {
233
+ expect(row.category).toBe('electronics');
234
+ });
235
+ });
236
+
237
+ it('should apply greater-than filter', async () => {
238
+ const res = await app.request(
239
+ '/api/tables/studio_hono_products/rows?filter[price][gt]=100',
240
+ );
241
+
242
+ expect(res.status).toBe(200);
243
+ const data = await res.json();
244
+ expect(data.rows.length).toBe(3);
245
+ data.rows.forEach((row: any) => {
246
+ expect(Number(row.price)).toBeGreaterThan(100);
247
+ });
248
+ });
249
+
250
+ it('should apply boolean filter', async () => {
251
+ const res = await app.request(
252
+ '/api/tables/studio_hono_products/rows?filter[in_stock][eq]=false',
253
+ );
254
+
255
+ expect(res.status).toBe(200);
256
+ const data = await res.json();
257
+ expect(data.rows.length).toBe(1);
258
+ expect(data.rows[0].name).toBe('Keyboard');
259
+ });
260
+
261
+ it('should apply IN filter', async () => {
262
+ const res = await app.request(
263
+ '/api/tables/studio_hono_products/rows?filter[name][in]=Laptop,Mouse',
264
+ );
265
+
266
+ expect(res.status).toBe(200);
267
+ const data = await res.json();
268
+ expect(data.rows.length).toBe(2);
269
+ });
270
+
271
+ it('should apply multiple filters', async () => {
272
+ const res = await app.request(
273
+ '/api/tables/studio_hono_products/rows?filter[category][eq]=electronics&filter[in_stock][eq]=true',
274
+ );
275
+
276
+ expect(res.status).toBe(200);
277
+ const data = await res.json();
278
+ expect(data.rows.length).toBe(2);
279
+ data.rows.forEach((row: any) => {
280
+ expect(row.category).toBe('electronics');
281
+ expect(row.inStock).toBe(true);
282
+ });
283
+ });
284
+
285
+ it('should apply sorting', async () => {
286
+ const res = await app.request(
287
+ '/api/tables/studio_hono_products/rows?sort=price:desc',
288
+ );
289
+
290
+ expect(res.status).toBe(200);
291
+ const data = await res.json();
292
+ const prices = data.rows.map((r: any) => Number(r.price));
293
+
294
+ for (let i = 1; i < prices.length; i++) {
295
+ expect(prices[i]).toBeLessThanOrEqual(prices[i - 1]);
296
+ }
297
+ });
298
+
299
+ it('should apply multiple sort columns', async () => {
300
+ const res = await app.request(
301
+ '/api/tables/studio_hono_products/rows?sort=category:asc,price:desc',
302
+ );
303
+
304
+ expect(res.status).toBe(200);
305
+ const data = await res.json();
306
+ expect(data.rows.length).toBe(5);
307
+
308
+ // Electronics should come first (alphabetically)
309
+ const electronics = data.rows.filter(
310
+ (r: any) => r.category === 'electronics',
311
+ );
312
+ expect(electronics.length).toBe(3);
313
+
314
+ // Within electronics, prices should be descending
315
+ const electronicPrices = electronics.map((r: any) => Number(r.price));
316
+ for (let i = 1; i < electronicPrices.length; i++) {
317
+ expect(electronicPrices[i]).toBeLessThanOrEqual(
318
+ electronicPrices[i - 1],
319
+ );
320
+ }
321
+ });
322
+
323
+ it('should return 404 for non-existent table', async () => {
324
+ const res = await app.request('/api/tables/non_existent_table/rows');
325
+
326
+ expect(res.status).toBe(404);
327
+ const data = await res.json();
328
+ expect(data.error).toContain('not found');
329
+ });
330
+
331
+ it('should cap pageSize at 100', async () => {
332
+ const res = await app.request(
333
+ '/api/tables/studio_hono_products/rows?pageSize=500',
334
+ );
335
+
336
+ expect(res.status).toBe(200);
337
+ // Since we only have 5 rows, we just verify the request succeeds
338
+ const data = await res.json();
339
+ expect(data.rows).toBeDefined();
340
+ });
341
+ });
342
+
343
+ describe('GET /', () => {
344
+ it('should return dashboard UI or API info', async () => {
345
+ const res = await app.request('/');
346
+
347
+ expect(res.status).toBe(200);
348
+ const contentType = res.headers.get('content-type') || '';
349
+
350
+ // If UI is embedded, returns HTML; otherwise returns JSON API info
351
+ if (contentType.includes('text/html')) {
352
+ const html = await res.text();
353
+ expect(html).toContain('<!doctype html>');
354
+ } else {
355
+ const data = await res.json();
356
+ expect(data.message).toBe('Studio API is running');
357
+ expect(data.endpoints).toBeDefined();
358
+ }
359
+ });
360
+ });
361
+ });