@octabits-io/backfill
SQL migrations change shapes; backfills move data into them. This is that second half, made repeatable: a named, idempotent unit of work, a marker table that records a fully clean run, and a chain runner that owns the skip / mark / partial-retry protocol so a deploy pipeline can call the whole list on every deploy and pay one primary-key lookup per finished backfill. Pure functions with no dependency at all; the marker table ships as a plain-SQL store over a structural executor and as a Drizzle adapter.
npm i @octabits-io/backfill # nothing else — bring the executor or the ORM you already have
import { runBackfills } from '@octabits-io/backfill';
import { createPostgresBackfillMarkerStore } from '@octabits-io/backfill/postgres';
// after your schema migrations, on every deploy:
await runBackfills(createPostgresBackfillMarkerStore({ db: pool }), [
{
name: 'backfill-invoice-totals-v2',
run: async () => {
const { processed, failures } = await transformPendingRows(); // WHERE new_col IS NULL AND legacy_col IS NOT NULL
const pending = await countRowsStillNeedingWork();
return { processed, failures, pending };
},
},
]);
| Subpath | Deps | Contents |
|---|---|---|
. |
none | the BackfillMarkerStore contract, runBackfills and its protocol, createInMemoryBackfillMarkerStore(), the BackfillDef / BackfillOutcome / BackfillRunSummary / DataMigrationRun / RunBackfillsOptions / BackfillLogger types |
./postgres |
none (structural executor) | createPostgresBackfillMarkerStore({ db, table? }) and dataMigrationRunsDdl({ table? }) over any query(text, params) → { rows } — a pg Pool or Client, PGlite, a postgres.js wrapper |
. (migrations) |
none | scanMigration(sql, { name }), splitMigration(sql), ACKNOWLEDGEABLE_RISKS, the MigrationFinding / MigrationRisk types — reading a migration for what breaks existing rows |
./postgres (migrations) |
none (structural executor, one connection) | checkMigrationsOnRows({ db, migrations }) on a throwaway database, dryRunMigrations({ db, migrations }) in a rolled-back transaction |
./drizzle |
drizzle-orm (optional peer) |
dataMigrationRunsColumns to spread into your schema, createDrizzleBackfillMarkerStore(db), the marker helpers ensureDataMigrationRunsTable / isDataMigrationCompleted / markDataMigrationCompleted, and runBackfills(db, …) — the root’s runner with a db as its first argument |
A boundary lint keeps the root and ./postgres importing nothing at all and
drizzle-orm confined to ./drizzle, and the whole package type-checks with
no Node typings.
The protocol
A backfill is a named, idempotent unit of work whose run() selects only
the rows that still need it — re-running on a clean database must do nothing —
and reports { processed, failures, pending }. runBackfills decides what
that means:
- a marked backfill is skipped, so a finished one costs one primary-key lookup per deploy;
- a clean run (
failures === 0 && pending === 0, noskipMarking) is marked once, and never re-marked — the first marker’s timestamp and notes win; - a run with
pending > 0is left unmarked and retried on the next deploy, automatically; skipMarking: truesays the invocation covered only part of the data, so a partial-scope run never claims global completion;failures > 0throws and aborts the chain, so the deploy fails loudly instead of quietly marking bad state.
force: true re-runs a marked backfill without re-marking it.
The marker table
Markers live in an append-only data_migration_runs table — the same
category as an ORM’s own migrations table, created on demand with
CREATE TABLE IF NOT EXISTS, no migration file and no snapshot footprint.
dataMigrationRunsDdl({ table? }) emits it for schemas that would rather
declare it. Name a different table with table on either store.
It has no scope column, on purpose: “done” is a global state, because a
backfill targets a legacy shape that disappears everywhere at once. Scope
fan-out — per organisation, per workspace, per account — lives inside your
run(); encode a scope in the name if a deployment genuinely needs per-scope
marking, and return skipMarking: true from a filtered invocation.
On Drizzle
import { runBackfills, ensureDataMigrationRunsTable } from '@octabits-io/backfill/drizzle';
await runBackfills(db, backfills); // the same protocol, over the Drizzle marker table
Same name as the root’s runner, different first argument: this one takes the
db, the root’s takes a marker store. The db is a structural view
(execute / select / insert), so a Drizzle instance and a transaction
context both satisfy it and a second copy of drizzle-orm in the tree cannot
break it. The marker helpers are exported for standalone CLIs that own the
protocol themselves — individually invocable backfill commands with a
--force flag, say.
A host that declares every platform table in its own schema and lets
drizzle-kit own the migration spreads dataMigrationRunsColumns — the marker
table column for column, held equal to dataMigrationRunsDdl() by a test —
and ensureDataMigrationRunsTable runs that same DDL, so the two adapters can
never create two tables:
import { dataMigrationRunsColumns } from '@octabits-io/backfill/drizzle';
export const dataMigrationRuns = pgTable('data_migration_runs', dataMigrationRunsColumns);
Migrations that keep the rows
A backfill is the middle step of every change to a table that holds data: expand (add the new shape next to the old), fill (move the data), contract (constrain, drop what is gone). The two checks below fail a migration that skips a step, before it runs anywhere that matters.
scanMigration reads a migration’s SQL — what drizzle-kit, Prisma or a person
writes — and names what needs a second look:
| Risk | Statement | Cleared by |
|---|---|---|
not_null_without_default |
ADD COLUMN … NOT NULL with no default, on a table the migration did not create |
the safe form only: nullable or with a default, NOT NULL later |
set_not_null_without_fill |
ALTER COLUMN … SET NOT NULL with no UPDATE of that column before it in the same migration |
the safe form only: fill first, in the same migration or by a finished backfill plus that UPDATE as the net |
drop_table, drop_column, rename, change_type, deletes_rows |
they pass every dry run and still lose data, or break the version still running during the deploy | the sequence, or a -- reviewed: <reason> comment directly above the statement — the finding comes back acknowledged with the reason, for the reviewer |
import { scanMigration } from '@octabits-io/backfill';
const findings = scanMigration(sql, { name: '0007_contact_phone.sql' });
const blocking = findings.filter((f) => !f.acknowledged); // fail CI on these
const reviewed = findings.filter((f) => f.acknowledged); // print these for the reviewer
checkMigrationsOnRows runs them. On a throwaway database it applies the
migrations in order, and before each one puts a row into every table that is
still empty — nullable columns left NULL, every other column a plain value
of its type, a foreign key pointing at its parent’s row — then applies the
migration in a transaction. A migration that only works on empty tables fails
with the statement and Postgres’s own message, and the check stops there, as
a deploy would. Tables it cannot fill (a type with no plain value, a check the
synthetic row breaks) come back as notes.
import { PGlite } from '@electric-sql/pglite';
import { checkMigrationsOnRows } from '@octabits-io/backfill/postgres';
const report = await checkMigrationsOnRows({ db: await PGlite.create(), migrations });
// report.failures: [{ migration, statement, error, message }] — empty when every migration keeps the rows
dryRunMigrations is the same idea on real data: the pending migrations
inside one transaction, rolled back whatever happens — for a restored copy of
production before a deploy. Both take one connection (a pg Client,
PGlite), not a pool, and both rely on Postgres running DDL in a transaction.
Which migrations are pending is your migration tool’s knowledge, not this
package’s.
The octabits starter runs both: the guard applies every migration over a row
per table and reads each one, and pnpm db:dry-run tries the pending ones on
the developer’s data or DATABASE_URL.
When not to use it
- To change a schema. This is the layer above your migrations. It does
not create columns, and it does not replace
drizzle-kit, Prisma Migrate, Atlas orpsql— it checks what they wrote. - For recurring work. A backfill is one-shot: it runs until the legacy shape is gone, then it is a lookup forever. Anything on a schedule is a job for a queue.
- For long streaming work inside a request. The runner is a deploy-time
chain. Batch inside
run()and reportpendingso a big backfill finishes over several deploys, or drive it from a job if it must run for hours. - As a lock. Two deploys running the chain concurrently may both execute
a backfill; the marker insert is
ON CONFLICT DO NOTHING, so the marking is safe, but the work is not serialised for you.
Conformance
One scenario suite runs against all three marker stores — the in-memory reference, the plain-SQL store and the Drizzle adapter — on an embedded PGlite (real Postgres, no Docker). A new adapter passes the suite or it is not an adapter.
Stability
Pre-1.0: patches never break, minors say so in the changelog. The DDL helper
is part of the surface — a column it emits is a column a store will read — and
so are the summary status values (completed, already_completed,
pending, unmarked), which a deploy log is read against.
From @octabits-io/backfill@0.5.2 in the platform repository.