From cea38904f3b87bae2e9d2de1aa3c17c0b31c3019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 19:01:35 +0000 Subject: [PATCH 1/2] fix(stdlib): let bun:sqlite query/run/transaction reach the Database dispatcher `db.query(sql)` lowers through codegen's static NATIVE_MODULE_TABLE entry, which is gated on proving the receiver's class is bun:sqlite's `Database`. The moment the database reaches a call site through any indirection -- a field, an interface-typed parameter, a generator return, Drizzle's driver object -- that proof is gone and the call falls to the dynamic handle tower in `common/dispatch/method_dispatch.rs`. `dispatch_node_sqlite_database_method` has always implemented `query`, `run` and `transaction` (the same handle registry backs both the node:sqlite and bun:sqlite bindings), but those three names were missing from the `matches!` gate in front of it. The tower fell all the way through and returned `undefined` -- silently, not as a throw, which is why it surfaced far from the cause. `db.query(sql)` answering `undefined` is how `opencode models` dies. Ordering is unchanged: the DatabaseSync arm already runs before the tag-store and StatementSync arms, and `dispatch_node_sqlite_database_method` returns `None` for a handle that is not a database, so the later `run` gates still see everything they used to. Fixes #10290. Refs #10107. --- .../src/common/dispatch/method_dispatch.rs | 14 ++ .../issue_10290_bun_sqlite_indirect_query.rs | 125 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 24697034e1..ac7079b3b8 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -250,6 +250,20 @@ pub unsafe extern "C" fn js_handle_method_dispatch( | "enableLoadExtension" | "loadExtension" | "location" + // #10290: `query`/`run`/`transaction` are the bun:sqlite `Database` + // surface. `dispatch_node_sqlite_database_method` has always + // implemented all three (the same registry backs both bindings), + // but they were missing from THIS gate, so they never reached it. + // Codegen's static NATIVE_MODULE_TABLE entry covers `db.query(...)` + // only while the receiver's class is provable; the moment the + // database reaches a call site through any indirection — a field, + // an interface-typed parameter, a generator return, Drizzle's + // driver object — the call falls to this dynamic tower and + // silently returned `undefined`. `db.query(sql)` answering + // `undefined` is how `opencode models` dies. + | "query" + | "run" + | "transaction" | "__perry_dispose__" | "@@__perry_wk_dispose" ) { diff --git a/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs b/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs new file mode 100644 index 0000000000..49fa22f3b4 --- /dev/null +++ b/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs @@ -0,0 +1,125 @@ +//! Regression test for #10290: `bun:sqlite` `Database.query()` answered +//! `undefined` whenever the database reached the call site through any +//! indirection. +//! +//! `db.query(sql)` lowers through codegen's static NATIVE_MODULE_TABLE entry, +//! which is gated on proving the receiver's class is `bun:sqlite`'s +//! `Database`. The moment the database arrives as a field, an interface-typed +//! parameter, a generator return or a driver object, that proof is gone and +//! the call falls to the dynamic handle tower in +//! `common/dispatch/method_dispatch.rs`. +//! +//! `dispatch_node_sqlite_database_method` has always implemented `query`, +//! `run` and `transaction` — the same handle registry backs both the +//! `node:sqlite` and `bun:sqlite` bindings — but those three names were +//! missing from the `matches!` gate in front of it, so the tower fell all the +//! way through and returned `undefined`. A silent `undefined` from `query`, +//! not a throw, which is why it surfaced far from the cause. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn runtime_dir() -> PathBuf { + std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + perry_bin() + .parent() + .expect("compiler directory") + .to_path_buf() + }) +} + +/// `runGen` launders the database through a generator, which is enough to +/// erase the static class the native-table lowering needs. `ind === db`. +const SOURCE: &str = r#" +import { Database } from "bun:sqlite" + +const show = (n: string, f: () => any) => { + try { + const r = f() + console.log(n, r === undefined ? "undefined" : (typeof r === "object" ? "object" : JSON.stringify(r))) + } catch (e: any) { + console.log(n, "THREW", e?.message) + } +} + +const db: any = new Database(":memory:") +db.run("create table t (a integer, b text)") +db.run("insert into t values (1,'x')") + +function* gen(): any { return db } +function runGen(g: any) { const it = g(); let r = it.next(); while (!r.done) r = it.next(r.value); return r.value } +const ind: any = runGen(gen) + +console.log("same object:", ind === db) +show("direct query", () => db.query("select * from t")) +show("indirect query", () => ind.query("select * from t")) +show("indirect prepare", () => ind.prepare("select * from t")) +show("indirect run", () => ind.run("insert into t values (2,'y')")) +show("indirect transaction", () => typeof ind.transaction) +console.log("rows", JSON.stringify(ind.query("select * from t").all())) +"#; + +/// Byte-for-byte what bun prints. +const EXPECTED: &str = "\ +same object: true +direct query object +indirect query object +indirect prepare object +indirect run object +indirect transaction \"function\" +rows [{\"a\":1,\"b\":\"x\"},{\"a\":2,\"b\":\"y\"}] +"; + +#[test] +fn bun_sqlite_query_survives_indirection() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("main.ts"), SOURCE).unwrap(); + + let output = root.join("main_bin"); + let out = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + // No `--platform bun`: the flag pulls the `net` well-known wrapper in, + // and a wrapper archive built outside this cargo invocation is refused + // at link (#7629). `bun:sqlite` resolves on either platform, and the + // handle tower under test is platform-independent. + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("run perry compile"); + assert!( + out.status.success(), + "bun:sqlite probe must compile; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary must run; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + let stdout = String::from_utf8(run.stdout).expect("UTF-8 stdout"); + assert!( + !stdout.contains("undefined"), + "no Database method may answer undefined through indirection; stdout:\n{stdout}" + ); + assert_eq!( + stdout, EXPECTED, + "the dynamic handle tower must reach the same Database implementation \ + the static native-table lowering does" + ); +} From 3ea94d11983b98faa7e7bbe6110ccaa93d92f386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 22:21:04 +0200 Subject: [PATCH 2/2] test(stdlib): invoke transaction through the indirect receiver Reading `typeof ind.transaction` only exercises property dispatch; the gate this PR changes is the METHOD path. Call the wrapper `transaction(fn)` returns, which is what bun does, so the test fails if `transaction` leaves the gate again. Review follow-up on #10305. --- crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs b/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs index 49fa22f3b4..c8a4d65fbd 100644 --- a/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs +++ b/crates/perry/tests/issue_10290_bun_sqlite_indirect_query.rs @@ -61,7 +61,9 @@ show("direct query", () => db.query("select * from t")) show("indirect query", () => ind.query("select * from t")) show("indirect prepare", () => ind.prepare("select * from t")) show("indirect run", () => ind.run("insert into t values (2,'y')")) -show("indirect transaction", () => typeof ind.transaction) +// Reading the property only exercises property dispatch; the gate under +// test is the METHOD path, so call the wrapper `transaction(fn)` returns. +show("indirect transaction", () => ind.transaction(() => "committed")()) console.log("rows", JSON.stringify(ind.query("select * from t").all())) "#; @@ -72,7 +74,7 @@ direct query object indirect query object indirect prepare object indirect run object -indirect transaction \"function\" +indirect transaction \"committed\" rows [{\"a\":1,\"b\":\"x\"},{\"a\":2,\"b\":\"y\"}] ";