From c03232f08caf5537a058bddef7cb786164f2e8ac Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 24 Jul 2026 18:45:33 +0100 Subject: [PATCH 1/2] feat(database): injectable console page (schema browser + SQL panel) Ships the database worker's #/ext/database page over the injectable-UI protocol (#570): a schema tree with lazy per-driver column/PK/FK/index introspection, a sortable paginated row grid with a row inspector, and a read-only SQL panel (shared Monaco CodeEditor, EXPLAIN, per-db history, client-side write-verb gate). Reads the live database via database::query and database::listDatabases. Registered through host.pages.register in the existing database/ui setup, alongside the function-trigger renderer. The SQL editor feeds the live table names plus SQL keywords to the CodeEditor completions prop for as-you-type suggestions. --- database/ui/package.json | 3 +- database/ui/page.tsx | 19 +- database/ui/src/page/RowDetail.tsx | 92 ++++ database/ui/src/page/SchemaTree.tsx | 225 ++++++++++ database/ui/src/page/SqlPanel.tsx | 266 ++++++++++++ database/ui/src/page/TableDataPanel.tsx | 206 +++++++++ database/ui/src/page/cells.ts | 23 + database/ui/src/page/db-data.ts | 547 ++++++++++++++++++++++++ database/ui/src/page/icons.tsx | 191 +++++++++ database/ui/src/page/index.tsx | 249 +++++++++++ database/ui/src/page/page-styles.ts | 316 ++++++++++++++ database/ui/src/page/pagination.tsx | 75 ++++ database/ui/src/page/result-grid.tsx | 278 ++++++++++++ database/ui/src/page/useDatabaseRead.ts | 59 +++ pnpm-lock.yaml | 3 + 15 files changed, 2546 insertions(+), 6 deletions(-) create mode 100644 database/ui/src/page/RowDetail.tsx create mode 100644 database/ui/src/page/SchemaTree.tsx create mode 100644 database/ui/src/page/SqlPanel.tsx create mode 100644 database/ui/src/page/TableDataPanel.tsx create mode 100644 database/ui/src/page/cells.ts create mode 100644 database/ui/src/page/db-data.ts create mode 100644 database/ui/src/page/icons.tsx create mode 100644 database/ui/src/page/index.tsx create mode 100644 database/ui/src/page/page-styles.ts create mode 100644 database/ui/src/page/pagination.tsx create mode 100644 database/ui/src/page/result-grid.tsx create mode 100644 database/ui/src/page/useDatabaseRead.ts diff --git a/database/ui/package.json b/database/ui/package.json index 6c2a47a5..3a3cf59a 100644 --- a/database/ui/package.json +++ b/database/ui/package.json @@ -8,7 +8,8 @@ "watch": "node build.mjs --watch" }, "dependencies": { - "@iii-dev/console-ui": "workspace:*" + "@iii-dev/console-ui": "workspace:*", + "zod": "^4.0.0" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/database/ui/page.tsx b/database/ui/page.tsx index 82aee1ce..a1c00905 100644 --- a/database/ui/page.tsx +++ b/database/ui/page.tsx @@ -5,18 +5,27 @@ * asset: ../styles.css ships over `console:style` as database/styles.css — * the console mounts and link-swaps it, styles-before-scripts on boot. * - * `setup(host)` registers one contribution: the function-trigger renderer - * in src/function-trigger-message/ — how every database::* call renders in - * chat and traces (SQL, request chips, result tables). No page and no - * config form yet; those slots can be added here later. + * `setup(host)` registers two contributions: + * - src/function-trigger-message/ — how every database::* call renders in + * chat and traces (SQL, request chips, result tables). + * - src/page/ — the `#/ext/database` browser: schema tree, sortable row + * grid, row inspector, and a read-only SQL editor (shared Monaco). Reads + * the live database over `database::query`/`database::listDatabases`. * - * Registration goes through `host` so the loader disposes it on hot + * Registrations go through `host` so the loader disposes them on hot * reload / worker disconnect. */ import type { Host } from '@iii-dev/console-ui' import { createDatabaseTriggerRenderer } from './src/function-trigger-message' +import { DatabasePage } from './src/page' export default function setup(host: Host) { host.functionTriggers.register(createDatabaseTriggerRenderer(host)) + + host.pages.register({ + id: 'database', + title: 'database', + render: () => , + }) } diff --git a/database/ui/src/page/RowDetail.tsx b/database/ui/src/page/RowDetail.tsx new file mode 100644 index 00000000..b23e4264 --- /dev/null +++ b/database/ui/src/page/RowDetail.tsx @@ -0,0 +1,92 @@ +/** + * Inspector for one selected row: every column with its full (untruncated) + * value, JSON pretty-printed, one copy button per field. Complements the + * grid, which truncates long values to keep rows scannable. + */ + +import { Button, JsonHighlight } from '@iii-dev/console-ui' +import { useState } from 'react' +import { cellText, copyText } from './cells' +import type { ColumnInfo } from './db-data' +import { Check, Copy, KeyRound, X } from './icons' + +interface RowDetailProps { + table: string + row: Record + columns: ColumnInfo[] + onClose: () => void +} + +export function RowDetail({ table, row, columns, onClose }: RowDetailProps) { + const [copied, setCopied] = useState(null) + const names = + columns.length > 0 ? columns.map((c) => c.name) : Object.keys(row) + const byName = new Map(columns.map((c) => [c.name, c])) + + const copyValue = (name: string) => { + copyText(cellText(row[name])) + setCopied(name) + window.setTimeout(() => { + setCopied((cur) => (cur === name ? null : cur)) + }, 1200) + } + + return ( + + ) +} diff --git a/database/ui/src/page/SchemaTree.tsx b/database/ui/src/page/SchemaTree.tsx new file mode 100644 index 00000000..c607452c --- /dev/null +++ b/database/ui/src/page/SchemaTree.tsx @@ -0,0 +1,225 @@ +/** + * Sidebar schema explorer, grouped into tables and views. Each entry expands + * to its column list (name, type, PK/FK markers) plus indexes for tables, + * fetched lazily per table (through the tab's `host`) and cached for the life + * of the component — the parent remounts it on db switch / refresh, which is + * the cache-invalidation point. + */ + +import { type Host, Skeleton } from '@iii-dev/console-ui' +import { type ComponentType, useState } from 'react' +import { + type ColumnInfo, + type DbDriver, + type DbTable, + type IndexInfo, + tableColumns, + tableIndexes, +} from './db-data' +import { + ChevronRight, + Eye, + type IconProps, + KeyRound, + Link2, + Table2, +} from './icons' + +interface SchemaTreeProps { + host: Host + db: string + driver: DbDriver + tables: DbTable[] + selectedTable: string | null + onSelectTable: (name: string) => void +} + +interface TableSchema { + columns: ColumnInfo[] + indexes: IndexInfo[] +} + +export function SchemaTree({ + host, + db, + driver, + tables, + selectedTable, + onSelectTable, +}: SchemaTreeProps) { + const [expanded, setExpanded] = useState>(new Set()) + const [schemaByTable, setSchemaByTable] = useState< + Record + >({}) + + const toggle = (table: DbTable) => { + setExpanded((cur) => { + const next = new Set(cur) + if (next.has(table.name)) { + next.delete(table.name) + } else { + next.add(table.name) + } + return next + }) + if (schemaByTable[table.name] === undefined) { + setSchemaByTable((cur) => ({ ...cur, [table.name]: 'loading' })) + void Promise.all([ + tableColumns(host, db, driver, table.name), + table.kind === 'table' + ? tableIndexes(host, db, driver, table.name).catch(() => []) + : Promise.resolve([]), + ]) + .then(([columns, indexes]) => { + setSchemaByTable((cur) => ({ + ...cur, + [table.name]: { columns, indexes }, + })) + }) + .catch(() => { + setSchemaByTable((cur) => ({ ...cur, [table.name]: 'error' })) + }) + } + } + + const groups: { + label: string + kind: DbTable['kind'] + icon: ComponentType + }[] = [ + { label: 'tables', kind: 'table', icon: Table2 }, + { label: 'views', kind: 'view', icon: Eye }, + ] + + return ( +
+ {groups.map((group) => { + const entries = tables.filter((t) => t.kind === group.kind) + if (entries.length === 0) return null + return ( +
+ {group.kind === 'view' ? ( +
views · {entries.length}
+ ) : null} +
    + {entries.map((table) => { + const isOpen = expanded.has(table.name) + const isSelected = selectedTable === table.name + const schema = schemaByTable[table.name] + const Icon = group.icon + return ( +
  • +
    + + +
    + {isOpen ? : null} +
  • + ) + })} +
+
+ ) + })} +
+ ) +} + +function TableSchemaRows({ + schema, +}: { + schema: TableSchema | 'loading' | 'error' | undefined +}) { + if (schema === 'loading' || schema === undefined) { + return ( +
+ +
+ ) + } + if (schema === 'error') { + return

failed to read schema

+ } + return ( +
    + {schema.columns.length === 0 ? ( +
  • no columns
  • + ) : ( + schema.columns.map((col) => ( +
  • + {col.pk ? ( + + ) : col.fkTarget ? ( + + ) : ( + + )} + {col.name} + {col.type} +
  • + )) + )} + {schema.indexes.length > 0 ? ( + <> +
  • indexes
  • + {schema.indexes.map((idx) => ( +
  • + + {idx.name} + {idx.unique ? ( + unique + ) : null} +
  • + ))} + + ) : null} +
+ ) +} diff --git a/database/ui/src/page/SqlPanel.tsx b/database/ui/src/page/SqlPanel.tsx new file mode 100644 index 00000000..7445704c --- /dev/null +++ b/database/ui/src/page/SqlPanel.tsx @@ -0,0 +1,266 @@ +/** + * Ad-hoc read-only SQL: the shared Monaco `CodeEditor` (⌘⏎ runs), an EXPLAIN + * action that prefixes the driver-native explain form, per-database history + * in localStorage, and results in the shared grid with duration. Write verbs + * never leave the browser — `runReadOnlySql` rejects them. + */ + +import { + Button, + CodeEditor, + type CodeEditorHandle, + type Host, + StatusPanel, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type AdhocResult, + type DbDriver, + explainPrefix, + isReadOnlySql, + runReadOnlySql, +} from './db-data' +import { AlertCircle, History, Play, X } from './icons' +import { ResultGrid } from './result-grid' + +interface SqlPanelProps { + host: Host + db: string + driver: DbDriver + /** Prefill from "query this table" affordances; applied when it changes. */ + seedSql?: string + /** Table names in the active database — fed to the editor's autocomplete + alongside the SQL keywords. */ + tables?: readonly string[] +} + +const HISTORY_LIMIT = 20 + +/** The keyword slice offered as-you-type; the table names ride alongside. */ +const SQL_KEYWORDS = [ + 'select', + 'from', + 'where', + 'order by', + 'group by', + 'having', + 'limit', + 'offset', + 'join', + 'left join', + 'inner join', + 'on', + 'as', + 'and', + 'or', + 'not', + 'null', + 'is null', + 'is not null', + 'in', + 'like', + 'between', + 'distinct', + 'count', + 'sum', + 'avg', + 'min', + 'max', + 'asc', + 'desc', +] + +function historyKey(db: string): string { + return `iii-console:database:sql-history:${db}` +} + +function loadHistory(db: string): string[] { + try { + const raw = window.localStorage.getItem(historyKey(db)) + const parsed: unknown = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) + ? parsed.filter((s): s is string => typeof s === 'string') + : [] + } catch { + return [] + } +} + +function saveHistory(db: string, entries: string[]) { + try { + window.localStorage.setItem( + historyKey(db), + JSON.stringify(entries.slice(0, HISTORY_LIMIT)), + ) + } catch { + // storage full/unavailable — history is a convenience, not state + } +} + +export function SqlPanel({ host, db, driver, seedSql, tables }: SqlPanelProps) { + const [sql, setSql] = useState(seedSql ?? '') + const completions = useMemo( + () => Array.from(new Set([...(tables ?? []), ...SQL_KEYWORDS])), + [tables], + ) + const [running, setRunning] = useState(false) + const [error, setError] = useState(null) + const [outcome, setOutcome] = useState(null) + const [history, setHistory] = useState(() => loadHistory(db)) + const [historyOpen, setHistoryOpen] = useState(false) + const editorRef = useRef(null) + + useEffect(() => { + if (seedSql) setSql(seedSql) + }, [seedSql]) + + const run = useCallback( + async (statement: string) => { + const trimmed = statement.trim() + if (!trimmed || running) return + setRunning(true) + setError(null) + try { + const next = await runReadOnlySql(host, db, trimmed) + setOutcome(next) + setHistory((cur) => { + const updated = [trimmed, ...cur.filter((s) => s !== trimmed)] + saveHistory(db, updated) + return updated + }) + } catch (err) { + setOutcome(null) + setError(err instanceof Error ? err.message : String(err)) + } finally { + setRunning(false) + } + }, + [host, db, running], + ) + + const readOnly = sql.trim() === '' || isReadOnlySql(sql) + + return ( +
+
+
+ { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + void run(sql) + } + }} + /> +
+
+ + + + {!readOnly ? ( + + write statements are rejected — this panel is read-only + + ) : null} + {outcome ? ( + + {outcome.result.row_count} row + {outcome.result.row_count === 1 ? '' : 's'} · {outcome.durationMs} + ms + + ) : null} +
+ {historyOpen && history.length > 0 ? ( +
+ {history.map((entry) => ( +
+ + +
+ ))} +
+ ) : null} +
+
+ {error ? ( +
+ } + headline="query failed" + detail={error} + /> +
+ ) : outcome ? ( + + ) : ( +

+ {running + ? 'running…' + : 'results appear here. try: select name from sqlite_master limit 10'} +

+ )} +
+
+ ) +} diff --git a/database/ui/src/page/TableDataPanel.tsx b/database/ui/src/page/TableDataPanel.tsx new file mode 100644 index 00000000..90453fe0 --- /dev/null +++ b/database/ui/src/page/TableDataPanel.tsx @@ -0,0 +1,206 @@ +/** + * Paged read-only view of one table: `SELECT * LIMIT/OFFSET` (plus ORDER BY + * when a column header is sorted) through `database::query`, a total row + * count for the pager, and column introspection for PK markers + the row + * inspector. The grid is the shared `ResultGrid`, so cells render the same + * way as in the SQL panel and the chat family. + */ + +import { + EmptyState, + type Host, + Skeleton, + StatusPanel, +} from '@iii-dev/console-ui' +import { useCallback, useMemo, useState } from 'react' +import { + countTableRows, + type DbDriver, + fetchTablePage, + PAGE_SIZE, + type TableSort, + tableColumns, +} from './db-data' +import { AlertCircle, type IconProps, Table2 } from './icons' +import { Pagination } from './pagination' +import { RowDetail } from './RowDetail' +import { ResultGrid } from './result-grid' +import { useDatabaseRead } from './useDatabaseRead' + +interface TableDataPanelProps { + host: Host + db: string + driver: DbDriver + table: string + enabled: boolean + /** Seed the SQL panel with a SELECT for this table and switch to it. */ + onOpenInSql?: (table: string) => void +} + +const TableIcon = (p: IconProps) => + +export function TableDataPanel({ + host, + db, + driver, + table, + enabled, + onOpenInSql, +}: TableDataPanelProps) { + const [page, setPage] = useState(0) + const [pageSize, setPageSize] = useState(PAGE_SIZE) + const [sort, setSort] = useState(null) + const [selectedRow, setSelectedRow] = useState(null) + + const pageFetcher = useCallback( + () => fetchTablePage(host, db, driver, table, page, pageSize, sort), + [host, db, driver, table, page, pageSize, sort], + ) + const countFetcher = useCallback( + () => countTableRows(host, db, driver, table), + [host, db, driver, table], + ) + const columnsFetcher = useCallback( + () => tableColumns(host, db, driver, table), + [host, db, driver, table], + ) + const pageRead = useDatabaseRead(enabled, pageFetcher) + const countRead = useDatabaseRead(enabled, countFetcher) + const columnsRead = useDatabaseRead(enabled, columnsFetcher) + + const columnInfo = useMemo(() => columnsRead.data ?? [], [columnsRead.data]) + const primaryKeys = useMemo( + () => columnInfo.filter((c) => c.pk).map((c) => c.name), + [columnInfo], + ) + + const total = countRead.data ?? null + const totalPages = useMemo(() => { + if (total === null) return page + 2 + return Math.max(1, Math.ceil(total / pageSize)) + }, [total, page, pageSize]) + + if (pageRead.error) { + return ( + } + headline="database read failed" + detail={pageRead.error} + /> + ) + } + + if (pageRead.loading && !pageRead.data) { + return ( +
+ + + + +
+ ) + } + + const result = pageRead.data?.result + if (!result || (result.row_count === 0 && page === 0)) { + return ( + + ) + } + + const rows = result.rows + const detailRow = + selectedRow !== null && selectedRow < rows.length ? rows[selectedRow] : null + + return ( +
+
+
+ {table} + + {result.columns?.length ?? Object.keys(rows[0] ?? {}).length}{' '} + columns + + {total !== null ? `${total} rows` : '… rows'} + {sort ? ( + + ) : null} + {pageRead.loading ? ( + + refreshing… + + ) : null} + {onOpenInSql ? ( + + ) : null} +
+
+ { + setSort(next) + setPage(0) + setSelectedRow(null) + }} + onRowClick={(i) => setSelectedRow((cur) => (cur === i ? null : i))} + selectedRow={selectedRow} + stickyHeader + hideFooter + /> +
+
+ { + setPage(next - 1) + setSelectedRow(null) + }} + onPageSizeChange={(next) => { + setPageSize(next) + setPage(0) + setSelectedRow(null) + }} + /> +
+
+ {detailRow ? ( + setSelectedRow(null)} + /> + ) : null} +
+ ) +} diff --git a/database/ui/src/page/cells.ts b/database/ui/src/page/cells.ts new file mode 100644 index 00000000..194983ff --- /dev/null +++ b/database/ui/src/page/cells.ts @@ -0,0 +1,23 @@ +/** Cell-value helpers shared by the result grid and the row inspector. */ + +/** Copyable string form of a cell value. */ +export function cellText(value: unknown): string { + if (value === null || value === undefined) return 'NULL' + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + return String(value) +} + +/** Best-effort clipboard write — the copy affordance is a convenience. */ +export function copyText(text: string): void { + try { + void navigator.clipboard?.writeText(text) + } catch { + // clipboard blocked/unavailable — nothing better to do here + } +} diff --git a/database/ui/src/page/db-data.ts b/database/ui/src/page/db-data.ts new file mode 100644 index 00000000..20376607 --- /dev/null +++ b/database/ui/src/page/db-data.ts @@ -0,0 +1,547 @@ +/** + * Read-only RPC surface for the injected database page, built entirely on + * `database::query` + `database::listDatabases` — the worker has no + * dedicated introspection functions, so table discovery issues the + * driver-native catalog query (sqlite_master / information_schema). + * Mutations stay in agent flows behind the approval gate. + * + * Ported from the console's `lib/database.ts`: the catalog SQL and zod + * parsing are verbatim; only the transport changed — every call now takes + * the tab's `host` and routes through `host.iii.trigger(...)` instead of a + * console-internal iii client. + */ + +import type { Host } from '@iii-dev/console-ui' +import { z } from 'zod' + +/* ---------------------------------------------------------------------- * + * Response schemas — inlined from the console's `database::*` parsers so + * this page carries no dependency on console-internal modules. + * ---------------------------------------------------------------------- */ + +export const columnMetaSchema = z.object({ + name: z.string(), + type: z.string().optional(), +}) +export type ColumnMeta = z.infer + +export const queryResponseSchema = z.object({ + rows: z.array(z.record(z.string(), z.unknown())), + row_count: z.number(), + columns: z.array(columnMetaSchema).optional(), +}) +export type QueryResponse = z.infer + +export const listDatabasesResponseSchema = z.object({ + databases: z.array( + z.object({ + name: z.string(), + driver: z.string().optional(), + url: z.string().optional(), + pool: z + .object({ + max: z.number().optional(), + idle_timeout_ms: z.number().optional(), + acquire_timeout_ms: z.number().optional(), + }) + .optional(), + tls: z.object({ mode: z.string().optional() }).optional(), + }), + ), + count: z.number().optional(), +}) +export type ListDatabasesResponse = z.infer + +export interface TableSort { + column: string + dir: 'asc' | 'desc' +} + +export type TypeCategory = + | 'numeric' + | 'text' + | 'bool' + | 'date' + | 'json' + | 'binary' + | 'other' + +/** Coarse category for a driver-reported column type. */ +export function typeCategory(type: string | undefined): TypeCategory { + if (!type) return 'other' + const t = type.toLowerCase() + if (/bool/.test(t)) return 'bool' + if (/int|real|float|double|decimal|numeric|serial|money/.test(t)) { + return 'numeric' + } + if (/date|time|year/.test(t)) return 'date' + if (/json/.test(t)) return 'json' + if (/blob|bytea|binary/.test(t)) return 'binary' + if (/char|text|clob|uuid|enum/.test(t)) return 'text' + return 'other' +} + +/* ---------------------------------------------------------------------- */ + +const DATABASE_QUERY_FN = 'database::query' +const DATABASE_LIST_FN = 'database::listDatabases' + +export const PAGE_SIZE = 50 + +export type DbDriver = 'sqlite' | 'postgres' | 'mysql' | 'unknown' + +export interface DbInfo { + name: string + driver: DbDriver + url?: string + poolMax?: number +} + +export interface DbTable { + /** Display + query identifier; schema-qualified for postgres. */ + name: string + kind: 'table' | 'view' +} + +function parseDriver(raw: string | undefined): DbDriver { + if (raw === 'sqlite' || raw === 'postgres' || raw === 'mysql') return raw + return 'unknown' +} + +async function triggerRaw( + host: Host, + fnId: string, + payload: Record, +): Promise { + return host.iii.trigger(fnId, payload) +} + +export async function listDbs(host: Host): Promise { + const res = listDatabasesResponseSchema.safeParse( + await triggerRaw(host, DATABASE_LIST_FN, {}), + ) + if (!res.success) return [] + return res.data.databases.map((db) => ({ + name: db.name, + driver: parseDriver(db.driver), + url: db.url, + poolMax: db.pool?.max, + })) +} + +async function runQuery( + host: Host, + db: string, + sql: string, + params: unknown[] = [], +): Promise { + const res = queryResponseSchema.safeParse( + await triggerRaw(host, DATABASE_QUERY_FN, { db, sql, params }), + ) + if (!res.success) { + throw new Error('unexpected response shape from database::query') + } + return res.data +} + +/* ---- identifier quoting ---- */ + +/** + * Quote one identifier for the driver. Table names come from the driver's + * own catalog, but quoting is still mandatory — names may contain + * uppercase, spaces, or reserved words. + */ +export function quoteIdent(driver: DbDriver, ident: string): string { + if (driver === 'mysql') { + return `\`${ident.replaceAll('`', '``')}\`` + } + return `"${ident.replaceAll('"', '""')}"` +} + +/** Quote a possibly schema-qualified table reference (`schema.table`). */ +function quoteTableRef(driver: DbDriver, table: string): string { + if (driver === 'postgres' && table.includes('.')) { + const [schema, ...rest] = table.split('.') + return `${quoteIdent(driver, schema)}.${quoteIdent(driver, rest.join('.'))}` + } + return quoteIdent(driver, table) +} + +/* ---- catalog queries ---- */ + +const TABLE_LIST_SQL: Record, string> = { + sqlite: + "SELECT name, CASE type WHEN 'view' THEN 'view' ELSE 'table' END AS kind FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY kind, name", + postgres: + "SELECT CASE WHEN table_schema = 'public' THEN table_name ELSE table_schema || '.' || table_name END AS name, CASE table_type WHEN 'VIEW' THEN 'view' ELSE 'table' END AS kind FROM information_schema.tables WHERE table_type IN ('BASE TABLE', 'VIEW') AND table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY 2, 1", + mysql: + "SELECT table_name AS name, CASE TABLE_TYPE WHEN 'VIEW' THEN 'view' ELSE 'table' END AS kind FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY 2, 1", +} + +const tableRowSchema = z.object({ + name: z.string(), + kind: z.string().optional(), +}) + +export async function listTables( + host: Host, + db: string, + driver: DbDriver, +): Promise { + if (driver === 'unknown') { + throw new Error(`cannot introspect tables for an unknown driver`) + } + const res = await runQuery(host, db, TABLE_LIST_SQL[driver]) + return res.rows + .map((row) => tableRowSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + kind: p.data.kind === 'view' ? ('view' as const) : ('table' as const), + })) +} + +/* ---- indexes ---- */ + +export interface IndexInfo { + name: string + unique: boolean + /** Column list or full definition, driver-dependent. */ + detail: string +} + +const sqliteIndexSchema = z.object({ + name: z.string(), + unique: z.number(), + origin: z.string().optional(), +}) + +const pgIndexSchema = z.object({ + name: z.string(), + definition: z.string(), +}) + +const mysqlIndexSchema = z.object({ + name: z.string(), + unique: z.number(), + columns: z.string(), +}) + +export async function tableIndexes( + host: Host, + db: string, + driver: DbDriver, + table: string, +): Promise { + if (driver === 'sqlite') { + const res = await runQuery( + host, + db, + `PRAGMA index_list(${quoteIdent(driver, table)})`, + ) + return res.rows + .map((row) => sqliteIndexSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + unique: p.data.unique === 1, + detail: p.data.origin === 'pk' ? 'primary key' : '', + })) + } + if (driver === 'postgres') { + const { schema, name } = splitTableRef(table) + const res = await runQuery( + host, + db, + `SELECT indexname AS name, indexdef AS definition FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 ORDER BY indexname`, + [schema ?? 'public', name], + ) + return res.rows + .map((row) => pgIndexSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + unique: /\bUNIQUE\b/i.test(p.data.definition), + detail: p.data.definition.replace(/^.*USING /i, ''), + })) + } + if (driver === 'mysql') { + const res = await runQuery( + host, + db, + `SELECT INDEX_NAME AS name, MIN(NON_UNIQUE) = 0 AS \`unique\`, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? GROUP BY INDEX_NAME ORDER BY INDEX_NAME`, + [splitTableRef(table).name], + ) + return res.rows + .map((row) => mysqlIndexSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + unique: p.data.unique === 1, + detail: `(${p.data.columns})`, + })) + } + return [] +} + +/* ---- ad-hoc read-only queries ---- */ + +/** Leading keyword of a statement (comments stripped). */ +function leadingKeyword(sql: string): string { + const cleaned = sql + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n]*/g, ' ') + .trim() + return cleaned.split(/[^a-zA-Z_]+/, 1)[0]?.toUpperCase() ?? '' +} + +const READ_ONLY_KEYWORDS = new Set([ + 'SELECT', + 'WITH', + 'EXPLAIN', + 'PRAGMA', + 'SHOW', + 'DESCRIBE', + 'VALUES', +]) + +/** Console-side gate: the page only issues read statements. */ +export function isReadOnlySql(sql: string): boolean { + return READ_ONLY_KEYWORDS.has(leadingKeyword(sql)) +} + +export interface AdhocResult { + result: QueryResponse + durationMs: number +} + +/** + * Run one read-only statement from the SQL panel. Write verbs are rejected + * before they reach the worker — mutations stay in agent flows behind the + * approval gate. + */ +export async function runReadOnlySql( + host: Host, + db: string, + sql: string, +): Promise { + if (!isReadOnlySql(sql)) { + throw new Error( + 'read-only console: only SELECT / WITH / EXPLAIN / PRAGMA / SHOW run here. write statements go through agent flows and the approval gate.', + ) + } + const started = performance.now() + const result = await runQuery(host, db, sql) + return { result, durationMs: Math.round(performance.now() - started) } +} + +/** Driver-native EXPLAIN prefix for the SQL panel's explain toggle. */ +export function explainPrefix(driver: DbDriver): string { + if (driver === 'sqlite') return 'EXPLAIN QUERY PLAN ' + return 'EXPLAIN ' +} + +export async function countTableRows( + host: Host, + db: string, + driver: DbDriver, + table: string, +): Promise { + const ref = quoteTableRef(driver, table) + const res = await runQuery(host, db, `SELECT COUNT(*) AS n FROM ${ref}`) + const n = res.rows[0]?.n + if (typeof n === 'number') return n + if (typeof n === 'string' && /^\d+$/.test(n)) return Number(n) + return null +} + +/* ---- column introspection ---- */ + +export interface ColumnInfo { + name: string + type: string + nullable: boolean + pk: boolean + /** Referenced `table.column` when this column is a foreign key. */ + fkTarget?: string +} + +/** Split a possibly schema-qualified table into schema + bare name. */ +function splitTableRef(table: string): { schema: string | null; name: string } { + if (!table.includes('.')) return { schema: null, name: table } + const [schema, ...rest] = table.split('.') + return { schema, name: rest.join('.') } +} + +const sqlitePragmaColumnSchema = z.object({ + name: z.string(), + type: z.string(), + notnull: z.number(), + pk: z.number(), +}) + +const sqlitePragmaFkSchema = z.object({ + table: z.string(), + from: z.string(), + to: z.string().nullable(), +}) + +async function sqliteColumns( + host: Host, + db: string, + table: string, +): Promise { + const ref = quoteIdent('sqlite', table) + const cols = await runQuery(host, db, `PRAGMA table_info(${ref})`) + let fks: Map = new Map() + try { + const fkRes = await runQuery(host, db, `PRAGMA foreign_key_list(${ref})`) + fks = new Map( + fkRes.rows + .map((row) => sqlitePragmaFkSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => [p.data.from, `${p.data.table}.${p.data.to ?? 'rowid'}`]), + ) + } catch { + // foreign_key_list errors on tables without FKs in some builds; treat + // as "no foreign keys" rather than failing the whole column read. + } + return cols.rows + .map((row) => sqlitePragmaColumnSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + type: p.data.type || 'any', + nullable: p.data.notnull === 0, + pk: p.data.pk > 0, + fkTarget: fks.get(p.data.name), + })) +} + +const infoSchemaColumnSchema = z.object({ + name: z.string(), + type: z.string(), + nullable: z.string(), + key_kind: z.string().nullable().optional(), + fk_target: z.string().nullable().optional(), +}) + +async function postgresColumns( + host: Host, + db: string, + table: string, +): Promise { + const { schema, name } = splitTableRef(table) + const res = await runQuery( + host, + db, + `SELECT c.column_name AS name, c.data_type AS type, c.is_nullable AS nullable, + (SELECT tc.constraint_type FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + WHERE tc.table_schema = c.table_schema AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name AND tc.constraint_type = 'PRIMARY KEY' + LIMIT 1) AS key_kind, + (SELECT ccu.table_name || '.' || ccu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + WHERE tc.table_schema = c.table_schema AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name AND tc.constraint_type = 'FOREIGN KEY' + LIMIT 1) AS fk_target + FROM information_schema.columns c + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position`, + [schema ?? 'public', name], + ) + return res.rows + .map((row) => infoSchemaColumnSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + type: p.data.type, + nullable: p.data.nullable === 'YES', + pk: p.data.key_kind === 'PRIMARY KEY', + fkTarget: p.data.fk_target ?? undefined, + })) +} + +async function mysqlColumns( + host: Host, + db: string, + table: string, +): Promise { + const { name } = splitTableRef(table) + const res = await runQuery( + host, + db, + `SELECT c.COLUMN_NAME AS name, c.COLUMN_TYPE AS type, c.IS_NULLABLE AS nullable, + c.COLUMN_KEY AS key_kind, + (SELECT CONCAT(kcu.REFERENCED_TABLE_NAME, '.', kcu.REFERENCED_COLUMN_NAME) + FROM information_schema.KEY_COLUMN_USAGE kcu + WHERE kcu.TABLE_SCHEMA = c.TABLE_SCHEMA AND kcu.TABLE_NAME = c.TABLE_NAME + AND kcu.COLUMN_NAME = c.COLUMN_NAME + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + LIMIT 1) AS fk_target + FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION`, + [name], + ) + return res.rows + .map((row) => infoSchemaColumnSchema.safeParse(row)) + .filter((p) => p.success) + .map((p) => ({ + name: p.data.name, + type: p.data.type, + nullable: p.data.nullable === 'YES', + pk: p.data.key_kind === 'PRI', + fkTarget: p.data.fk_target ?? undefined, + })) +} + +export async function tableColumns( + host: Host, + db: string, + driver: DbDriver, + table: string, +): Promise { + if (driver === 'sqlite') return sqliteColumns(host, db, table) + if (driver === 'postgres') return postgresColumns(host, db, table) + if (driver === 'mysql') return mysqlColumns(host, db, table) + return [] +} + +/* ---- paged reads ---- */ + +export interface TablePage { + result: QueryResponse + page: number + pageSize: number +} + +export async function fetchTablePage( + host: Host, + db: string, + driver: DbDriver, + table: string, + page: number, + pageSize: number = PAGE_SIZE, + sort?: TableSort | null, +): Promise { + const ref = quoteTableRef(driver, table) + const offset = page * pageSize + const orderBy = sort + ? ` ORDER BY ${quoteIdent(driver, sort.column)} ${sort.dir === 'desc' ? 'DESC' : 'ASC'}` + : '' + const result = await runQuery( + host, + db, + `SELECT * FROM ${ref}${orderBy} LIMIT ${pageSize} OFFSET ${offset}`, + ) + return { result, page, pageSize } +} diff --git a/database/ui/src/page/icons.tsx b/database/ui/src/page/icons.tsx new file mode 100644 index 00000000..984dc78a --- /dev/null +++ b/database/ui/src/page/icons.tsx @@ -0,0 +1,191 @@ +/** + * Small inline SVG icon set — the injected page can't pull in lucide-react + * (nothing is bundled but zod), so the handful of glyphs the ported + * components used (PK key, FK link, sort arrows, chevrons, …) are hand-drawn + * here. Every icon inherits `currentColor` and takes a numeric `size` + * (Tailwind `w-*`/`h-*` classes don't apply in injected UI). `className` is + * accepted so components like the console's `EmptyState`, which pass one, + * type-check. + */ + +import type { CSSProperties, ReactNode } from 'react' + +export interface IconProps { + size?: number + className?: string + style?: CSSProperties + 'aria-hidden'?: boolean + 'aria-label'?: string +} + +function Svg({ + size = 12, + children, + className, + style, + ...rest +}: IconProps & { children: ReactNode }) { + return ( + + ) +} + +export function KeyRound(props: IconProps) { + return ( + + + + + + ) +} + +export function Link2(props: IconProps) { + return ( + + + + + + ) +} + +export function Table2(props: IconProps) { + return ( + + + + + ) +} + +export function Eye(props: IconProps) { + return ( + + + + + ) +} + +export function ChevronRight(props: IconProps) { + return ( + + + + ) +} + +export function ChevronLeft(props: IconProps) { + return ( + + + + ) +} + +export function ArrowUp(props: IconProps) { + return ( + + + + + ) +} + +export function ArrowDown(props: IconProps) { + return ( + + + + + ) +} + +export function Check(props: IconProps) { + return ( + + + + ) +} + +export function Copy(props: IconProps) { + return ( + + + + + ) +} + +export function X(props: IconProps) { + return ( + + + + ) +} + +export function Play(props: IconProps) { + return ( + + + + ) +} + +export function History(props: IconProps) { + return ( + + + + + + ) +} + +export function RefreshCw(props: IconProps) { + return ( + + + + + + + ) +} + +export function AlertCircle(props: IconProps) { + return ( + + + + + ) +} + +export function Database(props: IconProps) { + return ( + + + + + + ) +} diff --git a/database/ui/src/page/index.tsx b/database/ui/src/page/index.tsx new file mode 100644 index 00000000..715702fb --- /dev/null +++ b/database/ui/src/page/index.tsx @@ -0,0 +1,249 @@ +/** + * The database page (#/ext/database): the database worker's read surface — + * configured databases, their schema (tables + columns via the driver's own + * catalog, since the worker has no introspection functions), and paged, + * sortable table contents with a row inspector, plus an ad-hoc read-only SQL + * panel. Data loads on demand (db/table switch / refresh) — the worker emits + * no change events yet, so there is nothing to subscribe to. Read-only on + * purpose: INSERT/UPDATE/DDL stay in agent flows behind the approval gate. + * + * The host only mounts this page when the database worker is connected, so + * there is no presence gate here; a failed `listDatabases` surfaces as the + * "worker unavailable" panel below. + */ + +import { + Badge, + Button, + EmptyState, + type Host, + Select, + Skeleton, + StatusPanel, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useState } from 'react' +import { + type DbInfo, + listDbs, + listTables, + PAGE_SIZE, + quoteIdent, +} from './db-data' +import { + AlertCircle, + Database, + type IconProps, + RefreshCw, + Table2, +} from './icons' +import { PAGE_CSS } from './page-styles' +import { SchemaTree } from './SchemaTree' +import { SqlPanel } from './SqlPanel' +import { TableDataPanel } from './TableDataPanel' +import { useDatabaseRead } from './useDatabaseRead' + +type PanelMode = 'data' | 'sql' + +const DatabaseIcon = (p: IconProps) => +const TableIcon = (p: IconProps) => + +export function DatabasePage({ host }: { host: Host }) { + const [selectedDb, setSelectedDb] = useState(undefined) + const [selectedTable, setSelectedTable] = useState(null) + const [mode, setMode] = useState('data') + const [seedSql, setSeedSql] = useState(undefined) + const [bump, setBump] = useState(0) + + const dbsFetcher = useCallback(() => listDbs(host), [host]) + const dbsRead = useDatabaseRead(true, dbsFetcher) + const dbs = dbsRead.data ?? [] + const activeDb: DbInfo | undefined = + dbs.find((db) => db.name === selectedDb) ?? dbs[0] + + const tablesFetcher = useCallback(() => { + if (!activeDb) return Promise.resolve([]) + return listTables(host, activeDb.name, activeDb.driver) + }, [host, activeDb]) + const tablesRead = useDatabaseRead(!!activeDb, tablesFetcher) + const tables = tablesRead.data ?? [] + + // Keep the selected table valid when the db or its table list changes. + useEffect(() => { + if (selectedTable && !tables.some((t) => t.name === selectedTable)) { + setSelectedTable(null) + } + }, [tables, selectedTable]) + + const refresh = () => { + dbsRead.refresh() + tablesRead.refresh() + setBump((b) => b + 1) + } + + return ( +
+ +
+
+
database
+
+ {activeDb?.url ?? + (dbsRead.error + ? 'worker not connected' + : 'no database configured')} +
+
+
+ {activeDb ? ( + <> +
+ {(['data', 'sql'] as const).map((m) => ( + + ))} +
+ {activeDb.driver} + {dbs.length > 1 ? ( + ` styled from design tokens. + */ + +import { Button } from '@iii-dev/console-ui' +import { ChevronLeft, ChevronRight } from './icons' + +interface PaginationProps { + currentPage: number + totalPages: number + totalItems: number + pageSize: number + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + pageSizeOptions?: number[] +} + +export function Pagination({ + currentPage, + totalPages, + totalItems, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = [25, 50, 100], +}: PaginationProps) { + const start = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1 + const end = Math.min(currentPage * pageSize, totalItems) + return ( +
+
+ show + +
+
+ + {start}–{end} of {totalItems} + + + + page {currentPage} of {totalPages} + + +
+
+ ) +} diff --git a/database/ui/src/page/result-grid.tsx b/database/ui/src/page/result-grid.tsx new file mode 100644 index 00000000..36de04df --- /dev/null +++ b/database/ui/src/page/result-grid.tsx @@ -0,0 +1,278 @@ +/** + * ResultGrid — tabular rendering for `QueryResp`-shaped payloads + * (`{ rows, row_count, columns }`). Cells render by value type: NULL is a + * ghost literal, booleans and numbers are monospace (numbers right-aligned), + * JSON values collapse to a compact preview, long strings truncate with the + * full value on hover. Every cell copies its value on click. + * + * Ported from the console's chat `ResultGrid` for the injected page: the + * source already rendered a real ``, so the port keeps the markup and + * swaps Tailwind utilities for the page's scoped classes + design tokens. + */ + +import { useState } from 'react' +import { cellText, copyText } from './cells' +import { type ColumnMeta, type TableSort, typeCategory } from './db-data' +import { ArrowDown, ArrowUp, Check, KeyRound } from './icons' + +const CLAMP_ROWS = 30 +const STRING_TRUNCATE = 160 +const JSON_PREVIEW = 80 + +interface ResultGridProps { + columns?: ColumnMeta[] + rows: Record[] + rowCount?: number + /** Column names that form the primary key — marked with a key glyph. */ + primaryKeys?: string[] + /** Current sort; render an indicator on the sorted column. */ + sort?: TableSort | null + /** When set, headers become buttons cycling asc → desc → off. */ + onSort?: (sort: TableSort | null) => void + /** When set, rows highlight on hover and clicking selects one. */ + onRowClick?: (index: number) => void + selectedRow?: number | null + /** Sticky header + no clamp — the page's scroll container paginates. */ + stickyHeader?: boolean + /** Hide the "N rows" footer (the page shows its own pagination bar). */ + hideFooter?: boolean +} + +/** Column order: declared `columns` metadata first, else keys of row 0. */ +function resolveColumns( + columns: ColumnMeta[] | undefined, + rows: Record[], +): ColumnMeta[] { + if (columns && columns.length > 0) return columns + if (rows.length === 0) return [] + return Object.keys(rows[0]).map((name) => ({ name })) +} + +function jsonPreview(value: unknown): string { + let text: string + try { + text = JSON.stringify(value) + } catch { + text = String(value) + } + if (text.length <= JSON_PREVIEW) return text + return `${text.slice(0, JSON_PREVIEW)}…` +} + +function CellValue({ + value, + category, +}: { + value: unknown + category: ReturnType +}) { + if (value === null || value === undefined) { + return NULL + } + if (typeof value === 'boolean') { + return ( + + {String(value)} + + ) + } + if (typeof value === 'number') { + return {String(value)} + } + if (typeof value === 'object') { + const preview = jsonPreview(value) + return ( + + {preview} + + ) + } + const text = String(value) + const cls = + category === 'date' + ? 'db-cell-date' + : category === 'numeric' + ? 'db-cell-num' + : 'db-cell-str' + if (text.length > STRING_TRUNCATE) { + return ( + + {text.slice(0, STRING_TRUNCATE)}… + + ) + } + return {text} +} + +function nextSort(current: TableSort | null | undefined, column: string) { + if (!current || current.column !== column) { + return { column, dir: 'asc' as const } + } + if (current.dir === 'asc') return { column, dir: 'desc' as const } + return null +} + +export function ResultGrid({ + columns, + rows, + rowCount, + primaryKeys, + sort, + onSort, + onRowClick, + selectedRow, + stickyHeader, + hideFooter, +}: ResultGridProps) { + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(null) + const cols = resolveColumns(columns, rows) + const total = rowCount ?? rows.length + const clamped = !stickyHeader && !expanded && rows.length > CLAMP_ROWS + const visible = clamped ? rows.slice(0, CLAMP_ROWS) : rows + const pkSet = new Set(primaryKeys ?? []) + + if (total === 0 || cols.length === 0) { + return
· 0 rows
+ } + + const copyCell = (key: string, value: unknown) => { + copyText(cellText(value)) + setCopied(key) + window.setTimeout(() => { + setCopied((cur) => (cur === key ? null : cur)) + }, 1200) + } + + return ( +
+
+
+ + + {cols.map((col) => { + const category = typeCategory(col.type) + const isNum = category === 'numeric' + const sorted = sort?.column === col.name ? sort.dir : null + const label = ( + <> + {pkSet.has(col.name) ? ( + + ) : null} + {col.name} + {col.type ? ( + {col.type} + ) : null} + {sorted === 'asc' ? ( + + ) : sorted === 'desc' ? ( + + ) : null} + + ) + return ( + + ) + })} + + + + {visible.map((row, i) => { + const rowClass = [ + onRowClick ? 'clickable' : '', + selectedRow === i ? 'selected' : '', + ] + .filter(Boolean) + .join(' ') + return ( + onRowClick(i) : undefined} + className={rowClass || undefined} + > + {cols.map((col) => { + const category = typeCategory(col.type) + const isNum = category === 'numeric' + const key = `${i}:${col.name}` + const content = + copied === key ? ( + + copied + + ) : ( + + ) + return ( + + ) + })} + + ) + })} + +
+ {onSort ? ( + + ) : ( + {label} + )} +
+ {onRowClick ? ( + content + ) : ( + + )} +
+
+ {hideFooter ? null : ( +
+ + {total} row{total === 1 ? '' : 's'} + + {clamped ? ( + + ) : null} + {expanded && rows.length > CLAMP_ROWS ? ( + + ) : null} +
+ )} +
+ ) +} diff --git a/database/ui/src/page/useDatabaseRead.ts b/database/ui/src/page/useDatabaseRead.ts new file mode 100644 index 00000000..d994cb8e --- /dev/null +++ b/database/ui/src/page/useDatabaseRead.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * One in-flight database read: run `fetcher` while `enabled`, re-run when + * the fetcher identity changes (callers memo it on db/table/page deps) or on + * `refresh`. No polling and no live trigger bindings on purpose — the worker + * emits no change events yet (`database::row-change` is not functional), so + * the page refreshes on demand instead of guessing. + */ + +export interface DatabaseRead { + data: T | null + loading: boolean + error: string | null + refresh: () => void +} + +export function useDatabaseRead( + enabled: boolean, + fetcher: () => Promise, +): DatabaseRead { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + const [token, setToken] = useState(0) + + const refresh = useCallback(() => setToken((t) => t + 1), []) + + // `token` is a re-run token (bumped by manual refresh), not read by the + // effect body — it only needs to be in the dependency list. + useEffect(() => { + if (!enabled) { + setData(null) + setLoading(false) + setError(null) + return + } + setLoading(true) + let cancelled = false + void (async () => { + try { + const next = await fetcher() + if (cancelled) return + setData(next) + setError(null) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [enabled, fetcher, token]) + + return { data, loading, error, refresh } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fdb7b31..9d7289f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui + zod: + specifier: ^4.0.0 + version: 4.4.3 devDependencies: '@types/react': specifier: ^19.2.14 From cf43f36e238e40175a2b2261f0beda5beb0949d4 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 25 Jul 2026 14:52:54 +0100 Subject: [PATCH 2/2] fix(database): address review on the injectable page - read-only SQL gate hardened: single-statement only (semicolons in strings/comments ignored), reject any write/DDL keyword anywhere so mutating CTEs and EXPLAIN ANALYZE cannot slip past the leading keyword, and reject writable PRAGMA forms (journal_mode=WAL) while keeping read PRAGMAs - listDbs throws on a shape mismatch like runQuery, so a malformed response is distinguishable from an empty database list - fetchTablePage fetches a pageSize+1 sentinel row so pagination stays bounded when the row count is unknown; normalize page/size to non-negative integers before interpolating - copyText swallows the async clipboard rejection via ?.catch, not just the sync call - icons preserve caller aria-hidden/aria-label (labeled icons stay accessible) instead of forcing aria-hidden - result grid sets aria-sort on sortable headers Worker-side read-only transaction enforcement is out of scope here (it is a database worker change); this tightens the client gate as defense in depth. SqlPanel already remounts per database via its key, so no separate db-change reset is needed. --- database/ui/src/page/TableDataPanel.tsx | 9 ++-- database/ui/src/page/cells.ts | 10 ++-- database/ui/src/page/db-data.ts | 62 ++++++++++++++++++++----- database/ui/src/page/icons.tsx | 10 +++- database/ui/src/page/result-grid.tsx | 14 +++++- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/database/ui/src/page/TableDataPanel.tsx b/database/ui/src/page/TableDataPanel.tsx index 90453fe0..a9c3bb29 100644 --- a/database/ui/src/page/TableDataPanel.tsx +++ b/database/ui/src/page/TableDataPanel.tsx @@ -75,10 +75,13 @@ export function TableDataPanel({ ) const total = countRead.data ?? null + const hasMore = pageRead.data?.hasMore ?? false const totalPages = useMemo(() => { - if (total === null) return page + 2 - return Math.max(1, Math.ceil(total / pageSize)) - }, [total, page, pageSize]) + if (total !== null) return Math.max(1, Math.ceil(total / pageSize)) + // Unknown count (or count query failed): allow one more page only when the + // sentinel row says a next page exists — bounds otherwise-unbounded "next". + return hasMore ? page + 2 : page + 1 + }, [total, hasMore, page, pageSize]) if (pageRead.error) { return ( diff --git a/database/ui/src/page/cells.ts b/database/ui/src/page/cells.ts index 194983ff..bbab02b1 100644 --- a/database/ui/src/page/cells.ts +++ b/database/ui/src/page/cells.ts @@ -13,11 +13,9 @@ export function cellText(value: unknown): string { return String(value) } -/** Best-effort clipboard write — the copy affordance is a convenience. */ +/** Best-effort clipboard write — the copy affordance is a convenience. + * clipboard may be missing (non-secure context) and writeText may reject + * (permission denied); the `?.catch` swallows both. */ export function copyText(text: string): void { - try { - void navigator.clipboard?.writeText(text) - } catch { - // clipboard blocked/unavailable — nothing better to do here - } + void navigator.clipboard?.writeText(text)?.catch(() => {}) } diff --git a/database/ui/src/page/db-data.ts b/database/ui/src/page/db-data.ts index 20376607..8bf152ec 100644 --- a/database/ui/src/page/db-data.ts +++ b/database/ui/src/page/db-data.ts @@ -120,7 +120,9 @@ export async function listDbs(host: Host): Promise { const res = listDatabasesResponseSchema.safeParse( await triggerRaw(host, DATABASE_LIST_FN, {}), ) - if (!res.success) return [] + if (!res.success) { + throw new Error('unexpected response shape from database::listDatabases') + } return res.data.databases.map((db) => ({ name: db.name, driver: parseDriver(db.driver), @@ -286,13 +288,16 @@ export async function tableIndexes( /* ---- ad-hoc read-only queries ---- */ -/** Leading keyword of a statement (comments stripped). */ -function leadingKeyword(sql: string): string { - const cleaned = sql +/** Strip comments and quoted text (strings + delimited identifiers) so the + single-statement and write-form checks never trip on a `;`, `=`, or keyword + that lives inside a literal. */ +function stripSqlNoise(sql: string): string { + return sql .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(/--[^\n]*/g, ' ') - .trim() - return cleaned.split(/[^a-zA-Z_]+/, 1)[0]?.toUpperCase() ?? '' + .replace(/'(?:''|[^'])*'/g, "''") + .replace(/"(?:""|[^"])*"/g, '""') + .replace(/`(?:``|[^`])*`/g, '``') } const READ_ONLY_KEYWORDS = new Set([ @@ -305,9 +310,28 @@ const READ_ONLY_KEYWORDS = new Set([ 'VALUES', ]) -/** Console-side gate: the page only issues read statements. */ +/** Write / DDL / maintenance keywords that must not appear anywhere in a + read-only statement — catches mutating CTEs (`WITH x AS (DELETE …)`) and + `EXPLAIN ANALYZE `, which the leading keyword alone waves through. */ +const WRITE_KEYWORD = + /\b(INSERT|UPDATE|DELETE|REPLACE|MERGE|UPSERT|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|ATTACH|DETACH|REINDEX|VACUUM|ANALYZE|COMMIT|ROLLBACK|BEGIN)\b/i + +/** + * Console-side gate: the page issues a SINGLE read statement. Defense in depth + * (the worker is the real boundary): rejects any statement after the first `;`, + * a non-read leading keyword, any write/DDL keyword anywhere (so mutating CTEs + * and `EXPLAIN ANALYZE` cannot slip through), and writable `PRAGMA foo = bar` + * forms while keeping read PRAGMAs (`PRAGMA table_info(x)`). + */ export function isReadOnlySql(sql: string): boolean { - return READ_ONLY_KEYWORDS.has(leadingKeyword(sql)) + const stripped = stripSqlNoise(sql).trim() + const semi = stripped.indexOf(';') + if (semi !== -1 && stripped.slice(semi + 1).trim() !== '') return false + const keyword = (stripped.split(/[^a-zA-Z_]+/, 1)[0] ?? '').toUpperCase() + if (!READ_ONLY_KEYWORDS.has(keyword)) return false + if (WRITE_KEYWORD.test(stripped)) return false + if (keyword === 'PRAGMA' && stripped.includes('=')) return false + return true } export interface AdhocResult { @@ -522,6 +546,9 @@ export interface TablePage { result: QueryResponse page: number pageSize: number + /** True when a `pageSize + 1` sentinel row came back — a next page exists. + Lets pagination stay bounded when the total row count is unknown. */ + hasMore: boolean } export async function fetchTablePage( @@ -534,14 +561,27 @@ export async function fetchTablePage( sort?: TableSort | null, ): Promise { const ref = quoteTableRef(driver, table) - const offset = page * pageSize + // Normalize to non-negative integers before interpolating — a fractional or + // non-finite page/size would otherwise yield broken or unbounded SQL. + const size = Math.max(1, Math.trunc(pageSize) || PAGE_SIZE) + const safePage = Math.max(0, Math.trunc(page) || 0) + const offset = safePage * size const orderBy = sort ? ` ORDER BY ${quoteIdent(driver, sort.column)} ${sort.dir === 'desc' ? 'DESC' : 'ASC'}` : '' + // Fetch one extra sentinel row: its presence means a next page exists, so + // navigation stays bounded even when the total count is unknown. const result = await runQuery( host, db, - `SELECT * FROM ${ref}${orderBy} LIMIT ${pageSize} OFFSET ${offset}`, + `SELECT * FROM ${ref}${orderBy} LIMIT ${size + 1} OFFSET ${offset}`, ) - return { result, page, pageSize } + const hasMore = result.rows.length > size + const rows = result.rows.slice(0, size) + return { + result: { ...result, rows, row_count: rows.length }, + page: safePage, + pageSize: size, + hasMore, + } } diff --git a/database/ui/src/page/icons.tsx b/database/ui/src/page/icons.tsx index 984dc78a..51b2f10c 100644 --- a/database/ui/src/page/icons.tsx +++ b/database/ui/src/page/icons.tsx @@ -23,8 +23,14 @@ function Svg({ children, className, style, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, ...rest }: IconProps & { children: ReactNode }) { + // Decorative by default. A caller passing aria-label opts into a labeled, + // non-hidden icon (role=img so it reads as a graphic); an explicit + // aria-hidden always wins. + const labeled = ariaLabel != null return ( {children} diff --git a/database/ui/src/page/result-grid.tsx b/database/ui/src/page/result-grid.tsx index 36de04df..32755975 100644 --- a/database/ui/src/page/result-grid.tsx +++ b/database/ui/src/page/result-grid.tsx @@ -181,7 +181,19 @@ export function ResultGrid({ ) return ( - + {onSort ? (