01 What it is
One HTML file that turns five HR CSVs into a queryable SQLite database inside your browser, then lets you query it two ways: in plain English (translated to SQL by a local model) or by writing SQL yourself.
The whole idea
There is no backend. The database engine, the data, the UI, and the SQL all live in the page. The only outside calls are the sql.js library (CDN) and — for the English mode only — your local Ollama server.
02 Architecture
Two independent paths share one SQLite engine. Direct SQL reaches the engine straight; Ask goes through the model first.
Inside sql-workbench.html (browser)
UI layer
tabs · chat · editor · result grid
→
sql.js engine
SQLite compiled to WASM · in-memory
↑
embedded data
DDL + 5 CSVs as strings
NL → SQL
prompt · extraction · read-only guard
→
UI layer
shows generated SQL, runs it
CDN · jsdelivr
sql-wasm.js + .wasm
→
page loads engine
Ollama :11434
/v1/chat/completions · deepseek
⇄Ask mode only
returns one SELECT
Layers, bottom-up
- Data layer — the 5 CSVs + the
CREATE TABLEDDL, embedded as JS strings. No file reads at runtime. - Engine layer —
sql.js(SQLite/WASM). One in-memory database, built once at load. - Logic layer — CSV parsing, query execution, result shaping, and (for Ask) prompt building + SQL extraction + a read-only guard.
- UI layer — tabs, chat stream, SQL editor, schema rail, result table.
03 Components
| Component | What it does | Talks to |
|---|---|---|
| Data blob | Holds DDL + every CSV row as a string. Source of truth at load time. | boot() |
| parseCSV | Minimal RFC-4180 parser. Handles quoted fields (the addresses contain commas). | boot() |
| boot() | Loads sql.js, creates the DB, runs DDL, inserts every row, records row counts. | sql.js, renderSchema |
| renderSchema | Builds the left rail of tables/columns; clicks insert TABLE.COLUMN into the editor. | insertIntoEditor |
| runSql / execSQL | Executes SQL against the engine, times it, renders or reports the error. | sql.js, renderResults |
| askLLM | Sends schema + question to Ollama, returns raw model text. | Ollama HTTP |
| extractSQL / isReadOnly | Pulls the SELECT out of the model reply; rejects anything non-read-only. | onAsk |
| onAsk | Orchestrates the Ask path: chat echo → model → guard → run → render. | askLLM, runSql |
04 Boot workflow
Runs once, when the page opens. Ends with the status chip reading ok · 5 tables · 36 rows.
- Page parses; the browser fetches
sql-wasm.jsfrom the CDN and its.wasmcompanion. initSqlJs()resolves;boot()creates an empty in-memory database.DDLruns → the 5 tables exist (empty).- For each table in
_order:parseCSV()splits the embedded text into rows, then a preparedINSERTwrites them with the fixed column list. - Row counts are stored in
DB_INFO;renderSchema()draws the rail; the status chip updates.
If boot fails
The chip turns red and the schema rail shows the error. Almost always a blocked CDN — the engine could not download. Check network / CDN access.
05 Ask workflow (English → SQL)
- You type a question and press Enter (
onAsk). - Your question is echoed into the chat; a “thinking…” bubble appears.
askLLMPOSTs tohttp://localhost:11434/v1/chat/completionswith a system prompt = the DDL + a column glossary + few-shot examples + the rule “output only one SELECT”.- The reply is passed to
extractSQL, which strips fences/prose and keeps theSELECT. isReadOnlychecks it: must start withSELECT/WITH/EXPLAINand contain no destructive keyword.- The SQL is shown in a copyable block, then executed via
runSql; the result grid fills below.
Why the model sees the schema
The prompt embeds the exact table/column names and the join keys, so the model names things correctly instead of guessing. That glossary is what makes joins like EMPLOYEES.DEP_ID = DEPARTMENTS.DEPT_ID_DEP work.
Ollama must be reachable from the browser
Browsers block cross-origin calls by default. Start Ollama with OLLAMA_ORIGINS='*', or serve the page from http://localhost. On failure the page prints both fixes and Direct mode keeps working.
06 Direct SQL workflow
- Switch to Write SQL (or click a column in the schema rail to jump there).
- Type SQL in the editor.
Ctrl/Cmd+Enteror Run query. - The statement executes against the same in-memory engine — no model, no network.
- Results render as a grid; multiple statements return the last result set.
Direct mode is the fallback: it works with no Ollama, no keys, no server — just open the file.
07 Code map
Line ranges inside sql-workbench.html (729 lines). Sections are numbered in the source comments.
| Lines | Section | Contents |
|---|---|---|
| 1–9 | head | Meta, viewport, Google Fonts (Fraunces + JetBrains Mono). |
| 10–242 | CSS | Design tokens (:root), dark + light themes, layout, all component styles. |
| 254–313 | markup | Header bar, schema rail, tabs + both panels, results area. |
| 314 | CDN script | Loads sql-wasm.js (pins engine version 1.13.0). |
| 321–322 | config | SQLJS_CDN, OLLAMA_URL. |
| 324–385 | §1 data | DDL and the DATA object (5 CSVs + column lists + _order). |
| 387–406 | §2 parseCSV | RFC-4180 CSV parser — quote-aware, skips CR. |
| 408–435 | §3 boot | Engine init, DDL, per-table inserts, DB_INFO, status chip. |
| 437–463 | §4 schema pane | Rail rendering; column click → editor insert. |
| 465–534 | §5 exec/render | execSQL, buildResultCard, buildErrorCard, runSql (target-aware). |
| 536–552 | §6 editor | SQL textarea wiring, Ctrl+Enter, insertIntoEditor. |
| 554–562 | §7 tabs | switchTab — Ask ⇄ Write SQL. |
| 564–639 | §8 NL→SQL | GLOSSARY, SYSTEM_PROMPT, extractSQL, isReadOnly, askLLM. |
| 641–714 | §9 chat | addMsg, addSqlBlock (copy button), onAsk orchestration + inline result host. |
| 716–720 | §10 theme | Dark/light toggle. |
| 722–725 | §11 start | boot() call with failure handler. |
08 Data model
Five tables. CSVs are headerless, so columns are matched positionally against the lists in DATA.
| Table | Rows | Columns |
|---|---|---|
| EMPLOYEES | 10 | EMP_ID, F_NAME, L_NAME, SSN, B_DATE, SEX, ADDRESS, JOB_ID, SALARY, MANAGER_ID, DEP_ID |
| JOBS | 10 | JOB_IDENT, JOB_TITLE, MIN_SALARY, MAX_SALARY |
| JOB_HISTORY | 10 | EMPL_ID, START_DATE, JOBS_ID, DEPT_ID |
| DEPARTMENTS | 3 | DEPT_ID_DEP, DEP_NAME, MANAGER_ID, LOC_ID |
| LOCATIONS | 3 | LOCT_ID, DEP_ID_LOC |
Join keys
- EMPLOYEES.DEP_ID
- → DEPARTMENTS.DEPT_ID_DEP (values '2','5','7')
- EMPLOYEES.JOB_ID
- → JOBS.JOB_IDENT
- JOB_HISTORY.EMPL_ID
- → EMPLOYEES.EMP_ID
- JOB_HISTORY.JOBS_ID
- → JOBS.JOB_IDENT
- DEPARTMENTS.LOC_ID
- → LOCATIONS.LOCT_ID
Dates & types
Dates are stored as YYYY-MM-DD text; SQLite compares them as strings, which sorts correctly. Column types come from your DDL but SQLite is dynamically typed, so no coercion runs on insert.
09 Config knobs
- model
- Top-bar input. Must match a name from
ollama list. Defaultdeepseek-v4.1-flash:cloud. - OLLAMA_URL
- Line 322. Change if Ollama runs on another host/port.
- SQLJS_CDN
- Line 321. Bump or mirror the engine version here.
- DATA / DDL
- Lines 326–385. Swap in new CSVs or a new schema; keep
colsin file order. - SYSTEM_PROMPT
- Lines 579–602. Tune the NL→SQL rules, glossary, and few-shot examples.
10 Limits & gotchas
- In-memory only. Reload resets to the original CSVs. Nothing is saved back to your files.
- Read-only for Ask. Generated DML/DDL is refused by design. Direct mode allows writes, but they vanish on reload.
- Ollama origin. Ask mode needs
OLLAMA_ORIGINS='*'or a served page. Direct mode never needs it. - CDN dependency at load. No sql.js download → no database. Cache it locally if you need fully offline runs.
- Headerless CSVs. If you replace a CSV, column order must match the
colsarray exactly. - Extraction is heuristic. If the model wraps SQL in prose the parser still finds it, but a model that emits two statements may confuse the guard.