Migrations
Change your schema without breaking the content and data you already have.
Sooner or later you rename a field, split one into two, or add a required one. The schema change itself is one line. The real work is updating everything already written in the old shape, so it still passes the new schema.
A Graft migration is a TypeScript file that does that update. There are two kinds, depending on where the old data lives:
| Kind | Updates | Use it for |
|---|---|---|
| Content migration (a "codemod") | Your Markdown files in Git | Normal collections |
| Data migration (a "backfill") | Rows in Postgres | Operational data collections |
This page walks through one of each, then covers how they run and how they interact with renames and branches.
Example: add a required field to your pages
You add a required description field to pages. Every existing page lacks
it, so the next compile would fail. Write a content migration that fills it in
from the tagline:
import type { DocumentData } from "@usegraft/core";
import { defineContentMigration } from "@usegraft/content-migrations";
import { pages } from "../graft.config";
type Page = DocumentData<typeof pages>;
export default defineContentMigration({
collection: pages,
description: "Fill in description from tagline",
transform: (doc) => ({
data: {
// Start from every field the file has. A field left out of `data` is
// removed from the file, so listing fields one by one drops the rest.
...(doc.data as Omit<Page, "description">),
description: String(doc.data.tagline ?? ""),
},
}),
});transform receives each document's frontmatter (doc.data) and body
(doc.body) as they are on disk, in the old shape. It returns the new
frontmatter, and a new body if you want to change that too.
The old frontmatter is untyped, because it no longer matches your schema. The
example keeps every existing field by spreading doc.data, cast to the fields
it already had, and adds the new one. TypeScript checks the new field against
your current schema, and the run checks every rewritten file against the
schema before writing any of them.
The migration changes the files, not the index. Changing only the index would be undone by the next compile, which rebuilds the index from the files.
Run it
graft migrate # list what would run, change nothing
graft migrate --apply # run it
A content migration checks every rewritten file against the current schema
before writing any of them. If one would fail, none are written. When it
succeeds, review the diff with git diff and commit it like any other change.
Migrations run in file-number order. Graft records each one it applies in a
table called the ledger (migrations_applied), and later runs skip it.
graft migrate --apply takes a lock on the branch before it reads the ledger.
If two deploys start a run at the same moment, the second waits for the first to
finish, then skips every migration the first one applied. graft merge --apply
takes the same lock on both the branch it merges and the branch it merges into.
The lock holds one database connection for the whole run, so the pool needs at
least two.
Example: tidy up existing rows
For operational data, use defineDataMigration. This one lowercases every
stored email address:
import { defineDataMigration } from "@usegraft/core";
import { submissions } from "../graft.config";
export default defineDataMigration({
collection: submissions,
description: "Normalize emails to lowercase",
transform: ({ data }) => ({ ...data, email: String(data.email).toLowerCase() }),
});The row updates and the ledger entry are saved in one transaction. Either both happen or neither does.
After renaming or deleting a collection
If you rename or delete a whole collection, the index still holds documents
under the old name. Graft does not recognize them, so the next compile stops
with INDEX_OWNERSHIP rather than deleting content it does not know about.
When the rename is intentional, compile once with --prune-unknown to remove
the old documents:
graft compile --prune-unknown
Use it only for this. Normally that error means something is wrong, such as two projects sharing one database.
Migrations and branches
When you merge a preview branch,
graft merge first replays any migrations that ran on the branch but not on
the target, then moves the branch's data rows. Merge the Git branch first: a
content migration's rewritten files arrive through that Git merge, and
graft merge only records its ledger entry.
Next steps
- Schema covers declaring fields.
- Branching & previews covers merging.
- Operational data explains the rows a data migration updates.