From db36f7d66d6af1adc29918301d7b0f700a040ff0 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:17:44 -0400 Subject: [PATCH 1/5] test: frontend SQL execution and results --- packages/rust/src/cli/frontend.js | 12 + packages/rust/tests/cli_e2e.rs | 44 +++ packages/rust/tests/frontend/query.test.mjs | 385 ++++++++++++++++++++ packages/rust/tests/frontend_js.rs | 20 + 4 files changed, 461 insertions(+) create mode 100644 packages/rust/src/cli/frontend.js create mode 100644 packages/rust/tests/frontend/query.test.mjs create mode 100644 packages/rust/tests/frontend_js.rs diff --git a/packages/rust/src/cli/frontend.js b/packages/rust/src/cli/frontend.js new file mode 100644 index 000000000..513aff530 --- /dev/null +++ b/packages/rust/src/cli/frontend.js @@ -0,0 +1,12 @@ +export const STARTER_QUERY = ''; +export const DISPLAY_ROW_LIMIT = 0; + +export function formatCell() { + return { text: '', isNull: false }; +} + +export function createQueryRunner() { + return { run: async () => {}, state: () => ({ status: 'idle' }) }; +} + +export function renderResult() {} diff --git a/packages/rust/tests/cli_e2e.rs b/packages/rust/tests/cli_e2e.rs index 6c3752650..e921523ce 100644 --- a/packages/rust/tests/cli_e2e.rs +++ b/packages/rust/tests/cli_e2e.rs @@ -200,6 +200,50 @@ fn frontend_is_opt_in_and_serves_a_bundled_page() { kill_and_wait(server); } +#[test] +fn frontend_page_has_query_controls_and_serves_its_script() { + let root = TempDir::new().unwrap(); + let port = free_port(); + let server = spawn_dirsql(root.path(), port); + wait_until_ready(port, Duration::from_secs(5)); + let client = Client::new(); + let base = format!("http://localhost:{port}"); + assert_eq!( + client + .get(format!("{base}/frontend.js")) + .send() + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + kill_and_wait(server); + + let server = spawn_dirsql_with_args(root.path(), port, &["--frontend"]); + wait_until_ready(port, Duration::from_secs(5)); + let page = client.get(&base).send().unwrap().text().unwrap(); + assert!(page.contains("().unwrap()["error"].is_string()); + kill_and_wait(server); +} + #[test] fn frontend_still_loads_and_reports_an_invalid_config() { let root = TempDir::new().unwrap(); diff --git a/packages/rust/tests/frontend/query.test.mjs b/packages/rust/tests/frontend/query.test.mjs new file mode 100644 index 000000000..ed127a34b --- /dev/null +++ b/packages/rust/tests/frontend/query.test.mjs @@ -0,0 +1,385 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DISPLAY_ROW_LIMIT, + STARTER_QUERY, + createQueryRunner, + formatCell, + renderResult, +} from '../../src/cli/frontend.js'; + +function response(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (body === undefined) throw new SyntaxError('no body'); + return body; + }, + }; +} + +function clock(...ticks) { + let i = 0; + return () => ticks[Math.min(i++, ticks.length - 1)]; +} + +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function fakeDocument() { + function element(tag) { + const el = { + tag, + children: [], + attrs: {}, + className: '', + _text: '', + get textContent() { + return el._text + el.children.map((c) => c.textContent).join(''); + }, + set textContent(v) { + el._text = String(v); + el.children = []; + }, + set innerHTML(_) { + throw new Error('innerHTML must never be used'); + }, + setAttribute(k, v) { + el.attrs[k] = String(v); + }, + append(...nodes) { + el.children.push(...nodes); + }, + replaceChildren(...nodes) { + el._text = ''; + el.children = nodes; + }, + find(tagName) { + const out = []; + const walk = (n) => { + if (n.tag === tagName) out.push(n); + n.children.forEach(walk); + }; + walk(el); + return out; + }, + }; + return el; + } + return { createElement: element }; +} + +function run(state) { + const doc = fakeDocument(); + const container = doc.createElement('div'); + renderResult(doc, container, state); + return container; +} + +describe('starter query', () => { + it('is an editable bounded path-table query with the limit visible', () => { + assert.equal(STARTER_QUERY, "SELECT * FROM './' LIMIT 100"); + }); +}); + +describe('formatCell', () => { + it('renders NULL distinctly from the text "NULL"', () => { + assert.deepEqual(formatCell(null), { text: 'NULL', isNull: true }); + assert.deepEqual(formatCell('NULL'), { text: 'NULL', isNull: false }); + }); + + it('renders scalars and multiline text as text', () => { + assert.deepEqual(formatCell(42), { text: '42', isNull: false }); + assert.deepEqual(formatCell(true), { text: 'true', isNull: false }); + assert.deepEqual(formatCell('a\nb'), { text: 'a\nb', isNull: false }); + assert.deepEqual(formatCell(''), { text: '', isNull: false }); + }); + + it('renders structured values as JSON text', () => { + assert.deepEqual(formatCell({ a: [1] }), { text: '{"a":[1]}', isNull: false }); + }); +}); + +describe('query success', () => { + it('posts the SQL as written to /query and reports rows and elapsed time', async () => { + const calls = []; + const fetch = async (url, init) => { + calls.push([url, init.method, init.body]); + return response(200, [{ path: 'a.txt', size: 3 }, { path: 'b.txt', size: 4 }]); + }; + const changes = []; + const runner = createQueryRunner({ + fetch, + now: clock(1000, 1042), + onChange: (s) => changes.push(s.status), + }); + const sql = 'select path, size from "./" limit 5'; + await runner.run(sql); + assert.deepEqual(calls, [['/query', 'POST', sql]]); + assert.deepEqual(changes, ['running', 'success']); + const state = runner.state(); + assert.equal(state.status, 'success'); + assert.deepEqual(state.columns, ['path', 'size']); + assert.equal(state.rowCount, 2); + assert.equal(state.shownCount, 2); + assert.equal(state.truncated, false); + assert.equal(state.elapsedMs, 42); + }); + + it('separates display truncation from the returned row count', async () => { + const rows = Array.from({ length: DISPLAY_ROW_LIMIT + 5 }, (_, i) => ({ n: i })); + const runner = createQueryRunner({ + fetch: async () => response(200, rows), + now: clock(0, 1), + onChange() {}, + }); + await runner.run('SELECT 1'); + const state = runner.state(); + assert.equal(state.rowCount, DISPLAY_ROW_LIMIT + 5); + assert.equal(state.shownCount, DISPLAY_ROW_LIMIT); + assert.equal(state.truncated, true); + const text = run(state).textContent; + assert.match(text, new RegExp(`${DISPLAY_ROW_LIMIT + 5} rows returned`)); + assert.match(text, new RegExp(`first ${DISPLAY_ROW_LIMIT} shown`)); + assert.equal(run(state).find('tr').length, DISPLAY_ROW_LIMIT + 1); + }); + + it('renders a header, one row per result and NULL cells', async () => { + const runner = createQueryRunner({ + fetch: async () => response(200, [{ a: 1, b: null }]), + now: clock(0, 5), + onChange() {}, + }); + await runner.run('SELECT 1'); + const view = run(runner.state()); + assert.deepEqual( + view.find('th').map((t) => t.textContent), + ['a', 'b'], + ); + const cells = view.find('td'); + assert.deepEqual( + cells.map((c) => c.textContent), + ['1', 'NULL'], + ); + assert.match(cells[1].className, /null/); + assert.match(view.textContent, /1 row returned in 5 ms/); + }); +}); + +describe('zero rows', () => { + it('says so explicitly and does not invent columns', async () => { + const runner = createQueryRunner({ + fetch: async () => response(200, []), + now: clock(0, 7), + onChange() {}, + }); + await runner.run("SELECT * FROM './nothing'"); + const state = runner.state(); + assert.equal(state.status, 'success'); + assert.deepEqual(state.columns, []); + assert.equal(state.rowCount, 0); + const view = run(state); + assert.match(view.textContent, /0 rows returned in 7 ms/); + assert.match(view.textContent, /column names are not available/i); + assert.equal(view.find('th').length, 0); + assert.equal(view.find('table').length, 0); + }); +}); + +describe('read-only rejection', () => { + it('shows the server message as a query error', async () => { + const runner = createQueryRunner({ + fetch: async () => response(400, { error: 'attempt to write a readonly database' }), + now: clock(0, 3), + onChange() {}, + }); + await runner.run('DELETE FROM t'); + const state = runner.state(); + assert.equal(state.status, 'error'); + assert.equal(state.kind, 'query'); + assert.equal(state.message, 'attempt to write a readonly database'); + const view = run(state); + assert.match(view.textContent, /Query error/); + assert.match(view.textContent, /attempt to write a readonly database/); + assert.equal(view.find('table').length, 0); + assert.equal(view.find('p')[0].attrs.role, 'alert'); + }); +}); + +describe('server failure', () => { + it('reports a server error status with its message', async () => { + const runner = createQueryRunner({ + fetch: async () => response(503, { error: 'config failed to load' }), + now: clock(0, 3), + onChange() {}, + }); + await runner.run('SELECT 1'); + const state = runner.state(); + assert.equal(state.kind, 'server'); + assert.equal(state.message, 'config failed to load'); + assert.match(run(state).textContent, /Server error \(HTTP 503\)/); + }); + + it('reports a non-JSON failure response by status', async () => { + const runner = createQueryRunner({ + fetch: async () => response(502), + now: clock(0, 3), + onChange() {}, + }); + await runner.run('SELECT 1'); + const state = runner.state(); + assert.equal(state.kind, 'server'); + assert.match(state.message, /502/); + }); + + it('reports an unreachable server', async () => { + const runner = createQueryRunner({ + fetch: async () => { + throw new TypeError('Failed to fetch'); + }, + now: clock(0, 3), + onChange() {}, + }); + await runner.run('SELECT 1'); + const state = runner.state(); + assert.equal(state.kind, 'network'); + assert.match(run(state).textContent, /Could not reach the server: Failed to fetch/); + }); + + it('reports a malformed success body as a server error', async () => { + const runner = createQueryRunner({ + fetch: async () => response(200), + now: clock(0, 3), + onChange() {}, + }); + await runner.run('SELECT 1'); + assert.equal(runner.state().status, 'error'); + assert.equal(runner.state().kind, 'server'); + }); +}); + +describe('safe rendering', () => { + it('shows HTML-like cells, headers and errors as text only', async () => { + const evil = ''; + const runner = createQueryRunner({ + fetch: async () => response(200, [{ [evil]: `${evil}\nline two` }]), + now: clock(0, 1), + onChange() {}, + }); + await runner.run('SELECT 1'); + const view = run(runner.state()); + assert.equal(view.find('img').length, 0); + assert.deepEqual( + view.find('th').map((t) => t.textContent), + [evil], + ); + assert.deepEqual( + view.find('td').map((t) => t.textContent), + [`${evil}\nline two`], + ); + + const failing = createQueryRunner({ + fetch: async () => response(400, { error: evil }), + now: clock(0, 1), + onChange() {}, + }); + await failing.run('SELECT 1'); + const errView = run(failing.state()); + assert.equal(errView.find('img').length, 0); + assert.match(errView.textContent, //); + }); +}); + +describe('request ordering and duplicates', () => { + it('a delayed earlier response cannot overwrite a later query', async () => { + const first = deferred(); + const second = deferred(); + const pending = [first, second]; + const fetch = () => pending.shift().promise; + const runner = createQueryRunner({ fetch, now: clock(0, 1, 2, 3), onChange() {} }); + const a = runner.run('SELECT 1'); + const b = runner.run('SELECT 2'); + second.resolve(response(200, [{ v: 'second' }])); + await b; + assert.deepEqual(runner.state().rows, [{ v: 'second' }]); + first.resolve(response(200, [{ v: 'first' }])); + await a; + assert.equal(runner.state().sql, 'SELECT 2'); + assert.deepEqual(runner.state().rows, [{ v: 'second' }]); + }); + + it('a delayed earlier failure cannot overwrite a later success', async () => { + const first = deferred(); + const second = deferred(); + const pending = [first, second]; + const runner = createQueryRunner({ + fetch: () => pending.shift().promise, + now: clock(0, 1, 2, 3), + onChange() {}, + }); + const a = runner.run('SELECT 1'); + const b = runner.run('SELECT 2'); + second.resolve(response(200, [{ v: 1 }])); + await b; + first.resolve(response(500, { error: 'late' })); + await a; + assert.equal(runner.state().status, 'success'); + }); + + it('aborts the superseded request', async () => { + const signals = []; + const runner = createQueryRunner({ + fetch: (_url, init) => { + signals.push(init.signal); + return new Promise(() => {}); + }, + now: clock(0), + onChange() {}, + }); + runner.run('SELECT 1'); + runner.run('SELECT 2'); + assert.equal(signals[0].aborted, true); + assert.equal(signals[1].aborted, false); + }); + + it('ignores an identical submission while one is in flight', async () => { + const gate = deferred(); + let calls = 0; + const runner = createQueryRunner({ + fetch: () => { + calls += 1; + return gate.promise; + }, + now: clock(0, 1), + onChange() {}, + }); + const a = runner.run('SELECT 1'); + await runner.run('SELECT 1'); + assert.equal(calls, 1); + gate.resolve(response(200, [])); + await a; + await runner.run('SELECT 1'); + assert.equal(calls, 2); + }); + + it('shows a running state with the SQL in flight', () => { + const runner = createQueryRunner({ + fetch: () => new Promise(() => {}), + now: clock(0), + onChange() {}, + }); + runner.run('SELECT 1'); + assert.deepEqual( + { status: runner.state().status, sql: runner.state().sql }, + { status: 'running', sql: 'SELECT 1' }, + ); + assert.match(run(runner.state()).textContent, /Running/); + }); +}); diff --git a/packages/rust/tests/frontend_js.rs b/packages/rust/tests/frontend_js.rs new file mode 100644 index 000000000..e20857b5d --- /dev/null +++ b/packages/rust/tests/frontend_js.rs @@ -0,0 +1,20 @@ +//! The bundled frontend's script, exercised by Node's built-in test runner. + +use std::path::Path; +use std::process::Command; + +#[test] +fn frontend_script_passes_its_node_tests() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/frontend"); + let output = Command::new("node") + .arg("--test") + .arg(dir.join("query.test.mjs")) + .output() + .expect("node must be installed to test the bundled frontend"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} From 82a7f54ebcfd3e6146882c697223f8bc1fe670aa Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:32:34 -0400 Subject: [PATCH 2/5] feat: run SQL and show results in the frontend --- PARITY.md | 5 +- docs/reference/cli.md | 10 +- .../changelog.d/2026-10-11-frontend-query.md | 1 + .../migrations.d/2026-10-11-frontend-query.md | 21 +++ packages/rust/src/cli/frontend.html | 20 +++ packages/rust/src/cli/frontend.js | 163 +++++++++++++++++- packages/rust/src/cli/router.rs | 19 ++ 7 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 packages/rust/changelog.d/2026-10-11-frontend-query.md create mode 100644 packages/rust/migrations.d/2026-10-11-frontend-query.md diff --git a/PARITY.md b/PARITY.md index afe474f0f..fb478452e 100644 --- a/PARITY.md +++ b/PARITY.md @@ -369,8 +369,9 @@ Rust binary and the Python and TypeScript launchers. It serves the embedded SQL explorer shell at `/` on the API's origin and exposes its load diagnostic through `GET /frontend/state` (HTTP 503 when unavailable). The page needs no runtime asset download; the option adds no SDK binding API. Without the flag, -the existing HTTP routes are unchanged. Query controls and distribution -validation are tracked in the subsequent frontend issues. +the existing HTTP routes are unchanged. The page runs SQL through `POST +/query` (script at `/frontend.js`). Distribution validation is tracked in a +subsequent frontend issue. **Parity by construction, no drift; no SDK API change (#662).** Query is the default CLI mode: `dirsql ""` runs one query and prints JSON rows, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e0a4383c0..de5289869 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -240,8 +240,14 @@ requests, closes open `/events` streams, and exits. Pass `--frontend` to serve the bundled dirsql SQL explorer shell at `/` on the same origin as the HTTP API. It works offline and prints `Frontend at http://:/` without opening a browser. With `--port 0`, the -printed URL uses the assigned port. The initial shell shows server status; -query controls are coming in a subsequent update. +printed URL uses the assigned port. The page offers an editable SQL box +holding the starter query `SELECT * FROM './' LIMIT 100`. Nothing runs until +you press **Run** (or Ctrl/Cmd+Enter); pressing it again refreshes the +result. Queries go through `POST /query`, so the read-only rule applies, and +run exactly as written. The page shows elapsed time, the number of rows +returned, and errors. Values render as text (`NULL` is marked), and only the +first 1000 rows are displayed. An empty result says so and shows no column +names, since the response carries none. ```bash dirsql server --frontend diff --git a/packages/rust/changelog.d/2026-10-11-frontend-query.md b/packages/rust/changelog.d/2026-10-11-frontend-query.md new file mode 100644 index 000000000..a211739c1 --- /dev/null +++ b/packages/rust/changelog.d/2026-10-11-frontend-query.md @@ -0,0 +1 @@ +**Added** SQL execution in the `dirsql server --frontend` explorer: an editable `LIMIT 100` starter query, an explicit Run control, elapsed time, result rows rendered as text, and query and server errors. diff --git a/packages/rust/migrations.d/2026-10-11-frontend-query.md b/packages/rust/migrations.d/2026-10-11-frontend-query.md new file mode 100644 index 000000000..dfe18bb96 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-11-frontend-query.md @@ -0,0 +1,21 @@ +### Frontend SQL execution + +#### Summary + +The `dirsql server --frontend` page can now run read-only SQL and show the result. It adds the `/frontend.js` route, served only with `--frontend`. Nothing breaks. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +_None._ + +#### Verification + +Run `dirsql server --frontend --port 0`, open the printed URL, and press Run. The page lists the files under the current directory. diff --git a/packages/rust/src/cli/frontend.html b/packages/rust/src/cli/frontend.html index 18f20a66d..6300a4fb6 100644 --- a/packages/rust/src/cli/frontend.html +++ b/packages/rust/src/cli/frontend.html @@ -16,6 +16,18 @@ .panel { margin-top: 2.5rem; padding: 1.5rem; border: 1px solid #d9e1da; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #17302609; } .panel h2 { margin: 0 0 .5rem; font-size: 1rem; } .panel p { margin: 0; } + form { margin-top: 2rem; } + label { display: block; font-weight: 600; margin-bottom: .4rem; } + textarea { width: 100%; min-height: 7rem; padding: .7rem; border: 1px solid #b9c7bd; border-radius: 8px; font: .95rem/1.5 ui-monospace, monospace; resize: vertical; } + button { margin-top: .6rem; padding: .5rem 1.2rem; border: 0; border-radius: 8px; background: #1f6b46; color: #fff; font: inherit; font-weight: 600; cursor: pointer; } + button[aria-busy="true"] { opacity: .6; cursor: progress; } + .hint { font-size: .85rem; margin: .4rem 0 0; } + #result { margin-top: 1.5rem; overflow-x: auto; } + #result p.error { color: #a0362a; white-space: pre-wrap; overflow-wrap: anywhere; } + table { border-collapse: collapse; font: .85rem/1.4 ui-monospace, monospace; background: #fff; } + th, td { border: 1px solid #d9e1da; padding: .3rem .6rem; text-align: left; vertical-align: top; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 40rem; } + th { background: #eef2ee; } + td.null { color: #7a8a80; font-style: italic; } .error { border-color: #d7a7a1; background: #fff8f7; } .error h2 { color: #a0362a; } .error pre { white-space: pre-wrap; overflow-wrap: anywhere; font: .9rem/1.5 ui-monospace, monospace; margin: .8rem 0; } @@ -30,7 +42,15 @@

dirsql SQL explorer

Connecting to dirsql…

Checking the server state.

+
+ + + +

Runs exactly as written; edit the LIMIT as needed. Press Ctrl+Enter or Cmd+Enter to run.

+
+
+