Data Migrations
One-off backfills and repairs that run once per environment, inside the deployed Worker, dry-run by default.
Schema migrations change the shape of your tables, and Drizzle generates
them for you. Data migrations change the rows: a backfill, a repair, a
one-time move. You write them as ordinary TypeScript under
quickback/migrations/data/, and quickback migrate data runs them inside the
deployed Worker for one environment.
import { defineDataMigration } from "@quickback/compiler";
import { and, eq, isNull } from "drizzle-orm";
import { projects } from "../../db/schema";
export default defineDataMigration({
id: "2026-10-21-archive-stale-drafts",
databases: ["DB"],
description: "Archive draft projects that never got a budget",
async run({ db, dryRun, log }) {
const stale = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.status, "draft"), isNull(projects.budget)));
log(`${stale.length} stale draft project(s)`);
if (dryRun) return;
for (const row of stale) {
await db.update(projects).set({ status: "archived" }).where(eq(projects.id, row.id));
}
},
});Declare who may run them. There is no default role:
dataMigrations: {
access: { userRole: ["sysadmin"] },
},Then deploy and run:
quickback deploy --env staging
quickback migrate data --env staging # dry run: logs, writes nothing
quickback migrate data --env staging --apply # writes, records the idThe definition
| Field | |
|---|---|
id | Ledger key, as a string literal. It runs once per environment. Ids run in sorted order, so start them with a date. |
databases | Database bindings the migration touches (["DB"]). db is the first one, and dbs[binding] gives each. On D1 with split databases, the auth binding (AUTH_DB) is also allowed. |
description | Shown by quickback migrate data. |
run({ db, dbs, env, dryRun, log }) | The work. log() lines (up to 500) go to the CLI and the ledger row. |
The compiler reads id and databases statically. A computed id, an unknown
binding, a duplicate id, or a missing run is a compile error that names the
file. From @quickback/compiler you may import only defineDataMigration (and
types). Relative imports resolve against the generated tree the same way they
do in action files, so ../../db/schema is the generated schema barrel.
Guarantees
- Dry run by default. Without
--apply, the runner callsrun({ dryRun: true }), and every write method on the handle (insert,update,delete,batch,run,execute,transaction) throws. A migration that forgets to checkdryRunfails loudly instead of writing. - Once per environment. Each target's features database has its own
_qb_data_migrationsledger. Beforerunstarts,--applyclaims the id with a single conflict-freeINSERT, so two concurrent applies cannot both run. An applied id answers409 DATA_MIGRATION_ALREADY_APPLIED. - Failures are recorded and retryable. A thrown error marks the row
failed, with the message and log. The nextquickback migrate datare-runs it. - No implied transaction. D1 has no transactions across statements, so write migrations that are safe to re-run after a partial failure. Select what still needs changing, as in the example above.
- Audited. When the project has the audit wrapper, writes are stamped
system:data-migration-<id>, and the ledger row records the operator's user id. With release stamping on, it also records the release that applied the migration.
Why it runs in the Worker
The runner is a route the compiler mounts at
/api/v1/system/data-migrations, gated to dataMigrations.access.userRole.
It is not a local script, and it is not a separate Wrangler entrypoint. The
Worker is the one place where both database families are reachable through the
bindings the app itself uses. Code outside a Worker cannot run JavaScript
against a D1 binding, and a Postgres database behind Hyperdrive is only
reachable from inside the Worker. The runner uses the same service handle as
cron and queue handlers: drizzle(env.DB) on D1, and the service-role
createServiceDb(env) on Neon and PlanetScale, where the ledger lives in the
app schema behind a service-role-only RLS policy. Your machine needs only an
operator's bearer token, never database credentials.
Each request must finish within the Worker's request limits. Split very large backfills into several migrations, or make one resumable: a failed run can be re-applied, and it picks up whatever rows still match.
The CLI
quickback migrate data finds the route and each target's API hostname in
reports/ops.manifest.json. See the
CLI reference for every flag. It refuses
to run when the deployed Worker does not know a migration the build declares,
because that means the current build is not deployed yet.
PlanetScale Postgres
Use PlanetScale Postgres with Quickback through Cloudflare Hyperdrive — roles, connection usernames, the cache-disabled binding, and named environments.
API Contract
The contract layer in one place — envelope, Problem Details, cursors, Idempotency-Key, include, and the routes prefix.