Migrations

Change your schema without breaking the content and data you already have.

Deploy & operate3 min read

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:

KindUpdatesUse it for
Content migration (a "codemod")Your Markdown files in GitNormal collections
Data migration (a "backfill")Rows in PostgresOperational 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:

migrations/0001-pages-description.ts
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:

migrations/0002-lowercase-emails.ts
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