Skip to content
Merged
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
5 changes: 3 additions & 2 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<sql>"` runs one query and prints JSON rows,
Expand Down
10 changes: 8 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>:<actual-port>/` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/cli_version_test.py tests/e2e/query_contract_test.py tests/e2e/fanout_test.py tests/e2e/extension_package_test.py tests/e2e/server_speed_test.py -x -q",
"ran_at": 1791765336,
"exit_code": 0,
"commit": "a2b5671787b3cfa038a128d5cbbeac13e6c268a9",
"branch": "bot/1487-frontend-query-1011"
}
1 change: 1 addition & 0 deletions packages/rust/changelog.d/2026-10-11-frontend-query.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/rust/migrations.d/2026-10-11-frontend-query.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/rust/src/cli/frontend.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -30,7 +42,15 @@ <h1>dirsql SQL explorer</h1>
<h2 id="state-title">Connecting to dirsql…</h2>
<p id="state-message">Checking the server state.</p>
</section>
<form id="query" onsubmit="return false">
<label for="sql">SQL (read-only)</label>
<textarea id="sql" name="sql" spellcheck="false" aria-describedby="sql-hint">SELECT * FROM './' LIMIT 100</textarea>
<button type="button" id="run">Run</button>
<p class="hint" id="sql-hint">Runs exactly as written; edit the LIMIT as needed. Press Ctrl+Enter or Cmd+Enter to run.</p>
</form>
<section id="result" aria-live="polite" aria-label="Query result"></section>
</main>
<script type="module" src="/frontend.js"></script>
<script>
(async () => {
const panel = document.getElementById('state');
Expand Down
161 changes: 161 additions & 0 deletions packages/rust/src/cli/frontend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
export const STARTER_QUERY = "SELECT * FROM './' LIMIT 100";
export const DISPLAY_ROW_LIMIT = 1000;

export function formatCell(value) {
if (value === null || value === undefined) return { text: 'NULL', isNull: true };
if (typeof value === 'object') return { text: JSON.stringify(value), isNull: false };
return { text: String(value), isNull: false };
}

async function failureOf(response) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body && typeof body.error === 'string') message = body.error;
} catch {}
return { kind: response.status === 400 ? 'query' : 'server', httpStatus: response.status, message };
}

export function createQueryRunner({ fetch, now, onChange }) {
let current = { status: 'idle' };
let seq = 0;
let inflight = null;

const set = (state) => {
current = state;
onChange(state);
};

async function execute(sql, id, controller) {
const started = now();
const elapsed = () => Math.round(now() - started);
let outcome;
try {
const response = await fetch('/query', { method: 'POST', body: JSON.stringify({ sql }), signal: controller.signal });
if (response.ok) {
const rows = await response.json();
if (!Array.isArray(rows)) throw new Error('unexpected response');
const shown = rows.slice(0, DISPLAY_ROW_LIMIT);
outcome = {
status: 'success',
sql,
rows: shown,
columns: shown.length ? Object.keys(shown[0]) : [],
rowCount: rows.length,
shownCount: shown.length,
truncated: rows.length > shown.length,
};
} else {
outcome = { status: 'error', sql, ...(await failureOf(response)) };
}
} catch (error) {
outcome =
error instanceof TypeError || error.name === 'AbortError'
? { status: 'error', sql, kind: 'network', message: error.message }
: { status: 'error', sql, kind: 'server', message: error.message };
}
if (id !== seq) return;
inflight = null;
set({ ...outcome, elapsedMs: elapsed() });
}

return {
state: () => current,
run(sql) {
if (inflight && inflight.sql === sql) return Promise.resolve();
if (inflight) inflight.controller.abort();
seq += 1;
const controller = new AbortController();
inflight = { sql, controller, done: null };
set({ status: 'running', sql });
inflight.done = execute(sql, seq, controller);
return inflight.done;
},
};
}

const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;

function summaryOf(state) {
const returned = `${plural(state.rowCount, 'row')} returned in ${state.elapsedMs} ms`;
if (state.truncated) return `${returned}. Display limit: first ${state.shownCount} shown.`;
if (state.rowCount === 0) return `${returned}. Column names are not available for an empty result.`;
return `${returned}.`;
}

function tableOf(doc, state) {
const table = doc.createElement('table');
const head = doc.createElement('tr');
for (const column of state.columns) {
const th = doc.createElement('th');
th.setAttribute('scope', 'col');
th.textContent = column;
head.append(th);
}
table.append(head);
for (const row of state.rows) {
const tr = doc.createElement('tr');
for (const column of state.columns) {
const { text, isNull } = formatCell(row[column]);
const td = doc.createElement('td');
td.textContent = text;
if (isNull) td.className = 'null';
tr.append(td);
}
table.append(tr);
}
return table;
}

const ERROR_LABEL = { query: 'Query error', server: 'Server error', network: 'Could not reach the server' };

function errorText(state) {
if (state.kind === 'network') return `${ERROR_LABEL.network}: ${state.message}`;
const status = state.httpStatus ? ` (HTTP ${state.httpStatus})` : '';
return `${ERROR_LABEL[state.kind]}${status}: ${state.message}`;
}

export function renderResult(doc, container, state) {
const note = doc.createElement('p');
if (state.status === 'running') {
note.textContent = 'Running…';
container.replaceChildren(note);
return;
}
if (state.status === 'error') {
note.className = 'error';
note.setAttribute('role', 'alert');
note.textContent = errorText(state);
container.replaceChildren(note);
return;
}
if (state.status !== 'success') {
container.replaceChildren();
return;
}
note.textContent = summaryOf(state);
container.replaceChildren(...(state.rowCount ? [note, tableOf(doc, state)] : [note]));
}

if (typeof document !== 'undefined') {
const sql = document.getElementById('sql');
const run = document.getElementById('run');
const result = document.getElementById('result');
sql.value = STARTER_QUERY;
const runner = createQueryRunner({
fetch: (url, init) => fetch(url, init),
now: () => performance.now(),
onChange(state) {
run.setAttribute('aria-busy', String(state.status === 'running'));
renderResult(document, result, state);
},
});
const submit = () => runner.run(sql.value);
run.addEventListener('click', submit);
sql.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
});
}
19 changes: 19 additions & 0 deletions packages/rust/src/cli/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ pub(super) fn router(ctx: SharedCtx, frontend: bool) -> Router {
if frontend {
app = app
.route("/", get(|| async { Html(include_str!("frontend.html")) }))
.route("/frontend.js", get(handle_frontend_script))
.route("/frontend/state", get(handle_frontend_state));
}
app.with_state(ctx)
}

async fn handle_frontend_script() -> Response {
(
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
include_str!("frontend.js"),
)
.into_response()
}

async fn handle_frontend_state(State(ctx): State<SharedCtx>) -> Response {
match &ctx.state {
AppState::Ready(_) => Json(json!({ "error": null })).into_response(),
Expand Down Expand Up @@ -215,6 +224,16 @@ mod tests {
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[tokio::test]
async fn frontend_script_is_served_as_javascript() {
let resp = handle_frontend_script().await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()[header::CONTENT_TYPE],
"text/javascript; charset=utf-8"
);
}

#[test]
fn error_response_sets_json_content_type() {
let resp = error_response(StatusCode::BAD_REQUEST, "boom");
Expand Down
44 changes: 44 additions & 0 deletions packages/rust/tests/cli_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<textarea"), "{page}");
assert!(page.contains("LIMIT 100"), "{page}");
assert!(page.contains("Run"), "{page}");
let script = client.get(format!("{base}/frontend.js")).send().unwrap();
assert_eq!(script.status(), StatusCode::OK);
assert!(
script.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("text/javascript")
);
assert!(script.text().unwrap().contains("createQueryRunner"));

let write = client
.post(format!("{base}/query"))
.body("CREATE TABLE t (a)")
.send()
.unwrap();
assert_eq!(write.status(), StatusCode::BAD_REQUEST);
assert!(write.json::<Value>().unwrap()["error"].is_string());
kill_and_wait(server);
}

#[test]
fn frontend_still_loads_and_reports_an_invalid_config() {
let root = TempDir::new().unwrap();
Expand Down
Loading
Loading