# Schema migrations (/product/infrastructure/schema-migrations)



## Two kinds of DDL

**Stateful** DDL, the tables, columns, constraints, indexes, policies and materialized views, is the **chain**: numbered files applied once per database, in order, and recorded.

**Stateless** DDL, the functions, views, triggers, grants, RPCs and seeds, carries its own full definition and is **replayed** on every apply. A change to an RPC, a policy helper, a view or a grant is therefore never a migration; it lands with the deploy.

Views are stateless because Postgres refuses to change a column a view selects, and a diff cannot know to drop the view first. Each view is dropped and recreated on every apply, so a numbered migration is free to reach the column underneath, and every view runs as its caller so it never widens what its tables allow.

## The ledger

Each database records what it carries in its own schema, out of the API's reach: id, kind, checksum, applied at. A chain row means that file is applied and skipped forever after. A replay row records the checksum last asserted; the file runs regardless and is re-stamped only when it changed, which is how the runner knows the relation surface moved and the Data API needs reprovisioning.

The runner refuses a database it cannot place. The schema present with no ledger, or a ledger with no schema, is a contradiction it stops on rather than guessing.

## Authoring

Edit the schema, generate the next numbered file from the diff, review it, and edit it only for what a diff cannot know: a rename it saw as drop-plus-add, a backfill, a cast on a type change. Never write one from scratch; a hand-authored file that yields a different schema than the repo declares reads as coverage while providing none.

The baseline is frozen. Every provisioned database is stamped past it, so a regenerated baseline reaches none of them.

**Additive by default.** A chain migration may not drop a table or column, truncate, delete, change a type, set not-null, or add a not-null column without a default. Anything else is expand and contract across releases: add the column, backfill, dual-write, drop the old one. A deliberate destructive migration declares itself and why in its first lines.

## Applying

Per database: adopt or read the ledger, apply the chain entries it is missing, replay the stateless set, stamp. Idempotent, so a second run is free, and a fresh database is the same code path: a new tenant is stamped at the head because it applied the chain, not because it was assumed current.

When an apply creates a table, view or function, the runner reprovisions the tenant's Data API, because Neon snapshots the grant surface when the endpoint is created. The fleet fan-out runs one tenant at a time under a deadline and returns the slug it stopped at, so a re-run resumes from there.

## Verifying

Before a fleet apply, a dry run builds a **reference**: a throwaway branch of the tenant's own project with the schema dropped and the whole tree applied fresh. It then branches the tenant again, applies the pending chain there, and diffs the two fingerprints, columns, constraints, indexes, views, policies. Every differing line is printed and both branches are deleted. That is the only thing that substantiates "the chain reproduces the baseline".
