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 migrate --latest --apply # your database, while you iterate
fougere freeze v2 # fronds/blog/versions/v2/ — in the change that moves an entity
fougere migrate --apply # every other database: what was frozen, and nothing else
A database someone else shares receives a version, never what happens to be on disk: without
--latest, fougere migrate refuses a frond never frozen, or whose entities moved since its
last version, and names it.
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 facade. 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.
The two readings produce opposite DDL — one keeps a column of live data, the other throws
it away — and no pair of shapes holds the intent.
The entity says it, beside the field:
export default class Post extends entity(
{ id: primary(), title: text(), content: text() },
{ previous: { content: 'body' } },
) {}
fougere freeze reads it as the answer to its question, and records the rename in the version.
On your own database, fougere migrate --latest reads it against the live table: body is
there and content is not, so the column is renamed and the rows follow. Once frozen, the
declaration has done its work and can go — the version carries it to every other database.
A table that keeps a column no field declares while it gains a new one is what an undeclared
rename looks like, and fougere migrate says so before anything runs:
▲ posts keeps body, which no field declares, while content is added —
if one replaces the other, declare it on the entity: previous: { content: 'body' }
fougere freeze asks the same question when it cuts a version, and writes nothing until it
is settled:
◆ 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
What a migration realises
fougere migrate does two things, in this order: what touches live data — a rename, a drop —
and then the additive half, which creates a missing table and adds a missing column. The
order matters: renamed first, a column is moved rather than added empty beside the old one.
The half that touches live data does only what a step — or a previous: — actually says:
| The step says | The database does |
|---|---|
renamed | ALTER TABLE … RENAME COLUMN — the data follows |
removed | DROP COLUMN |
added, optional | nothing; the additive half covers it |
| an index that appeared | nothing; the additive half 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 facade 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 has run, and a table that is not there yet is created at its final shape.
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.
The boot reads, and never writes
Nothing is migrated at boot. A boot compares the database with the entities and refuses when it is behind, naming what is missing:
Fougere boot refused: the database is missing 2 change(s) from the entities:
products — no table
orders.total — no column
On your machine: fougere migrate --latest --apply
On a shared database: fougere freeze, then fougere migrate --apply
Every other step of a boot checks and refuses; the one that used to write was also the one
two replicas raced over, and the one that served an empty database beside the real one with a
green boot. Writing the schema is a step you run — in development npm run migrate, in a
deployment a release step before the servers start.
A process whose database is born with it — path: ':memory:', a test — has nobody else to
migrate it, so it says it writes its own: extensions: [migrating(storage.migrate)], or
migrates: true for bootApp and configureFougere.
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. It is cut in every change that moves an
entity, because it is what a deployment applies: a database behind by three versions follows
the whole chain, renames and drops included, long after previous: left the entity.
Next: Handlers — operations, binding rules, output scoping.