Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,9 @@ Redis is a key-value store, so the `sql` field carries a Redis command instead o

#### POST /api/db/schema

Get database schema including tables, columns, indexes, and foreign keys.
Get database schema including tables, columns, indexes, and foreign keys. Add the optional
`?table=<tableName>` query parameter to fetch one table's full detail; without it, the complete
schema is returned.

**Authentication:** Required

Expand Down Expand Up @@ -769,6 +771,10 @@ Get database schema including tables, columns, indexes, and foreign keys.
]
```

With `?table=<tableName>`, the `200 OK` response uses the same full `TableSchema` shape in a
single-element array. An empty `table` value returns `400 Bad Request`; a table that no longer
exists or is not visible returns `404` with `{ "error": "Table no longer exists or is not visible" }`.

**Response (503 Service Unavailable):**
```json
{
Expand Down Expand Up @@ -1316,6 +1322,7 @@ precedence; the connectivity check still uses its own 10000 ms timeout.
```typescript
interface TableSchema {
name: string; // Table name
detailsLoaded?: boolean; // False for inventory entries until table detail is loaded
columns: ColumnSchema[]; // Column definitions
indexes: IndexSchema[]; // Index definitions
foreignKeys?: ForeignKeySchema[];
Expand Down Expand Up @@ -1605,6 +1612,10 @@ curl -X POST http://localhost:3000/api/db/schema \
}'
```

Append `?table=<tableName>` to the same request to load one table's full detail. The response is a
single-element array; an empty parameter returns `400`, and a missing or invisible table returns
`404`.

#### AI Explanation of a Plan
```bash
curl -X POST http://localhost:3000/api/ai/explain \
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ Multi-statement queries execute sequentially via `POST /api/db/multi-query`.

Studio ships both as a standalone app and as the `@libredb/studio` npm package consumed by libredb-platform (built with `tsup` via `build:lib`).

- **`src/workspace/`** — `StudioWorkspace.tsx` is the embeddable shell. Its adapter hooks (`hooks/use-connection-adapter`, `hooks/use-query-adapter`) let the host (standalone or platform) supply connections and query execution, so the same UI runs in both contexts.
- **`src/workspace/`** — `StudioWorkspace.tsx` is the embeddable shell. Its adapter hooks (`hooks/use-connection-adapter`, `hooks/use-query-adapter`) let the host (standalone or platform) supply connections, query execution, and schema readers. `onSchemaFetch` is the required complete-schema reader; optional `onSchemaListFetch` supplies the fast inventory, and optional `onTableSchemaFetch` supplies one-table detail and falls back to the complete reader when omitted.
- **`src/exports/`** — barrel modules (`components.ts`, `providers.ts`, `workspace.ts`, `types.ts`) that define the package's public surface; `package.json` `exports`/`main`/`module` point at the tsup `dist/` output.
- **`src/styles/theme.css`** — the semantic colour tokens every exported component resolves through, shipped as `dist/styles.css` (`exports["./styles.css"]`) because `globals.css` is not packaged. A host imports it once: `import "@libredb/studio/styles.css"`. `build:lib` is `tsup && node scripts/copy-theme.mjs` in that order — tsup cleans `dist/`, so the copy has to follow it. See [`docs/ui/theming.md`](ui/theming.md).
- Platform integration rules (Tailwind tokens, Lucide stroke widths, chunk scanning) live in `CLAUDE.md`.
Expand Down
47 changes: 33 additions & 14 deletions docs/providers/oracle.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,20 @@ deliberately stricter than node-oracledb's tokenizer, which opens a q-string at
`q`/`Q` whatever comes before it; the strict side is the one whose mistake costs a bound — and, since
#297, a confirmation prompt on that statement — rather than a misplaced clause.

### 3.3 Owner-scoped, five-query schema introspection
### 3.3 Owner-scoped, two-phase schema introspection

`getSchema()` ([`oracle.ts`](../../src/lib/db/providers/sql/oracle.ts)) runs **five bulk queries**
over the `ALL_*` data-dictionary views — tables, columns, primary keys, foreign keys, indexes —
all filtered by `OWNER = :1` (the connecting user, upper-cased) and then **grouped in memory** by
table. This is neither the Postgres single-CTE approach nor MySQL's per-table N+1: it is a fixed
5 round-trips regardless of table count. There is no `getSchemaList()`/`getSchemaRelations()`
(no two-phase split), and the returned `TableSchema` has **no `size` field** (only `rowCount` from
`NUM_ROWS`, an optimizer estimate that can be stale/`NULL`).
Automatic connection and DDL refresh use `getSchemaList()` ([`oracle.ts`](../../src/lib/db/providers/sql/oracle.ts)).
It reads only `ALL_TABLES`, filtered by `OWNER = :1` (the connecting user, upper-cased), and returns
table names plus the estimated `NUM_ROWS`. Each entry has `detailsLoaded: false` and empty
`columns`, `indexes`, and `foreignKeys` arrays. Expanding a table or using a table-level tool calls
`getTableSchema(tableName)`, which runs the five detail queries with both owner and table name bound
on every query; it returns `null` when no matching visible table exists.

`getSchema()` remains the full-schema path and runs the five bulk queries over the `ALL_*`
data-dictionary views — tables, columns, primary keys, foreign keys, and indexes — all filtered by
`OWNER = :1` and grouped in memory. This is a fixed five round-trips regardless of table count.
There is still no `getSchemaRelations()`, and the returned `TableSchema` has **no `size` field**
(only `rowCount` from `NUM_ROWS`, an optimizer estimate that can be stale/`NULL`).

### 3.4 No transaction auto-rollback timeout

Expand Down Expand Up @@ -832,8 +837,8 @@ Surfaced via `POST /api/db/transaction`.

## 7. Schema introspection

`getSchema()` returns one `TableSchema` per table owned by the connecting user. Five `ALL_*` queries
(`OWNER = :user`), grouped client-side:
`getSchema()` remains the complete schema read: one `TableSchema` per table owned by the connecting
user, populated by five `ALL_*` queries (`OWNER = :user`) grouped client-side:

| Data | Source view(s) |
|------|----------------|
Expand All @@ -843,7 +848,15 @@ Surfaced via `POST /api/db/transaction`.
| Foreign keys | `ALL_CONSTRAINTS` (type `'R'`) joined to the referenced constraint's columns |
| Indexes | `ALL_INDEXES` + `ALL_IND_COLUMNS` (`unique` = `UNIQUENESS = 'UNIQUE'`) |

No `getSchemaList()`/`getSchemaRelations()`; no `size` on the returned tables (see [§3.3](#33-owner-scoped-five-query-schema-introspection)).
`getSchemaList()` is the fast inventory used by automatic connection and DDL refresh. It reads only
`ALL_TABLES` and returns names plus estimated `NUM_ROWS`, with `detailsLoaded: false` and empty
`columns`, `indexes`, and `foreignKeys`. Table expansion and table-level tools call
`getTableSchema(tableName)`, which repeats the five queries with `OWNER` and `TABLE_NAME` bound on
each query and returns `null` when the table is not visible. There is no `getSchemaRelations()`;
tables have no `size` field (see [§3.3](#33-owner-scoped-two-phase-schema-introspection)).

Both shells show 100 tables per page and search all table names plus loaded columns. ERD, Docs, and
SchemaDiff require the user to click **Load full schema**; the full read may still be expensive.

---

Expand Down Expand Up @@ -1235,12 +1248,15 @@ const provider = await createDatabaseProvider({

await provider.connect();
const res = await provider.query('SELECT id, email FROM users WHERE active = :1', [1]);
const schema = await provider.getSchema(); // 5 ALL_* queries, grouped in memory
const tables = await provider.getSchemaList(); // 1 ALL_TABLES query; detailsLoaded: false
const users = await provider.getTableSchema('USERS'); // 5 owner/table-bound queries, or null
const schema = await provider.getSchema(); // full 5 ALL_* queries, grouped in memory
await provider.disconnect();
```

Over the API: `POST /api/db/query`, `POST /api/db/transaction`, `POST /api/db/cancel`,
`POST /api/db/maintenance` (admin), `POST /api/db/schema/list` (falls back to `getSchema()`).
`POST /api/db/maintenance` (admin), `POST /api/db/schema/list` (fast inventory), and
`POST /api/db/schema?table=...` (one-element detail response or 404).

---

Expand Down Expand Up @@ -1324,7 +1340,10 @@ Over the API: `POST /api/db/query`, `POST /api/db/transaction`, `POST /api/db/ca
([§7.2](#72-when-the-connection-count-is-not-measurable)). `getPerformanceMetrics()` reports only the cache-hit ratio (no QPS,
deadlocks, or buffer-pool usage), and **omits even that** when `V$SYSSTAT` is unreadable rather
than substituting a figure — [§7.1](#71-when-the-cache-hit-ratio-is-not-measurable).
- **No two-phase schema loading** — `/api/db/schema/list` falls back to the full `getSchema()`.
- **Schema loading is two-phase.** Automatic connection and DDL refresh use the cheap
`getSchemaList()` inventory; table expansion and table-level tools load one table on demand.
Both shells use the same 100-row paging and search table names plus loaded columns. ERD, Docs, and
SchemaDiff require **Load full schema**, and that complete read may still be expensive.

---

Expand Down
10 changes: 3 additions & 7 deletions src/app/api/db/schema/list/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@ import { handleSchemaRequest } from "@/lib/api/schema-route";
export const dynamic = "force-dynamic";

/**
* Fast structural schema (tables + columns + PKs), excluding the expensive
* foreign-key/index introspection. Used by the schema explorer to render the
* table tree immediately; relationships/indexes are fetched separately via
* /api/db/schema/relations and merged in asynchronously.
*
* Falls back to the full getSchema() for providers that don't implement the
* fast path, so non-postgres databases keep working unchanged.
* Fast schema list. Structural lists are enriched with /schema/relations;
* name-only inventories (detailsLoaded:false) use /schema?table=... on demand.
* Providers without a fast list fall back to the full getSchema().
*/
export async function POST(req: NextRequest) {
return handleSchemaRequest(req, "api/db/schema/list", (provider) =>
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/db/schema/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ export async function POST(req: NextRequest) {
}

const provider = await getOrCreateProvider(connection);
const tableName = new URL(req.url).searchParams.get("table");
if (tableName !== null) {
if (!tableName) return NextResponse.json({ error: "Table name is required" }, { status: 400 });
const table = provider.getTableSchema
? await provider.getTableSchema(tableName)
: (await provider.getSchema()).find((entry) => entry.name === tableName);
return table
? NextResponse.json([table])
: NextResponse.json({ error: "Table no longer exists or is not visible" }, { status: 404 });
}
const schema = await provider.getSchema();

return NextResponse.json(schema);
Expand Down
19 changes: 15 additions & 4 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,19 @@ export function CommandPalette({
onLogout,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const visibleTables = useMemo(() => {
if (schema.length <= 100) return schema;
return schema.filter((table) => table.name.toLowerCase().includes(search.toLowerCase())).slice(0, 100);
}, [schema, search]);

// Register Cmd+K / Ctrl+K keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
setSearch("");
}
};
document.addEventListener("keydown", handleKeyDown);
Expand All @@ -106,7 +112,12 @@ export function CommandPalette({
className="sm:max-w-[560px] bg-surface border-hairline-strong"
showCloseButton={false}
>
<CommandInput placeholder="Search tables, connections, queries, actions..." className="text-fg" />
<CommandInput
value={search}
onValueChange={setSearch}
placeholder="Search tables, connections, queries, actions..."
className="text-fg"
/>
<CommandList className="max-h-[400px]">
<CommandEmpty className="text-fg-muted">No results found.</CommandEmpty>

Expand Down Expand Up @@ -180,13 +191,13 @@ export function CommandPalette({

{/* Tables */}
{schema.length > 0 && (
<CommandGroup heading="Tables">
{schema.map((table) => (
<CommandGroup heading={schema.length > 100 ? "Tables (up to 100 matches; type to narrow)" : "Tables"}>
{visibleTables.map((table) => (
<CommandItem key={table.name} onSelect={() => runAction(() => onTableClick(table.name))}>
<Table2 strokeWidth={1.5} className="w-3.5 h-3.5 text-fg-muted" />
<span>{table.name}</span>
<span className="ml-auto text-xs text-fg-subtle">
{table.columns.length} cols
{table.detailsLoaded === false ? "Details on demand" : `${table.columns.length} cols`}
{table.rowCount !== undefined && ` / ${table.rowCount} rows`}
</span>
</CommandItem>
Expand Down
38 changes: 31 additions & 7 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { SchemaLoadGate } from "@/components/schema-explorer/SchemaLoadGate";
import type { CsvDelimiter } from "@/lib/export/csv";

import { appFetch } from "@/lib/config/base-path";
Expand Down Expand Up @@ -109,6 +110,7 @@ export default function Studio() {
activeConnection: conn.activeConnection,
metadata,
schema: conn.schema,
ensureSchema: conn.ensureSchema,
});

// 4. Transaction Control
Expand Down Expand Up @@ -412,6 +414,16 @@ export default function Studio() {
downloadText(file.content, file.mimeType, resultExportFileName(file.extension, hydrated?.runId));
};

const openTableTool = (name: string, open: (name: string) => void) => {
if (conn.schema.find((table) => table.name === name)?.detailsLoaded === false) {
void conn.ensureSchema(name).then((details) => {
if (details) open(name);
});
} else {
open(name);
}
};

const onTableClick = (tableName: string) => {
tabMgr.handleTableClick(tableName, queryExec.executeQuery);
};
Expand Down Expand Up @@ -500,6 +512,7 @@ export default function Studio() {
<>
<ResizablePanel id="studio-sidebar" defaultSize="22" minSize="15" maxSize="35">
<Sidebar
onLoadTable={conn.ensureSchema}
connections={conn.connections}
activeConnection={conn.activeConnection}
schema={conn.schema}
Expand All @@ -521,9 +534,9 @@ export default function Studio() {
onOpenMaintenance={openMaintenance}
databaseType={conn.activeConnection?.type}
metadata={metadata}
onProfileTable={(name) => setProfilerTable(name)}
onGenerateCode={(name) => setCodeGenTable(name)}
onGenerateTestData={(name) => setTestDataTable(name)}
onProfileTable={(name) => openTableTool(name, setProfilerTable)}
onGenerateCode={(name) => openTableTool(name, setCodeGenTable)}
onGenerateTestData={(name) => openTableTool(name, setTestDataTable)}
/>
</ResizablePanel>
<ResizableHandle className="w-1 bg-transparent hover:bg-brand-tint/30 transition-colors" />
Expand Down Expand Up @@ -598,7 +611,15 @@ export default function Studio() {
<React.Suspense
fallback={<ViewLoading label="Loading the diagram" className="absolute inset-0 z-20" />}
>
<SchemaDiagram schema={conn.schema} onClose={() => setShowDiagram(false)} />
<SchemaLoadGate
key={conn.activeConnection?.id}
schema={conn.schema}
onLoadSchema={conn.ensureSchema}
onClose={() => setShowDiagram(false)}
className="absolute inset-0 z-20"
>
<SchemaDiagram schema={conn.schema} onClose={() => setShowDiagram(false)} />
</SchemaLoadGate>
</React.Suspense>
</ChunkBoundary>
)}
Expand Down Expand Up @@ -637,6 +658,8 @@ export default function Studio() {
<div className="md:hidden h-full bg-sunken overflow-auto p-4">
{conn.activeConnection ? (
<SchemaExplorer
key={conn.activeConnection?.id}
onLoadTable={conn.ensureSchema}
schema={conn.schema}
isLoadingSchema={conn.isLoadingSchema}
schemaError={conn.schemaError}
Expand All @@ -653,9 +676,9 @@ export default function Studio() {
onOpenMaintenance={openMaintenance}
databaseType={conn.activeConnection?.type}
metadata={metadata}
onProfileTable={(name) => setProfilerTable(name)}
onGenerateCode={(name) => setCodeGenTable(name)}
onGenerateTestData={(name) => setTestDataTable(name)}
onProfileTable={(name) => openTableTool(name, setProfilerTable)}
onGenerateCode={(name) => openTableTool(name, setCodeGenTable)}
onGenerateTestData={(name) => openTableTool(name, setTestDataTable)}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-fg-muted">
Expand Down Expand Up @@ -708,6 +731,7 @@ export default function Studio() {
<ResizableHandle className="h-1 bg-fill hover:bg-brand-tint/20" />
<ResizablePanel id="studio-editor-bottom" defaultSize="60" minSize="20">
<BottomPanel
onLoadSchema={conn.ensureSchema}
mode={queryExec.bottomPanelMode}
onSetMode={queryExec.setBottomPanelMode}
currentTab={tabMgr.currentTab}
Expand Down
Loading
Loading