npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@planafoot/drizzle-cf-migrations

npm license: MIT

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 d1

Both 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.ts

Programmatic 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 generate writes, <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 zero ON 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 x sequence drizzle-kit emits for any change plain ALTER TABLE can't express (a DEFAULT change, tightening to NOT NULL, a type change, an added CHECK, 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 CASCADE in 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-rc line (>=1.0.0-rc <2) for both drizzle-orm and drizzle-kit. This tracks pre-1.0 APIs (durable-sqlite migrator shape, getTableConfig FK 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 apply tracks "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-sqlite3 and vitest are optional peers, needed only if you import from ./testing. drizzle-kit is also an optional peer, needed only if you use the generate command (or call drizzle-kit yourself in a programmatic flow) — classify alone doesn't need it. drizzle-orm is a required peer regardless of which subpath you use, since the FK-inventory builder imports drizzle-orm/sqlite-core directly.
  • 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.