Quickback Docs

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.

quickback/migrations/data/2026-10-21-archive-stale-drafts.ts
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:

quickback/quickback.config.ts
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 id

The definition

Field
idLedger key, as a string literal. It runs once per environment. Ids run in sorted order, so start them with a date.
databasesDatabase 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.
descriptionShown 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 calls run({ dryRun: true }), and every write method on the handle (insert, update, delete, batch, run, execute, transaction) throws. A migration that forgets to check dryRun fails loudly instead of writing.
  • Once per environment. Each target's features database has its own _qb_data_migrations ledger. Before run starts, --apply claims the id with a single conflict-free INSERT, so two concurrent applies cannot both run. An applied id answers 409 DATA_MIGRATION_ALREADY_APPLIED.
  • Failures are recorded and retryable. A thrown error marks the row failed, with the message and log. The next quickback migrate data re-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.

On this page