Manage core database 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 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
Section titled “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.
Run these commands from the project whose dependencies produced the manifest. First build and inspect the target.
pnpm buildpnpm emdash migrate --statusAfter 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.
pnpm emdash migratepnpm wrangler deploypnpm emdash migrate --checkemdash 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 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.
Select the database explicitly
Section titled “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
Section titled “Provision D1 before migrating it”Creating a D1 database and migrating its schema are separate operations. emdash migrate never creates a missing database.
-
Provision the database and record its production UUID.
Terminal window pnpm wrangler d1 create my-site-production -
Add that UUID to the intended binding and environment in
wrangler.jsonc. -
Build the site so the D1 binding is recorded in
.emdash/migrations.json. -
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.
Terminal window export CLOUDFLARE_ACCOUNT_ID="..."export CLOUDFLARE_API_TOKEN="..."pnpm emdash migrate \--status \--wrangler-config wrangler.jsonc \--wrangler-env productionpnpm 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
Section titled “Configure D1 migrations in CI”D1 does not provide the advisory migration lock used by PostgreSQL. Run at most one migration job for an account and database UUID.
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 byemdash migrate --statusafter 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.
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.
Hyperdrive connects to the origin
Section titled “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
Section titled “Adopt runtime enforcement gradually”The following EmDash integration configuration enables runtime enforcement while retaining automatic migrations in development.
emdash({ database, migrations: { runtime: "check", dev: "auto", },});autois the backwards-compatible default. Runtime startup checks and applies pending migrations.checkperforms 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.manualperforms 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
Section titled “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
Section titled “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
Section titled “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:
SELECT n.nspname AS schema_name, c.relname AS table_name, pg_get_userbyid(c.relowner) AS ownerFROM pg_class AS cJOIN pg_namespace AS n ON n.oid = c.relnamespaceWHERE 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:
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 ownerFROM pg_proc AS pJOIN pg_namespace AS n ON n.oid = p.pronamespaceWHERE 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:
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.
Troubleshooting
Section titled “Troubleshooting”- No migration manifest found. Build or sync the project first. Use
--manifestfor a non-standard artifact location or explicitly choose--from-configfor 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 --statusagainst the same account and database UUID, inspect the result, and escalate if the migration stopped part-way through. - 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.