@planafoot/drizzle-cf-migrations
v1.2.1
Published
Migration safety gate + append-only track machinery for Drizzle on Cloudflare SQLite (D1 + Durable Objects)
Downloads
17
Maintainers
Readme
@planafoot/drizzle-cf-migrations
Migration safety gate + append-only track machinery for Drizzle on Cloudflare SQLite — D1 and Durable Object storage alike.
What / why
Drizzle migrations that work fine against a normal SQLite file can silently destroy data
or permanently brick a Durable Object when run against Cloudflare's SQLite engines. The
classic "disable foreign keys, rebuild the table, re-enable them" dance that drizzle-kit
generates for any change ALTER TABLE can't express is not safe here — it just looks safe,
because it fails without an error.
The reason is three platform facts working together: PRAGMA foreign_keys = OFF is a
silent no-op inside the implicit transaction both D1 and Durable Object SQLite wrap every
statement in, so it never actually turns enforcement off; ON DELETE CASCADE (and SET
NULL / SET DEFAULT) actions fire at DROP TABLE time as an implicit DELETE, not at
commit, so PRAGMA defer_foreign_keys doesn't defer them either; and Durable Object SQLite
additionally runs its own foreign-key integrity check at the commit boundary that does not
honor deferral at all, rolling back — and re-bricking on every subsequent open — a rebuild
of any table another table references. A migration that rebuilds an FK-target table can
report success while quietly deleting every row in its cascade children, or take a Durable
Object down for good.
This package is the guardrail: a migration classifier that recognizes these dangerous shapes and fails the generate step before they ship, plus the track/bundle machinery that wires a Drizzle migrate-on-open flow into a Durable Object safely. It's the safety gate extracted from a production app (planafoot) — every tagged release of this package is exactly the code that app runs against its own D1 database and five Durable Object stores.
Install + quickstart
npm install -D @planafoot/drizzle-cf-migrations "drizzle-orm@>=1.0.0-rc <2" "drizzle-kit@>=1.0.0-rc <2"Add better-sqlite3 and vitest too if you'll use the ./testing subpath (see below).
Describe your migration tracks and their schemas in a config module. Here's one D1 track and one Durable Object track:
// drizzle-cf.config.js
import { defineTracks } from '@planafoot/drizzle-cf-migrations';
/** @type {import('@planafoot/drizzle-cf-migrations').CfMigrationsConfig} */
export default {
tracks: defineTracks({
d1: {
dir: 'migrations/d1',
drizzleConfig: 'drizzle.config.ts',
bundle: null // null = flat-file D1 track, not a DO bundle
},
counters: {
dir: 'migrations/counters',
drizzleConfig: 'drizzle.counters.config.ts',
bundle: {
outFile: 'src/counters-do/migrations.generated.ts',
exportName: 'countersMigrations'
}
}
}),
// Per-track drizzle schema module loader, used to build the FK inventory the
// classifier needs to tell a leaf-table drop from a cascade-target drop.
schemas: {
d1: () => import('./src/db/schema.js'),
counters: () => import('./src/counters-do/schema.js')
},
// Optional: appended to violation messages so they point at your own doctrine doc.
docsRef: 'docs/schema-evolution.md'
};drizzle.config.ts / drizzle.counters.config.ts are ordinary drizzle-kit configs
(dialect: 'sqlite', a schema path, an out matching the track's dir) — nothing
Cloudflare-specific about them.
CLI
# drizzle-kit generate → classify against the doctrine → bundle (DO tracks) or flatten (D1)
npx drizzle-cf-migrations generate counters
# classify the newest dated migration of a track without generating anything new
npx drizzle-cf-migrations classify d1Both subcommands take --config <path> (default ./drizzle-cf.config.js). The config is
loaded with a plain dynamic import(), so a .js/.mjs config runs under plain node;
a TypeScript config needs a loader in the process doing the importing — run the CLI itself
under tsx so the same loader hooks the config's dynamic import too:
npx tsx node_modules/.bin/drizzle-cf-migrations generate counters --config ./drizzle-cf.config.tsProgrammatic API
Everything the CLI does is exported directly, for wiring into your own build tooling:
import { execSync } from 'node:child_process';
import {
newestMigration,
buildFkInventory,
classifyMigration,
buildMigrationsBundle,
runPrepareMigrations
} from '@planafoot/drizzle-cf-migrations';
import * as schema from './src/db/schema.js';
execSync('drizzle-kit generate --config drizzle.config.ts', { stdio: 'inherit' });
const newest = newestMigration('migrations/d1');
if (newest) {
const inventory = buildFkInventory(schema);
const report = classifyMigration(newest.name, newest.sql, 'd1', inventory, {
docsRef: 'docs/schema-evolution.md'
});
if (report.violations.length > 0) {
throw new Error(report.violations.join('\n'));
}
}
// D1: flatten dated migrations/d1/<ts>_<name>/migration.sql to the flat files
// wrangler expects. For a DO track, call buildMigrationsBundle(...) instead.
runPrepareMigrations('migrations/d1');The doctrine decision table
A few terms first, since the table assumes them:
- Dated source migration — the directory
drizzle-kit generatewrites,<migrations-dir>/<timestamp>_<name>/migration.sql. It's the one file you're allowed to hand-edit; the committed drizzle snapshot still advances (so drizzle stops re-proposing the same diff) even when you replace its SQL with a safer recipe. -- allow-rebuild: <reason>annotation — a line in a D1 migration's SQL that attests, by a human or agent who checked, that the table being rebuilt has zeroON DELETE-action children at apply time. It is D1-only, is not honored in a Durable Object track, and is not a rubber stamp — the classifier still requires the shape to actually qualify.- Rebuild-shape — the 12-step
CREATE __new_x/ copy /DROP TABLE x/RENAME __new_x TO xsequencedrizzle-kitemits for any change plainALTER TABLEcan't express (a DEFAULT change, tightening toNOT NULL, a type change, an addedCHECK, or a primary-key change). It's the shape that silently trips the FK hazards above. - Cascade-ectomy — rebuilding the child side of a cascade first (dropping its
ON DELETE CASCADEin favor of a plain FK, which is safe — see the migrate-on-open section) so the parent then has zero delete-action children and qualifies for the sanctioned parent rebuild above.
| Change | D1 | Durable Object tracks |
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New table / index / trigger | ✅ as generated | ✅ as generated |
| Drop index / trigger | ✅ | ✅ |
| ADD COLUMN (nullable, or NOT NULL + constant default) | ✅ | ✅ |
| RENAME COLUMN | ✅ | ✅ — children stay bound, cascades stay wired |
| RENAME TABLE | ✅ — child FK clauses auto-rewrite; recreate any trigger/view naming the old table | ✅ — child FK re-binds by name, cascades stay wired |
| DROP COLUMN (unencumbered) | ✅ — DROP INDEX any dependents first; confirm drizzle emits ALTER … DROP COLUMN, not a rebuild | ✅ — same |
| DROP COLUMN (indexed / UNIQUE / FK / CHECK-referenced) | ✋ DROP INDEX first if only indexed; otherwise it fails cleanly with a full rollback | ✋ fails cleanly, rolls back — no partial damage |
| Change a column DEFAULT | ✋ hand-edit: defaults only affect future inserts — replace the rebuild with a no-op, or a backfill UPDATE | same |
| Tighten to NOT NULL | ✋ prefer enforcing at your app's write seam; if the DDL truth matters, add-copy-rename (add column, backfill, two RENAME COLUMNs, drop the old one) | same, seam enforcement strongly preferred — a DO is single-writer, so a seam check is exactly as strong as a CHECK |
| Add / change a CHECK | 🚫 don't put evolving invariants in DDL — enforce at the app's write seam, or emulate with a droppable RAISE(ABORT) trigger | 🚫 stricter: never add a CHECK to a DO table at all |
| Change column type / affinity | ✋ add-copy-rename (affinity is mostly cosmetic; an empty migration is often correct) | same |
| Rebuild a table with zero delete-action children (no inbound FKs, or only NO ACTION children) | ✅ but requires the -- allow-rebuild: annotation attesting zero delete-action children at apply time — the classifier flags every rebuild-shape by default | ✅ physically possible on the leaf/child side — but the classifier hard-fails every rebuild-shape in a DO track unconditionally; there is no annotation escape valve. Shipping one means patching your own CI backstop to allowlist that specific migration file — treat it as a deliberate, reviewed exception, never routine |
| Rebuild a plain (NO ACTION) FK target (a parent with only non-cascading children) | ✅ with the same -- allow-rebuild: attestation; a mis-ordered rebuild here fails loudly at apply, which is exactly why the line is drawn at delete-action children rather than "any FK target" | 🚫 — bricks the DO at the commit-boundary FK check even wrapped in one transaction under defer_foreign_keys |
| Rebuild a table with any delete-action child (cascade / set null / set default), or change its primary key | 🚫 — no sanctioned in-place rebuild. Rebuilding the parent and its cascade children together, copies-before-drops, does not work: the rebuilt child re-declares its FK against the parent's final name, so it's still cascade-linked to the still-live old parent table when DROP TABLE parent fires its implicit DELETE — which wipes the freshly-copied rows right back out of the new child. Do a cascade-ectomy first, or a blue/green migration (export → new database → import → repoint) | 🚫 impossible in place — escape-hatch territory (cascade fires at statement time, not at commit) |
Migrate-on-open (./do)
The ./do subpath is for Durable Object constructors. safeMigrate applies a committed
migration bundle and captures any failure instead of throwing:
import { DurableObject } from 'cloudflare:workers';
import { drizzle } from 'drizzle-orm/durable-sqlite';
import { safeMigrate, bundleMigrationStatus } from '@planafoot/drizzle-cf-migrations/do';
import { countersMigrations } from './migrations.generated.js';
import * as schema from './schema.js';
export class CountersDO extends DurableObject {
db = drizzle(this.ctx.storage, { schema, logger: false });
migrateError: Error | null = null;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.migrateError = safeMigrate(this.db, countersMigrations);
});
}
// A release probe: hit this RPC across your fleet of DO instances right after a
// deploy to catch a migrate-on-open failure before a user does.
status() {
return bundleMigrationStatus(this.ctx, this.migrateError);
}
}bundleMigrationStatus reads the applied-migration count straight from the durable-sqlite
migrator's own bookkeeping table and pairs it with the captured error as a { applied,
bricked } status — cheap enough to call from a health-check RPC and script over every
instance you can address.
Why capture, not throw. A Durable Object whose constructor throws can never be reached
again by any RPC — not even an admin one. If migration failure threw, a bricked DO would
be permanently unreachable, including by whatever teardown call (ctx.storage.deleteAll(),
an evacuation RPC, a rebuild-from-scratch path) you'd want to run to fix it. safeMigrate
returns the error instead, so the constructor still completes: read-only RPCs can keep
serving, and write RPCs that check migrateError first can refuse cleanly instead of
corrupting state further. A bricked DO must stay destroyable.
sqlDumpFromStorage (also on ./do) produces a verbatim, re-importable .sql dump of a
DO's SQLite storage — schema DDL plus one multi-row INSERT per non-empty table — useful
for the evacuation/blue-green paths the decision table above defers to. It takes a
structural SqlStorageLike (the slice of Cloudflare's real SqlStorage it needs: exec
returning an iterable cursor with .toArray(), .raw(), and .columnNames), so this
package carries no dependency on Cloudflare's ambient Workers types — your real
DurableObjectState/SqlStorage satisfies DoStateLike/SqlStorageLike structurally.
Test harness (./testing)
freshBundleSqlite builds an in-memory better-sqlite3 database with a migration bundle
applied, with foreign keys turned on to match the Durable Object engine's always-on
enforcement. describeAppendOnlyBundle is a Vitest spec factory that guards the one
invariant every DO-track bundle must hold:
import { describe, it, expect } from 'vitest';
import {
freshBundleSqlite,
describeAppendOnlyBundle
} from '@planafoot/drizzle-cf-migrations/testing';
import { countersMigrations } from './migrations.generated.js';
describeAppendOnlyBundle({
label: 'counters',
bundle: countersMigrations,
frozenBaseline: '20260101000000_init',
baselineContains: ['CREATE TABLE `counter`'],
harness: {
fresh: () => ({ sqlite: freshBundleSqlite(countersMigrations) }),
tables: ['counter']
}
});
describe('counter schema', () => {
it('starts empty', () => {
const sqlite = freshBundleSqlite(countersMigrations);
expect(sqlite.prepare('SELECT * FROM counter').all()).toEqual([]);
});
});Why append-only is absolute for DO tracks. A Durable Object applies its bundle on
open — migrate-on-open — via the durable-sqlite migrator, which tracks which migration
names it has already run. If you ever regenerate the bundle by deleting your dated
migration history and re-diffing from scratch (re-squashing to a fresh baseline), the new
baseline's CREATE TABLE statements have never run against an existing DO's storage —
they're new names as far as that migrator is concerned. On its next open, the DO replays
them against tables that already exist, the statements fail, the migrator's wrapping
transaction rolls back, and the DO is bricked on every subsequent open until you ship a
fix. There is no re-squashing a DO-track bundle safely once anything has shipped against
it: append new incremental migrations, never regenerate the history. frozenBaseline in
the spec above pins the first key so a re-squash trips the test immediately, in CI, instead
of on a real instance.
Constraints
- Drizzle version. Peer-pinned to the
1.0.0-rcline (>=1.0.0-rc <2) for bothdrizzle-ormanddrizzle-kit. This tracks pre-1.0 APIs (durable-sqlitemigrator shape,getTableConfigFK reference shape) that are not guaranteed stable until Drizzle reaches 1.0 proper — re-verify this package's peer range and the FK-inventory code against drizzle 1.0 stable once it ships, rather than assuming the rc contract holds. - D1 flattening assumes wrangler's filename-keyed ledger.
wrangler d1 migrations applytracks "applied" by exact filename, so flattened D1 migration filenames must be unique forever and never renamed after the fact — renaming (or regenerating under a changed name) makes an already-applied migration look unapplied and replays it. This package's flattening step (FLATTEN_MARKER,runPrepareMigrations) mints each flat filename once and marks its dated source directory so it's never flattened a second time under a different name. better-sqlite3andvitestare optional peers, needed only if you import from./testing.drizzle-kitis also an optional peer, needed only if you use thegeneratecommand (or calldrizzle-kityourself in a programmatic flow) —classifyalone doesn't need it.drizzle-ormis a required peer regardless of which subpath you use, since the FK-inventory builder importsdrizzle-orm/sqlite-coredirectly.- Node ≥ 22.14.
Provenance
This package is developed upstream in a private production application (planafoot) and
mirrored to its public repository on every release — each release is exactly the code that
application runs against its own D1 database and Durable Object stores in production.
Issues and pull requests are welcome on the public repository; see
CONTRIBUTING.md for how changes flow back upstream and ship.
