# Manage core database migrations

> Run EmDash's internal schema migrations as a serialized deployment step.

Source: https://emdashcmseverything.com/official-docs/deployment/core-migrations/

EmDash core migrations update EmDash's own tables and the standard columns on content tables. They do not create, remove, or rename your collections and fields; see [Evolving a Deployed Site](https://emdashcmseverything.com/official-docs/deployment/schema-evolution/) for content-model changes.

Runtime migration mode defaults to `auto`, so existing deployments keep applying pending core migrations on startup. Deployment-managed migrations let a build migrate its database before new application code receives traffic, then let the runtime verify or trust that deployment step.

Core migrations are forward-only. They are written so a command can be retried after statements that definitely completed, but an interrupted remote command can leave an ambiguous result. The safe response is to inspect the same database with `emdash migrate --status`, not to assume that either the whole migration or none of it ran.

## Build, migrate, deploy, check

An Astro build or sync writes `.emdash/migrations.json`. This secret-free manifest records the exact EmDash version, ordered migration set, locale configuration, and adapter migration executor used by that build.

> **Ignore the generated manifest**
>
> Add `.emdash/migrations.json` to `.gitignore`. Deployment jobs generate and consume this file from the build workspace; it does not belong in source control. EmDash templates already include this rule.

Run these commands from the project whose dependencies produced the manifest. First build and inspect the target.

```bash
pnpm build
pnpm emdash migrate --status
```

After confirming that the reported target is the intended database, start the interactive migration. Review the target again at the prompt before confirming. Then deploy the same build and check the deployed schema.

```bash
pnpm emdash migrate
pnpm wrangler deploy
pnpm emdash migrate --check
```

`emdash migrate --status` reports applied, pending, and unknown migrations without changing the database. The plain `emdash migrate` command displays the target and asks for confirmation before applying pending migrations.

`--check` never applies migrations and exits non-zero when known migrations are pending or the database contains migration records unknown to the build. Use `--status` when you want to inspect the same migration sets without check's non-zero "work required" exit status. The [CLI reference](https://emdashcmseverything.com/official-docs/reference/cli/#emdash-migrate) distinguishes pending, unknown, confirmation, interruption, and operational exit codes.

Non-interactive apply and every `--json` apply require `--expected-target-fingerprint`; the command fails if the resolved target does not match. Use these options in automated deployment jobs, not for the interactive workflow above.

Use `--manifest path/to/migrations.json` for a manifest stored elsewhere. For local investigation, `--from-config [--config astro.config.mjs]` explicitly evaluates trusted project configuration without running Astro hooks or starting a server. Deployment pipelines should consume the build manifest.

> **Caution**
>
> Keep the manifest and application artifact together. Stale schema versions, migration registries,
> fingerprints, and executor entrypoints are rejected against the project's installed EmDash package.
> Artifacts from different builds of the same version can share that identity, so target review and
> artifact pairing remain deployment safeguards. A global CLI cannot substitute its own registry.

## Select the database explicitly

The configured adapter contributes secret-free target information to the manifest. Credentials remain in environment variables and are read only by the migration command.

| Adapter | Manifest target | Default credential variable | Useful override |
| --- | --- | --- | --- |
| SQLite | Database path or `file:` URL | — | `--database <path>` |
| libSQL | Public URL | `TURSO_AUTH_TOKEN` | Configure `migrationAuthTokenEnv` |
| PostgreSQL | Connection variable name | `DATABASE_URL` | `--database-url-env <name>` |
| Cloudflare D1 | Wrangler binding name | `CLOUDFLARE_API_TOKEN` | `--d1`, `--account-id`, `--wrangler-config`, `--wrangler-env` |
| Hyperdrive | Primary binding and origin variable name | Binding-specific direct-origin variable | Configure `migrationConnectionStringEnv` |

Relative SQLite paths resolve from the project root, not from the installed EmDash package or the shell's current subdirectory. PostgreSQL, libSQL, and Hyperdrive target labels omit credentials and URL parameters.

## Provision D1 before migrating it

Creating a D1 database and migrating its schema are separate operations. `emdash migrate` never creates a missing database.

1. Provision the database and record its production UUID.

   ```bash
   pnpm wrangler d1 create my-site-production
   ```

2. Add that UUID to the intended binding and environment in `wrangler.jsonc`.

3. Build the site so the D1 binding is recorded in `.emdash/migrations.json`.

4. Set the account ID and a scoped API token with D1 Edit permission. Inspect the selected target, then run the interactive migration. Confirm the prompt only when the account and database match the intended production database.

   ```bash
   export CLOUDFLARE_ACCOUNT_ID="..."
   export CLOUDFLARE_API_TOKEN="..."
   pnpm emdash migrate \
     --status \
     --wrangler-config wrangler.jsonc \
     --wrangler-env production
   pnpm emdash migrate \
     --wrangler-config wrangler.jsonc \
     --wrangler-env production
   ```

You can instead provide `--account-id` with `--d1 <database-uuid-or-name>`. Name lookup must resolve to exactly one database. Preview IDs, placeholder IDs, conflicting accounts, and ambiguous bindings fail closed.

## Configure D1 migrations in CI

EmDash holds a migration lock in the D1 database while it applies migrations, from `emdash migrate` and from runtime migrations in `auto` mode alike. A second run that starts while the lock is held waits up to 10 seconds. If the first run finishes in that time, the second run succeeds without applying anything; otherwise, it fails without applying migrations. Run one migration job at a time for an account and database UUID so a second job waits in the CI queue instead of failing.

Set the following secret and variables in the CI environment:

- Secret `CLOUDFLARE_API_TOKEN`: a scoped token with D1 Edit permission.
- Variable `CLOUDFLARE_ACCOUNT_ID`: the Cloudflare account ID that owns the database.
- Variable `D1_DATABASE_ID`: the production D1 database UUID.
- Variable `EMDASH_TARGET_FINGERPRINT`: the fingerprint printed by `emdash migrate --status` after you have reviewed the account and database locally.

The following GitHub Actions workflow uses those values and keys the concurrency group by both immutable D1 identifiers. Its apply step is non-interactive, so it supplies the reviewed target fingerprint explicitly.

```yaml title=".github/workflows/deploy.yml"
name: Deploy

on:
  workflow_dispatch:

concurrency:
  group: emdash-migrations-${{ vars.CLOUDFLARE_ACCOUNT_ID }}-${{ vars.D1_DATABASE_ID }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - name: Inspect EmDash migration target
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: |
          pnpm emdash migrate --status --json \
            --account-id "${{ vars.CLOUDFLARE_ACCOUNT_ID }}" \
            --d1 "${{ vars.D1_DATABASE_ID }}"
      - name: Apply EmDash migrations
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          EMDASH_TARGET_FINGERPRINT: ${{ vars.EMDASH_TARGET_FINGERPRINT }}
        run: |
          pnpm emdash migrate \
            --account-id "${{ vars.CLOUDFLARE_ACCOUNT_ID }}" \
            --d1 "${{ vars.D1_DATABASE_ID }}" \
            --expected-target-fingerprint "$EMDASH_TARGET_FINGERPRINT"
      - run: pnpm wrangler deploy
      - name: Check EmDash migrations
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: |
          pnpm emdash migrate --check \
            --account-id "${{ vars.CLOUDFLARE_ACCOUNT_ID }}" \
            --d1 "${{ vars.D1_DATABASE_ID }}"
```

Update `EMDASH_TARGET_FINGERPRINT` only after reviewing a changed target locally. The fingerprint contains no credential, but changing it without checking the account and database removes the guard against migrating the wrong database.

## Apply large content migrations from the CLI

Some core migrations process every content entry and revision, so their work grows with the size of the site. A deployed Cloudflare Worker stops a request when it reaches its subrequest or CPU limit, and on a large site such a migration reaches that limit before it finishes.

In `auto` mode, a deployed Worker counts the rows first. When the pending migrations of this kind would process more than about 5,000 content entries and revisions, the Worker applies none of the pending migrations and answers requests with a 503 response. The Worker log shows the following error, which names the migration.

```text
Pending migration 079_datetime_normalization processes more than 5000 content rows, too many to apply while serving a request. Apply it with `emdash migrate`: https://docs.emdashcms.com/deployment/core-migrations/#apply-large-content-migrations-from-the-cli
```

Apply the pending migrations from the deployment machine with `emdash migrate`, as described in [Build, migrate, deploy, check](#build-migrate-deploy-check). A Worker instance checks again 30 seconds after its last check, so pages return within 30 seconds of the command finishing.

Node deployments and the development server apply these migrations at startup regardless of size.

## Release a stuck migration lock

A D1 migration run that stops before it releases the migration lock leaves the lock held. This happens when a CI job is cancelled during `emdash migrate`, when a Worker in `auto` mode stops during a runtime migration, or when a development server is stopped while it applies migrations. A run that fails with a migration error releases the lock. EmDash does not release a lock it does not hold, because the holder may still be applying migrations or may have stopped partway through one. Until the lock is released, the site cannot apply its pending migrations. Once the lock has been held for more than a minute, a site in `auto` mode answers requests with a 503 response, as `check` mode does while migrations are pending.

After the lock has been held for more than a minute, `emdash migrate` and runtime migrations stop waiting for it and report the following error:

```text
The migration lock has been held since 2026-09-01T12:00:00.000Z (lock 1788264000000). A migration may still be running; if none is, check the database and release the lock: https://docs.emdashcms.com/deployment/core-migrations/#release-a-stuck-migration-lock
```

Taking over or releasing the lock of a remote D1 database uses `emdash migrate`, so it needs a build that wrote `.emdash/migrations.json` and an API token with D1 Edit permission, as described in [Provision D1 before migrating it](#provision-d1-before-migrating-it).

1. Confirm that no migration job, deployment, or other `emdash migrate` command is running against the database.

2. Inspect the lock and the migration sets with the same target options that the migration used.

   ```bash
   pnpm emdash migrate --status
   ```

   The report starts with the lock and its id. If the stopped run was applying a migration, that was the first pending migration, and it may be partly applied.

   ```text
   Migration lock: held since 2026-09-01T12:00:00.000Z (id 1788264000000)
   ```

   A Worker in `auto` mode can also hold the lock while it applies migrations. Run the command again a minute or more later, and wait instead of taking over the lock while the known applied migrations keep changing.

3. Take over the lock with that id and apply the pending migrations. The command asks you to confirm the target, and it takes over the lock only while the lock still has that id. In a non-interactive shell, add `--expected-target-fingerprint` with the target fingerprint that `--status` printed.

   ```bash
   pnpm emdash migrate --take-over-lock 1788264000000
   ```

   The lock passes straight to this run and is never free in between, so a Worker in `auto` mode cannot take it first. If the apply fails in the first pending migration, treat that migration as left partway through and follow the entry for an ambiguous D1 write in [Troubleshooting](#troubleshooting).

To free the lock without applying migrations, run `pnpm emdash migrate --release-lock 1788264000000` with the same confirmation. On a site in `auto` mode, a request can take the free lock and start the pending migrations before a later `emdash migrate` does, so use `--take-over-lock` there.

`emdash migrate` reaches remote D1 databases only. When the lock is held in the local D1 database of a development server, stop the server and clear the lock with Wrangler, replacing `DB` with the binding name and the number with the lock id from the error.

```bash
pnpm wrangler d1 execute DB --local --command "UPDATE _emdash_migrations_lock SET is_locked = 0 WHERE is_locked = 1788264000000"
```

## Hyperdrive connects to the origin

Hyperdrive's migration executor opens a direct PostgreSQL connection to the origin. It does not send migration traffic through Hyperdrive, use the optional cached binding, or inherit private-network reachability from the Worker.

The deployment runner must be able to reach the origin. Set `migrationConnectionStringEnv` on `hyperdrive()` when the default binding-specific variable is unsuitable, and provide that variable only to the migration job. Keep runtime Hyperdrive credentials and direct-origin deployment credentials separate.

## Adopt runtime enforcement gradually

The following EmDash integration configuration enables runtime enforcement while retaining automatic migrations in development.

```js title="astro.config.mjs"
emdash({
	database,
	migrations: {
		runtime: "check",
		dev: "auto",
	},
});
```

- `auto` is the backwards-compatible default. Runtime startup checks and applies pending migrations. On a deployed Cloudflare Worker, it leaves [large content migrations](#apply-large-content-migrations-from-the-cli) to `emdash migrate`.
- `check` performs one directional status query and returns 503 before serving a request when known migrations are pending. It tolerates records from a newer compatible build during a rolling deployment. Requests to the image endpoint EmDash installs are still served, because they skip the runtime startup that runs this check.
- `manual` performs no runtime migration or status query. Use it only after the deployment pipeline applies and checks every build reliably.

`EMDASH_MIGRATIONS_MODE` can override the runtime mode when the same artifact is promoted through multiple environments. Setup and development bypass routes obey the effective mode; they cannot silently migrate behind `check` or `manual`.

A conservative rollout is `auto` while introducing the deployment job, then `check` after the job is reliable, then `manual` when an external check is enforced for every deployment.

## Compatibility during rolling deploys

Core migrations follow expand/deploy/contract sequencing. A deployment may temporarily run old and new application isolates against the expanded database, and a backfill may still be in progress. Do not contract a schema until every deployed version has stopped using it.

Unknown applied migration records are tolerated by runtime `check` for this rolling-deployment direction only. The CLI's exact check reports them and apply refuses to mutate, because the database may be newer or may have a divergent migration history.

## Rollback boundary

Deploying the previous application artifact does not reverse a core migration. Before applying pending migrations, take a restorable database backup and record the application artifact that matches it. If the previous application cannot run against the migrated schema, restore the pre-migration database and application together. Do not delete rows from `_emdash_migrations` or run a migration's internal `down()` function as an operational rollback.

## Repair mixed PostgreSQL ownership

Use this runbook when an existing PostgreSQL site has created EmDash objects with more than one owner and later migrations fail with errors such as `must be owner of table`. Choose the canonical role that the primary EmDash connection will keep using. Take a restorable database backup and stop application traffic and schema changes before changing ownership.

Inspect every table in the active schema:

```sql
SELECT
  n.nspname AS schema_name,
  c.relname AS table_name,
  pg_get_userbyid(c.relowner) AS owner
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
  AND c.relkind IN ('r', 'p')
ORDER BY c.relname;
```

EmDash objects include `_emdash_*` and `_plugin_*` system tables, `ec_*` collection tables, and unprefixed tables such as `content_taxonomies`, `media`, `options`, `revisions`, and `taxonomies`. In a dedicated EmDash schema, every application table should have the canonical owner.

EmDash also creates PostgreSQL functions used by media-usage triggers. Inspect function ownership and retain each function's argument signature for the repair command:

```sql
SELECT
  n.nspname AS schema_name,
  p.proname AS function_name,
  pg_get_function_identity_arguments(p.oid) AS arguments,
  pg_get_userbyid(p.proowner) AS owner
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
WHERE n.nspname = current_schema()
ORDER BY p.proname, arguments;
```

Transfer each mismatched object with a superuser or provider role that can change its ownership. Use the real schema, object, role, and function signature from the inventory instead of copying the example names unchanged:

```sql
ALTER TABLE emdash.content_taxonomies OWNER TO emdash_app;
ALTER TABLE emdash.ec_posts OWNER TO emdash_app;
ALTER FUNCTION emdash.emdash_media_usage_capture_work() OWNER TO emdash_app;
```

Changing a table's owner also covers its attached indexes, constraints, and triggers, but not independent trigger functions. Repeat both inventory queries until every EmDash table and function reports the canonical owner. Then connect as that role and verify `current_database()`, `current_schema()`, and the migration status before restarting traffic.

For a non-superuser to transfer ownership, it must own or inherit ownership of the object, be able to `SET ROLE` to the new owner, and the new owner must have `CREATE` on the schema. Managed PostgreSQL providers may require their administrative role to perform the transfer.

> **Do not reassign unrelated objects**
>
> `REASSIGN OWNED` changes every object owned by a role in the current database, including objects unrelated to EmDash, and can affect shared objects. Use targeted, schema-qualified `ALTER TABLE` and `ALTER FUNCTION` statements unless the old role was dedicated exclusively to this EmDash database.

## Troubleshooting

- **No migration manifest found.** Build or sync the project first. Use `--manifest` for a non-standard artifact location or explicitly choose `--from-config` for local investigation.
- **The artifact does not match project EmDash.** Rebuild and deploy the application and manifest together. Run the project's CLI instead of a global installation.
- **The target is missing or ambiguous.** Provision it first, then supply an explicit database path, connection-variable name, D1 selector, or selected Wrangler config and environment. EmDash does not guess from unrelated environment variables or bindings.
- **The target fingerprint changed.** Stop and review the displayed account, environment, database name, UUID, or path. Update the expected fingerprint only after confirming the intended target.
- **Unknown migration records are present.** Do not delete the records or rerun apply. Confirm that the application artifact is the intended version and investigate whether a newer or divergent build migrated the database.
- **A D1 write outcome is ambiguous.** Do not replay the migration command. Run `emdash migrate --status` against the same account and database UUID, inspect the result, and escalate if the migration stopped part-way through. The exception is `079_datetime_normalization`: when it is the next pending migration, run the migration command again. The datetime normalization continues after the last rows it wrote, or starts over with a full check if the site timezone changed in between.
- **Datetime normalization requires manual review.** A legacy datetime falls in a repeated or skipped daylight-saving hour in the site's configured timezone. The error lists each affected content row or revision. Correct those values with an explicit UTC offset, then retry the migration. A first run of the migration writes no datetime until every stored value can be resolved. A run that continues an interrupted normalization skips that check, writes the remaining rows, and then reports the values that need review.
- **Hyperdrive cannot connect.** Test reachability from the deployment runner to the PostgreSQL origin and verify the direct-origin variable. Worker-to-Hyperdrive connectivity does not prove the runner can reach the origin.
