Documentation menu

Writing models#

A model is a .sql file containing one select statement. Supernova materializes
it as a table in your lake and rebuilds it weekdays at 7am, 12pm, 5pm and 10pm and Sundays at 10pm, 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.

text
models/
  monthly_revenue.sql
  revenue/
    active_subscriptions.sql
    churn_risk.sql

The 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#

sql
-- 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._deleted

This 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.

DirectiveEffect
-- name: Monthly revenueDisplay name in the file browser and run history
-- schema: revenueOutput schema (default models; lowercase, can't shadow a synced source's schema)
-- cadence: manualExcluded from scheduled runs; rebuilds only when you run it
-- depends_on: stripe.chargesAdds a dependency the SQL alone doesn't reveal
-- unique_key: idIncremental 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:

sql
-- unique_key: id

Now 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#

Two hours after each default sync slot — weekdays at 7am, 12pm, 5pm and 10pm and Sundays at 10pm, in your organization's
timezone — 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:

sql
-- lint:
--   TS0400: allow
--   TS0609: deny

allow 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.