Writing models#
A model is a .sql file containing one select statement. Supernova materializes
it as a table in your lake and rebuilds it every 4 hours, in dependency order with
every other model. There is no configuration file, no scheduler to set up — the
SQL itself is the whole definition.
Where models live#
Models are files in your data repo, under models/. Edit them on the Files
page, or clone the repo and use your own editor — it's git either way, with full
history.
models/
monthly_revenue.sql
revenue/
active_subscriptions.sql
churn_risk.sqlThe file's basename is the table name: models/revenue/churn_risk.sql
materializes a table named churn_risk. Folders organize files; they don't change
the table name.
A complete model#
-- name: Active subscriptions
-- schema: revenue
select
s.id,
s.customer_id,
c.email,
s.status,
s.created_at
from titan.stripe.subscriptions s
join titan.stripe.customers c on c.id = s.customer_id
where s.status in ('active', 'past_due')
and not s._deletedThis produces revenue.active_subscriptions, queryable like any synced table.
Without the -- schema: line it would land in the default models schema.
A model can select from other models — just name their tables. Supernova reads
the SQL, builds the dependency graph, and runs everything in the right order; a
model that reads its own output table is understood as reading the previous
run's result, which is how running totals and slowly-built history are done.
The header directives#
Directives are comment lines in the file's first 20 lines. All are optional.
| Directive | Effect |
|---|---|
-- name: Monthly revenue | Display name in the file browser and run history |
-- schema: revenue | Output schema (default models; lowercase, can't shadow a synced source's schema) |
-- cadence: manual | Excluded from scheduled runs; rebuilds only when you run it |
-- depends_on: stripe.charges | Adds a dependency the SQL alone doesn't reveal |
-- unique_key: id | Incremental updates by key instead of a full rebuild — see below |
-- lint: | Per-model diagnostic policy, one TS0000: allow or deny per line |
A malformed directive doesn't half-work: the model refuses to run, with an error
naming the line. Better a loud stop than a silently ignored typo.
Rebuild or upsert#
By default each run rebuilds the model's table from scratch — simplest, and right
for most models. Declaring a key changes the strategy:
-- unique_key: idNow each run upserts by that key: rows with a matching key are replaced, new
rows are added, everything else is left alone. Use it when the model accumulates
history that a rebuild would have to recompute, or when downstream tools watch the
table and shouldn't see it vanish mid-rebuild.
Runs#
Every 4 hours, one run per organization executes all models in dependency order —
up to 4 at a time where the graph allows. The Models page shows the graph,
each model's last result, and a Run now button; a running model gets up to 4
hours before the run marks it failed and moves on to what doesn't depend on it.
Type checking#
Every model is checked against your lake's actual schema before it runs — the
same checker as the editor, so a column dropped by a source or a type changed
upstream is caught as a named diagnostic, not discovered as a broken dashboard.
The -- lint: header tunes severity per model:
-- lint:
-- TS0400: allow
-- TS0609: denyallow demotes a diagnostic to a warning; deny promotes it to a blocking
error. Add type annotations to the expressions
you most depend on and the checker holds them to it, run after run.