QUESTPIE
ShipMigrations

Commands

Every migrate command the CLI registers, the flags it takes, the ledger table it writes to, and what each one does when there is nothing to do.

View markdown

Seven commands deal with the database schema. Each one takes -c, --config <path>, which defaults to questpie.config.ts.

CommandAlso acceptsWhat it does
questpie migrate:createmigrate:generateDiffs the schema against the snapshot chain, writes a file
questpie migratemigrate:upRuns pending migrations, then the search index migrations
questpie migrate:downRolls back the last batch
questpie migrate:statusPrints executed and pending migrations
questpie migrate:resetRolls back every executed migration
questpie migrate:freshReset, then up, then reset seed tracking
questpie pushApplies the schema straight to a development database

The starters wrap all seven as package scripts with the config path filled in. So bun run migrate and bun run migrate:create work from the project root. Push is scripted as db:push.

migrate:create

FlagWhat it does
-n, --name <name>Names the file. Random name if you omit it
--dry-runPrints the name and directory, writes no file
--verboseAccepted and currently unused
--non-interactiveAnswers any prompt with its default

It writes two files. <timestamp>_<name>.ts in your migrations folder, and a matching .json under migrations/snapshots/. The timestamp is YYYYMMDDTHHmmss. The migration id is the name in camelCase with that timestamp appended, so --name add_slug gives addSlug20260803T120000.

No name produces something like bright_azure_falcon. Readable, but say what the migration does instead.

migrate:create never opens a database connection. It compares your schema against the union of the on-disk snapshots and the snapshots embedded in the migrations the app already imports. No difference means no file. It prints No schema changes detected and stops.

migrate

FlagWhat it does
-t, --target <migration>Stops after this migration id, inclusive
--dry-runPrints the action and exits

It checks the PostgreSQL version first and requires 15 or newer. Then it creates questpie_migrations if it is missing and reads which ids are already there.

Each pending migration runs in one transaction. The transaction takes pg_advisory_xact_lock, re-checks the ledger, runs up(), then inserts the ledger row. Every migration in one run shares a batch number.

An unknown --target throws before anything runs. --dry-run prints the action and the target only. It does not list the pending migrations.

After the migrations finish, migrate runs the search adapter's index migrations. Those use IF NOT EXISTS, so they re-run on every migrate. The CLI prints nothing about them. Only app.migrations.search() hands back the applied and skipped lists. A failure that mentions an extension or a permission is re-thrown with the statement attached.

migrate:down

FlagWhat it does
-b, --batch <number>Rolls back the batch you name. Must be a positive integer
-t, --target <migration>Rolls back to this migration id, inclusive
--dry-runPrints the action and exits

With no flag it rolls back the last batch. That is every migration applied by the last migrate run, not the last migration.

Each down() runs in its own transaction and deletes its ledger row. A ledger row whose migration is no longer in the app gets a warning and a skip. Its row stays behind, so the id still counts as executed.

down() reverses DDL. It cannot bring data back. A migration that dropped a column rolls back to that column, empty.

migrate:status

Creates the ledger table if it is missing. Then prints the current batch, the executed migrations with their batch and time, and the pending ones.

migrate:reset and migrate:fresh

Both take --dry-run. reset rolls back every executed migration in reverse order. fresh resets and then runs everything again. Through the CLI, fresh also clears seed tracking, so your seeds can run again on the rebuilt tables.

push

FlagWhat it does
-f, --forceAcknowledges the development warning
-v, --verbosePrints the SQL it is about to run

Push loads your app, asks drizzle-kit for the statements that would bring the live database in line with app.getSchema(), then applies the complete diff in one PostgreSQL transaction. If any statement fails, every earlier statement in that push is rolled back. Push records no migration history.

--force changes the output and nothing else. Push applies either way.

The diff is scoped to the schemas your own tables use, and the ledger table is excluded by name. The pgboss schema is excluded too. Before applying, a second check scans the planned statements. Anything that would drop, truncate or alter framework or adapter state aborts the command with nothing executed.

The ledger table

CREATE TABLE questpie_migrations (
	id TEXT PRIMARY KEY,
	name TEXT NOT NULL,
	batch INTEGER NOT NULL,
	executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

id is the primary key, so migration ids must be unique across your whole app. name currently holds the same string as id. Every command that reaches the database creates this table on demand, so there is nothing to set up.

When no migrations are registered

A project with no migration files has an empty migrations array. Every migrate command, status included, prints No migrations found and exits without touching the database.

Hand-written migrations

questpie add migration <name> writes an empty migration with up and down stubs next to your other ones. Use it for DDL the generator cannot express, like a data fix or an extension you install yourself.

A hand-written migration has no snapshot. Snapshots are what migrate:create diffs against. So a change made only in hand-written SQL stays invisible to the generator, and the generator may plan it again later.

Quieter output

Set QUESTPIE_MIGRATIONS_SILENT to 1, true or yes to drop the info and warning lines. Errors still print. Under NODE_ENV=test the runner is silent by default.

On this page