Executing a panel
Validation and storage — the spec becomes a version
Section titled “Validation and storage — the spec becomes a version”When the user saves, the client posts the spec to /api/dashboards. The server
does not trust the streamed object; it re-parses and re-validates from
scratch.
resolveAndValidateDashboard (src/lib/dashboard-service.ts) is the gate:
- Every referenced
sourceIdmust resolve to a real, non-tombstoned source. - All panels must belong to one workspace, derived from the trusted source records — mixing workspaces is rejected.
- Every panel’s SQL is re-run through
validateSqlagainst its own source’s catalog. Generation-time validity is not assumed; the source is re-authorized and the SQL re-checked at save.
The derived workspace then authorizes dashboard:create, and the spec is
written as a new immutable row. Specs are never mutated in place — an edit
inserts a new version. That is what lets viewing and polling be pure replays of
a fixed spec, and it means a saved dashboard is a stable, auditable artifact.
See Data model.
The SQL guard
Section titled “The SQL guard”validateSql (src/lib/sql/safety.ts) is the trust boundary for
model-authored SQL. It parses the statement with the real PostgreSQL grammar
(libpg_query, compiled to WebAssembly, wrapped in src/lib/sql/ast.ts) and
walks the tree. It rejects anything that is not a single read-only statement
against allowlisted tables:
- Exactly one statement, and it is a
SELECT. Anything the parser cannot parse is rejected. Any other statement type anywhere in the tree —INSERTinside a CTE,EXPLAIN,COPY,MERGE,SET— is rejected by type, not by keyword. - Only allowlisted node types. The walker knows the constructs a read-only
SELECT is built from; a construct it has not been told about is rejected by
name.
SELECT INTO, row locking (FOR UPDATE/FOR SHARE) and$Nplaceholders — reserved for the server’s bound parameters — get their own errors. - No comments. The lexer decides, so
'--'inside a string literal is fine. - Forbidden functions: anything that could exfiltrate data or bypass the
allowlist —
file,url,remote,s3,dblink,pg_read_file, the*_to_xmlfamily that executes a query string — plus server-side sleeps, session state, server identity, advisory locks and backend signals. Matched by unqualified name against every call in the tree, in any position, sopg_catalog.pg_sleep(1)is caught. - Forbidden time and non-deterministic values:
now()and every synonym,random(), the parenless keywords (current_timestamp,current_user, …) and the date/time input words ('now','today','yesterday') that PostgreSQL reads as the clock. The model must not filter or branch on time. - Table allowlist: every relation the tree reads must appear in the selected
source’s catalog — in the top-level
FROM, a nested CTE, a lateral subquery, a set-operation arm, or aLIMITexpression alike. CTE names are resolved with PostgreSQL’s scoping rules first, soFROM bafterWITH b AS (…)is the CTE, while a name a later or sibling CTE defines is a real table.
The function denylists also run over the raw text after the parse-tree pass, as a cheap second layer that does not depend on the parser.
If any check fails, the save — or a later tick — reports a clear error instead of touching the database.
Checking a query before saving
Section titled “Checking a query before saving”The panel editor does not make an author discover a rejection at save time.
- Validate posts the statement to
/api/sql/validate, which resolves and authorizes the source exactly as/api/querydoes and calls the samevalidateSql. Nothing is planned and nothing connects to the database, so the check is instant — and because it is the same call the save makes, the verdict cannot drift from it. A rejection is a200carrying{ ok: false, error }: the request succeeded, and the verdict is the payload. - Run preview posts the panel’s
sourceId, SQL,timeFieldand the dashboard’s current time range to/api/queryand renders the rows through the samePanelViewthe dashboard uses, with the panel’s own viz and format.Ctrl/⌘+Enterin the SQL field runs it. - What runs posts the same request to
/api/sql/plan— see Seeing what actually runs below.
None of them touches the dashboard or writes a version, and none is a way around the guard: the preview is an ordinary guarded execution, and the save re-validates every panel regardless of what was checked here.
Writing the query — the catalog-aware editor
Section titled “Writing the query — the catalog-aware editor”The SQL field is a CodeMirror editor (src/components/sql/SqlEditor.tsx),
loaded on demand so that nothing in a dashboard viewer’s bundle carries an
editor. Until the chunk arrives — or if it never does — the same field renders
as a plain textarea with the same value, so the editor is an improvement on the
control, never a prerequisite for it.
What it knows comes from the selected source’s catalog, projected for the
browser by sourceCatalog (src/lib/registry.ts): the schema name, the
allowlisted tables and their columns, and nothing that says how to reach the
database. The host, port, database, TLS setting and secret_ref stay on the
server.
- Completion offers exactly the allowlisted tables and their columns, with each column’s type as the detail line. What completes is what the guard accepts, so taking a suggestion cannot produce a catalog rejection. It reconfigures in place when the panel’s source changes.
- Hints underline the guard’s most common refusals while typing —
a comment, a second statement, a table outside the allowlist,
now(),current_timestamp,'today'. They come from a lexical scan (src/lib/sql/hints.ts), not a second copy of the guard, and the two are asymmetric on purpose: a hint is only raised when a scan can be certain the server will refuse. Anything less certain stays silent, because underlining a valid query teaches authors to ignore the underlines. A statement with no hints has not been accepted — it has only not been convicted;Validateabove is the answer. timeFieldis a picker rather than free text. It offers the query’s own output columns first (read off the SELECT list) and the catalog’s timestamp columns after — which are the output columns when the query selects*— and warns when the declared field is not among the outputs. That is the missing-timeFielderror at the bottom of this page, said before the query runs instead of after. Free text stays available for the cases the scan cannot name.
Ctrl/⌘ + Enter runs the preview from the editor. Escape moves focus to
the editor’s wrapper so Tab continues into the rest of the form; Tab itself
is left alone rather than bound to indentation, so the editor is never a
keyboard trap.
One operational detail: the page’s Content-Security-Policy has no
'unsafe-inline' for styles, and CodeMirror builds its stylesheet at runtime.
The editor reads this document’s nonce back out of the DOM
(src/lib/csp-nonce.ts) and hands it to EditorView.cspNonce; without it the
browser drops the stylesheet and the editor renders as unstyled text.
The server owns time
Section titled “The server owns time”Real data enters the system only here — server-side, from a stored spec. Three
places share the same guard code: the live poller, the one-shot query route, and
the read-only dashboard chat’s runQuery tool. The core is
buildExecutablePlan, which runs after validateSql has passed.
The validated query is wrapped as a subquery, and the dashboard’s resolved
from/to are injected as bound parameters on the declared timeField:
SELECT * FROM (<the model's validated SQL>) AS _holoWHERE _holo.<timeField> >= $1::timestamptz AND _holo.<timeField> < $2::timestamptzLIMIT <maxQueryRows> -- default 5000from/tocome fromresolveTimeRange(src/lib/time.ts), which turns the IR’s relative expressions (now-1h) into concrete dates. The model never supplies a time value; it only named the column.timeFieldis re-checked against a strict identifier regex before interpolation — it is an identifier, so it cannot be a bound parameter.- A hard
LIMITcaps rows regardless of what the query does, andMAX_RESULT_BYTES(default 4 MiB) caps the serialized size of the result:executePlanmeasures rows as they stream in from Postgres and stops keeping them the moment the cap is crossed, so a wide-row result fails with an actionable message naming the limit instead of being buffered whole.
Read-only execution
Section titled “Read-only execution”executePlan (src/lib/timescaledb/client.ts) runs the plan in a read-only
transaction as the source’s read-only role, with a statement timeout
(QUERY_TIMEOUT_SECONDS, default 20s). Inside the transaction it first pins
SET LOCAL search_path to the source’s configured schema — validated as a bare
identifier — plus public, so the catalog’s bare table names resolve only
against the allowlisted schema. Credentials are resolved from the environment
via the source’s secret_ref; they are never stored in the spec and never leave
the server.
Net effect: the model controls what to compute, but not the time window, not resource usage, and not which credentials or tables it can touch.
Seeing what actually runs
Section titled “Seeing what actually runs”Everything above happens to a statement after its author has stopped looking at
it. POST /api/sql/plan shows the result, for one panel, without running it:
- the wrapped statement as the server would send it,
LIMITand all; - the bound parameters, with the relative expression each was resolved from
(
$1 = 2026-09-22T11:00:00Z, resolved fromnow-1h) and labelled as server-supplied; - the row, time and byte limits in force;
- the session statements the query runs inside — the read-only transaction and
the pinned
search_path.
It is the same route as /api/query with the execution removed: the same
validateSql, resolveTimeRange, buildExecutablePlan and
sessionStatements. Re-deriving any of them for display would let the
explanation drift from the behaviour it explains, which is the whole point of
showing it. Nothing connects to the source’s database and nothing is written.
A statement the guard refuses is a 400 with the guard’s own message. Unlike
/api/sql/validate there is no {ok:false} verdict to return, because a
refused statement has no plan.
Authorization is /api/sql/validate’s — dashboard:generate on the source’s
workspace — for the same reason: the request body carries arbitrary SQL and a
rejection names the table or function the guard refused, so answering is the
same catalog disclosure that previewing a query is.
The response shape is an explicit allowlist (src/lib/query-plan.ts), like
panelDetails(): a source is named by its opaque id, and no host, database,
user or secret_ref has a field to travel in.
It is reachable from the panel editor (What runs) and from the viewer’s generated-SQL dialog, which is where a reader who did not write the panel can check that the window really is the server’s.
Actionable errors versus opaque ones
Section titled “Actionable errors versus opaque ones”When a statement fails, executePlan distinguishes statement-level errors —
Postgres SQLSTATE codes for a bad column, syntax, type mismatch, timeout, or a
timeField naming no output column — from connection and infrastructure errors.
The former are wrapped as QueryExecutionError, and routes return them as a
400 carrying the real message so an editor can correct and retry. Infrastructure
errors stay a generic 500 and are never surfaced.
The missing-timeField case gets a purpose-written message, because the generic
Postgres error (column _holo.minute does not exist) does not explain the fix:
time column “minute” is not produced by this query. Set the panel’s timeField to the SELECT output alias of your time bucket (e.g.
time_bucket(...) AS minute), or clear it when the result has no time column.