From 071baf8d7a3c95cca2035f67e5f199c1348e7f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 08:28:20 +0200 Subject: [PATCH] feat(sqlite): add bun:sqlite compatibility --- changelog.d/8525-bun-sqlite.md | 8 + crates/perry-api-manifest/src/entries.rs | 1 + .../perry-api-manifest/src/entries/part_1.rs | 20 ++ .../perry-codegen/src/lower_call/builtin.rs | 23 ++ .../src/lower_call/native_table/databases.rs | 127 ++++++++++ .../runtime_decls/stdlib_ffi/data_stores.rs | 13 ++ .../src/js_transform/local_natives.rs | 32 +++ crates/perry-hir/src/lower/expr_new.rs | 14 ++ crates/perry-hir/src/lower/expr_new/member.rs | 9 + crates/perry-hir/src/lower/tests.rs | 35 +++ crates/perry-stdlib/src/sqlite.rs | 8 + crates/perry-stdlib/src/sqlite/bind.rs | 10 +- crates/perry-stdlib/src/sqlite/bun.rs | 220 ++++++++++++++++++ crates/perry-stdlib/src/sqlite/connection.rs | 16 +- crates/perry-stdlib/src/sqlite/dispatch.rs | 33 +++ crates/perry-stdlib/src/sqlite/node_db.rs | 12 +- crates/perry-stdlib/src/sqlite/options.rs | 2 + crates/perry/src/commands/stdlib_features.rs | 2 +- docs/api/perry.d.ts | 15 +- docs/src/api/reference.md | 32 ++- docs/src/stdlib/overview.md | 2 +- test-files/test_gap_bun_sqlite_8510.ts | 44 ++++ .../expected/test_gap_bun_sqlite_8510.txt | 9 + 23 files changed, 675 insertions(+), 12 deletions(-) create mode 100644 changelog.d/8525-bun-sqlite.md create mode 100644 crates/perry-stdlib/src/sqlite/bun.rs create mode 100644 test-files/test_gap_bun_sqlite_8510.ts create mode 100644 test-parity/expected/test_gap_bun_sqlite_8510.txt diff --git a/changelog.d/8525-bun-sqlite.md b/changelog.d/8525-bun-sqlite.md new file mode 100644 index 0000000000..fff619eb77 --- /dev/null +++ b/changelog.d/8525-bun-sqlite.md @@ -0,0 +1,8 @@ +### Added + +- Add a native `bun:sqlite` compatibility facade backed by the same rusqlite + engine as Perry's `node:sqlite` implementation. `Database` construction, + prepared statements, positional and named parameters, object and array row + modes, blobs, safe integers, transactions, change metadata, serialization, + extension loading, and handle lifetime operations now support OpenCode's Bun + SQLite adapter without leaving an unresolved `bun:` import in the graph. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 7b927219f6..70da076cca 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -59,6 +59,7 @@ pub const NATIVE_MODULES: &[&str] = &[ // #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier // (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`. "bun:ffi", + "bun:sqlite", // Bun facade over Perry's native SQLite engine "node-cron", // cron-style scheduler (npm node-cron; aliases `cron`) "nodemailer", // SMTP email sending // ── Node.js builtin modules ── diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index a8c6e8e574..eff44bac89 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -229,6 +229,26 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ .stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"), method("bun:ffi", "read", false, None) .stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"), + // bun:sqlite (#8510) shares node:sqlite's rusqlite handles while keeping + // Bun's public constructor and statement vocabulary. + class("bun:sqlite", "Database"), + class("bun:sqlite", "Statement"), + method("bun:sqlite", "Database", false, None), + method("bun:sqlite", "query", true, Some("Database")), + method("bun:sqlite", "prepare", true, Some("Database")), + method("bun:sqlite", "run", true, Some("Database")), + method("bun:sqlite", "close", true, Some("Database")), + method("bun:sqlite", "serialize", true, Some("Database")), + method("bun:sqlite", "loadExtension", true, Some("Database")), + method("bun:sqlite", "transaction", true, Some("Database")), + property("bun:sqlite", "filename"), + property("bun:sqlite", "inTransaction"), + method("bun:sqlite", "run", true, Some("Statement")), + method("bun:sqlite", "get", true, Some("Statement")), + method("bun:sqlite", "all", true, Some("Statement")), + method("bun:sqlite", "values", true, Some("Statement")), + method("bun:sqlite", "safeIntegers", true, Some("Statement")), + method("bun:sqlite", "finalize", true, Some("Statement")), class("sqlite", "DatabaseSync"), class("sqlite", "Session"), class("sqlite", "SQLTagStore"), diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 2a93457e4c..5dca17baa9 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -637,6 +637,29 @@ pub(super) fn lower_builtin_new<'a>( let handle = blk.call(I64, "js_pg_pool_new", &[(DOUBLE, &config_val)]); Ok(Some(nanbox_pointer_inline(blk, &handle))) } + // bun:sqlite Database — distinct internal name avoids colliding with + // better-sqlite3's exported `Database` while preserving full JS values + // for Bun's optional filename and flags object. + "BunSqliteDatabase" => { + let path_idx = adopt_optional_arg(ctx, args, 0, group)?; + let options_idx = adopt_optional_arg(ctx, args, 1, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let path_value = match path_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), + }; + let options_value = match options_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), + }; + let blk = ctx.block(); + let handle = blk.call( + I64, + "js_bun_sqlite_database_new", + &[(DOUBLE, &path_value), (DOUBLE, &options_value)], + ); + Ok(Some(nanbox_pointer_inline(blk, &handle))) + } // better-sqlite3 Database — `new Database(filename)` opens a SQLite // connection. Without this, `new Database(...)` falls into lower_new's // empty-object placeholder, so `db` is a generic ObjectHeader pointer diff --git a/crates/perry-codegen/src/lower_call/native_table/databases.rs b/crates/perry-codegen/src/lower_call/native_table/databases.rs index eff2c09489..31e19161e8 100644 --- a/crates/perry-codegen/src/lower_call/native_table/databases.rs +++ b/crates/perry-codegen/src/lower_call/native_table/databases.rs @@ -717,6 +717,133 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_VOID, }, + // ========== bun:sqlite ========== + NativeModSig { + module: "bun:sqlite", + has_receiver: false, + method: "Database", + class_filter: None, + runtime: "js_bun_sqlite_database_call", + args: &[NA_F64, NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "query", + class_filter: Some("Database"), + runtime: "js_bun_sqlite_database_query", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "prepare", + class_filter: Some("Database"), + runtime: "js_bun_sqlite_database_query", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "run", + class_filter: Some("Database"), + runtime: "js_bun_sqlite_database_run", + args: &[NA_F64, NA_VARARGS], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "close", + class_filter: Some("Database"), + runtime: "js_node_sqlite_database_sync_close", + args: &[], + ret: NR_I32, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "serialize", + class_filter: Some("Database"), + runtime: "js_node_sqlite_database_sync_serialize", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "loadExtension", + class_filter: Some("Database"), + runtime: "js_node_sqlite_database_sync_load_extension", + args: &[NA_F64], + ret: NR_I32, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "transaction", + class_filter: Some("Database"), + runtime: "js_bun_sqlite_database_transaction", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "run", + class_filter: Some("Statement"), + runtime: "js_node_sqlite_statement_sync_run", + args: &[NA_VARARGS], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "get", + class_filter: Some("Statement"), + runtime: "js_node_sqlite_statement_sync_get", + args: &[NA_VARARGS], + ret: NR_F64, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "all", + class_filter: Some("Statement"), + runtime: "js_node_sqlite_statement_sync_all", + args: &[NA_VARARGS], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "values", + class_filter: Some("Statement"), + runtime: "js_bun_sqlite_statement_values", + args: &[NA_VARARGS], + ret: NR_PTR, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "safeIntegers", + class_filter: Some("Statement"), + runtime: "js_bun_sqlite_statement_safe_integers", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun:sqlite", + has_receiver: true, + method: "finalize", + class_filter: Some("Statement"), + runtime: "js_bun_sqlite_statement_finalize", + args: &[], + ret: NR_VOID, + }, // ========== node:sqlite ========== NativeModSig { module: "sqlite", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/data_stores.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/data_stores.rs index 36748f5299..cfdaa4c587 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/data_stores.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/data_stores.rs @@ -103,6 +103,19 @@ pub(crate) fn declare_data_stores(module: &mut LlModule) { module.declare_function("js_sqlite_transaction", I64, &[I64, I64]); module.declare_function("js_sqlite_transaction_commit", VOID, &[I64]); module.declare_function("js_sqlite_transaction_rollback", VOID, &[I64]); + module.declare_function("js_bun_sqlite_database_call", I64, &[DOUBLE, DOUBLE]); + module.declare_function("js_bun_sqlite_database_new", I64, &[DOUBLE, DOUBLE]); + module.declare_function("js_bun_sqlite_database_query", I64, &[I64, DOUBLE]); + module.declare_function("js_bun_sqlite_database_run", I64, &[I64, DOUBLE, I64]); + module.declare_function("js_bun_sqlite_database_filename", I64, &[I64]); + module.declare_function("js_bun_sqlite_database_transaction", I64, &[I64, DOUBLE]); + module.declare_function("js_bun_sqlite_statement_values", I64, &[I64, I64]); + module.declare_function( + "js_bun_sqlite_statement_safe_integers", + DOUBLE, + &[I64, DOUBLE], + ); + module.declare_function("js_bun_sqlite_statement_finalize", VOID, &[I64]); module.declare_function("js_node_sqlite_backup", I64, &[DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_node_sqlite_database_sync_call", I64, &[DOUBLE, DOUBLE]); module.declare_function("js_node_sqlite_database_sync_new", I64, &[DOUBLE, DOUBLE]); diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index 115cac9436..43fb9f3e5b 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -1151,6 +1151,31 @@ pub fn fix_native_instance_expr_with_locals( } fix_native_instance_expr_with_locals(inner, native_instances, local_id_instances); } + // #8510: the AST lowerer's any-receiver fallback folds a zero-argument + // `.values()` into ArrayValues before this pass knows that the local is + // a bun:sqlite Statement. Recover the native call once the statement + // result from Database.query()/prepare() has been tracked. Without + // this, `statement.values()` runs the Array iterator helper against a + // native statement handle and produces undefined rows. + Expr::ArrayValues(array) => { + if let Expr::LocalGet(local_id) = array.as_ref() { + if matches!( + local_id_instances.get(local_id), + Some((module, class)) if module == "bun:sqlite" && class == "Statement" + ) { + let object = std::mem::replace(array.as_mut(), Expr::Undefined); + *expr = Expr::NativeMethodCall { + module: "bun:sqlite".to_string(), + class_name: Some("Statement".to_string()), + object: Some(Box::new(object)), + method: "values".to_string(), + args: Vec::new(), + }; + return; + } + } + fix_native_instance_expr_with_locals(array, native_instances, local_id_instances); + } // Recurse into other expressions Expr::Binary { left, right, .. } => { fix_native_instance_expr_with_locals(left, native_instances, local_id_instances); @@ -1398,6 +1423,9 @@ pub fn detect_native_instance_creation_with_context( ("sqlite", "DatabaseSync", "createSession") => { Some((module.clone(), "Session".to_string())) } + ("bun:sqlite", "Database", "query" | "prepare") => { + Some((module.clone(), "Statement".to_string())) + } _ => None, } } @@ -1434,6 +1462,9 @@ pub fn detect_native_instance_creation_with_context( ("sqlite", "DatabaseSync", "createSession") => { Some((module.clone(), "Session".to_string())) } + ("bun:sqlite", "Database", "query" | "prepare") => { + Some((module.clone(), "Statement".to_string())) + } _ => None, }; } @@ -1457,6 +1488,7 @@ pub fn detect_native_instance_creation_with_context( "Database" => Some(("better-sqlite3".to_string(), "Database".to_string())), "DatabaseSync" => Some(("sqlite".to_string(), "DatabaseSync".to_string())), "StatementSync" => Some(("sqlite".to_string(), "StatementSync".to_string())), + "BunSqliteDatabase" => Some(("bun:sqlite".to_string(), "Database".to_string())), _ => None, } } diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 102b1aeeeb..41b6f84c3c 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -73,6 +73,20 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } if let ast::Expr::Ident(callee_ident) = callee_expr { + // Keep Bun's `Database` distinct from better-sqlite3's same-named + // constructor while still allocating the shared native SQLite handle. + if matches!( + ctx.lookup_native_module(callee_ident.sym.as_ref()), + Some(("bun:sqlite", Some("Database"))) + ) { + return Ok(Expr::New { + class_name: "BunSqliteDatabase".to_string(), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + type_args: Vec::new(), + byte_offset: new_byte_offset, + cap_args_appended: 0, + }); + } let module_constructor = ctx .lookup_native_module(callee_ident.sym.as_ref()) .map(|(module_name, method)| { diff --git a/crates/perry-hir/src/lower/expr_new/member.rs b/crates/perry-hir/src/lower/expr_new/member.rs index 441c95c29e..f9a0bd98a9 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -424,6 +424,15 @@ pub(crate) fn lower_new_member_native( } if let Some((module_name, _)) = ctx.lookup_native_module(module_alias) { let class_name = prop_ident.sym.as_ref(); + if module_name == "bun:sqlite" && class_name == "Database" { + return Ok(Some(Expr::New { + class_name: "BunSqliteDatabase".to_string(), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + type_args: Vec::new(), + byte_offset: new_byte_offset, + cap_args_appended: 0, + })); + } if matches!( (module_name, class_name), ("events", "EventEmitter") diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 661ecc8b14..eb89365c2f 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -646,6 +646,41 @@ fn test_perry_ui_state_value_uses_native_getter() { ); } +/// #8510: `.values()` on a bun:sqlite Statement is not Array.prototype.values. +/// The statement is discovered by the post-lowering native-instance pass, so +/// that pass must repair the eager any-receiver ArrayValues fold. +#[test] +fn test_bun_sqlite_statement_values_uses_native_dispatch() { + use crate::ir::clear_current_module_source; + use crate::js_transform::fix_local_native_instances; + + let source = r#" + import { Database } from "bun:sqlite"; + const db = new Database(":memory:"); + const statement = db.query("SELECT 1"); + const rows = statement.values(); + console.log(rows[0][0]); + "#; + let module = perry_parser::parse_typescript(source, "bun_sqlite_values.ts") + .expect("source should parse"); + let mut hir = + super::lower_module(&module, "test", "bun_sqlite_values.ts").expect("source should lower"); + clear_current_module_source(); + fix_local_native_instances(&mut hir); + + let dump = format!("{hir:#?}"); + assert!( + dump.contains("module: \"bun:sqlite\"") + && dump.contains("class_name: Some(\n \"Statement\"") + && dump.contains("method: \"values\""), + "Statement.values() must lower through bun:sqlite native dispatch: {dump}" + ); + assert!( + !dump.contains("ArrayValues"), + "Statement.values() must not retain the Array iterator fold: {dump}" + ); +} + /// #6642: the Widget `.addChild()` compatibility method must use the same /// native FFI dispatch as the canonical `widgetAddChild(parent, child)` free /// function, including for basic widget factories such as VStack and Text. diff --git a/crates/perry-stdlib/src/sqlite.rs b/crates/perry-stdlib/src/sqlite.rs index 2e65bdc48e..b0b854c22e 100644 --- a/crates/perry-stdlib/src/sqlite.rs +++ b/crates/perry-stdlib/src/sqlite.rs @@ -12,6 +12,7 @@ use std::sync::{Mutex, Once, OnceLock}; mod backup; mod better; mod bind; +mod bun; mod connection; mod dispatch; mod node_db; @@ -24,6 +25,7 @@ mod options; // keep resolving and sibling modules reach one another via `use super::*`. pub(crate) use backup::*; pub(crate) use bind::*; +pub(crate) use bun::*; pub(crate) use connection::*; pub(crate) use dispatch::*; pub(crate) use node_db::*; @@ -44,6 +46,8 @@ pub struct NodeSqliteDbHandle { pub conn: Mutex>, pub path: String, pub read_only: bool, + pub read_write: bool, + pub create: bool, pub enable_foreign_keys: bool, pub enable_dqs: bool, pub timeout_ms: i32, @@ -178,6 +182,8 @@ pub(crate) struct NodeSqliteAggregateState { pub(crate) struct NodeSqliteOptions { open: bool, read_only: bool, + read_write: bool, + create: bool, enable_foreign_keys: bool, enable_dqs: bool, timeout_ms: i32, @@ -195,6 +201,8 @@ impl Default for NodeSqliteOptions { Self { open: true, read_only: false, + read_write: true, + create: true, enable_foreign_keys: true, enable_dqs: false, timeout_ms: 0, diff --git a/crates/perry-stdlib/src/sqlite/bind.rs b/crates/perry-stdlib/src/sqlite/bind.rs index 515fffc0be..d3968a0979 100644 --- a/crates/perry-stdlib/src/sqlite/bind.rs +++ b/crates/perry-stdlib/src/sqlite/bind.rs @@ -830,10 +830,18 @@ pub(crate) unsafe fn make_null_proto_object( pub(crate) unsafe fn node_sqlite_row_value( stmt: &NodeSqliteStmtHandle, raw_stmt: *mut ffi::sqlite3_stmt, +) -> JSValue { + node_sqlite_row_value_with_mode(stmt, raw_stmt, stmt.return_arrays.load(Ordering::Relaxed)) +} + +pub(crate) unsafe fn node_sqlite_row_value_with_mode( + stmt: &NodeSqliteStmtHandle, + raw_stmt: *mut ffi::sqlite3_stmt, + return_arrays: bool, ) -> JSValue { let column_count = ffi::sqlite3_column_count(raw_stmt); let read_bigints = stmt.read_bigints.load(Ordering::Relaxed); - if stmt.return_arrays.load(Ordering::Relaxed) { + if return_arrays { let mut arr = js_array_alloc(column_count as u32); for index in 0..column_count { arr = js_array_push(arr, node_sqlite_column_value(raw_stmt, index, read_bigints)); diff --git a/crates/perry-stdlib/src/sqlite/bun.rs b/crates/perry-stdlib/src/sqlite/bun.rs new file mode 100644 index 0000000000..4cd7c3ff06 --- /dev/null +++ b/crates/perry-stdlib/src/sqlite/bun.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::common::{get_handle, Handle}; +use perry_runtime::{ + closure::{ + js_closure_alloc, js_closure_call_array, js_closure_get_capture_f64, + js_closure_get_capture_ptr, js_closure_set_capture_f64, js_closure_set_capture_ptr, + js_register_closure_rest, ClosureHeader, + }, + js_array_alloc, js_array_get, js_array_length, js_array_push, js_nanbox_get_pointer, + js_nanbox_pointer, js_string_from_bytes, ArrayHeader, JSValue, ObjectHeader, StringHeader, +}; +use rusqlite::ffi; +use std::sync::{atomic::Ordering, Once}; + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_call( + _path_value: f64, + _options_value: f64, +) -> Handle { + throw_plain_type("Cannot call a class constructor Database without |new|") +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_new(path_value: f64, options_value: f64) -> Handle { + let path = if value_from_f64(path_value).is_undefined() { + ":memory:".to_string() + } else { + let value = node_sqlite_database_path(path_value); + if value.is_empty() { + ":memory:".to_string() + } else { + value + } + }; + + let mut options = NodeSqliteOptions::default(); + let explicit_options = !value_from_f64(options_value).is_undefined(); + if explicit_options { + validate_optional_object(options_value); + options.read_only = bool_option(options_value, "readonly", false); + options.read_write = bool_option(options_value, "readwrite", false); + options.create = bool_option(options_value, "create", false); + if options.read_only && options.read_write { + throw_plain_type( + "flags must not include both SQLITE_OPEN_READONLY and SQLITE_OPEN_READWRITE", + ); + } + if !options.read_only && !options.read_write && !options.create { + throw_sqlite_error("flags must include SQLITE_OPEN_READONLY or SQLITE_OPEN_READWRITE"); + } + // Bun's strict mode accepts bare named keys and rejects unknown ones. + let strict = bool_option(options_value, "strict", false); + options.allow_bare_named_parameters = strict; + options.allow_unknown_named_parameters = !strict; + options.read_bigints = bool_option(options_value, "safeIntegers", false); + } + options.enable_foreign_keys = false; + options.allow_extension = true; + options.defensive = false; + register_node_sqlite_database(path, options, "bun:sqlite") +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_query(db_handle: Handle, sql_value: f64) -> Handle { + js_node_sqlite_database_sync_prepare(db_handle, sql_value, undefined_f64()) +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_run( + db_handle: Handle, + sql_value: f64, + params: *const ArrayHeader, +) -> *mut ObjectHeader { + let statement = js_bun_sqlite_database_query(db_handle, sql_value); + let result = js_node_sqlite_statement_sync_run(statement, params); + finalize_node_sqlite_statement_handle(statement); + result +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_filename(db_handle: Handle) -> *mut StringHeader { + let db = get_handle::(db_handle) + .unwrap_or_else(|| throw_invalid_state("database is not open")); + js_string_from_bytes(db.path.as_ptr(), db.path.len() as u32) +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_statement_values( + stmt_handle: Handle, + params: *const ArrayHeader, +) -> *mut ArrayHeader { + with_node_sqlite_statement(stmt_handle, params, |conn, stmt, raw_stmt| { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let rows = js_array_alloc(0); + let rows_handle = scope.root_raw_mut_ptr(rows); + let row_handle = scope.root_nanbox_u64(JSValue::undefined().bits()); + loop { + match ffi::sqlite3_step(raw_stmt) { + ffi::SQLITE_ROW => { + let row = node_sqlite_row_value_with_mode(stmt, raw_stmt, true); + row_handle.set_nanbox_u64(row.bits()); + let rows = js_array_push( + rows_handle.get_raw_mut_ptr(), + JSValue::from_bits(row_handle.get_nanbox_u64()), + ); + rows_handle.set_raw_mut_ptr(rows); + } + ffi::SQLITE_DONE => break, + _ => throw_sqlite_error_from_conn(conn), + } + } + rows_handle.get_raw_mut_ptr() + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_statement_safe_integers( + stmt_handle: Handle, + enabled_value: f64, +) -> f64 { + let stmt = get_handle::(stmt_handle) + .unwrap_or_else(|| throw_invalid_state("statement has been finalized")); + if stmt.finalized.load(Ordering::Relaxed) { + throw_invalid_state("statement has been finalized"); + } + let enabled = value_from_f64(enabled_value); + if enabled.is_undefined() { + return bool_f64(stmt.read_bigints.load(Ordering::Relaxed)); + } + stmt.read_bigints + .store(enabled.to_bool(), Ordering::Relaxed); + js_nanbox_pointer(stmt_handle) +} + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_statement_finalize(stmt_handle: Handle) { + finalize_node_sqlite_statement_handle(stmt_handle); +} + +unsafe extern "C" fn bun_sqlite_transaction_wrapper( + wrapper: *const ClosureHeader, + rest_value: f64, +) -> f64 { + let db_handle = js_closure_get_capture_f64(wrapper, 0) as Handle; + let callback = js_closure_get_capture_ptr(wrapper, 1) as *const ClosureHeader; + let rest = js_nanbox_get_pointer(rest_value) as *const ArrayHeader; + let arg_count = js_array_length(rest); + let args: Vec = (0..arg_count) + .map(|index| f64_from_jsvalue(js_array_get(rest, index))) + .collect(); + let nested = with_open_node_connection(db_handle, |conn| !conn.is_autocommit()); + let begin = if nested { + "SAVEPOINT `bun:sqlite transaction`" + } else { + "BEGIN" + }; + with_open_node_connection(db_handle, |conn| { + if let Err((message, code)) = node_sqlite_exec_batch(conn, begin) { + throw_sqlite_error_ext(&message, code); + } + }); + + match perry_runtime::exception::js_call_catching(|| { + js_closure_call_array( + callback as i64, + if args.is_empty() { + std::ptr::null() + } else { + args.as_ptr() + }, + args.len() as i64, + ) + }) { + Ok(value) => { + let finish = if nested { + "RELEASE `bun:sqlite transaction`" + } else { + "COMMIT" + }; + with_open_node_connection(db_handle, |conn| { + if let Err((message, code)) = node_sqlite_exec_batch(conn, finish) { + throw_sqlite_error_ext(&message, code); + } + }); + value + } + Err(error) => { + let rollback = if nested { + "ROLLBACK TO `bun:sqlite transaction`; RELEASE `bun:sqlite transaction`" + } else { + "ROLLBACK" + }; + with_open_node_connection(db_handle, |conn| { + let _ = node_sqlite_exec_batch(conn, rollback); + }); + perry_runtime::exception::js_throw(error) + } + } +} + +static BUN_SQLITE_TRANSACTION_WRAPPER_REGISTERED: Once = Once::new(); + +#[no_mangle] +pub unsafe extern "C" fn js_bun_sqlite_database_transaction( + db_handle: Handle, + callback_value: f64, +) -> *mut ClosureHeader { + let callback = closure_ptr_from_value(callback_value) + .unwrap_or_else(|| throw_plain_type("Expected a function")); + BUN_SQLITE_TRANSACTION_WRAPPER_REGISTERED.call_once(|| { + // The wrapper has no fixed arguments and receives every invocation + // argument in the synthetic rest array. This preserves Bun's + // `transaction(fn)(...args)` forwarding for arbitrary callback arity. + js_register_closure_rest(bun_sqlite_transaction_wrapper as *const u8, 0); + }); + let wrapper = js_closure_alloc(bun_sqlite_transaction_wrapper as *const u8, 2); + js_closure_set_capture_f64(wrapper, 0, db_handle as f64); + js_closure_set_capture_ptr(wrapper, 1, callback as i64); + wrapper +} diff --git a/crates/perry-stdlib/src/sqlite/connection.rs b/crates/perry-stdlib/src/sqlite/connection.rs index f774a3c898..7ad83b8eca 100644 --- a/crates/perry-stdlib/src/sqlite/connection.rs +++ b/crates/perry-stdlib/src/sqlite/connection.rs @@ -6,12 +6,16 @@ use std::sync::atomic::Ordering; use std::time::Duration; pub(crate) fn open_node_sqlite_connection(db: &NodeSqliteDbHandle) -> rusqlite::Result { - let flags = if db.read_only { - OpenFlags::SQLITE_OPEN_READ_ONLY - } else { - OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE - } | OpenFlags::SQLITE_OPEN_URI - | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let mut flags = OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX; + if db.read_only { + flags |= OpenFlags::SQLITE_OPEN_READ_ONLY; + } + if db.read_write { + flags |= OpenFlags::SQLITE_OPEN_READ_WRITE; + } + if db.create { + flags |= OpenFlags::SQLITE_OPEN_CREATE; + } let conn = if db.path == ":memory:" { Connection::open_in_memory_with_flags(flags)? diff --git a/crates/perry-stdlib/src/sqlite/dispatch.rs b/crates/perry-stdlib/src/sqlite/dispatch.rs index d7949e9ff1..4235c182d8 100644 --- a/crates/perry-stdlib/src/sqlite/dispatch.rs +++ b/crates/perry-stdlib/src/sqlite/dispatch.rs @@ -24,6 +24,9 @@ fn database_method_name_static(property: &str) -> Option<&'static [u8]> { "close" => Some(b"close"), "exec" => Some(b"exec"), "prepare" => Some(b"prepare"), + "query" => Some(b"query"), + "run" => Some(b"run"), + "transaction" => Some(b"transaction"), "serialize" => Some(b"serialize"), "deserialize" => Some(b"deserialize"), "function" => Some(b"function"), @@ -47,6 +50,9 @@ fn tag_store_method_name_static(property: &str) -> Option<&'static [u8]> { "run" => Some(b"run"), "get" => Some(b"get"), "all" => Some(b"all"), + "values" => Some(b"values"), + "safeIntegers" => Some(b"safeIntegers"), + "finalize" => Some(b"finalize"), "iterate" => Some(b"iterate"), "clear" => Some(b"clear"), _ => None, @@ -111,6 +117,18 @@ pub unsafe fn dispatch_node_sqlite_database_method( let stmt = js_node_sqlite_database_sync_prepare(handle, arg0, arg1); Some(js_nanbox_pointer(stmt)) } + "query" => Some(js_nanbox_pointer(js_bun_sqlite_database_query( + handle, arg0, + ))), + "run" => { + let params = packed_args_array(args.get(1..).unwrap_or_default()); + Some(js_nanbox_pointer( + js_bun_sqlite_database_run(handle, arg0, params) as i64, + )) + } + "transaction" => Some(js_nanbox_pointer( + js_bun_sqlite_database_transaction(handle, arg0) as i64, + )), "serialize" => Some(js_nanbox_pointer( js_node_sqlite_database_sync_serialize(handle, arg0) as i64, )), @@ -166,6 +184,10 @@ pub unsafe fn dispatch_node_sqlite_database_property( return None; } match property_name { + "filename" => Some(f64_from_jsvalue(JSValue::string_ptr( + js_bun_sqlite_database_filename(handle), + ))), + "inTransaction" => Some(js_node_sqlite_database_sync_is_transaction(handle)), "isOpen" => Some(js_node_sqlite_database_sync_is_open(handle)), "isTransaction" => Some(js_node_sqlite_database_sync_is_transaction(handle)), "limits" => Some(js_nanbox_pointer(js_node_sqlite_database_sync_limits( @@ -308,6 +330,17 @@ pub unsafe fn dispatch_node_sqlite_statement_method( "all" => Some(js_nanbox_pointer( js_node_sqlite_statement_sync_all(handle, args_arr) as i64, )), + "values" => Some(js_nanbox_pointer( + js_bun_sqlite_statement_values(handle, args_arr) as i64, + )), + "safeIntegers" => Some(js_bun_sqlite_statement_safe_integers( + handle, + args.first().copied().unwrap_or_else(undefined_f64), + )), + "finalize" => { + js_bun_sqlite_statement_finalize(handle); + Some(undefined_f64()) + } "iterate" => Some(js_node_sqlite_statement_sync_iterate(handle, args_arr)), "columns" => Some(js_nanbox_pointer( js_node_sqlite_statement_sync_columns(handle) as i64, diff --git a/crates/perry-stdlib/src/sqlite/node_db.rs b/crates/perry-stdlib/src/sqlite/node_db.rs index b67b9eb910..e5033d727d 100644 --- a/crates/perry-stdlib/src/sqlite/node_db.rs +++ b/crates/perry-stdlib/src/sqlite/node_db.rs @@ -166,11 +166,21 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_new( ) -> Handle { let path = node_sqlite_database_path(path_value); let options = parse_node_sqlite_options(options_value); + register_node_sqlite_database(path, options, "node:sqlite") +} + +pub(crate) unsafe fn register_node_sqlite_database( + path: String, + options: NodeSqliteOptions, + type_name: &str, +) -> Handle { let open = options.open; let handle = register_handle(NodeSqliteDbHandle { conn: Mutex::new(None), path, read_only: options.read_only, + read_write: options.read_write, + create: options.create, enable_foreign_keys: options.enable_foreign_keys, enable_dqs: options.enable_dqs, timeout_ms: options.timeout_ms, @@ -192,7 +202,7 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_new( perry_runtime::symbol::js_object_set_symbol_property( js_nanbox_pointer(handle), type_symbol, - f64_from_jsvalue(string_value("node:sqlite")), + f64_from_jsvalue(string_value(type_name)), ); if open { js_node_sqlite_database_sync_open(handle); diff --git a/crates/perry-stdlib/src/sqlite/options.rs b/crates/perry-stdlib/src/sqlite/options.rs index 2c942ebad0..f5d263a5dd 100644 --- a/crates/perry-stdlib/src/sqlite/options.rs +++ b/crates/perry-stdlib/src/sqlite/options.rs @@ -277,6 +277,8 @@ pub(crate) unsafe fn parse_node_sqlite_options(options_value: f64) -> NodeSqlite options.open = bool_option(options_value, "open", options.open); options.read_only = bool_option(options_value, "readOnly", options.read_only); + options.read_write = !options.read_only; + options.create = !options.read_only; options.enable_foreign_keys = bool_option( options_value, "enableForeignKeyConstraints", diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index 3fa6f34c49..f79759d6bb 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -80,7 +80,7 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // node:sqlite (#3183/#3184) shares the rusqlite-backed // `database-sqlite` feature with better-sqlite3 — DatabaseSync / // StatementSync route to the same `js_sqlite_*` runtime. - "sqlite" => &["database-sqlite"], + "sqlite" | "bun:sqlite" => &["database-sqlite"], // tursodb (#424) lives in the external // `PerryTS/tursodb-bindings` repo (`bun add @perryts/tursodb`) // since v0.5.557 — perry's package.json `perry.nativeLibrary` diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index ca3308e3f2..61bb8d7aa9 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2028 entries across 123 modules +// Coverage: 2033 entries across 124 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -336,6 +336,19 @@ declare module "bun:ffi" { export function viewSource(...args: any[]): any; } +declare module "bun:sqlite" { + /** stdlib */ + export class Database { [key: string]: any; } + /** stdlib */ + export class Statement { [key: string]: any; } + /** stdlib */ + export const filename: any; + /** stdlib */ + export const inTransaction: any; + /** stdlib */ + export function Database(...args: any[]): any; +} + declare module "cheerio" { /** stdlib */ export function load(p0: string): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 55fc3f5126..26c026c5c9 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2951 entries across 125 modules. +Total: 2969 entries across 126 modules. ## Modules @@ -20,6 +20,7 @@ Total: 2951 entries across 125 modules. - [`buffer`](#buffer) - [`bun`](#bun) - [`bun:ffi`](#bunffi) +- [`bun:sqlite`](#bunsqlite) - [`cheerio`](#cheerio) - [`child_process`](#child_process) - [`cluster`](#cluster) @@ -382,6 +383,35 @@ Total: 2951 entries across 125 modules. - `FFIType` - `suffix` +## `bun:sqlite` + +### Classes + +- `Database` +- `Statement` + +### Methods + +- `Database` — module +- `all` — instance *(class: `Statement`)* +- `close` — instance *(class: `Database`)* +- `finalize` — instance *(class: `Statement`)* +- `get` — instance *(class: `Statement`)* +- `loadExtension` — instance *(class: `Database`)* +- `prepare` — instance *(class: `Database`)* +- `query` — instance *(class: `Database`)* +- `run` — instance *(class: `Database`)* +- `run` — instance *(class: `Statement`)* +- `safeIntegers` — instance *(class: `Statement`)* +- `serialize` — instance *(class: `Database`)* +- `transaction` — instance *(class: `Database`)* +- `values` — instance *(class: `Statement`)* + +### Properties + +- `filename` +- `inTransaction` + ## `cheerio` ### Methods diff --git a/docs/src/stdlib/overview.md b/docs/src/stdlib/overview.md index ea23d26fa4..959135dc42 100644 --- a/docs/src/stdlib/overview.md +++ b/docs/src/stdlib/overview.md @@ -29,7 +29,7 @@ works unchanged. ### Databases - **mysql2** — MySQL client - **pg** — PostgreSQL client -- **better-sqlite3** — SQLite +- **bun:sqlite** / **node:sqlite** / **better-sqlite3** — SQLite, backed by Perry's native engine - **mongodb** — MongoDB client - **ioredis** / **redis** — Redis client diff --git a/test-files/test_gap_bun_sqlite_8510.ts b/test-files/test_gap_bun_sqlite_8510.ts new file mode 100644 index 0000000000..9c34a64c3a --- /dev/null +++ b/test-files/test_gap_bun_sqlite_8510.ts @@ -0,0 +1,44 @@ +// bun:sqlite compatibility over Perry's native SQLite engine (#8510). +import { Database } from "bun:sqlite"; + +const db = new Database(":memory:", { readwrite: true, create: true }); +console.log("filename:", db.filename); + +db.run("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, payload BLOB)"); +const insert = db.query("INSERT INTO items (name, payload) VALUES (?, ?)"); +const first = insert.run("alpha", new Uint8Array([1, 2, 3])); +console.log("insert:", first.changes, first.lastInsertRowid); + +const named = db.prepare("INSERT INTO items (name, payload) VALUES ($name, $payload)"); +named.run({ $name: "beta", $payload: null }); + +const select = db.query("SELECT id, name, payload FROM items ORDER BY id"); +console.log("rows:", select.all().length, select.get()!.name); +const values = select.values(); +console.log("values:", values[0][0], values[0][1], values[0][2].length); +select.safeIntegers(true); +console.log("bigint:", typeof select.get()!.id, String(select.get()!.id)); + +const add = db.transaction((prefix: string, name: string) => { + const value = prefix + name; + db.run("INSERT INTO items (name) VALUES (?)", value); + return value; +}); +console.log("transaction:", add("g", "amma"), db.query("SELECT COUNT(*) AS n FROM items").get()!.n); + +try { + db.transaction(() => { + db.run("INSERT INTO items (name) VALUES ('rolled back')"); + throw new Error("rollback"); + })(); +} catch {} +console.log("rollback:", db.query("SELECT COUNT(*) AS n FROM items").get()!.n); + +console.log("serialized:", db.serialize().byteLength > 0); +select.finalize(); +db.close(); +try { + select.all(); +} catch (error) { + console.log("closed:", error instanceof Error); +} diff --git a/test-parity/expected/test_gap_bun_sqlite_8510.txt b/test-parity/expected/test_gap_bun_sqlite_8510.txt new file mode 100644 index 0000000000..de40f8790c --- /dev/null +++ b/test-parity/expected/test_gap_bun_sqlite_8510.txt @@ -0,0 +1,9 @@ +filename: :memory: +insert: 1 1 +rows: 2 alpha +values: 1 alpha 3 +bigint: bigint 1 +transaction: gamma 3 +rollback: 3 +serialized: true +closed: true