@deckbuilderkit/sql-ui 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/sql-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/sql-ui"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@deckbuilderkit/editor": "0.1.1",
23
+ "@deckbuilderkit/ui": "0.1.1"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^18.3.1",
27
+ "react-dom": "^18.3.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^18.3.12",
31
+ "react": "^18.3.1",
32
+ "react-dom": "^18.3.1",
33
+ "@types/react-dom": "^18.3.1"
34
+ }
35
+ }
@@ -0,0 +1,96 @@
1
+ import { copyText, Icon, useToast } from '@deckbuilderkit/ui';
2
+ import { t } from './i18n';
3
+ import type { DbPlanRow } from './types';
4
+
5
+ const operationOf = (r: DbPlanRow) => (r.options ? `${r.operation} ${r.options}` : r.operation);
6
+ const objectOf = (r: DbPlanRow) => (r.objectName ? (r.objectOwner ? `${r.objectOwner}.${r.objectName}` : r.objectName) : '');
7
+ const num = (n: number | null) => (n === null ? '' : String(n));
8
+
9
+ /** The id of the step with the highest cost, leaving out the root (its cost is the total). */
10
+ export function costliestStep(rows: DbPlanRow[]): number | undefined {
11
+ let best: DbPlanRow | undefined;
12
+ for (const r of rows) {
13
+ if (r.parentId === null || r.cost === null) continue;
14
+ if (!best || r.cost > (best.cost ?? -1)) best = r;
15
+ }
16
+ return best?.id;
17
+ }
18
+
19
+ /** A fixed-width text plan in the style of DBMS_XPLAN, with the predicates below the table. */
20
+ export function planText(rows: DbPlanRow[]): string {
21
+ const head = [t('workbench.plan.id'), t('workbench.plan.operation'), t('workbench.plan.object'), t('workbench.plan.rows'), t('workbench.plan.bytes'), t('workbench.plan.cost')];
22
+ const body = rows.map((r) => [String(r.id), `${' '.repeat(r.depth)}${operationOf(r)}`, objectOf(r), num(r.cardinality), num(r.bytes), num(r.cost)]);
23
+ const widths = head.map((h, i) => Math.max(h.length, ...body.map((cells) => cells[i].length)));
24
+ const right = new Set([0, 3, 4, 5]);
25
+ const line = (cells: string[]) => `| ${cells.map((c, i) => (right.has(i) ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(' | ')} |`;
26
+ const rule = `${'-'.repeat(widths.reduce((sum, w) => sum + w + 3, 1))}`;
27
+ const out = [rule, line(head), rule, ...body.map(line), rule];
28
+ const predicates = rows.flatMap((r) => [
29
+ ...(r.accessPredicates ? [`${String(r.id).padStart(3)} - access(${r.accessPredicates})`] : []),
30
+ ...(r.filterPredicates ? [`${String(r.id).padStart(3)} - filter(${r.filterPredicates})`] : []),
31
+ ]);
32
+ if (predicates.length > 0) out.push('', `${t('workbench.plan.predicates')}:`, ...predicates);
33
+ return out.join('\n');
34
+ }
35
+
36
+ interface Props {
37
+ rows: DbPlanRow[];
38
+ onClose?: () => void;
39
+ }
40
+
41
+ /** An EXPLAIN PLAN as an indented tree, the costliest step marked. */
42
+ export function PlanView({ rows, onClose }: Props) {
43
+ const toast = useToast();
44
+ const hot = costliestStep(rows);
45
+ const copy = async () => {
46
+ const ok = await copyText(planText(rows));
47
+ toast({ message: t(ok ? 'workbench.plan.copied' : 'workbench.log.copyFailed') });
48
+ };
49
+ return (
50
+ <div className="wb-plan">
51
+ <div className="row wb-plan-bar">
52
+ <span className="mono-label">{t('workbench.plan.title')}</span>
53
+ <button className="btn btn-sm" onClick={copy} disabled={rows.length === 0}>
54
+ <Icon name="copy" /> {t('workbench.plan.copy')}
55
+ </button>
56
+ {onClose && <button className="btn btn-sm" onClick={onClose}>{t('workbench.plan.close')}</button>}
57
+ </div>
58
+ {rows.length === 0 ? (
59
+ <p className="muted wb-plan-empty">{t('workbench.plan.empty')}</p>
60
+ ) : (
61
+ <div className="wb-plan-scroll">
62
+ <table className="wb-plan-table">
63
+ <thead>
64
+ <tr>
65
+ <th className="num">{t('workbench.plan.id')}</th>
66
+ <th>{t('workbench.plan.operation')}</th>
67
+ <th>{t('workbench.plan.object')}</th>
68
+ <th className="num">{t('workbench.plan.rows')}</th>
69
+ <th className="num">{t('workbench.plan.bytes')}</th>
70
+ <th className="num">{t('workbench.plan.cost')}</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ {rows.map((r) => {
75
+ const predicates = [r.accessPredicates && `access(${r.accessPredicates})`, r.filterPredicates && `filter(${r.filterPredicates})`].filter(Boolean).join('\n');
76
+ return (
77
+ <tr key={r.id} className={r.id === hot ? 'wb-plan-hot' : undefined} title={r.id === hot ? [t('workbench.plan.costliest'), predicates].filter(Boolean).join('\n') : predicates || undefined}>
78
+ <td className="num">{r.id}</td>
79
+ <td style={{ paddingLeft: 6 + r.depth * 16 }}>
80
+ {operationOf(r)}
81
+ {predicates && <span className="wb-plan-pred"> *</span>}
82
+ </td>
83
+ <td>{objectOf(r)}</td>
84
+ <td className="num">{num(r.cardinality)}</td>
85
+ <td className="num">{num(r.bytes)}</td>
86
+ <td className="num">{num(r.cost)}</td>
87
+ </tr>
88
+ );
89
+ })}
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,43 @@
1
+ import { DataTable, OutputPanel, SplitView } from '@deckbuilderkit/ui';
2
+ import { PlanView } from './PlanView';
3
+ import { type useSqlRunner } from './useSqlRunner';
4
+
5
+ export const DEFAULT_RESULTS: [number, number] = [0.65, 0.35];
6
+
7
+ type Runner = ReturnType<typeof useSqlRunner>;
8
+
9
+ interface Props {
10
+ runner: Pick<Runner, 'shown' | 'cols' | 'tableRows' | 'selection' | 'setSelection' | 'lines' | 'clearLog'> & Partial<Pick<Runner, 'plan' | 'closePlan'>>;
11
+ sizes: [number, number];
12
+ onSizes: (sizes: [number, number]) => void;
13
+ widths?: Record<string, number>;
14
+ onWidths?: (widths: Record<string, number>) => void;
15
+ }
16
+
17
+ /** The result table (or the latest execution plan) beside the output log. Column widths are only passed for the demo rows; database results size per run. */
18
+ export function SqlResults({ runner, sizes, onSizes, widths, onWidths }: Props) {
19
+ const { shown, cols, tableRows, selection, setSelection, lines, clearLog, plan, closePlan } = runner;
20
+ return (
21
+ <SplitView direction="row" sizes={sizes} defaultSizes={DEFAULT_RESULTS} onResize={(s) => onSizes([s[0], s[1]])}>
22
+ <div key="table" className="wb-pane">
23
+ {plan ? (
24
+ <PlanView rows={plan} onClose={closePlan} />
25
+ ) : (
26
+ <DataTable
27
+ key={shown.kind === 'db' ? shown.run : 'demo'}
28
+ columns={cols}
29
+ rows={tableRows}
30
+ rowKey={(r) => r.key}
31
+ selection={selection}
32
+ onSelectionChange={setSelection}
33
+ widths={widths}
34
+ onWidthsChange={onWidths}
35
+ />
36
+ )}
37
+ </div>
38
+ <div key="output" className="wb-pane">
39
+ <OutputPanel lines={lines} onClear={clearLog} />
40
+ </div>
41
+ </SplitView>
42
+ );
43
+ }
@@ -0,0 +1,68 @@
1
+ import { toCsv, toTsv } from '@deckbuilderkit/ui/table';
2
+
3
+ export const COPY_FORMATS = ['tsv', 'csv', 'markdown', 'json', 'insert'] as const;
4
+ export type CopyFormat = (typeof COPY_FORMATS)[number];
5
+
6
+ export interface CopyColumn<T> {
7
+ label: string;
8
+ /** The cell as displayed (used by tsv, csv and markdown). */
9
+ text: (row: T) => string;
10
+ /** The raw cell value (used by json and insert). */
11
+ raw: (row: T) => unknown;
12
+ /** `'number'` columns are written unquoted by `insert`; unknown types are quoted. */
13
+ type?: string;
14
+ }
15
+
16
+ export const DEFAULT_TABLE_NAME = 'table_name';
17
+
18
+ const SIMPLE_SELECT = /^\s*select\b[\s\S]*?\bfrom\s+((?:"[^"]+"|[\w$#]+)(?:\s*\.\s*(?:"[^"]+"|[\w$#]+))?)\s*(?:where\b|order\b|group\b|having\b|fetch\b|;|$)/i;
19
+
20
+ /** The table of a simple `select ... from <name>` statement, else `table_name`. */
21
+ export function tableOf(sql: string | undefined): string {
22
+ if (!sql) return DEFAULT_TABLE_NAME;
23
+ const m = SIMPLE_SELECT.exec(sql);
24
+ return m ? m[1]!.replace(/\s*\.\s*/g, '.') : DEFAULT_TABLE_NAME;
25
+ }
26
+
27
+ const mdCell = (text: string) => text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r\n|\r|\n/g, '<br>');
28
+
29
+ const toMarkdown = <T>(cols: CopyColumn<T>[], rows: readonly T[]): string => {
30
+ const line = (cells: string[]) => `| ${cells.map(mdCell).join(' | ')} |`;
31
+ return [line(cols.map((c) => c.label)), line(cols.map(() => '---')), ...rows.map((r) => line(cols.map((c) => c.text(r))))].join('\n');
32
+ };
33
+
34
+ const toJson = <T>(cols: CopyColumn<T>[], rows: readonly T[]): string =>
35
+ JSON.stringify(rows.map((r) => Object.fromEntries(cols.map((c) => [c.label, c.raw(r) ?? null]))), null, 2);
36
+
37
+ const NUMERIC = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
38
+
39
+ /** One SQL literal: null → NULL, numbers bare only in a number column, anything else single-quoted. */
40
+ export function sqlLiteral(value: unknown, type?: string): string {
41
+ if (value === null || value === undefined) return 'NULL';
42
+ if (typeof value === 'boolean') return value ? '1' : '0';
43
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
44
+ if (type === 'number' && NUMERIC.test(text)) return text;
45
+ return `'${text.replace(/'/g, "''")}'`;
46
+ }
47
+
48
+ const toInserts = <T>(cols: CopyColumn<T>[], rows: readonly T[], table: string): string => {
49
+ const names = cols.map((c) => c.label).join(', ');
50
+ return rows.map((r) => `INSERT INTO ${table} (${names}) VALUES (${cols.map((c) => sqlLiteral(c.raw(r), c.type)).join(', ')});`).join('\n');
51
+ };
52
+
53
+ /** The clipboard text of `rows` in `format`. */
54
+ export function formatCopy<T>(format: CopyFormat, cols: CopyColumn<T>[], rows: readonly T[], sql?: string): string {
55
+ const display = cols.map((c) => ({ label: c.label, value: c.text }));
56
+ switch (format) {
57
+ case 'csv':
58
+ return toCsv(display, rows);
59
+ case 'markdown':
60
+ return toMarkdown(cols, rows);
61
+ case 'json':
62
+ return toJson(cols, rows);
63
+ case 'insert':
64
+ return toInserts(cols, rows, tableOf(sql));
65
+ default:
66
+ return toTsv(display, rows);
67
+ }
68
+ }
@@ -0,0 +1,14 @@
1
+ const stripNoise = (sql: string) =>
2
+ sql
3
+ .replace(/--[^\n]*/g, ' ')
4
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
5
+ .replace(/'(?:[^']|'')*'/g, "''")
6
+ .replace(/"(?:[^"]|"")*"/g, '""');
7
+
8
+ /** True when a statement drops or truncates, or deletes or updates without a WHERE. Comments and string contents are ignored. */
9
+ export function isDestructive(sql: string): boolean {
10
+ const text = stripNoise(sql).trim().replace(/;+\s*$/, '');
11
+ if (/^(drop|truncate)\b/i.test(text)) return true;
12
+ if (/^(delete|update)\b/i.test(text)) return !/\bwhere\b/i.test(text);
13
+ return false;
14
+ }
@@ -0,0 +1,25 @@
1
+ import { type ExportColumn, toCsv } from '@deckbuilderkit/ui';
2
+ import { t } from './i18n';
3
+ import type { SqlClient } from './types';
4
+
5
+ export type ExportFormat = 'csv' | 'json';
6
+
7
+ export const EXPORT_ROWS = 10_000;
8
+
9
+ export function toJson<T>(columns: ExportColumn<T>[], rows: T[]): string {
10
+ const objects = rows.map((row) => Object.fromEntries(columns.map((c) => [c.label, c.value(row) ?? null])));
11
+ return JSON.stringify(objects, null, 2);
12
+ }
13
+
14
+ /**
15
+ * Asks where to save, then writes the first `EXPORT_ROWS` rows as CSV or JSON.
16
+ * Returns the toast message, or undefined when the dialog was cancelled.
17
+ */
18
+ export async function exportResults<T>(client: SqlClient, format: ExportFormat, columns: ExportColumn<T>[], rows: T[], truncated: boolean): Promise<string | undefined> {
19
+ const { path } = await client.saveTarget(format);
20
+ if (!path) return undefined;
21
+ const slice = rows.slice(0, EXPORT_ROWS);
22
+ await client.saveDemo(path, format === 'json' ? toJson(columns, slice) : toCsv(columns, slice));
23
+ const partial = truncated || rows.length > EXPORT_ROWS;
24
+ return t(partial ? 'workbench.exported.partial' : 'workbench.exported', { count: slice.length, path });
25
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { createT, type Messages } from '@deckbuilderkit/ui';
2
+
3
+ export const en = {
4
+ 'workbench.destructive.title': 'Run destructive statement?',
5
+ 'workbench.destructive.body': 'This statement drops data or changes every row:',
6
+ 'workbench.destructive.run': 'Run',
7
+ 'workbench.log.timeout': 'No answer after {seconds} s; stopped waiting. The statement may still be running on the server.',
8
+ 'workbench.log.explain': 'Explaining: {query}',
9
+ 'workbench.log.explained': 'Plan: {count} steps',
10
+ 'workbench.log.explainDemo': 'Explain needs a database connection.',
11
+ 'workbench.plan.title': 'Execution plan',
12
+ 'workbench.plan.id': 'Id',
13
+ 'workbench.plan.operation': 'Operation',
14
+ 'workbench.plan.object': 'Object',
15
+ 'workbench.plan.cost': 'Cost',
16
+ 'workbench.plan.rows': 'Rows',
17
+ 'workbench.plan.bytes': 'Bytes',
18
+ 'workbench.plan.predicates': 'Predicates',
19
+ 'workbench.plan.costliest': 'Costliest step',
20
+ 'workbench.plan.empty': 'The plan is empty.',
21
+ 'workbench.plan.copy': 'Copy as text',
22
+ 'workbench.plan.copied': 'Plan copied',
23
+ 'workbench.plan.close': 'Show rows',
24
+ 'workbench.exported': 'Exported {count} rows to {path}',
25
+ 'workbench.exported.partial': 'Exported only the first {count} loaded rows to {path}',
26
+ 'workbench.log.copied': 'Copied {count} rows',
27
+ 'workbench.log.copyFailed': 'The clipboard refused the copy.',
28
+ 'workbench.log.start': 'Running: {query}',
29
+ 'workbench.log.progress': '{count} rows received',
30
+ 'workbench.log.done': 'Done: {count} rows',
31
+ 'workbench.log.cancelled': 'Cancelled after {count} rows',
32
+ 'workbench.log.truncated': 'Showing the first {count} rows; the result has more.',
33
+ 'workbench.log.affected': '{count} rows affected',
34
+ 'workbench.log.executed': 'Statement executed',
35
+ 'workbench.log.committed': 'Committed',
36
+ 'workbench.log.rolledBack': 'Rolled back',
37
+ 'workbench.log.disconnected': 'Disconnected',
38
+ };
39
+
40
+ export type SqlMessageKey = keyof typeof en;
41
+ export const t = createT<SqlMessageKey>();
42
+
43
+ export const ptBR: Messages<typeof en> = {
44
+ 'workbench.destructive.title': 'Rodar comando destrutivo?',
45
+ 'workbench.destructive.body': 'Este comando apaga dados ou altera todas as linhas:',
46
+ 'workbench.destructive.run': 'Rodar',
47
+ 'workbench.log.timeout': 'Sem resposta após {seconds} s; parei de esperar. O comando pode continuar rodando no servidor.',
48
+ 'workbench.log.explain': 'Explicando: {query}',
49
+ 'workbench.log.explained': 'Plano: {count} passos',
50
+ 'workbench.log.explainDemo': 'Explicar exige uma conexão com banco de dados.',
51
+ 'workbench.plan.title': 'Plano de execução',
52
+ 'workbench.plan.id': 'Id',
53
+ 'workbench.plan.operation': 'Operação',
54
+ 'workbench.plan.object': 'Objeto',
55
+ 'workbench.plan.cost': 'Custo',
56
+ 'workbench.plan.rows': 'Linhas',
57
+ 'workbench.plan.bytes': 'Bytes',
58
+ 'workbench.plan.predicates': 'Predicados',
59
+ 'workbench.plan.costliest': 'Passo mais caro',
60
+ 'workbench.plan.empty': 'O plano está vazio.',
61
+ 'workbench.plan.copy': 'Copiar como texto',
62
+ 'workbench.plan.copied': 'Plano copiado',
63
+ 'workbench.plan.close': 'Mostrar linhas',
64
+ 'workbench.exported': '{count} linhas exportadas para {path}',
65
+ 'workbench.exported.partial': 'Apenas as primeiras {count} linhas carregadas foram exportadas para {path}',
66
+ 'workbench.log.copied': '{count} linhas copiadas',
67
+ 'workbench.log.copyFailed': 'A área de transferência recusou a cópia.',
68
+ 'workbench.log.start': 'Executando: {query}',
69
+ 'workbench.log.progress': '{count} linhas recebidas',
70
+ 'workbench.log.done': 'Concluído: {count} linhas',
71
+ 'workbench.log.cancelled': 'Cancelado após {count} linhas',
72
+ 'workbench.log.truncated': 'Mostrando as primeiras {count} linhas; o resultado tem mais.',
73
+ 'workbench.log.affected': '{count} linhas afetadas',
74
+ 'workbench.log.executed': 'Comando executado',
75
+ 'workbench.log.committed': 'Confirmado (commit)',
76
+ 'workbench.log.rolledBack': 'Desfeito (rollback)',
77
+ 'workbench.log.disconnected': 'Desconectado',
78
+ };
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { COPY_FORMATS, type CopyColumn, type CopyFormat, formatCopy } from './copy-format';
2
+ export { isDestructive } from './destructive';
3
+ export { EXPORT_ROWS, type ExportFormat, exportResults } from './export-results';
4
+ export { en as sqlEn, ptBR as sqlPtBR, type SqlMessageKey } from './i18n';
5
+ export { PlanView } from './PlanView';
6
+ export { DEFAULT_RESULTS, SqlResults } from './SqlResults';
7
+ export * from './types';
8
+ export { type Shown, type SqlRunnerSettings, type TableRow, useSqlRunner } from './useSqlRunner';
9
+ export { type TableDetailsEntry, useSqlSchema, useTableDetails } from './useSqlSchema';
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ export interface DemoRow {
2
+ id: number;
3
+ name: string;
4
+ amount: number;
5
+ date: string;
6
+ }
7
+
8
+ export interface DbStatus {
9
+ connected: boolean;
10
+ /** Uncommitted changes may exist. */
11
+ pending: boolean;
12
+ busy: boolean;
13
+ }
14
+
15
+ export interface DbColumn {
16
+ name: string;
17
+ type: 'number' | 'text' | 'date' | 'binary';
18
+ /** A TIMESTAMP column (also reported as type `date`). */
19
+ timestamp?: boolean;
20
+ }
21
+
22
+ export interface DbSchemaTable {
23
+ name: string;
24
+ kind: 'table' | 'view';
25
+ columns: Array<{ name: string; type: string; nullable: boolean }>;
26
+ }
27
+
28
+ export interface DbSchema {
29
+ owner: string;
30
+ owners: string[];
31
+ tables: DbSchemaTable[];
32
+ truncated: boolean;
33
+ }
34
+
35
+ export interface DbTableIndex {
36
+ name: string;
37
+ unique: boolean;
38
+ columns: string[];
39
+ }
40
+
41
+ export interface DbTableConstraint {
42
+ name: string;
43
+ /** Primary key, unique, foreign key (references), check. */
44
+ type: 'P' | 'U' | 'R' | 'C';
45
+ columns: string[];
46
+ condition?: string;
47
+ references?: { owner: string; table: string; constraint: string; columns: string[] };
48
+ }
49
+
50
+ export interface DbTableDetails {
51
+ owner: string;
52
+ name: string;
53
+ indexes: DbTableIndex[];
54
+ constraints: DbTableConstraint[];
55
+ ddl: string | null;
56
+ }
57
+
58
+ /** One step of an EXPLAIN PLAN, as PLAN_TABLE holds it. */
59
+ export interface DbPlanRow {
60
+ id: number;
61
+ parentId: number | null;
62
+ depth: number;
63
+ operation: string;
64
+ options: string | null;
65
+ objectOwner: string | null;
66
+ objectName: string | null;
67
+ cost: number | null;
68
+ cardinality: number | null;
69
+ bytes: number | null;
70
+ accessPredicates: string | null;
71
+ filterPredicates: string | null;
72
+ }
73
+
74
+ export type DbRunResult = { kind: 'rows'; run: string; columns: DbColumn[] } | { kind: 'done'; rowsAffected?: number };
75
+
76
+ /** A saved database connection; the password is never returned, only whether one is stored. */
77
+ export interface DbConnection {
78
+ id: string;
79
+ name: string;
80
+ url: string;
81
+ user: string;
82
+ password?: { hasSecret: true };
83
+ }
84
+
85
+ /** The server calls the SQL hooks make; the app builds it from its api and passes it to each hook. */
86
+ export interface SqlClient {
87
+ connections: () => Promise<DbConnection[]>;
88
+ saveTarget: (format: 'csv' | 'json') => Promise<{ path: string | null }>;
89
+ saveDemo: (path: string, text: string) => Promise<unknown>;
90
+ dbRun: (id: string, sql: string, maxRows?: number) => Promise<DbRunResult>;
91
+ dbExplain: (id: string, sql: string) => Promise<DbPlanRow[]>;
92
+ dbTable: (id: string, owner: string, name: string) => Promise<DbTableDetails>;
93
+ dbSchema: (id: string, owner?: string) => Promise<DbSchema>;
94
+ dbCommit: (id: string) => Promise<DbStatus>;
95
+ dbRollback: (id: string) => Promise<DbStatus>;
96
+ dbDisconnect: (id: string) => Promise<DbStatus>;
97
+ dbStatus: (id: string) => Promise<DbStatus>;
98
+ }
@@ -0,0 +1,306 @@
1
+ import { createElement, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { type RunMode, splitStatements } from '@deckbuilderkit/editor';
3
+ import { type ApiError, type DataTableColumn, type OutputLine, type ResultFormat, copyText, errorMessage, errorOf, formatCell, useStream } from '@deckbuilderkit/ui';
4
+ import { type CopyColumn, type CopyFormat, formatCopy } from './copy-format';
5
+ import { isDestructive } from './destructive';
6
+ import { t } from './i18n';
7
+ import type { DbColumn, DbPlanRow, DbStatus, DemoRow, SqlClient } from './types';
8
+
9
+ const ROW_COUNT = 50_000;
10
+ const MAX_STREAM_ITEMS = 100_001;
11
+ const PROGRESS_EVERY = 10_000;
12
+ const BANNER_CODES = ['dbLogin', 'dbUnreachable'];
13
+
14
+ export interface TableRow {
15
+ key: string;
16
+ cells: unknown[];
17
+ }
18
+
19
+ /** What the table shows: nothing yet, the demo rows, or the columns of one database result. */
20
+ export type Shown = { kind: 'none' } | { kind: 'demo' } | { kind: 'db'; run: string; columns: DbColumn[] };
21
+
22
+ const demoColumns = (): DataTableColumn<TableRow>[] => [
23
+ { id: 'id', label: 'id', width: 80, value: (r) => r.cells[0], align: 'right' },
24
+ { id: 'name', label: 'name', width: 200, value: (r) => r.cells[1] },
25
+ { id: 'amount', label: 'amount', width: 120, value: (r) => r.cells[2], align: 'right' },
26
+ { id: 'date', label: 'date', width: 140, value: (r) => r.cells[3] },
27
+ ];
28
+
29
+ const dbTableColumns = (columns: DbColumn[], format: ResultFormat): DataTableColumn<TableRow>[] =>
30
+ columns.map((c, i) => {
31
+ const cell = (r: TableRow) => formatCell(r.cells[i], c.type, format, c.timestamp ? 'timestamp' : 'date');
32
+ return {
33
+ id: `c${i}`,
34
+ label: c.name,
35
+ width: c.type === 'number' ? 120 : 160,
36
+ value: (r) => r.cells[i],
37
+ render: (r) => {
38
+ const { text, dim } = cell(r);
39
+ return dim ? createElement('span', { className: 'dtable-null' }, text) : text;
40
+ },
41
+ copy: (r) => cell(r).text,
42
+ align: c.type === 'number' ? 'right' : 'left',
43
+ };
44
+ });
45
+
46
+ const demoCells = (r: DemoRow) => [r.id, r.name, r.amount, r.date];
47
+ const isTruncatedMarker = (item: unknown) => typeof item === 'object' && item !== null && !Array.isArray(item) && '$truncated' in item;
48
+
49
+ type Confirm = (opts: { title: string; body?: string; okLabel?: string; danger?: boolean }) => Promise<boolean>;
50
+
51
+ /** Rejects with a timeout error after `seconds` (never, when 0), leaving `promise` running. */
52
+ function withTimeout<T>(promise: Promise<T>, seconds: number): Promise<T> {
53
+ if (seconds <= 0) return promise;
54
+ let timer: ReturnType<typeof setTimeout>;
55
+ const limit = new Promise<never>((_, reject) => {
56
+ timer = setTimeout(() => reject(new Error(t('workbench.log.timeout', { seconds }))), seconds * 1000);
57
+ });
58
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer));
59
+ }
60
+
61
+ /** What the runner reads from the app's settings; the app resolves the defaults. */
62
+ export interface SqlRunnerSettings {
63
+ /** Most rows a database query returns. */
64
+ maxRows: number;
65
+ results: ResultFormat;
66
+ /** Format of `copy`; TSV when omitted. */
67
+ copyFormat?: CopyFormat;
68
+ /** Ask `confirm` before a destructive statement; needs `confirm`. */
69
+ confirmDestructive?: boolean;
70
+ /** Seconds to wait for a database statement; 0 or omitted means no limit. */
71
+ queryTimeout?: number;
72
+ }
73
+
74
+ /** Runs SQL on connection `db` (the demo rows when undefined) and holds the result table, the log and the transaction state. */
75
+ export function useSqlRunner(client: SqlClient, db: string | undefined, settings: SqlRunnerSettings, confirm?: Confirm) {
76
+ const [lines, setLines] = useState<OutputLine[]>([]);
77
+ const [actionError, setActionError] = useState<ApiError>();
78
+ const [selection, setSelection] = useState<string[]>([]);
79
+ const [shown, setShown] = useState<Shown>({ kind: 'none' });
80
+ const [starting, setStarting] = useState(false);
81
+ const [status, setStatus] = useState<DbStatus>();
82
+ const [plan, setPlan] = useState<DbPlanRow[]>();
83
+ const [explaining, setExplaining] = useState(false);
84
+ const rows = useStream<unknown>({ maxItems: MAX_STREAM_ITEMS });
85
+ const cancelled = useRef(false);
86
+ const reported = useRef(0);
87
+ const wasRunning = useRef(false);
88
+ const lastSql = useRef<string>();
89
+
90
+ const maxRows = settings.maxRows;
91
+
92
+ const log = (text: string, tone?: OutputLine['tone']) => setLines((l) => [...l, { text, tone, at: Date.now() }]);
93
+
94
+ const truncated = rows.items.length > 0 && isTruncatedMarker(rows.items[rows.items.length - 1]);
95
+ const tableRows = useMemo<TableRow[]>(() => {
96
+ if (shown.kind === 'none') return [];
97
+ const body = truncated ? rows.items.slice(0, -1) : rows.items;
98
+ return body.map((item, i) => ({ key: String(i), cells: shown.kind === 'demo' ? demoCells(item as DemoRow) : (item as unknown[]) }));
99
+ }, [rows.items, shown, truncated]);
100
+
101
+ const cols = useMemo(() => (shown.kind === 'db' ? dbTableColumns(shown.columns, settings.results) : demoColumns()), [shown, settings.results]);
102
+
103
+ const refreshStatus = () => {
104
+ if (db) client.dbStatus(db).then(setStatus, () => {});
105
+ };
106
+
107
+ useEffect(() => {
108
+ setStatus(undefined);
109
+ refreshStatus();
110
+ window.addEventListener('focus', refreshStatus);
111
+ return () => window.removeEventListener('focus', refreshStatus);
112
+ // Deps are deliberately partial.
113
+ }, [db]);
114
+
115
+ const fail = (err: unknown) => {
116
+ const error = errorOf(err);
117
+ log(errorMessage(error), 'err');
118
+ if (error.code && BANNER_CODES.includes(error.code)) setActionError(error);
119
+ };
120
+
121
+ const begin = () => {
122
+ cancelled.current = false;
123
+ reported.current = 0;
124
+ setActionError(undefined);
125
+ setSelection([]);
126
+ setPlan(undefined);
127
+ };
128
+
129
+ const runDemo = (sql: string) => {
130
+ begin();
131
+ log(t('workbench.log.start', { query: sql.trim() }));
132
+ setShown({ kind: 'demo' });
133
+ rows.start('/api/demo/rows', { count: ROW_COUNT });
134
+ };
135
+
136
+ /** Runs the statements in order, each logged; stops at the first error. Only the last result set is shown. */
137
+ const runDb = async (id: string, statements: string[]) => {
138
+ begin();
139
+ setStarting(true);
140
+ try {
141
+ for (const [i, sql] of statements.entries()) {
142
+ log(t('workbench.log.start', { query: sql.trim() }));
143
+ lastSql.current = sql;
144
+ const last = i === statements.length - 1;
145
+ const result = await withTimeout(client.dbRun(id, sql, maxRows), settings.queryTimeout ?? 0);
146
+ if (result.kind === 'rows' && last) {
147
+ setShown({ kind: 'db', run: result.run, columns: result.columns });
148
+ rows.start('/api/db/rows', { run: result.run });
149
+ } else if (result.kind === 'rows') {
150
+ log(t('workbench.log.executed'), 'ok');
151
+ } else {
152
+ if (last) setShown({ kind: 'none' });
153
+ log(result.rowsAffected === undefined ? t('workbench.log.executed') : t('workbench.log.affected', { count: result.rowsAffected }), 'ok');
154
+ }
155
+ }
156
+ refreshStatus();
157
+ } catch (err) {
158
+ fail(err);
159
+ refreshStatus();
160
+ } finally {
161
+ setStarting(false);
162
+ }
163
+ };
164
+
165
+ const run = async (text: string, mode: RunMode) => {
166
+ if (rows.running || starting) return;
167
+ if (!db) {
168
+ runDemo(text);
169
+ return;
170
+ }
171
+ const statements = mode === 'all' ? splitStatements(text).map((s) => s.text) : [text];
172
+ if (statements.length === 0 || statements.every((s) => !s.trim())) return;
173
+ const risky = statements.filter(isDestructive);
174
+ if (risky.length > 0 && confirm && settings.confirmDestructive) {
175
+ const ok = await confirm({
176
+ title: t('workbench.destructive.title'),
177
+ body: `${t('workbench.destructive.body')}\n${risky.map((s) => s.trim()).join('\n')}`.slice(0, 600),
178
+ okLabel: t('workbench.destructive.run'),
179
+ danger: true,
180
+ });
181
+ if (!ok) return;
182
+ }
183
+ void runDb(db, statements);
184
+ };
185
+
186
+ /** Shows the execution plan of `text` without running it: no rows, no history entry, no transaction change. */
187
+ const explain = async (text: string) => {
188
+ if (rows.running || starting || explaining || !text.trim()) return;
189
+ if (!db) {
190
+ log(t('workbench.log.explainDemo'), 'warn');
191
+ return;
192
+ }
193
+ setActionError(undefined);
194
+ setExplaining(true);
195
+ log(t('workbench.log.explain', { query: text.trim() }));
196
+ try {
197
+ const steps = await withTimeout(client.dbExplain(db, text), settings.queryTimeout ?? 0);
198
+ setPlan(steps);
199
+ log(t('workbench.log.explained', { count: steps.length }), 'ok');
200
+ } catch (err) {
201
+ fail(err);
202
+ } finally {
203
+ setExplaining(false);
204
+ }
205
+ };
206
+
207
+ const cancel = () => {
208
+ cancelled.current = true;
209
+ rows.cancel();
210
+ };
211
+
212
+ /** Cancels and empties the table, e.g. when the connection changes. */
213
+ const reset = () => {
214
+ cancel();
215
+ setActionError(undefined);
216
+ setSelection([]);
217
+ setPlan(undefined);
218
+ setShown({ kind: 'none' });
219
+ };
220
+
221
+ // Progress lines every 10 000 rows, then one line when the stream ends.
222
+ useEffect(() => {
223
+ while (rows.running && tableRows.length >= reported.current + PROGRESS_EVERY) {
224
+ reported.current += PROGRESS_EVERY;
225
+ log(t('workbench.log.progress', { count: reported.current }));
226
+ }
227
+ if (wasRunning.current && !rows.running) {
228
+ if (cancelled.current) log(t('workbench.log.cancelled', { count: tableRows.length }), 'warn');
229
+ else if (rows.error) fail(rows.error);
230
+ else {
231
+ log(t('workbench.log.done', { count: tableRows.length }), 'ok');
232
+ if (truncated) log(t('workbench.log.truncated', { count: tableRows.length }), 'warn');
233
+ }
234
+ refreshStatus();
235
+ }
236
+ wasRunning.current = rows.running;
237
+ // Deps are deliberately partial.
238
+ }, [tableRows.length, rows.running]);
239
+
240
+ const finishTransaction = async (op: 'commit' | 'rollback') => {
241
+ if (!db) return;
242
+ setActionError(undefined);
243
+ try {
244
+ setStatus(await (op === 'commit' ? client.dbCommit(db) : client.dbRollback(db)));
245
+ log(t(op === 'commit' ? 'workbench.log.committed' : 'workbench.log.rolledBack'), 'ok');
246
+ } catch (err) {
247
+ fail(err);
248
+ }
249
+ };
250
+
251
+ /** Copies the selected rows (all when none is selected) in the copy format setting, and logs the outcome. */
252
+ const copy = async () => {
253
+ const chosen = new Set(selection);
254
+ const picked = selection.length > 0 ? tableRows.filter((r) => chosen.has(r.key)) : tableRows;
255
+ const columns: CopyColumn<TableRow>[] = cols.map((c, i) => ({
256
+ label: c.label,
257
+ text: (r) => String((c.copy ?? c.value)(r) ?? ''),
258
+ raw: (r) => r.cells[i],
259
+ type: shown.kind === 'db' ? shown.columns[i]?.type : undefined,
260
+ }));
261
+ if (await copyText(formatCopy(settings.copyFormat ?? 'tsv', columns, picked, lastSql.current))) log(t('workbench.log.copied', { count: picked.length }), 'ok');
262
+ else log(t('workbench.log.copyFailed'), 'err');
263
+ };
264
+
265
+ /** Cancels the running query and closes the connection; the caller confirms a pending transaction first. */
266
+ const disconnect = async () => {
267
+ if (!db) return;
268
+ cancel();
269
+ try {
270
+ setStatus(await client.dbDisconnect(db));
271
+ log(t('workbench.log.disconnected'), 'ok');
272
+ } catch (err) {
273
+ fail(err);
274
+ }
275
+ };
276
+
277
+ return {
278
+ run,
279
+ explain,
280
+ explaining,
281
+ plan,
282
+ closePlan: () => setPlan(undefined),
283
+ cancel,
284
+ reset,
285
+ busy: rows.running || starting,
286
+ running: rows.running,
287
+ starting,
288
+ shown,
289
+ cols,
290
+ tableRows,
291
+ truncated,
292
+ selection,
293
+ setSelection,
294
+ lines,
295
+ log,
296
+ clearLog: () => setLines([]),
297
+ actionError,
298
+ setActionError,
299
+ status,
300
+ refreshStatus,
301
+ commit: () => finishTransaction('commit'),
302
+ rollback: () => finishTransaction('rollback'),
303
+ disconnect,
304
+ copy,
305
+ };
306
+ }
@@ -0,0 +1,134 @@
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
2
+ import { type ApiError, errorOf } from '@deckbuilderkit/ui';
3
+ import type { DbSchema, DbTableDetails, SqlClient } from './types';
4
+
5
+ interface Entry {
6
+ data?: DbSchema;
7
+ error?: ApiError;
8
+ loading: boolean;
9
+ }
10
+
11
+ const IDLE: Entry = { loading: false };
12
+ const entries = new Map<string, Entry>();
13
+ const requests = new Map<string, number>();
14
+ const listeners = new Set<() => void>();
15
+
16
+ const keyOf = (db: string, owner: string | undefined) => `${db}\n${owner ?? ''}`;
17
+
18
+ const subscribe = (listener: () => void) => {
19
+ listeners.add(listener);
20
+ return () => {
21
+ listeners.delete(listener);
22
+ };
23
+ };
24
+
25
+ const store = (key: string, entry: Entry) => {
26
+ entries.set(key, entry);
27
+ listeners.forEach((listener) => listener());
28
+ };
29
+
30
+ /** Fetches the schema into the cache; a newer request for the same key wins. The old data stays until the new one arrives. */
31
+ const fetchSchema = (client: SqlClient, db: string, owner: string | undefined) => {
32
+ const key = keyOf(db, owner);
33
+ const request = (requests.get(key) ?? 0) + 1;
34
+ requests.set(key, request);
35
+ store(key, { data: entries.get(key)?.data, loading: true });
36
+ client.dbSchema(db, owner).then(
37
+ (data) => {
38
+ if (requests.get(key) === request) store(key, { data, loading: false });
39
+ },
40
+ (err) => {
41
+ if (requests.get(key) === request) store(key, { data: entries.get(key)?.data, error: errorOf(err), loading: false });
42
+ },
43
+ );
44
+ };
45
+
46
+ const ensure = (client: SqlClient, db: string, owner: string | undefined) => {
47
+ const entry = entries.get(keyOf(db, owner));
48
+ if (!entry || (!entry.data && !entry.loading)) fetchSchema(client, db, owner);
49
+ };
50
+
51
+ /** Schema of connection `db` (which connects), cached per connection and owner, shared by every consumer. */
52
+ export function useSqlSchema(client: SqlClient, db: string | undefined) {
53
+ const [owner, setOwner] = useState<string>();
54
+ const key = db ? keyOf(db, owner) : undefined;
55
+ const entry = useSyncExternalStore(subscribe, () => (key ? (entries.get(key) ?? IDLE) : IDLE));
56
+
57
+ useEffect(() => {
58
+ if (db) ensure(client, db, owner);
59
+ }, [db, owner]);
60
+
61
+ const sqlSchema = useMemo(() => {
62
+ const out: Record<string, string[]> = {};
63
+ for (const table of entry.data?.tables ?? []) out[table.name] = table.columns.map((c) => c.name);
64
+ return out;
65
+ }, [entry.data]);
66
+
67
+ return {
68
+ schema: entry.data,
69
+ loading: !!db && (entry.loading || (!entry.data && !entry.error)),
70
+ error: entry.error,
71
+ owner,
72
+ setOwner,
73
+ reload: () => {
74
+ if (!db) return;
75
+ dropDetails(db);
76
+ fetchSchema(client, db, owner);
77
+ },
78
+ sqlSchema,
79
+ };
80
+ }
81
+
82
+ export interface TableDetailsEntry {
83
+ data?: DbTableDetails;
84
+ error?: ApiError;
85
+ loading: boolean;
86
+ }
87
+
88
+ const details = new Map<string, TableDetailsEntry>();
89
+ const inflight = new Map<string, Promise<DbTableDetails | undefined>>();
90
+ let detailsVersion = 0;
91
+ const detailsKey = (db: string, owner: string, name: string) => `${db}\n${owner}\n${name}`;
92
+
93
+ const storeDetails = (key: string, entry: TableDetailsEntry) => {
94
+ details.set(key, entry);
95
+ detailsVersion++;
96
+ listeners.forEach((listener) => listener());
97
+ };
98
+
99
+ const dropDetails = (db: string) => {
100
+ for (const key of details.keys()) if (key.startsWith(`${db}\n`)) details.delete(key);
101
+ detailsVersion++;
102
+ listeners.forEach((listener) => listener());
103
+ };
104
+
105
+ /** Indexes, constraints and DDL per table of connection `db`, fetched once on `load` and cached until the schema reloads. */
106
+ export function useTableDetails(client: SqlClient, db: string | undefined) {
107
+ useSyncExternalStore(subscribe, () => detailsVersion);
108
+ return {
109
+ get: (owner: string, name: string) => (db ? details.get(detailsKey(db, owner, name)) : undefined),
110
+ /** Resolves with the details, or undefined when they failed (the entry then holds the error). */
111
+ load: (owner: string, name: string): Promise<DbTableDetails | undefined> => {
112
+ if (!db) return Promise.resolve(undefined);
113
+ const key = detailsKey(db, owner, name);
114
+ const entry = details.get(key);
115
+ if (entry?.data) return Promise.resolve(entry.data);
116
+ const pending = inflight.get(key);
117
+ if (pending) return pending;
118
+ storeDetails(key, { loading: true });
119
+ const request = client.dbTable(db, owner, name).then(
120
+ (data) => {
121
+ storeDetails(key, { data, loading: false });
122
+ return data;
123
+ },
124
+ (err) => {
125
+ storeDetails(key, { error: errorOf(err), loading: false });
126
+ return undefined;
127
+ },
128
+ );
129
+ inflight.set(key, request);
130
+ void request.finally(() => inflight.delete(key));
131
+ return request;
132
+ },
133
+ };
134
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.