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 saysThe database does
renamedALTER TABLE … RENAME COLUMN — the data follows
removedDROP COLUMN
added, optionalnothing; the additive half covers it
an index that appearednothing; the additive half proposes every declared index
a boundary that movednothing — 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 a NOT NULL column 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 CHECK until 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.