Skip to content
Guides/Detail

Your tests run a different database driver than production

19/08/2026 (edited)
Tech
1243 Words
6Min read

Your production database driver is probably not the one your tests use. On a serverless runtime it usually cannot be.

That difference is normally small enough to ignore, right up until the moment something exists on one driver and throws on the other. Then the only question that matters is whether the type system is hiding the gap from you.

Ours was. It shipped a 500 on every authenticated call, through 1,257 green tests, a clean typecheck and green CI, on 13/08/2026.

Two drivers, because the environments differ

Serverless runtimes push you towards a different Postgres driver than the one your laptop uses. On Cloudflare Workers the usual route is an HTTP driver: one ordinary HTTPS request per query, no connection pool to keep alive between invocations.

Be precise about why, because I was not. Workers can open raw TCP sockets, through the connect() API in cloudflare:sockets, and Cloudflare’s own guidance for Postgres is to reach for Hyperdrive, which wraps that with pooling and caching. So this is a trade-off between real options, not a hard platform limit. We took the HTTP driver.

Tests, meanwhile, run node-postgres against a local Postgres, because that is fast, offline, and the thing every developer already has.

Both choices are defensible. The gap between them is the problem, and the gap exists whatever the reason for the split.

An HTTP driver has no interactive transactions. There is no session for a transaction to live in, so there is nothing to begin, commit or roll back. The same absence removes session-level advisory locks.

Your local Postgres supports all of it perfectly. That is why the tests pass.

The cast that makes the gap compile

Here is the line that turns an ordinary difference into a trap:

const sql = neon(connectionString);
return drizzleNeonHttp(sql, { schema }) as unknown as NodePgDatabase<typeof schema>;

That cast exists for a good reason. Every tool signature in the codebase is typed against one database type, and without it you would thread a union through several hundred function signatures for no benefit.

It is still a lie, and the compiler believes it completely.

db.transaction(...) now type-checks in every file. Your editor autocompletes it. Review does not flag it. CI does not flag it. It throws No transactions support in neon-http driver at runtime, on Workers, only.

A cast does not convert anything. It ends the conversation about a difference that still exists.

Why did a green suite ship a 500 on every call?

Because the suite runs the other driver.

The broken path was OAuth first-authorisation, which needs to create an account the first time somebody connects. It used a transaction, correctly by every standard except the one that mattered. Against a local Postgres, over node-postgres, it worked. Every test of it passed.

Deployed, every authenticated call hit the throw. Not a percentage of them. All of them.

The suite was not weak. It was measuring a different system, accurately.

This is worth separating from ordinary flakiness, because the instinct after a production bug is to add more tests, and here more tests of the same kind would have produced more confidence and the same outcome.

A comment is not a control

The part that should bother you: the constraint was already written down.

/** Start an act-as session. [...] NO db.transaction() - neon-http throws; each
 *  statement is independent and findActiveImpersonation tolerates a partial
 *  apply. */

That comment was in the codebase before the bug, written by somebody who had already been bitten, sitting directly above a function that carefully avoided the thing.

It did not help. It was in one file, and the transaction went into a different one.

Knowledge in a comment protects the file it is in. If a constraint applies to the whole codebase, it has to be enforced across the whole codebase, or it will hold everywhere except the one place somebody was not looking.

Assert the constraint statically

You cannot test this behaviourally. Reproducing it needs the runtime and the driver production uses, which is exactly what your test environment is not.

So assert it as a fact about the source instead:

describe("neon-http driver constraint", () => {
  it("src/ never calls db.transaction() - it throws at runtime on Workers", () => {
    const offenders = tsFiles(SRC)
      .filter((f) => /\.transaction\s*\(/.test(stripComments(readFileSync(f, "utf8"))))
      .map((f) => path.relative(SRC, f));

    expect(offenders).toEqual([]);
  });
});

Two details carry most of the value.

Strip comments before matching. Otherwise the warning comment above becomes a permanent false positive, and the first thing anybody does with a test that fails on a comment is weaken the pattern until it passes.

Test the matcher itself. A scanning test that silently stops matching is worse than no test, because it reports success forever:

it("still sees a real call, so the comment-stripping cannot mask one", () => {
  expect(/\.transaction\s*\(/.test(stripComments("await db.transaction(async (tx) => {})"))).toBe(true);
  expect(/\.transaction\s*\(/.test(stripComments("// NO db.transaction() - neon-http throws"))).toBe(false);
});

A static assertion is weaker proof than running the code. It is also available when running the code is not, and weaker proof that exists beats stronger proof that does not.

What do you do when you cannot have a transaction?

Use a unique index. With every statement standing alone, it is the only atomicity primitive left, and it is a good one.

The pattern: write the row that represents the claim, let Postgres reject the duplicate, and read the result to find out whether you won.

const claimed = await db
  .insert(schema.organiserMembers)
  .values({ organiserId: organiser.id, workosUserId: input.workosUserId, email, role: "owner" })
  .onConflictDoNothing()
  .returning();

if (claimed.length === 0) {
  // Lost the race. Undo what we created and return the winner's record instead.
}

Backed by a partial unique index, so it constrains the claim and nothing else:

CREATE UNIQUE INDEX "organiser_members_owner_user_ux"
  ON "organiser_members" ("workos_user_id")
  WHERE "organiser_members"."role" = 'owner';

Two callers arrive together. Both pass the existence check. Both insert. Exactly one gets a row back. The loser cleans up and reads the winner’s record.

This matters more than it used to, because an agent is a concurrent client by default. Point a model at a freshly authorised connector and it will fire several tool calls at once. The race is ordinary traffic, not a pathological case you can deprioritise.

Name your parity gaps out loud

Transactions were one difference. They are not the only one, and the rest are equally untested.

That is worth writing down as a known gap with a ticket number rather than leaving it as an unexamined good feeling about a green suite. Ours is tracked; the honest statement is that anything else differing between the two drivers would reach production the same way this did.

The transferable version is short.

Where your test environment cannot match production, you have three moves, in order of preference: run the real thing, assert the constraint statically, or write down the gap. Two of those are cheap. All three beat a green suite that is measuring the wrong system.

Back to guides
End of Post