The plain textarea that ended the engine course comes back as a component — with query history, a reseed control, and DuckDB’s error messages rendered verbatim. Alongside it, a performance panel that reports what the engine actually did: bundle choice, instantiate and open timings, storage usage, and the round-trip of every query as it happens. Checkpoint: ui-10.

Rebuilding the thing you deleted

Lesson 3 deleted a working SQL console. This one rebuilds it, and the diff between the two versions is the clearest summary of what an architecture bought.

The imperative version held its own results in local variables, rendered by hand, and cleared the output area on every error. The component holds one piece of state:

@state() private _result: SqlRunResult | null = null;

@state() is a reactive property with no attribute — internal, not part of the element’s public API. Set it and Lit re-renders. There is no result-clearing code, because rendering is a function of that value.

The error path is the same shape as the engine’s, and it stays the same shape all the way to the screen:

${this._result?.error
    ? html`<pre class="error" role="alert">${this._result.error}</pre>`
    : html`<table>…</table>`}

runQuery returns errors as values, so the component branches on data rather than catching exceptions. The message is DuckDB’s, unedited, in a <pre> so the parser’s caret still lines up under the offending token. role="alert" announces it.

The reseed button calls reseedDb() from the bootstrap module, which flips dbReady false, clears queryResult, wipes and refills the table, and flips it back — so every panel drops to skeletons and repopulates. That is what makes the console safe to experiment in: DELETE FROM sales is recoverable in one click, which is the difference between a console people use and one they read.

Ctrl+Enter, and why it is on the textarea

@keydown=${(e: KeyboardEvent) => {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._run();
}}

Bound to the textarea rather than the document. A global shortcut in a component is a shortcut that fires while the user is typing in some other component, and cleaning it up on disconnect is a step people forget. Scoped to the element that owns it, there is nothing to clean up.

Metrics were free

The performance panel required no new instrumentation, because the timings were already being collected. The query runner has been writing them since lesson 2:

queryStats.set({
    last: { countMs, rowsMs, aggregateMs, totalMs },
    queries: prev.queries + 1,
    cumulativeMs: prev.cumulativeMs + totalMs,
});

initMetrics and seedMetrics came from the bootstrap in lesson 1, and storageInfo from the engine course. The panel is a view of signals that were already there — it adds no measurement, no hooks, and no cost when closed.

That is the case for a store made of signals rather than props passed down a tree. A feature that reports on the whole application is, structurally, just another reader.

The always-visible chip is one line — ⚡ 76 ms · OPFS — and the full panel opens as a popover using the same pattern as lesson 5’s dropdown. Watch the chip while dragging the date slider: it updates once per settled query, which is the debounce from lesson 2 made visible.

Reading the engine’s own account

Two controls in the panel go past reporting.

EXPLAIN ANALYZE, from the engine course, runs a statement and reports per operator what actually happened — row counts and timings, not estimates. Paste the daily aggregate into the console, explain it, and read from the bottom up: the scan, the row count it produced, the hash aggregate that reduced it, the window function’s share of the total.

VACUUM and CHECKPOINT are the maintenance pair. Checkpoint flushes the write-ahead log into the OPFS file — the same call the seeder makes — and vacuum reclaims space. Running them and watching the storage figure move is the most direct way to understand that this is a database file on a disk, not an abstraction over one.

Build it

File Action What goes in it
src/components/sql-console.ts write Textarea with Ctrl+Enter, Run and Explain, SqlRunResult rendering, verbatim errors, truncation notice, reseed
src/components/perf-panel.ts write The status chip and its popover: engine facts, bootstrap timings, storage, rolling query stats, maintenance controls
index.html modify <perf-panel> in the header tools, <sql-console> at the end of main
src/index.ts modify Import both
src/style.css modify Add the console section to the panel rule

Done when: running SELEC * FROM sales shows DuckDB’s parser error while the table above keeps its rows, and the performance chip reports a round-trip in milliseconds that changes when you move a filter.

Answer key: ui-09...ui-10.

Challenge: destroy the data and get it back

Run DELETE FROM sales in the console. Watch every panel empty out — the stat cards, the charts, the heatmap, the table — because they are all views of a store fed by queries against a table that is now empty. Then press Reset & reseed and watch them all repopulate. Nothing coordinated that recovery; every panel is reading the same signals it always was.

Next lesson: the entire design system, in one diff you can read.