How a schema moves
An entity describes itself as it is today. fougere freeze records what it was, so the
difference between two moments becomes a value something can act on.
fougere freeze v1 # fronds/blog/versions/v1/shape.json
fougere migrate # what the database has not caught up with
fougere migrate --apply # make it so
One artefact, several readers
fronds/blog/versions/
v1/ shape.json what the shapes WERE
v2/ shape.json from.json …and the step that leads here
Both are written, and neither replaces the other. A snapshot alone loses the intent — a field gone plus a field appeared cannot be told from a rename. A step alone corrupts in silence — nothing checks it against anything. Together they carry an invariant: replaying the step over the previous snapshot must reproduce this one.
A migration reads the step once, against the rows. The second reader is why the step is an artefact rather than a function call: an old API version still being served would replay the same step at every call, at the door. That reader is not written yet — and it is the reason the comparison lives in one place, because writing it twice would let the two disagree about the same two shapes.
A rename is declared, never guessed
Fougere will not decide that body became content, even when it is the only possibility:
◆ post.body — what happened to it?
❯ renamed to content the data moves with it
dropped the column goes, and what it held goes with it
The two readings produce opposite DDL — one keeps a column of live data, the other
throws it away. The intent lives in the moment the change was made, and no pair of snapshots
holds it. This is the one place freeze asks, and nothing reaches the disk until it is
settled.
What a migration realises
An automatic pass is additive on purpose: it creates a missing table and adds a missing
column, and nothing else ever happens. fougere migrate is the other half — and only
what a step actually says:
| The step says | The database does |
|---|---|
renamed | ALTER TABLE … RENAME COLUMN — the data follows |
removed | DROP COLUMN |
added, optional | nothing; the additive pass already covers it |
| an index that appeared | nothing; every boot proposes every declared index |
a boundary that moved | nothing — who may read a field is not something a column holds |
A comparison reads all four axes, not the shape alone: an index, a unique group, a relation, a lifecycle and a boundary are differences too, and a step that reported only the shape was answering a quarter of the question.
Everything else is refused by name, in one run rather than one at a time:
- a required field with no default — existing rows have nothing to hold. Declare
text({ default: … }), the same answer a migration gives when adding aNOT NULLcolumn to a table that already has rows. - a field that became required — same reason, from the other direction.
- a type that moved — no conversion is derivable from two shapes.
- bounds that tightened — the door still enforces them; the table keeps its old
CHECKuntil you migrate it yourself. - a unique group that moved — rows already stored may contradict it, so adding the constraint is a decision about the rows and not about the DDL.
- a relation that moved — a foreign key is a constraint, and nothing here alters one.
- a default that moved — the table keeps the old one; nothing here alters a
DEFAULT. - an index that went — nothing drops an index today, so the table keeps it.
A plan carrying a single refusal runs nothing. Half a rename is worse than none: the data ends up in a column nothing knows the name of any more.
Replaying is safe
Run migrate twice and the second run answers up to date. There is no ledger of applied
migrations — a change is skipped because the columns themselves say it happened: a rename
whose old name is gone and whose new one is there has run.
That is why the chain is read whole and composed into one step rather than replayed a step at a time: a field renamed twice is followed to the name it ends on, which is the only one the tables can answer for. An intermediate name they never held is not a question they can be asked. Nothing has to know which version the database sits at, and a column someone renamed by hand is seen rather than contradicted. A ledger would be a second record of a fact the columns already carry, and the two would disagree the day they diverged.
What this does not do
Nothing runs at boot. Dropping and renaming touch live data, so it is a command you run, having read the plan. The additive pass stays where it was.
A freeze is not a build artefact. It is committed, because it records a past that no current code can regenerate — unlike the identity card, which is derived from today's entities and re-derivable at any time.
Next: The identity card — the same idea across processes rather than across time.