Repository navigation
fix(sqlite): undefined binds NULL, like Node 26.10 - #1756
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mainis red onTest 2/4 Fedora 44, and has been since the 13:17 run:@gjsify/sqlite'ssqlite data types › rejects unsupported data typesexpects.run(1, undefined)to throw, andunder 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
undefinedbound to a STRICT INTEGER column is stored as NULL, and reads back asnull..run(1, undefined)TypeError/ERR_INVALID_ARG_TYPEnullMeasured 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()tovalue->IsNullOrUndefined()insrc/node_sqlite.cc, so that passing a parameter explicitly asundefinedagrees with omitting it — which bound NULL already. It writes the rule intodoc/api/sqlite.md("undefinedis written asNULL…NULLalways reads back as {null}, neverundefined"), and it REMOVESundefinedfrom the very "unsupported data types" list this spec isported from, replacing it with a positive test.
What
@gjsify/sqlitedid — MEASUREDIt threw.
validateBindValue()returned early fornullonly, soundefinedfell through toInvalidArgTypeError— whilesqlLiteral(), thirteen lines below it, already renderedundefinedas
NULL, and#buildStatementalready bound an omitted parameter to NULL. The package carriedexactly 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()acceptsundefinedand binds NULL, unconditionally, on both legs.undefinedleaves 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 readsback
nulland that an explicitundefinedand 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", sotest:nodeasserts NATIVE Node's behaviourwhile
test:gjsasserts ours. Buttest:gjs-on-nodebuilds these SAME specs with--alias node:sqlite=@gjsify/sqliteand runs OUR implementation on a Node host — the node-giconsumer 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, oneprobe per leg:
[native code]binding?process.versions.node.gjstest:node)test:gjs)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, onsqlite suite as node-gi consumer and node-gi consumer harness. Row 3 is the trap
packages/node/url/src/index.spec.tsrecords, which anIS_GJScheck patched; rows 1 and 3 arethe 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-262renders 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_UNDEFINEDreads theversion only after that answer, and
IS_GJSis gone because it is subsumed. It cannot mask aregression in the direction that matters: were
@gjsify/sqliteto go back to refusingundefined, the probe stays false and the test still demands NULL.Verification
Suite:
@gjsify/sqlite, both legs, before and after.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 (
validateBindValueback toif (value === null) return;), both legs that run our code go red on the new test —gjsify format --checkandgjsify lintare clean on both changed files.🤖 Generated with Claude Code