Migrations
Banks ship database changes the same way they ship code: a reviewable SQL diff, recorded in source control, applied once, never edited after the fact. CrateStack’s migration runner enforces that contract.Shape
A migration is a struct, not a file convention — banks integrate it into whatever build tooling they already use:idis sortable —YYYYMMDDHHMMSS_<slug>is canonicaldescriptionis short and human-readableupis the SQL applied forward, sent as a single batch to Postgres and run inside one transactiondownis recorded but never executed by the runner — irreversible-by-default is the safe banking posture
Running
- compares each input migration against
cratestack_migrations - skips already-applied rows whose checksum matches
- aborts with
CratestackError::Internalif an applied row’s checksum has drifted - for each pending row: opens a transaction, sends the entire
upscript in one batch viasqlx::raw_sql(&migration.up), inserts the record intocratestack_migrations, commits
CREATE TABLE rolls back with the failed CREATE INDEX, and
cratestack_migrations does not record the partial attempt.
Checksum drift
Each migration’s checksum isSHA-256(id || \0 || description || \0 || up).
Editing an already-applied migration in source control changes the
checksum:
--force flag. Restoring the original SQL or rolling forward with a
new migration are the two acceptable resolutions.
Inspecting state
status(&pool, &migrations) returns one MigrationState per input:
Multi-statement scripts
An earlier version of the runner splitup on ; client-side before
executing each statement. That approach broke on dollar-quoted PL/pgSQL
bodies, which routinely contain their own semicolons (issue #270), so as of v0.6.0
the runner no longer parses or splits up at all: the entire script is
sent to Postgres in a single batch via sqlx::raw_sql(&migration.up),
which uses the simple-query protocol and lets Postgres itself handle
statement boundaries — dollar-quoting included. Common patterns this
enables:
INSERT rolls the
CREATE TABLE and CREATE INDEX back together.
What the runner is not
- not a
down/rollback engine —downis recorded for audit but never run - not a parallel-applier — migrations are sequential and serialized through the tracking table
- not a long-running-migration coordinator — banks executing a 6-hour
ALTER TABLEuse their own backfill tooling and record the migration as a no-op when the backfill is done
Generating migrations from .cstack
The runner consumes SQL migrations identically whether they are hand-written or generated. CrateStack ships a separate schema diff generator that produces those migrations from .cstack against a committed schema snapshot — see ADR 0004 for the full design.
Today, two subcommands are shipped:
cratestack migrate diff— offline. Diffs the current.cstackagainstmigrations/<backend>/schema.snapshot.jsonand writes a new migration directory.cratestack migrate baseline— adopts a database that already has tables and no priorcratestackmigration history, by introspecting it directly instead of assuming an empty starting point. See Adopting an Existing Database for a full walkthrough against a real database.
cratestack migrate verify— deferred. Intended as a CI gate that replays the full migration history against an ephemeral DB and checks the result matches the snapshot; blocked on ephemeral-DB spawning support.cratestack migrate drift— deferred. Intended as a read-only ops tool reporting differences between the committed snapshot and a live database, without writing anything. Distinct frommigrate baselineabove, which introspects a live database too but only for the one-time act of adoption — it writes a new snapshot and acratestack_migrationsrow, wheremigrate driftwould only report.
.cstack to SQL. Destructive operations (column drop, lossy type change) still require explicit opt-in, and renames still require an explicit @@rename (table) / @rename (column) annotation.
Hand-written migration steps coexist with generated ones via optional up.pre.sql / up.post.sql files inside the migration directory; the generator never overwrites them. Use these for backfills, lookup-table seeds, materialized-view refreshes, and any transform the diff engine cannot infer.
Table naming, pluralization, and @@rename
cratestack migrate diff matches tables by name only — it never
infers that two tables are “the same one, renamed.” A table that
disappears from one side and a differently-named table that appears on
the other look, to the diff engine, exactly like an unrelated table
being dropped and a new one being created. The only way to tell it
otherwise is @@rename(from = "<old_table_name>"), declared on the
model before running migrate diff:
DROP TABLE categorys
followed by CREATE TABLE categories — a migration that, if applied
against a real deployment, destroys the table’s data.
Why this matters right now: the y -> ies pluralization fix
A model’s table name is derived by pluralizing its snake_cased name.
Through v0.7.x that pluralizer only ever appended a bare s to a name
ending in y, so model Category derived table categorys. As of
cratestack#509
the pluralizer correctly turns a consonant + y ending into ies
— Category now derives categories, matching normal English
pluralization.
This is exactly the “table disappeared, differently-named table
appeared” case above, and it applies with no .cstack change on
your part — upgrading the framework version alone changes what table
name your existing model derives. Any deployment with a model whose
name ends in a consonant + y needs to add @@rename(from = "<old_pluralization>") before running migrate diff against the
upgraded framework, or the generated migration will drop and recreate
the table.
Affected: any model name ending in consonant + y — Category
(categorys -> categories), Delivery (deliverys ->
deliveries), Entry (entrys -> entries), Query (querys ->
queries).
Not affected: a model name ending in vowel + y — Key (keys),
Day (days) — the pluralization rule for those was already a bare
s and hasn’t changed.
If you’re unsure whether a model is affected, compare the table name
your currently-deployed schema uses against what cratestack check
(or a fresh migrate diff against an empty snapshot) derives for the
same model post-upgrade — a mismatch means @@rename is needed.
Foreign keys, referential actions, and composite uniqueness
The generator emits real integrity constraints for two.cstack attributes, not just the columns behind them.
@relation fields emit a FOREIGN KEY constraint. The owning side of a relation (the side with fields:/references:, not the has-many List side) is projected into an ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY (...) REFERENCES ... statement, named <table>_<column>_fkey to match Postgres’s own auto-generated name:
onDelete/onUpdate accept Cascade, Restrict, SetNull, SetDefault, or NoAction. Both are optional and default to NoAction — Postgres’s own default — which the generator omits from the DDL rather than spelling out. Two rules are enforced at cratestack check time, before they’d otherwise surface as a Postgres ADD CONSTRAINT failure with no .cstack context:
onDelete/onUpdatecan only be declared on the relation’s owning side — theList-typed “many” side has no physical column of its own to attach a constraint to.onDelete: SetNull/onUpdate: SetNullrequires the local foreign-key field to be optional (tenantId String?);SetDefaultrequires it to declare@default(...).
@@unique([...]) emits a CREATE UNIQUE INDEX. Field-level @unique already did this for a single column; @@unique extends the same <table>_<col1>_<col2>_..._key naming convention across every listed column, in declaration order:
ON CONFLICT (a, b, c) DO UPDATE when a unique index over exactly that column tuple exists, so upsert-based idempotency (see Upsert) depends on this DDL actually being emitted.
SQLite has no ALTER TABLE ... ADD CONSTRAINT. The embedded backend can’t retrofit a foreign key onto an existing table, so instead of silently producing no constraint, the generator emits a comment marking where the constraint would be, naming it explicitly so the gap is visible in the generated migration rather than invisible in the database.
Framework source of truth: issue #260 / PR #261 (foreign keys), issue #262 / PR #266 (@@unique indexes), and PR #268 (onDelete/onUpdate).
Schema
cratestack::MIGRATIONS_TABLE_DDL and applied
idempotently by ensure_migrations_table.
Read Next
- Adopting an Existing Database —
cratestack migrate baseline, a full walkthrough of pointing this migration runner at a real, already-populated database for the first time - ADR 0004: Schema diff and migration generation — how
.cstackchanges turn into the SQL this runner applies - Schema diff (CLI) —
cratestack diffchecks the same two.cstackversions for wire-contract breaking changes, independent of the DB migration this page describes - Composite keys —
@@id([...])/@@unique([...]), the multi-column constraints this generator emits - Field attributes — full
@relation/onDelete/onUpdate/@@uniquesyntax reference - Audit log — banks frequently land
@@auditretroactively via a migration - Soft delete —
deleted_atcolumns are typically added by a follow-up migration on existing models