`.where(col, 'is not', null)` compiles to `col is not $4` — the null is bound
as a parameter. Postgres rejects that outright:
syntax error at or near "$4" (42601, position 106)
SQLite accepts `col is ?` with a bound NULL, which is why this survived: the
shipped migrations target SQLite, so nobody ran the path on a Postgres server.
Why it took down every dispatch
-------------------------------
`Job.dispatch()` → `runDispatchPipeline()` → `isQuarantined()`, which runs
BEFORE any driver routing. So the malformed statement is issued even on
`QUEUE_DRIVER=sync`, where nothing should be persisted at all — that is the
part of #2215 that looked inexplicable.
`isQuarantined()` already degrades when `job_quarantine` is not migrated, via
`isMissingTableError`. But 42601 is a SYNTAX error, not 42P01, so the guard
correctly declined to swallow it and rethrew. Verified against a live Postgres:
before errno=42601 isMissingTableError=false → rethrown, dispatch dies
after errno=42P01 isMissingTableError=true → degrades, dispatch proceeds
So this one fix resolves the whole reported symptom; the absent
`job_quarantine` table was never the blocker.
Scope
-----
18 call sites, all switched to the purpose-built `whereNull()` /
`whereNotNull()`, which emit literal `IS NULL` / `IS NOT NULL`:
commerce (8), queue (6), notifications (3), actions (1). Every one of them was
silently broken on Postgres, including three unread-notification queries and
the batch finalize guard.
The batch test pinned the old spelling. Its intent — "finalizes exactly once
via a finished_at IS NULL guard" — is unchanged and now actually holds on
Postgres, where the old form never compiled.
Upstream
--------
bun-query-builder types `'is' | 'is not'` as valid `WhereOperator`s and then
emits an unusable statement for them. `orm.ts` and `browser.ts` special-case
null correctly; the `selectFrom` path does not. Worth fixing there too — this
bites every consumer, not just Stacks.
Refs #2215
Refs #2215.
The statement
.where(col, 'is not', null)compiles tocol is not $4— the null is bound as a parameter. Postgres rejects it outright, at exactly the reported offset:Reproduced via
toSQL():SQLite accepts
col is ?with a bound NULL. That is why this survived — the shipped migrations target SQLite, so nobody ran the path against a Postgres server.Why one bad
wherekilled every dispatchJob.dispatch()→runDispatchPipeline()→isQuarantined(), which runs before any driver routing. So the malformed statement goes out even onQUEUE_DRIVER=sync, where nothing should be persisted at all. That is the part of the report that looked inexplicable — the queue name was never being resolved as a connection; the quarantine probe simply runs unconditionally.isQuarantined()already degrades whenjob_quarantineis not migrated, viaisMissingTableError. But42601is a syntax error, not42P01, so the guard correctly declined to swallow it and rethrew.Verified against a live Postgres:
isMissingTableError42601false42P01trueSo this fix alone resolves the reported symptom. The missing
job_quarantinetable was never the blocker.Correction to the issue's third finding
The report says no migration ships for the queue tables. Not so —
0000000012-create-failed_jobs-table.sqland0000000034-create-jobs-table.sqlare both present here.job_quarantinegenuinely has none, but it is an opt-in table the code is designed to run without.Scope: 18 sites, all silently broken on Postgres
Switched to the purpose-built
whereNull()/whereNotNull(), which emit literalIS NULL/IS NOT NULL.Includes three unread-notification queries and the batch finalize guard — so batch finalization's atomicity guarantee never held on Postgres either.
The batch test pinned the old spelling; its intent ("finalizes exactly once via a finished_at IS NULL guard") is unchanged and now actually holds where it previously could not compile.
Upstream
bun-query-buildertypes'is' | 'is not'as validWhereOperators and then emits an unusable statement for them.orm.tsandbrowser.tsspecial-case null correctly; theselectFrompath does not. Worth fixing there — it bites every consumer. (orm.ts:2432also looks inverted: it keys onoperator === '=', so'is'+ null yieldsIS NOT NULL.) I'll file it separately.Verification
🤖 Generated with Claude Code