Blog

Fifty-seven round trips, or one

2026-06-10

An isometric illustration of a database — a tall stack of three wide cylindrical discs

Your test suite got slow. Not one test — all of them, by about the same amount, which is the signature of setup rather than logic.

We measured it on a real composed application in the Workers runtime. Migrating a fresh database took 1,028ms across 68 round trips. A drop-and-rebuild took 2,041ms. In pithy-sh/dashboard that was 78% of total wall time, and the floor it put under every test brought ordinary test bodies within reach of the 5,000ms timeout.

The cause was one line of driver behavior.

One statement, one round trip

kysely-d1 executes every compiled query as its own prepare().bind().all().

That is entirely reasonable for application queries. For a migration it is not. A single capability’s 0001_init composes nineteen tables and thirty-eight indexes — fifty-seven statements, and therefore fifty-seven separate hops to D1.

The work was never the problem. The waiting was.

batch is the shape a migration already has

D1 offers batch(): many statements, one round trip, in order, inside one implicit transaction.

Look at what a migration is. An ordered list of statements that must all apply or none of them. That is not compatible with batch — it is the exact thing batch exists for.

So a migration body runs against a Kysely whose driver queues its statements instead of sending them, and dispatches the whole queue as one batch when the body returns. You write ordinary Kysely. The driver decides that a migration is a unit.

sequenceDiagram
    participant M as A migration body
    participant D as The driver
    participant D1 as D1
    Note over M,D1: One statement, one round trip
    loop 57 times
        M->>D1: prepare().bind().all()
        D1-->>M: ok
    end
    Note over M,D1: Queued, then dispatched
    M->>D: The same 57 statements
    D->>D1: batch() — one hop, in order, one implicit transaction
    D1-->>D: ok

That is the fix: not a rewrite of every migration, and no new API to learn. The same code, sent differently.

Composed, ordered, and per capability

The rest of the model is deliberately unremarkable, which is the compliment it deserves.

Each capability owns its own migrations. They compose into a single registry, library-before-app, and each declares an order so the composed sequence is deterministic rather than dependent on config key order.

Order is what encodes dependency. Media records name an owner, owners are users, so auth’s tables must exist first — auth at 300, media at 350. That is the whole mechanism.

And when two capabilities claim the same slot, you find out immediately:

duplicate migration order 300 in database "app"

Named, at assembly, before anything is applied. Media actually shipped at 300 once, colliding with auth, and every project composing both hit that message on their next pithy migrate. Loud and early, rather than a half-applied schema.

Never rerun by hand

Applied migrations are recorded in a pithy_migrations table, and by default capabilities share a database rather than standing up their own — so one bookkeeping table tracks the whole composed set instead of several disagreeing about what has run.

You run pithy migrate against a named environment. It applies what has not been applied. There is no step where somebody pastes SQL into a console and remembers to tell the team.

What you still own

Your own migrations are yours, and they sit at an order above the capabilities so your tables can reference theirs.

Down-migrations are not the model. The kit rolls forward: a mistake is corrected by a new migration rather than by reversing an old one. That is a real constraint and worth knowing before you adopt it — it means a bad migration in production is fixed by writing the next one, which is the same discipline most teams end up at anyway after their first down-migration that could not actually be run.

And nothing here decides your schema. A capability brings the tables it needs to function. What your product’s data looks like is yours.