@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.
- package/dist/DataBrowser-DQ3-ZxdV.mjs +427 -0
- package/dist/DataBrowser-DQ3-ZxdV.mjs.map +1 -0
- package/dist/DataBrowser-SOcqmZb2.d.mts +267 -0
- package/dist/DataBrowser-c-Gs6PZB.cjs +432 -0
- package/dist/DataBrowser-c-Gs6PZB.cjs.map +1 -0
- package/dist/DataBrowser-hGwiTffZ.d.cts +267 -0
- package/dist/chunk-CUT6urMc.cjs +30 -0
- package/dist/data/index.cjs +4 -0
- package/dist/data/index.d.cts +2 -0
- package/dist/data/index.d.mts +2 -0
- package/dist/data/index.mjs +4 -0
- package/dist/index.cjs +239 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +132 -0
- package/dist/index.d.mts +132 -0
- package/dist/index.mjs +230 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server/hono.cjs +192 -0
- package/dist/server/hono.cjs.map +1 -0
- package/dist/server/hono.d.cts +19 -0
- package/dist/server/hono.d.mts +19 -0
- package/dist/server/hono.mjs +191 -0
- package/dist/server/hono.mjs.map +1 -0
- package/dist/types-BZv87Ikv.mjs +31 -0
- package/dist/types-BZv87Ikv.mjs.map +1 -0
- package/dist/types-CMttUZYk.cjs +43 -0
- package/dist/types-CMttUZYk.cjs.map +1 -0
- package/package.json +54 -0
- package/src/Studio.ts +318 -0
- package/src/data/DataBrowser.ts +166 -0
- package/src/data/__tests__/DataBrowser.integration.spec.ts +418 -0
- package/src/data/__tests__/filtering.integration.spec.ts +741 -0
- package/src/data/__tests__/introspection.integration.spec.ts +352 -0
- package/src/data/filtering.ts +191 -0
- package/src/data/index.ts +1 -0
- package/src/data/introspection.ts +220 -0
- package/src/data/pagination.ts +33 -0
- package/src/index.ts +31 -0
- package/src/server/__tests__/hono.integration.spec.ts +361 -0
- package/src/server/hono.ts +225 -0
- package/src/types.ts +278 -0
- package/src/ui-assets.ts +40 -0
- package/tsdown.config.ts +13 -0
- package/ui/index.html +12 -0
- package/ui/node_modules/.bin/browserslist +21 -0
- package/ui/node_modules/.bin/jiti +21 -0
- package/ui/node_modules/.bin/terser +21 -0
- package/ui/node_modules/.bin/tsc +21 -0
- package/ui/node_modules/.bin/tsserver +21 -0
- package/ui/node_modules/.bin/tsx +21 -0
- package/ui/node_modules/.bin/vite +21 -0
- package/ui/package.json +24 -0
- package/ui/src/App.tsx +141 -0
- package/ui/src/api.ts +71 -0
- package/ui/src/components/RowDetail.tsx +113 -0
- package/ui/src/components/TableList.tsx +51 -0
- package/ui/src/components/TableView.tsx +219 -0
- package/ui/src/main.tsx +10 -0
- package/ui/src/styles.css +36 -0
- package/ui/src/types.ts +50 -0
- package/ui/src/vite-env.d.ts +1 -0
- package/ui/tsconfig.json +21 -0
- package/ui/tsconfig.tsbuildinfo +1 -0
- 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
|
+
});
|