# Production migrations

Ship database schema changes to production safely with the Nextly migration CLI. Forward-only model, GitHub Actions, Vercel build step, and other-platform patterns.

Nextly's schema-evolution model splits responsibilities cleanly between development and production:

* **Local development:** `next dev` + Nextly's HMR pipeline auto-applies schema changes as you edit `nextly.config.ts`.
* **Production:** schema changes are committed as `.sql` files in your repo. A CI/CD step (or your laptop) runs `nextly migrate` against the production database **before** the new app code is deployed.

**By default the deployed Next.js app does not touch the schema** — you apply migrations in CI/build, before deploy (Patterns 1–3). Two production-grade options make this safe and flexible:

* **A migrate lock** guards every `nextly migrate`, so two runs never apply schema at once. It's a pooler-safe lock row (works through Neon/Supabase PgBouncer) with a TTL, plus a `--force-unlock` escape hatch.
* **Optional run-on-boot** (`db.runMigrationsOnBoot`, opt-in) applies pending migrations during app startup in production — safe across multiple instances thanks to the lock. See [Pattern 4](#pattern-4-run-migrations-on-boot-opt-in).

This page covers the common deploy patterns. Pick whichever fits your stack.

## The CLI commands you'll use

| Command                        | Purpose                                                                                                                                                                                                                   |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nextly migrate:baseline`      | Run **once**, when a project developed with `db:sync` starts using migrations. Records the schema the database already has as the starting point. See [Graduating from `db:sync`](#graduating-from-dbsync-to-migrations). |
| `nextly migrate:create [name]` | After you edit `nextly.config.ts`, generate a `.sql` file capturing the change. Commit it to git.                                                                                                                         |
| `nextly migrate:check`         | CI gate. Verifies migration file integrity and config drift. Does NOT connect to a database.                                                                                                                              |
| `nextly migrate`               | Applies pending `.sql` files to the target database. Run from CI or your laptop.                                                                                                                                          |
| `nextly migrate:status`        | Shows applied / pending / failed migrations with their checksums and durations.                                                                                                                                           |
| `nextly migrate:resolve`       | Recovery: mark a file applied/rolled-back or clean up a failed attempt without running SQL. See [Recovering from a failed migration](#recovering-from-a-failed-migration).                                                |
| `nextly migrate:fresh`         | Wipes all tables and re-applies every migration. Local-dev only. Confirmation required.                                                                                                                                   |
| `nextly migrate:down`          | Rolls back the most-recently-applied migration(s) using their `-- DOWN` section. CLI-only; never runs on boot. See [Rolling back a migration](#rolling-back-a-migration).                                                 |

Global flags inherited from the root `nextly` command: `--config <path>` (custom `nextly.config.ts` path), `--cwd <path>`, `--verbose`, `-q/--quiet`.

`nextly migrate:down` reverts a single migration at a time (or the last N via `--step`). It is a manual, CLI-only tool — it never runs on boot. **In production, prefer rolling forward** with a new corrective migration, or restore from a backup; rollback restores schema *shape*, not row *data*. See [Rolling back a migration](#rolling-back-a-migration).

### Per-command flags

* `nextly migrate` -- `--dry-run` (preview without executing), `--step <n>` (apply only N pending migrations), `--force-unlock` (clear a stale migrate lock left by a crashed run, then migrate).
* `nextly migrate:baseline` -- `--name <name>` (the migration's name; defaults to `baseline`), `--force-unlock`.
* `nextly migrate:create` -- `[name]` positional or `--name <name>` flag, `--blank` (empty file for custom SQL), `--non-interactive`, `--accept-renames`.
* `nextly migrate:status` -- `--json` (machine-readable output for CI scripting).
* `nextly migrate:resolve` -- exactly one of `--applied <file>`, `--rolled-back <file>`, `--failed-cleanup <file>`; `--skip-verify` (with `--applied`, skip the live-vs-snapshot check).
* `nextly migrate:fresh` -- `-f, --force` (skip confirmation), `--seed` (run seeders after migrations).
* `nextly migrate:down` -- `--step <n>` (roll back the last N migrations), `--allow-data-loss` (required when the DOWN drops a table or column), `--yes` (required in production), `--dry-run` (show targets + DOWN SQL without executing), `--force-unlock`.

***

## Graduating from `db:sync` to migrations

`nextly db:sync` is the development loop: it pushes your config straight at the
database, with no migration files in between. Production wants the opposite —
reviewable files applied in a known order. Moving from one to the other takes
one command.

The problem it solves is that your database already has tables and your
migration history has nothing in it. Without a starting point, the first
`nextly migrate:create` compares your config to an empty schema and writes
`CREATE TABLE` for every table that already exists. That file cannot be applied
to the database it came from.

Run this once, from a machine that can reach the database you developed against:

```bash
nextly migrate:baseline
```

It records the schema the database already has as the point the history begins,
writing two files:

* `migrations/<timestamp>_baseline.sql` — the statements that would build that
  schema from nothing. It is **not** run against your database; it is recorded
  as already applied. It exists so a new environment, a CI job, or
  `nextly migrate:fresh` can build the same schema from the history alone.
* `migrations/meta/<timestamp>_baseline.snapshot.json` — the starting point
  every later `migrate:create` measures its diff from.

Commit both. From then on the normal loop applies: edit `nextly.config.ts`, run
`nextly migrate:create`, and get a file containing only what changed.

> [!WARNING]
> **Do not keep using `db:sync` against a database that holds content.** It compares your
> config to the database, and when a column disappears and another appears on the same table
> it has to guess whether that is a rename. It guesses rename.
>
> On one real database, `db:sync` proposed **10 rename candidates**, including:
>
> ```
> seo -> created_by          on dc_comparisons
> seo -> first_published_at  on dc_comparisons
> ```
>
> Every pair was an embedded field-group column being read as an unrelated system column.
> Accepting them would have written content into `created_by` and `first_published_at`.
> `migrate:create` proposed **zero** renames on the same database, because it diffs against
> the recorded snapshot rather than guessing from shape.
>
> `db:sync` is for a development database you are willing to throw away.

```bash
# One time, on the existing database
nextly migrate:baseline

# From now on, the ordinary loop
nextly migrate:create add_subtitle
nextly migrate
```

Two things it deliberately refuses:

* **A project that already has a migration history.** Baselining again would
  give it two starting points, and every later diff would depend on which one
  was read. It reports the snapshot it found and writes nothing.
* **An empty database.** There is nothing to adopt; your first migration is an
  ordinary `nextly migrate:create`.

If you reach this the other way round — you ran `nextly migrate` on a
`db:sync` project and it refused with schema drift where every difference reads
`+ table '...' present in DB` — that is this same situation, and the error
points here.

***

## Pattern 1: GitHub Actions runs migration before Vercel deploys (recommended)

Most teams should use this. The migration runs on a CI machine that explicitly has your prod DB credentials; if it fails, the Vercel deploy never happens. Old code keeps serving traffic on the old schema.

```yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  migrate-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Verify migration integrity
        run: pnpm exec nextly migrate:check

      - name: Apply pending migrations
        env:
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
          NEXTLY_APPLIED_BY: github-actions-${{ github.run_id }}
        run: pnpm exec nextly migrate

      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
```

**Order of operations:**

1. CI VM checks out the code.
2. `nextly migrate:check` verifies file integrity + no uncommitted schema changes.
3. `nextly migrate` applies pending `.sql` files against the prod DB.
4. Vercel deploys the new app code.

If step 2 or 3 fails, step 4 doesn't run.

**`NEXTLY_APPLIED_BY`** is read by nothing in the current codebase. The `nextly_schema_events` table has an `applied_by` column, but the migration runner does not populate it, so setting this variable has no effect today.

## Pattern 2: Migration in Vercel's build step (simplest)

The default scaffold ships this `build` script in `package.json`:

```json
{
  "scripts": {
    "build": "nextly migrate && next build"
  }
}
```

Configure `DATABASE_URL` as a Vercel environment variable (available at build time). Every Vercel deploy applies pending migrations before compiling Next.js.

**Trade-offs:**

* Zero CI setup. Push to GitHub, Vercel does everything.
* Multiple Vercel deploys can race if you push twice in rapid succession (e.g. preview + production, or two PRs merging close together). Nextly's `filename` UNIQUE constraint catches double-insert; the second build will exit non-zero.
* The Vercel build machine needs prod DB access from its IP range (check with your DB host).

## Pattern 3: Manual deploys (laptop)

Run from your laptop:

```bash
DATABASE_URL=$PROD_URL pnpm exec nextly migrate
vercel --prod
```

For non-Vercel hosts, replace `vercel --prod` with whatever your deploy command is.

## Pattern 4: Run migrations on boot (opt-in)

For long-running servers/containers you can have the app apply pending migrations
during startup instead of (or in addition to) a CI step. Opt in via config:

```ts
// nextly.config.ts
import { defineConfig } from "nextly/config";

export default defineConfig({
  db: {
    runMigrationsOnBoot: true, // default: false
  },
});
```

* **Production only** (`NODE_ENV === "production"`) — a no-op in development.
* Pending migrations apply during initialization, **under the migrate lock**, so
  multiple instances starting together are safe: one applies while the others
  wait, then all serve with the schema ready.
* **Failure-safe:** a failed boot migration is logged and the app continues (it
  does not crash the process); resolve with `nextly migrate`.
* Tune lock takeover with `db.migrateLockTtlSeconds` (default **900s**).

> **Serverless caveat.** Running migrations on boot adds work to cold starts. On
> platforms with many short-lived instances (e.g. Vercel), prefer the CI patterns
> above. Boot-time migrations suit long-running servers/containers.

## Environment-specific configurations

If your `nextly.config.ts` differs by environment (e.g. a plugin enabled only in
production), a migration generated in one environment can miss the other's
schema. Three ways to handle it:

1. **Edit the generated migration** after `migrate:create` to include the
   environment-specific changes.
2. **Temporarily enable the production env vars locally** when generating, so the
   migration captures the production shape.
3. **Use separate migration files per environment**, applied in the matching
   environment.

## Other platforms

Nextly's CLI is platform-agnostic. The pattern is the same on Railway, Render, Fly.io, or AWS:

1. Configure your deploy pipeline to run `nextly migrate` against the production DB.
2. Ensure that step runs **before** the new code is promoted.
3. If the migration fails, the deploy should abort.

Specific examples:

* **Railway:** Use `deploy.preDeployCommand` in `railway.json`. This runs once before the new revision takes traffic, so it does not race across replicas:
  ```json
  {
    "deploy": {
      "preDeployCommand": ["nextly migrate"]
    }
  }
  ```
  Do NOT put `nextly migrate` in `startCommand`. `startCommand` runs on every container start and would race across replicas, which is precisely the failure mode this guide is designed to avoid.
* **Render:** Use a [pre-deploy command](https://render.com/docs/deploys#pre-deploy-commands).
* **Fly.io:** Use a `release_command` in `fly.toml`. The `[deploy]` is a TOML section header:
  ```toml
  [deploy]
  release_command = "nextly migrate"
  ```
* **AWS (ECS, etc.):** Add a one-shot task in your pipeline that runs `nextly migrate` before updating the service.

***

## What `nextly migrate` does

1. Connects to the database via `DATABASE_URL`.
2. Reads the `nextly_schema_events` ledger to see which `.sql` files have been applied (a `file_apply` event per file).
3. Verifies SHA-256 hashes of already-applied files. **Hash mismatch = abort with `MIGRATION_TAMPERED` (exit 2).** No partial state, nothing else runs.
4. Discovers pending `.sql` files in your `migrations/` directory (sorted by filename timestamp).
5. Applies each pending file in a transaction (PostgreSQL/SQLite) or statement-by-statement (MySQL; see [database support](https://nextlyhq.com/docs/database/support.md) for MySQL caveats).
6. Records each applied file in `nextly_schema_events` as a `file_apply` event, opened with `started_at`, `filename` and `sha256` and closed with `ended_at` and `statements_executed`.

| Exit code | Meaning                                                                  |
| --------- | ------------------------------------------------------------------------ |
| 0         | All pending applied OR no pending                                        |
| 1         | A migration failed during execution                                      |
| 2         | `MIGRATION_TAMPERED`. Abort before running any.                          |
| 3         | `MIGRATION_MISSING`. Abort; file referenced in DB but missing from disk. |

## Builder entities and production

Everything above describes how **columns** reach production. Entities created in the
[Visual Schema Builder](https://nextlyhq.com/docs/schema-builder.md) carry a second thing: a row in a registry table —
`dynamic_collections`, `dynamic_singles`, or for Field Groups `dynamic_components` — holding the
label, description and admin display settings. A database that has run
`nextly migrate:field-groups --apply --backup-confirmed` holds the Field Group registry as
`dynamic_field_groups` instead, and the runtime reads whichever of the two the database has.
Collections and Singles additionally carry the Versions, Revalidate and Webhooks switches; Field
Groups have none of those, and the Field Group registry has no columns for them under either name.
The runtime reads its Drizzle table from that row, so the row matters as much as the columns do.

### Commit `ui-schema.json`

The Builder dual-writes every entity it creates or saves into the UI-schema manifest — the file
`db.uiSchemaFile` names, `./ui-schema.json` by default — for all three entity kinds. Commit that
file with your migrations: it is the **input** `nextly migrate:create` reads to know a Builder
entity exists, and a Builder change missing from the commit cannot appear in any migration.

Two things to know before relying on it:

* **The Builder's own `.sql` file is unpaired.** Saving a Collection writes a timestamped migration
  but no `meta/*.snapshot.json`, and `nextly migrate:check` reports an unpaired `.sql` as
  `MISSING_SNAPSHOT`. Run `nextly migrate:create` to regenerate the pair, and commit the `.sql` and
  its snapshot together rather than committing the Builder's file as-is.
* **A collection created with a starting field is mirrored without it.** The create call writes the
  manifest entry with an empty field list, so the fields arrive only on the next save. Open the new
  collection and save it once before generating the migration.

It is input, not a deployment artifact. `nextly migrate` never reads `ui-schema.json`; it applies
`.sql` files. An entity's registry row reaches another database only when a migration file carries
its upsert, and `migrate:create` appends that upsert **only for entities whose table the migration
actually changes**. So the manifest gets an entity into the generator's view; a DDL change to that
entity is what gets its row into production.

The manifest is written by a development-only route, so it is a dev-time artifact you commit rather
than something production generates.

### What does not travel

* A **metadata-only** edit — a label, a description, or the Versions, Revalidate or Webhooks switch,
  with no column change for that entity — produces no upsert, because `migrate:create` writes a file
  only for DDL and filters metadata upserts to the tables that file touches. With no DDL anywhere in
  the run it writes nothing at all (`No changes detected`, exit code 2). The value stays in the
  manifest until some later migration changes that entity's table and carries the row with it.
  A **cleared** description is the exception, because it never travels: the manifest records an
  empty description as absent, and the upsert leaves a column the manifest does not carry as it
  was, so the other database keeps the old text through every later migration.
* Status and Internationalization are **not** metadata: both change columns. Note that an
  Internationalization toggle whose column moves are emitted as a companion migration can still
  leave the `localized` registry value behind, because the upsert is filtered by the same
  touched-table set the companion file does not populate. Check the registry row after toggling
  localization on an existing entity.
* **A deletion leaves the registry row behind.** Deleting a Collection in the Builder writes a
  migration that drops its table. Deleting a Single or a Field Group writes none: the Builder drops
  the development table and removes the manifest entry, and the next `migrate:create` finds that
  table in the previous snapshot but not in the manifest and writes the `DROP TABLE`. Either way the
  table goes. Its registry row does not: `migrate:create`
  builds registry upserts only for entities still in the manifest, and nothing on the
  `nextly migrate` path removes a row. The other environment keeps a row describing a table that no
  longer exists. There is no supported way to remove it through `nextly migrate` today, and
  `nextly prune` does not apply — it only removes `plugin:` provenance and always retains Builder
  entities.

### What `migrate` repairs

After applying files, `reconcileMigrationMetadata` does two things. It registers entities that an
applied migration's snapshot describes but the registry lacks, and it promotes a pending row to
`applied` once no unapplied migration still names that entity and its table exists. Which of those
reach a Builder entity is narrower than it sounds:

| Case                                                                               | Repaired by `nextly migrate`?                                                                                                                                                              |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A pending row, once every migration naming the entity has run and its table exists | Yes — promoted to `applied`                                                                                                                                                                |
| A missing **collection** or **single** row from a `migrate:create` deployment      | **No** — a `migrate:create` snapshot records tables, not entities, so registration finds nothing to insert. Only an entity-level snapshot, such as a scaffolded template's, restores a row |
| A missing **field group** row                                                      | No — registration covers collections and singles only                                                                                                                                      |
| An existing row with stale labels, descriptions or fields                          | No — the pass promotes status; it does not refresh content                                                                                                                                 |
| A metadata-only edit that produced no file                                         | No — there is no artifact to read                                                                                                                                                          |
| The row of a **deleted** entity                                                    | No — nothing on this path removes a row                                                                                                                                                    |

So run `nextly migrate` to settle a pending status, but do not rely on it to recreate a registry row
that a Builder deployment is missing.

### Moving an entity into code

Declaring an entity in `nextly.config.ts` versions its settings, but it is a conversion of the whole
entity rather than a way to version one switch: `defineCollection` and `defineSingle` take a
**complete** definition, fields included.

Remove the entity from the manifest (`db.uiSchemaFile`) as part of the move. `migrate:create` tolerates the
collision — it warns and lets the code-first definition win. What CI then catches depends on the
kind: `nextly migrate:check` runs `validateCrossFile`, which compares manifest entries against
code-first **Collection** slugs only. A Collection whose manifest entry stays behind fails there on
`NEXTLY_SCHEMA_SLUG_COLLISION`, so it generates a migration locally and is then rejected by CI. A
Single or Field Group left behind passes `migrate:check`, so nothing reminds you to remove it.

## What `nextly migrate:check` does (CI-friendly, no DB)

Runs five integrity checks; first failure exits non-zero with a specific code:

| Check               | What it catches                                                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `CHECKSUM_MISMATCH` | A `.sql` file's content differs from its paired `.snapshot.json`'s recorded hash. Someone edited the file after it was generated. |
| `MISSING_SNAPSHOT`  | A `.sql` file has no paired `.snapshot.json` (operator deleted the snapshot or the file was hand-added).                          |
| `INVALID_SNAPSHOT`  | A `.snapshot.json` exists but is corrupt, hand-edited, or version-incompatible.                                                   |
| `MISSING_MIGRATION` | A `.snapshot.json` has no paired `.sql` (someone deleted the SQL but kept the snapshot).                                          |
| `SCHEMA_DRIFT`      | Your current `nextly.config.ts` doesn't match the latest snapshot. You forgot to run `migrate:create` after editing config.       |

Run it in your PR CI to catch mistakes before they reach production.

## Environment variables

| Variable                            | Where used           | Purpose                                                                                                                                                                                                                                                                                                                              |
| ----------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `DATABASE_URL`                      | All commands         | Target database. Required (except SQLite, which falls back to `file:./data/nextly.db`).                                                                                                                                                                                                                                              |
| `NEXTLY_APPLIED_BY`                 | `migrate`            | No effect today: the column exists on `nextly_schema_events` but the runner does not write it.                                                                                                                                                                                                                                       |
| `NEXTLY_ACCEPT_DATA_LOSS=1`         | `migrate`, `db:sync` | Acknowledge destructive changes (drops, type narrowings) without an interactive prompt. Required for CI runs of destructive migrations.                                                                                                                                                                                              |
| `NEXTLY_ALLOW_CORE_DESTRUCTIVE=1`   | `migrate`            | Allow a destructive change to Nextly's **own** core tables, which `migrate` otherwise refuses with `NEXTLY_CORE_DESTRUCTIVE_REFUSED`. Applies to every operation in the run — see below before setting it.                                                                                                                           |
| `NEXTLY_DROP_RETIRED_AUTH_TABLES=1` | `migrate`            | Drop the retired `accounts` and `sessions` tables, when they are present in the shape Nextly created them. A table that only shares the name is left alone, but an Auth.js adapter's table has the same shape, so check a table is not another app's before setting it.                                                              |
| `NEXTLY_DROP_NONEMPTY_RETIRED=1`    | `migrate`            | With the flag above, also drop a retired table that still holds rows. Without it such a table is kept, with a warning, and the rest of the run goes ahead.                                                                                                                                                                           |
| `NEXTLY_ERASE_RETIRED_AUTH_TABLES`  | Runtime              | The retired tables that are Nextly's, comma-separated (`accounts`, `sessions` or `accounts,sessions`). Deleting a user erases their rows only from a table named here, and only while it has the shape Nextly created. Nextly keeps no record that it created either table, so unset, the rows stay and the startup warning says so. |

## When `migrate` refuses a destructive core change

`nextly migrate` stops rather than run an operation that could lose data from Nextly's own
tables, reporting `NEXTLY_CORE_DESTRUCTIVE_REFUSED`. The refusal **names every operation it
refused** — the list you see is the whole list, not a sample — so you can judge them one at
a time.

| Operation                                        | Verdict                                                                 |
| ------------------------------------------------ | ----------------------------------------------------------------------- |
| A widening, e.g. `varchar(20)` → `varchar(255)`  | **Safe.** Nothing that fits today stops fitting                         |
| A narrowing, e.g. `varchar(255)` → `varchar(20)` | **Safe only if you have measured the longest stored value** and it fits |
| A dropped column, or any other type change       | **Stop.** Work out what happens to the existing values first            |

Two independent facts make a widening safe: the direction, and knowing what the column
holds. A narrowing gives you the first only, so go and look:

```sql
SELECT max(length(your_column)) FROM your_table;
```

Then, and only then:

```bash
NEXTLY_ALLOW_CORE_DESTRUCTIVE=1 nextly migrate
```

> [!WARNING]
> The flag disables the refusal for **every** operation in that run, not just the one you
> judged safe. Read the whole list before setting it.

## What you should never do

* **Don't call `nextly migrate` from your deployed app's runtime.** It's a CLI tool, not a runtime API. Nextly enforces this with an ESLint rule for code in `init/`, `route-handler/`, `dispatcher/`, `api/`, `actions/`, `direct-api/`, `routeHandler.ts`, and `next.ts`.
* **Don't edit applied migration files.** The hash check catches this and aborts with `MIGRATION_TAMPERED`. To change something already applied, write a new corrective migration.
* **Concurrent `nextly migrate` runs are guarded by the migrate lock.** A second concurrent run won't apply schema at the same time — it either errors with `NEXTLY_MIGRATE_LOCK_BUSY` (CLI) or waits (boot run). If a crashed run leaves a stale lock, clear it with `nextly migrate --force-unlock`. (The lock is a pooler-safe row with a TTL; the `filename` UNIQUE constraint remains a second line of defense.)

## Recovering from a failed migration

`nextly migrate` is forward-only. When a file partially applies or the live
schema drifts, use `nextly migrate:resolve` to fix the bookkeeping in
`nextly_schema_events` — it never runs migration SQL itself. All three modes
are idempotent (re-running a no-op exits 0) and take the same lock as `migrate`.

If `nextly migrate` fails partway through:

1. The failure is recorded as a `file_apply` event with `status='failed'` and structured `error_json` containing the SQL state and message.
2. Inspect with `nextly migrate:status --verbose`. The error JSON tells you what broke.
3. Pick the recovery that matches what actually happened:
   * **The file actually applied but wasn't recorded** (e.g. the process died after the DDL committed): `nextly migrate:resolve --applied <file>`. This verifies the live schema matches the file's target snapshot before recording it. Pass `--skip-verify` only if you have manually confirmed the state.
   * **A failed attempt is blocking retries and the `.sql` needs editing first:** `nextly migrate:resolve --failed-cleanup <file>` flips the stuck failed row to `rolled_back`. Edit the file, then run `nextly migrate` again.
   * **You need to re-run a file** that was recorded as applied: `nextly migrate:resolve --rolled-back <file>` — the next `nextly migrate` treats it as pending again.
4. Otherwise, fix the SQL (revert + regenerate via `migrate:create`) or the underlying database state, then re-run `nextly migrate`.

For MySQL specifically, partial state is possible because MySQL DDL auto-commits per statement. Manual cleanup may be needed; see [MySQL caveats](https://nextlyhq.com/docs/database/support.md) for the recovery playbook.

### Core schema drift after `nextly upgrade`

User migration files contain **user-schema only** — core system tables are owned by the Nextly package version and reconciled by `nextly migrate` Phase 1. If `nextly migrate` reports core schema drift after upgrading from a very early alpha (e.g. a hand-edited bundled file), run `nextly upgrade --reconcile-core`. It reconciles core in dev-loose mode and prompts for confirmation on each destructive operation. Use it only when migrate reports core drift.

## Rolling back a migration

`nextly migrate:create` writes a `-- DOWN` section into each generated migration
(the inverse of its `-- UP`). `nextly migrate:down` reverts the most-recently
applied migration using that section:

```bash
# Roll back the last migration
nextly migrate:down

# Roll back the last 3
nextly migrate:down --step 3

# Preview without executing
nextly migrate:down --dry-run
```

**Rollback restores schema *shape*, not *data*.** Reverting an added column drops
it (and its data); reverting a dropped column re-adds an empty column — the old
rows are gone. For this reason:

* A migration whose DOWN drops a table or column requires `--allow-data-loss`.
* In production (`NODE_ENV=production`), `migrate:down` requires `--yes`.
* A migration with an empty `-- DOWN` (data-only or blank) is **irreversible** —
  `migrate:down` refuses it. Roll forward with a corrective migration instead.

After a successful rollback, the migration is recorded as `rolled_back` in
`nextly_schema_events` and becomes pending again, so the next `nextly migrate`
re-applies it.

**In production, prefer rolling forward** (a new corrective migration) or
restoring from a backup. `migrate:down` is a manual, CLI-only break-glass tool;
it never runs on boot.

## Recovery via corrective migrations

`migrate:down` handles single-step rollback, but for production incidents the
safer path is usually to **roll forward** -- write a new migration that fixes the
previous one. Examples:

* Accidentally added a `NOT NULL` column without a default and the deploy failed -> write a new migration that backfills the column then re-applies the constraint.
* Renamed a column too aggressively and broke a downstream service -> write a new migration that adds the old column name back as a copy until consumers are updated.
* Need to drop a table you added in a recent migration -> write a new migration with `DROP TABLE`.

The audit trail in `nextly_schema_events` keeps both rows, so post-incident review can see exactly what was applied when, and by which CI job.
