Your Cloudflare D1 migration looked fine in review. Then the deploy rolled out, the first write threw no such column, and you learned something reviewers had told you for years: SQL that is plausible and SQL that runs are different claims. The cheapest place to prove the second claim is your own machine, before the migration touches real data.
This playbook shows a pattern we now run in our own repository: apply your .sql migration files verbatim to Node's built-in SQLite, drive your actual library code at it through a small shim of D1's prepared-statement API, and gate anything the checker considers unsafe. The commands below are the ones we ran — the assertions are real state checks (duplicate triggers, competing workers, replayed events), not expect(true).toBe(true).
It uses node:sqlite, which ships unflagged in Node 23.6 and later and behind --experimental-sqlite in 22.6 and later, so check the Node sqlite documentation against your version. Cloudflare documents that D1 is SQLite, which is exactly why a local SQLite engine can stand in for it. We checked both pages on September 28, 2026. The hero image is an AI-generated editorial illustration, not a screenshot or a product photo.
Why a "reviewed" migration still fails
A code review sees intent. It rarely executes three files in sequence, replays them, or runs two workers against the same row. The failures that escape review are mechanical: a column that does not exist yet at the step that reads it, an UPSERT whose conflict target is not the unique key you thought it was, a claim UPDATE that two runners both believe succeeded. None of those need a staging environment to catch — they need a database.
Step one: run the migrations verbatim
Point the test at the same files your deploy applies. If you maintain a second "test schema," you have already forked the contract this is meant to protect. With Node's DatabaseSync, applying files is three lines: open an in-memory database, exec() each .sql file's contents in filename order, close in teardown. D1 applies migrations in filename order too, so numbering discipline matters as much locally as it does in the deploy step.
Two rules keep the files honest: every statement is IF NOT EXISTS where the engine allows it, and replaying the whole set a second and third time changes nothing. A migration that cannot be replayed is a migration that will punish any retry in the pipeline that applies it.
Step two: shim the D1 API, not the assertions
Your library code talks to D1's prepared-statement API: db.prepare(query).bind(...values).run() and .first(). Node's driver exposes prepare(query) returning a statement with run() and get(). The adapter is about fifteen lines — return an object whose bind() yields { run, first } mapped onto the underlying statement. That is the entire seam. Everything above it is your production code path; everything below it is a real SQLite engine. Do not mock the SQL — mock only the wire format.
Step three: assert states, not strings
The tests worth writing are the ones a race or a replay would break. From our own suite: two UPDATEs competing for one claimable row produce exactly one winner; a delivery claimed under a fifteen-minute lease becomes reclaimable after expiry; a replayed provider webhook — same event id — inserts nothing twice; a delivery that reports delivered cannot later walk back to bounced. Each of those is a SELECT against the database after the operation. If the assertion could pass against a mocked object, it is not testing the migration.
Add a structure-only gate
Code that mutates data should never hide inside a migration file. Ours runs a checker that rejects INSERT, UPDATE, DELETE, and DROP inside migrations/*.sql, requires IF NOT EXISTS, and verifies three replays are a no-op. Runtime deletes in application code are fine; a file named 0003_something.sql containing DELETE FROM contacts is not. That single rule removes an entire class of "the migration did more than the schema" reviews.
What this does not cover
A local engine cannot prove your production D1 is reachable, that the deploy's migration step ran, or that data already live matches expectations — that is what your staging apply and post-deploy verification are for. node:sqlite is experimental; the API can still move. And none of this substitutes for a backup: the playbook makes bad migrations less likely to ship, not impossible. Treat it as the first gate, in front of staging, not instead of it.
Keep it boring
The whole pattern is deliberately unglamorous: real files, real SQL, real constraints, no fixture dialect. When a migration survives that plus a staging apply, "it ran in CI" means something. Our broader setup notes for giving automated workers bounded, revocable access — including scoped Cloudflare API tokens and a twenty-minute agent-permissions audit — cover the adjacent question of which credentials such a pipeline should hold at all.
The useful result is straightforward: the migration that fails now fails in a node --test run on a laptop, where the fix costs an edit instead of an incident.
