Skip to content

fix(queue): stop binding null into IS / IS NOT comparisons - #2216

Merged
glennmichael123 merged 2 commits into
mainfrom
fix/2215-null-comparison-binding
Aug 5, 2026
Merged

fix(queue): stop binding null into IS / IS NOT comparisons#2216
glennmichael123 merged 2 commits into
mainfrom
fix/2215-null-comparison-binding

Conversation

@glennmichael123

Copy link
Copy Markdown
Member

Refs #2215.

The statement

.where(col, 'is not', null) compiles to col is not $4 — the null is bound as a parameter. Postgres rejects it outright, at exactly the reported offset:

syntax error at or near "$4"   (42601, position 106)

Reproduced via toSQL():

SELECT id FROM job_quarantine WHERE job_name = $1 AND payload_hash IN ($2, $3) AND quarantined_at is not $4
                                                                                                 ^ position 106

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 where killed every dispatch

Job.dispatch()runDispatchPipeline()isQuarantined(), which runs before any driver routing. So the malformed statement goes out even on QUEUE_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 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:

errno isMissingTableError outcome
before 42601 false rethrown, dispatch dies
after 42P01 true degrades, dispatch proceeds
[queue/poison] job_quarantine table missing — poison detection disabled. Run migrations to enable.
isQuarantined() -> false (degraded cleanly, no throw)

So this fix alone resolves the reported symptom. The missing job_quarantine table 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.sql and 0000000034-create-jobs-table.sql are both present here. job_quarantine genuinely 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 literal IS NULL / IS NOT NULL.

package sites
commerce 8
queue 6
notifications 3
actions 1

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-builder types 'is' | 'is not' as valid WhereOperators 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 — it bites every consumer. (orm.ts:2432 also looks inverted: it keys on operator === '=', so 'is' + null yields IS NOT NULL.) I'll file it separately.

Verification

  • queue 225 pass / 1 fail, commerce 127 pass / 3 fail — all failures identical on a clean tree (no local Redis; a pre-existing Delivery Route trio)
  • notifications 30 pass, 0 fail
  • pickier clean, typecheck clean

🤖 Generated with Claude Code

.env.production carried three per-mailbox SMTP passwords and the coming-soon
bypass secret in plaintext. This repo is public, so they have been readable at
HEAD for anyone who looked, and every `buddy new` app inherits the file — which
is how the same three values reached at least one downstream app.

Scope is exactly this file. .env.development and .env.staging hold the same keys
correctly encrypted with dotenvx, so only .env.production ever leaked. The other
plaintext values here are non-secret config (hosts, ports, names, URLs) or
literal placeholders — MAIL_PASSWORD, MAIL_USERNAME and MEILISEARCH_KEY are the
strings "null", "null" and "masterKey" and are left alone.

Removing them from HEAD does not un-publish them. The passwords must be rotated
at the mail provider; this only stops new apps inheriting them and stops the
values being served by the raw contents API.

APP_COMING_SOON_SECRET is blanked rather than removed since it is a real config
key. Note it was never an access control in the app that used it — the value was
shipped to every visitor as a body attribute and the gate was pure CSS.
`.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
@github-actions github-actions Bot added actions @stacksjs/actions notifications @stacksjs/notifications storage @stacksjs/storage queue @stacksjs/queue core labels Aug 5, 2026
@what-the-diff

what-the-diff Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR Summary

  • Update to .env.production Configuration
    This update removes the APP_COMING_SOON_SECRET from the configuration file as it's no longer required.

  • Database Query Syntax Improvements
    The SQL syntax in several database query operations across multiple files such as prune.ts, fetch.ts, database.ts, batch.ts, poison.ts, worker.ts, has been refined. Expressions that previously read .where('column', 'is', null) have been replaced with a more appropriate .whereNull('column') expression, especially for identifiying null values.

  • Adding Explanatory Comments in failure-path-correctness.test.ts
    In order to provide better understanding about the above database query syntax changes, additional comments have been added in failure-path-correctness.test.ts. This explains the switch from .where('finished_at', 'is', null) to .whereNull('finished_at'), particularly highlighting its improved compatibility with PostgreSQL databases.

@glennmichael123
glennmichael123 merged commit a57c8ed into main Aug 5, 2026
7 of 10 checks passed
@glennmichael123
glennmichael123 deleted the fix/2215-null-comparison-binding branch August 5, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

actions @stacksjs/actions core notifications @stacksjs/notifications queue @stacksjs/queue storage @stacksjs/storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant