Skip to content

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.

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.

Terminal window
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.

Terminal window
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 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.

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

AdapterManifest targetDefault credential variableUseful override
SQLiteDatabase path or file: URL--database <path>
libSQLPublic URLTURSO_AUTH_TOKENConfigure migrationAuthTokenEnv
PostgreSQLConnection variable nameDATABASE_URL--database-url-env <name>
Cloudflare D1Wrangler binding nameCLOUDFLARE_API_TOKEN--d1, --account-id, --wrangler-config, --wrangler-env
HyperdrivePrimary binding and origin variable nameBinding-specific direct-origin variableConfigure 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.

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.

    Terminal window
    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.

    Terminal window
    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.

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 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.

.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.

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.

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

astro.config.mjs
emdash({
database,
migrations: {
runtime: "check",
dev: "auto",
},
});
  • auto is the backwards-compatible default. Runtime startup checks and applies pending migrations.
  • 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.
  • 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.

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.

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.

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 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:

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:

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.

  • 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.
  • 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.