Custom migrations
A generated migration covers the changes the differ can see. Everything else — a view, a policy, an extension, a data backfill, a trigger — is written by hand, in the same directory and the same format. The runner does not care where a file came from.
Naming
<version>_<name>.sql is an up migration; <version>_<name>.down.sql is its down migration.
- The version is a signed 64-bit integer. Files are applied in ascending version order.
- The name is the rest of the stem, with underscores turned into spaces for the journal and
the CLI's output, so
0004_backfill_emailsprints asbackfill emails. - The two halves are paired by version, not by name. A down file whose name differs from its up file still belongs to it.
- A
.down.sqlwith no up file is an error when the directory is read:… has no matching up migration. - A file whose stem does not start with an integer is skipped, not reported, so a mistyped version disappears from the run.
- Only files ending in
.sqlare read. Subdirectories are not walked.
A migration without a down file is valid; it cannot be reverted. A migration with a down file but no up file is not.
Statements
A file is split on ;. The splitter understands:
- single-quoted, double-quoted and backtick-quoted spans, with a doubled quote as an escape;
--line comments, whose semicolons are ignored.
It does not understand PostgreSQL dollar-quoting or /* */ block comments. A DO $$ … $$
body containing a semicolon is split in the middle. Keep such a body in its own file with one
statement, or run it outside the runner.
A trailing statement without a semicolon is kept. An empty file produces no statements and is still recorded in the journal.
Checksums are append-only
Every migration is hashed with FNV-1a over its name, a direction marker and its statements. The digest is 16 hexadecimal characters and is stored in the journal. Editing an applied migration is refused before anything runs:
The down half is hashed separately, so editing a .down.sql is refused the same way. Adding a
down file to a migration that was applied without one is allowed, because there is no recorded
down checksum to compare against. Renaming the file's name part also changes the checksum.
Down migrations
Write the down file as the mirror of the up file: the inverse of each statement in the same position. The runner executes the down statements from the bottom of the file up.
The runner drops the index before the table because it reads the down file backwards. Writing the undo steps in the order you want them to run is the mistake that makes a revert fail halfway.
Objects the IR cannot express
A migration is the only home for anything the differ does not model. The linked page has the details and examples:
- views —
CREATE VIEW,CREATE OR REPLACE VIEW, materialised views; - row-level security —
ENABLE ROW LEVEL SECURITY,CREATE POLICY; - extensions —
CREATE EXTENSIONand the types it adds; - sequences — a standalone sequence and its
nextvaldefault.
The same applies to triggers and expression indexes. On SQLite a composite UNIQUE or a
CHECK cannot be added to an existing table at all, and on both SQLite and MySQL a CHECK is
not introspected. A data backfill belongs here too:
Building a migration in code
Migrator is public, so a migration can be assembled without a file. This is useful for tests
and for a schema that ships inside a binary:
Gotchas
- A checksum is a guard, not a plan. It proves the file did not change; it does not prove the file is correct.
- The journal is a table like any other. Do not write to
_besu_migrationsby hand; the runner will believe you. - A failed down migration stops the revert. The run is one transaction on PostgreSQL and SQLite, so the database is left as it was; on MySQL it may not be.
- The version is the identity, not the name. Two files with the same version are rejected at run time, even if their names differ.
- A hand-written file is still split on
;. Keeping one statement per file avoids the dollar-quoting limit.
