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.