Skip to content

fix(sqlite): undefined binds NULL, like Node 26.10 - #1756

Merged
JumpLink merged 2 commits into
mainfrom
fix/sqlite-undefined-bind
Sep 22, 2026
Merged

JumpLink merged 2 commits into
mainfrom
fix/sqlite-undefined-bind

Conversation

@JumpLink

@JumpLink JumpLink commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

main is red on Test 2/4 Fedora 44, and has been since the 13:17 run: @gjsify/sqlite's
sqlite data types › rejects unsupported data types expects .run(1, undefined) to throw, and
under CI's Node 26.10.0 it no longer does. Five of five recent runs fail the same way, and no PR
in that window touches packages/node/sqlite/.

What Node 26.10.0 actually does — MEASURED

undefined bound to a STRICT INTEGER column is stored as NULL, and reads back as null.

Node binary .run(1, undefined)
24.19.0, 25.2.1, 26.4.0, 26.8.2, 26.9.0 throws TypeError / ERR_INVALID_ARG_TYPE
26.10.0 inserts one row; the column reads back null

Measured by running one probe script against each binary. The other six inputs in the list —
function, Symbol, RegExp, Promise, Map, Set — still throw on every one of them.

The change is deliberate, and it is not a regression: nodejs/node#65709, "sqlite: bind undefined
to NULL"
, merged 2026-09-08 and first released in v26.10.0. It flips value->IsNull() to
value->IsNullOrUndefined() in src/node_sqlite.cc, so that passing a parameter explicitly as
undefined agrees with omitting it — which bound NULL already. It writes the rule into
doc/api/sqlite.md ("undefined is written as NULL … NULL always reads back as {null}, never
undefined"), and it REMOVES undefined from the very "unsupported data types" list this spec is
ported from, replacing it with a positive test.

What @gjsify/sqlite did — MEASURED

It threw. validateBindValue() returned early for null only, so undefined fell through to
InvalidArgTypeError — while sqlLiteral(), thirteen lines below it, already rendered undefined
as NULL, and #buildStatement already bound an omitted parameter to NULL. The package carried
exactly the asymmetry Node has now removed, and its own comment said so: "A parameter left out
stays absent and renders as NULL, which is what node:sqlite binds for a missing parameter."

Outcome chosen: Node changed deliberately, gjsify follows

Of the three options this is the one the evidence supports. Upstream did not slip — it documented
the new contract, versioned it, and retired its own assertion. Our implementation was one ||
away from it, and the asymmetry it removed was one we shared.

So: validateBindValue() accepts undefined and binds NULL, unconditionally, on both legs.
undefined leaves the unsupported list — but it is not deleted. It moves into a test of its own,
binds undefined as NULL, exactly like an omitted parameter, which asserts the stored value reads
back null and that an explicit undefined and an absent argument land on the same value.

The legs do not silently disagree — and the question is WHICH SQLITE, not which host

This package declares runtimes.node: "none", so test:node asserts NATIVE Node's behaviour
while test:gjs asserts ours. But test:gjs-on-node builds these SAME specs with
--alias node:sqlite=@gjsify/sqlite and runs OUR implementation on a Node host — the node-gi
consumer legs, on node/bun/deno. Host and implementation come apart there, and only the
implementation decides what stmt.run(1, undefined) does.

The first gate here read process.versions, so it answered the wrong question. Measured, one
probe per leg:

Leg [native code] binding? process.versions.node .gjs
node-gi consumer (aliased) false 24.19.0 —
native Node (test:node) true 24.19.0 —
GJS (test:gjs) false 20.0.0 1.88.1

Row 1 is the red it produced: Node 24 reads as "refuses", so the test demanded a throw from an
implementation that binds NULL — Expected [anonymous function] to throw an exception, on
sqlite suite as node-gi consumer and node-gi consumer harness. Row 3 is the trap
packages/node/url/src/index.spec.ts records, which an IS_GJS check patched; rows 1 and 3 are
the same defect, and Bun and Deno would be two more of it.

So the gate asks the MODULE instead — SQLITE_IS_HOST_BUILTIN. [native code] is what ECMA-262
renders for a function with no ECMAScript source: true means the binding came from the host,
false means it came from a bundle of our TypeScript. HOST_SQLITE_REFUSES_UNDEFINED reads the
version only after that answer, and IS_GJS is gone because it is subsumed. It cannot mask a
regression in the direction that matters: were @gjsify/sqlite to go back to refusing
undefined, the probe stays false and the test still demands NULL.

Verification

Suite: @gjsify/sqlite, both legs, before and after.

Leg Before After
GJS, gjs 1.88.1 89 pass · 175 assert 90 pass · 178 assert
Node 26.10.0 1 of 89 FAILED · 170 assert 90 pass · 179 assert
Node 24.19.0 89 pass · 176 assert 90 pass · 176 assert
node-gi consumer, Node 24.19.0 1 of 65 FAILED · 132 assert 65 pass · 135 assert

Node 26.9.0, the last release before the boundary, also runs 90 pass · 176 assert after.
Test counts are the ones to quote across commits; assertion counts move without changing
what is verified (tests/AGENTS.md rule 6b).

The node-gi row is the one this PR's second commit fixes; its "before" is the gate that asked
which host. Reproduced locally against the freshly node-gyp-built addon, not read off CI.

Teeth check: with the fix reverted in the implementation only (validateBindValue back to
if (value === null) return;), both legs that run our code go red on the new test —

❌ [Gjs 1.88.1] 1 of 90 tests failed · 174 assertions
❌ [Node.js 24.19.0] 1 of 65 tests failed · 131 assertions
  ✖ sqlite data types › binds undefined as NULL, exactly like an omitted parameter
InvalidArgTypeError: Provided value cannot be bound to SQLite parameter 2.

gjsify format --check and gjsify lint are clean on both changed files.

🤖 Generated with Claude Code

node:sqlite refused an explicitly-passed `undefined` until v26.10.0, where
nodejs/node#65709 made it bind NULL so that passing a parameter as `undefined`
agrees with omitting it — which bound NULL already, on both implementations.
The release documented the rule in doc/api/sqlite.md and dropped `undefined`
from the "unsupported data types" list this spec is ported from.

`@gjsify/sqlite` carried exactly the asymmetry Node removed: validateBindValue()
returned early for `null` alone, while sqlLiteral() below it already rendered
`undefined` as NULL and an omitted parameter already bound NULL. It now accepts
`undefined` on both legs, unconditionally.

`undefined` leaves the unsupported list but is not deleted: it moves into a test
of its own that asserts the stored value reads back `null` and that an explicit
`undefined` and an absent argument land on the same value. The Node leg runs
native node:sqlite (runtimes.node is "none") and the hosts disagree across the
26.10 line, so the boundary is named rather than left silent.
The `undefined` gate asked `process.versions`, so it answered "which host am I
on" — but `test:gjs-on-node` builds these same specs with
`--alias node:sqlite=@gjsify/sqlite` and runs OUR implementation on a Node host.
Host and implementation come apart there, and only the implementation decides
what `stmt.run(1, undefined)` does. Measured, one probe per leg:

  node-gi (aliased)  host_builtin=false  node=24.19.0  gjs=undefined
  native node        host_builtin=true   node=24.19.0  gjs=undefined
  gjs                host_builtin=false  node=20.0.0   gjs=1.88.1

Row 1 is the red: Node 24 reads as "refuses", so the test demanded a throw from
an implementation that binds NULL — `Expected [anonymous function] to throw an
exception`, on "sqlite suite as node-gi consumer" and "node-gi consumer
harness". Row 3 is the trap the old gate already knew about and patched with an
`IS_GJS` check; rows 1 and 3 are the same defect, and Bun and Deno would be two
more.

So the gate asks the module instead. `[native code]` is what ECMA-262 renders
for a function with no ECMAScript source: true means the binding came from the
host, false means it came from a bundle of our TypeScript. The version is read
only after that answer, and `IS_GJS` is gone because it is subsumed. It cannot
mask a regression in the direction that matters — were `@gjsify/sqlite` to go
back to refusing `undefined`, the probe stays false and the test still demands
NULL. Verified by breaking `validateBindValue` back to `value === null`: gjs
89/90 and node-gi 64/65, both on `InvalidArgTypeError: Provided value cannot be
bound to SQLite parameter 2.`

Green after: gjs 90 pass / 178 assert, node 24.19.0 90 / 176,
node-gi on node 24.19.0 65 / 135.
@JumpLink
JumpLink merged commit dc4883f into main Sep 22, 2026
68 checks passed
@JumpLink
JumpLink deleted the fix/sqlite-undefined-bind branch September 22, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant