diff --git a/PARITY.md b/PARITY.md index 4571d786b..d5caf8163 100644 --- a/PARITY.md +++ b/PARITY.md @@ -371,7 +371,8 @@ query root, and declared tables and columns 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. The page runs SQL through `POST -/query` (script at `/frontend.js`). Distribution validation is tracked in a +/query` (script at `/frontend.js`) and lists declared tables with a +quoted preview query beside the path-table guide. Distribution validation is tracked in a subsequent frontend issue. **Parity by construction, no drift; no SDK API change (#662).** Query is the diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e6da14635..6024f6318 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -249,6 +249,19 @@ 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. +Above the SQL box the page shows the query root and the path-table columns +(`path`, `basename`, `dir`, `ext`, `size`, `mtime`, `ctime`, plus `content`, +read only when named), with a button that loads the starter query. A config +with declared tables adds each table and its columns; choosing one loads +`SELECT * FROM "" LIMIT 100` into the box without running it. +Declared-table globs anchor at their config file's directory, while +path-tables resolve under the query root. With no declared tables, including +a config of only functions or extensions, the page shows the path-table +start alone. A config that fails to load shows its diagnostic and how to +restart. To refresh, run the query again: path-tables re-read the filesystem, +and declared tables reflect the server's index. The page does not use +`/events`. + ```bash dirsql server --frontend dirsql server --frontend -c .dirsql.toml diff --git a/packages/python/e2e-attestations/bot-1489-frontend-browse-1013.json b/packages/python/e2e-attestations/bot-1489-frontend-browse-1013.json new file mode 100644 index 000000000..81894d56f --- /dev/null +++ b/packages/python/e2e-attestations/bot-1489-frontend-browse-1013.json @@ -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 -x -q", + "ran_at": 1791927030, + "exit_code": 0, + "commit": "59221551ed292a404b0342d8455c18d2a946d7d6", + "branch": "bot/1489-frontend-browse-1013" +} diff --git a/packages/rust/changelog.d/2026-10-13-frontend-browse.md b/packages/rust/changelog.d/2026-10-13-frontend-browse.md new file mode 100644 index 000000000..bf3208ae2 --- /dev/null +++ b/packages/rust/changelog.d/2026-10-13-frontend-browse.md @@ -0,0 +1 @@ +**Added** Table browsing and configless onboarding in the `dirsql server --frontend` explorer: the query root, the path-table columns and an editable path-table example, declared tables with their columns and a quoted `LIMIT 100` preview query per table, and config-load diagnostics with restart guidance. diff --git a/packages/rust/migrations.d/2026-10-13-frontend-browse.md b/packages/rust/migrations.d/2026-10-13-frontend-browse.md new file mode 100644 index 000000000..c6f92b7b3 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-13-frontend-browse.md @@ -0,0 +1,21 @@ +### Frontend table browsing and onboarding + +#### Summary + +The `dirsql server --frontend` page now shows the query root, documents path-table columns, lists declared tables with their columns, and loads a quoted, bounded preview query when a table is chosen. A config that fails to load shows its diagnostic and how to restart. Nothing breaks. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +_None._ + +#### Verification + +Run `dirsql server --frontend --port 0 -c .dirsql.toml` in a directory with a config, open the printed URL, and choose a table. The SQL box reads `SELECT * FROM "
" LIMIT 100`; press Run to see its rows. diff --git a/packages/rust/src/cli/frontend.html b/packages/rust/src/cli/frontend.html index 6300a4fb6..6744c7637 100644 --- a/packages/rust/src/cli/frontend.html +++ b/packages/rust/src/cli/frontend.html @@ -15,7 +15,10 @@ p { line-height: 1.6; color: #4a5d51; max-width: 42rem; } .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; } + .panel p { margin: 0 0 .6rem; } + .panel h2:not(:first-child) { margin-top: 1.2rem; } + .panel ul { margin: .2rem 0 .8rem; padding-left: 1.2rem; line-height: 1.5; color: #4a5d51; } + .panel button.choice { margin: .2rem 0; padding: .25rem .6rem; background: #eef2ee; color: #173026; border: 1px solid #b9c7bd; font: .9rem/1.4 ui-monospace, monospace; } 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; } @@ -51,30 +54,5 @@

Connecting to dirsql…

- diff --git a/packages/rust/src/cli/frontend.js b/packages/rust/src/cli/frontend.js index 1df4a1005..ee9db3bab 100644 --- a/packages/rust/src/cli/frontend.js +++ b/packages/rust/src/cli/frontend.js @@ -137,11 +137,147 @@ export function renderResult(doc, container, state) { container.replaceChildren(...(state.rowCount ? [note, tableOf(doc, state)] : [note])); } +export async function loadState(fetch) { + try { + const response = await fetch('/frontend/state'); + if (!response.ok) { + const { message } = await failureOf(response); + return { status: response.status === 503 ? 'unavailable' : 'failed', message }; + } + const { root, tables } = await response.json(); + return { status: 'ready', root, tables }; + } catch (error) { + return { status: 'failed', message: error.message }; + } +} + +export function previewQuery(table) { + return `SELECT * FROM "${table.replaceAll('"', '""')}" LIMIT 100`; +} + +const PATH_COLUMNS = [ + ['path', 'path relative to the query root'], + ['basename', 'filename with extension'], + ['dir', 'parent directory'], + ['ext', 'extension without the dot'], + ['size', 'size in bytes'], + ['mtime', 'modification time, Unix seconds'], + ['ctime', 'creation/change time, Unix seconds'], +]; + +function node(doc, tag, ...children) { + const el = doc.createElement(tag); + el.append(...children); + return el; +} + +const code = (doc, text) => node(doc, 'code', text); + +function choice(doc, label, sql, onChoose) { + const button = node(doc, 'button', label); + button.setAttribute('type', 'button'); + button.className = 'choice'; + button.addEventListener('click', () => onChoose(sql)); + return button; +} + +function pathTableGuide(doc, onChoose) { + return [ + node(doc, 'h2', 'Path-tables'), + node( + doc, + 'p', + 'Query files with no configuration: write a quoted path or glob where a table name goes, such as ', + code(doc, "'./'"), + ' (files directly in the query root) or ', + code(doc, "'./**/*.md'"), + ' (Markdown files at any depth). Edit the path in the SQL to choose files.', + ), + choice(doc, STARTER_QUERY, STARTER_QUERY, onChoose), + node(doc, 'ul', ...PATH_COLUMNS.map(([name, meaning]) => node(doc, 'li', code(doc, name), ` ${meaning}`))), + node( + doc, + 'p', + code(doc, 'content'), + " holds the file's text. It is left out of SELECT * and read only when you name it, as in ", + code(doc, "SELECT path, content FROM './*.md' LIMIT 10"), + '.', + ), + ]; +} + +function tableCatalog(doc, tables, onChoose) { + if (!tables.length) return [node(doc, 'h2', 'Declared tables'), node(doc, 'p', 'No declared tables.')]; + return [ + node(doc, 'h2', 'Declared tables'), + node( + doc, + 'p', + "Declared-table globs are anchored at their config file's directory; path-table queries use the query root. Choose a table to load a preview query, then run it.", + ), + ...tables.map((table) => + node( + doc, + 'div', + choice(doc, table.name, previewQuery(table.name), onChoose), + node( + doc, + 'ul', + ...table.columns.map((column) => node(doc, 'li', code(doc, column.name), column.type ? ` ${column.type}` : '')), + ), + ), + ), + ]; +} + +export function renderState(doc, container, state, onChoose) { + if (state.status === 'ready') { + container.className = 'panel'; + container.replaceChildren( + node(doc, 'h2', 'Query root'), + node(doc, 'p', code(doc, state.root)), + ...pathTableGuide(doc, onChoose), + ...tableCatalog(doc, state.tables, onChoose), + node( + doc, + 'p', + "Run again to refresh: path-tables read the filesystem on every query; declared tables reflect the server's index.", + ), + ); + return; + } + const title = node(doc, 'h2', state.status === 'unavailable' ? 'Configuration could not load' : 'Could not connect'); + title.setAttribute('role', 'alert'); + container.className = 'panel error'; + if (state.status === 'unavailable') { + container.replaceChildren( + title, + node(doc, 'pre', state.message), + node( + doc, + 'p', + 'Fix the configuration, stop this server (Ctrl+C), and restart it with ', + code(doc, 'dirsql server --frontend -c '), + '.', + ), + ); + return; + } + container.replaceChildren(title, node(doc, 'p', `Could not load the server state: ${state.message}`)); +} + if (typeof document !== 'undefined') { const sql = document.getElementById('sql'); const run = document.getElementById('run'); const result = document.getElementById('result'); + const panel = document.getElementById('state'); sql.value = STARTER_QUERY; + loadState((url) => fetch(url)).then((state) => + renderState(document, panel, state, (query) => { + sql.value = query; + sql.focus(); + }), + ); const runner = createQueryRunner({ fetch: (url, init) => fetch(url, init), now: () => performance.now(), diff --git a/packages/rust/tests/cli_e2e.rs b/packages/rust/tests/cli_e2e.rs index ac0c190fc..ac2d99d97 100644 --- a/packages/rust/tests/cli_e2e.rs +++ b/packages/rust/tests/cli_e2e.rs @@ -258,6 +258,27 @@ fn frontend_page_has_query_controls_and_serves_its_script() { kill_and_wait(server); } +#[test] +fn frontend_script_offers_table_browsing_and_path_onboarding() { + let root = TempDir::new().unwrap(); + let port = free_port(); + let server = spawn_dirsql_with_args(root.path(), port, &["--frontend"]); + wait_until_ready(port, Duration::from_secs(5)); + let script = Client::new() + .get(format!("http://localhost:{port}/frontend.js")) + .send() + .unwrap() + .text() + .unwrap(); + for name in ["loadState", "renderState", "previewQuery"] { + assert!( + script.contains(name), + "{name} missing from the frontend script" + ); + } + 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/browse.test.mjs b/packages/rust/tests/frontend/browse.test.mjs new file mode 100644 index 000000000..ce9a247b4 --- /dev/null +++ b/packages/rust/tests/frontend/browse.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { STARTER_QUERY, loadState, previewQuery, renderState } from '../../src/cli/frontend.js'; +import { fakeDocument } from './fake-document.mjs'; + +function response(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (body === undefined) throw new SyntaxError('no body'); + return body; + }, + }; +} + +function render(state) { + const doc = fakeDocument(); + const container = doc.createElement('section'); + const chosen = []; + renderState(doc, container, state, (sql) => chosen.push(sql)); + return { container, chosen }; +} + +const STAT_COLUMNS = ['path', 'basename', 'dir', 'ext', 'size', 'mtime', 'ctime']; + +const buttonNamed = (container, label) => container.find('button').find((b) => b.textContent === label); + +describe('loadState', () => { + it('reads only /frontend/state and never opens /events', async () => { + const urls = []; + const state = await loadState(async (url) => { + urls.push(url); + return response(200, { error: null, root: '/data', tables: [] }); + }); + assert.deepEqual(urls, ['/frontend/state']); + assert.deepEqual(state, { status: 'ready', root: '/data', tables: [] }); + }); + + it('reports a config failure with its diagnostic', async () => { + const state = await loadState(async () => response(503, { error: 'failed to resolve missing.toml' })); + assert.deepEqual(state, { status: 'unavailable', message: 'failed to resolve missing.toml' }); + }); + + it('reports any other failure as unloaded', async () => { + assert.deepEqual(await loadState(async () => response(500, { error: 'catalog read failed' })), { + status: 'failed', + message: 'catalog read failed', + }); + assert.deepEqual(await loadState(async () => response(502)), { status: 'failed', message: 'HTTP 502' }); + assert.deepEqual( + await loadState(async () => { + throw new TypeError('Failed to fetch'); + }), + { status: 'failed', message: 'Failed to fetch' }, + ); + }); +}); + +describe('previewQuery', () => { + it('quotes the table name as an identifier and bounds the preview', () => { + assert.equal(previewQuery('posts'), 'SELECT * FROM "posts" LIMIT 100'); + assert.equal(previewQuery('order'), 'SELECT * FROM "order" LIMIT 100'); + assert.equal(previewQuery('two "words"'), 'SELECT * FROM "two ""words""" LIMIT 100'); + }); +}); + +describe('configless start', () => { + const state = { status: 'ready', root: '/home/me/notes', tables: [] }; + + it('shows the query root and offers the editable path-table example', () => { + const { container, chosen } = render(state); + assert.match(container.textContent, /\/home\/me\/notes/); + assert.match(container.textContent, /No declared tables/); + const example = buttonNamed(container, STARTER_QUERY); + assert.ok(example, 'path-table example button'); + example.click(); + assert.deepEqual(chosen, [STARTER_QUERY]); + }); + + it('documents the seven stat columns and the lazy content column', () => { + const { container } = render(state); + const codes = container.find('code').map((c) => c.textContent); + for (const column of [...STAT_COLUMNS, 'content']) assert.ok(codes.includes(column), column); + assert.match(container.textContent, /SELECT \*/); + assert.match(container.textContent, /name it/i); + }); + + it('explains editing the path or glob in SQL', () => { + const { container } = render(state); + const codes = container.find('code').map((c) => c.textContent); + assert.ok(codes.includes("'./**/*.md'")); + assert.match(container.textContent, /no configuration/i); + }); + + it('explains refresh by rerunning', () => { + assert.match(render(state).container.textContent, /Run again/); + }); +}); + +describe('declared tables', () => { + const state = { + status: 'ready', + root: '/srv/blog', + tables: [ + { + name: 'posts', + columns: [ + { name: 'title', type: 'TEXT' }, + { name: 'size', type: 'INTEGER' }, + ], + }, + { name: 'two "words"', columns: [{ name: 'select', type: '' }] }, + ], + }; + + it('lists each table with its columns and types', () => { + const { container } = render(state); + const text = container.textContent; + for (const fragment of ['posts', 'title', 'TEXT', 'size', 'INTEGER', 'two "words"', 'select']) { + assert.ok(text.includes(fragment), fragment); + } + }); + + it('populates a quoted bounded preview without running it', () => { + const { container, chosen } = render(state); + buttonNamed(container, 'two "words"').click(); + buttonNamed(container, 'posts').click(); + assert.deepEqual(chosen, ['SELECT * FROM "two ""words""" LIMIT 100', 'SELECT * FROM "posts" LIMIT 100']); + }); + + it('keeps the path-table example and explains the two anchors', () => { + const { container, chosen } = render(state); + buttonNamed(container, STARTER_QUERY).click(); + assert.deepEqual(chosen, [STARTER_QUERY]); + assert.doesNotMatch(container.textContent, /No declared tables/); + assert.match(container.textContent, /config file's directory/); + assert.match(container.textContent, /\/srv\/blog/); + assert.match(container.textContent, /reflect the server's index/); + }); +}); + +describe('config failure', () => { + it('shows the diagnostic and restart guidance as an alert', () => { + const { container } = render({ status: 'unavailable', message: 'failed to resolve missing.toml' }); + assert.match(container.textContent, /Configuration could not load/); + assert.match(container.textContent, /failed to resolve missing\.toml/); + assert.match(container.textContent, /restart/i); + assert.match(container.textContent, /dirsql server --frontend -c/); + assert.equal(container.find('h2')[0].attrs.role, 'alert'); + assert.equal(container.find('button').length, 0); + }); + + it('shows a failure to load the state', () => { + const { container } = render({ status: 'failed', message: 'Failed to fetch' }); + assert.match(container.textContent, /Could not load the server state: Failed to fetch/); + assert.equal(container.find('button').length, 0); + }); + + it('renders diagnostics, roots and names as text only', () => { + const evil = ''; + for (const state of [ + { status: 'unavailable', message: evil }, + { status: 'ready', root: evil, tables: [{ name: evil, columns: [{ name: evil, type: evil }] }] }, + ]) { + const { container } = render(state); + assert.equal(container.find('img').length, 0); + assert.ok(container.textContent.includes(evil)); + } + }); +}); diff --git a/packages/rust/tests/frontend/fake-document.mjs b/packages/rust/tests/frontend/fake-document.mjs new file mode 100644 index 000000000..0b45e5e24 --- /dev/null +++ b/packages/rust/tests/frontend/fake-document.mjs @@ -0,0 +1,49 @@ +export function fakeDocument() { + function element(tag) { + const el = { + tag, + children: [], + attrs: {}, + listeners: {}, + className: '', + _text: '', + get textContent() { + return el._text + el.children.map((c) => (typeof c === 'string' ? 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); + }, + addEventListener(type, fn) { + el.listeners[type] = fn; + }, + click() { + el.listeners.click?.({ preventDefault() {} }); + }, + 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((c) => typeof c !== 'string' && walk(c)); + }; + walk(el); + return out; + }, + }; + return el; + } + return { createElement: element }; +} diff --git a/packages/rust/tests/frontend/query.test.mjs b/packages/rust/tests/frontend/query.test.mjs index 408f5cbbb..d0282740c 100644 --- a/packages/rust/tests/frontend/query.test.mjs +++ b/packages/rust/tests/frontend/query.test.mjs @@ -8,6 +8,7 @@ import { formatCell, renderResult, } from '../../src/cli/frontend.js'; +import { fakeDocument } from './fake-document.mjs'; function response(status, body) { return { @@ -33,49 +34,6 @@ function deferred() { 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'); diff --git a/packages/rust/tests/frontend_js.rs b/packages/rust/tests/frontend_js.rs index e20857b5d..fff77f103 100644 --- a/packages/rust/tests/frontend_js.rs +++ b/packages/rust/tests/frontend_js.rs @@ -9,6 +9,7 @@ fn frontend_script_passes_its_node_tests() { let output = Command::new("node") .arg("--test") .arg(dir.join("query.test.mjs")) + .arg(dir.join("browse.test.mjs")) .output() .expect("node must be installed to test the bundled frontend"); assert!( diff --git a/packages/ts/e2e-attestations/bot-1489-frontend-browse-1013.json b/packages/ts/e2e-attestations/bot-1489-frontend-browse-1013.json new file mode 100644 index 000000000..4ea4c53f7 --- /dev/null +++ b/packages/ts/e2e-attestations/bot-1489-frontend-browse-1013.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791927100, + "exit_code": 0, + "commit": "818c961dcb2a35d8f6e5826bba55af22bcbcd5a1", + "branch": "bot/1489-frontend-browse-1013" +}