diff --git a/Cargo.lock b/Cargo.lock index cf0e5efc05..5b41c6387e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.87", +] + [[package]] name = "bindgen" version = "0.70.1" @@ -4819,6 +4839,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.168" @@ -4886,6 +4912,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "bindgen 0.69.5", "cc", "pkg-config", "vcpkg", @@ -4910,7 +4937,7 @@ version = "1.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca8dfd1a173826d193e3b955e07c22765829890f62c677a59c4a410cb4f47c01" dependencies = [ - "bindgen", + "bindgen 0.70.1", "libloading 0.8.5", ] @@ -8689,7 +8716,7 @@ version = "130.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a511192602f7b435b0a241c1947aa743eb7717f20a9195f4b5e8ed1952e01db1" dependencies = [ - "bindgen", + "bindgen 0.70.1", "bitflags 2.6.0", "fslock", "gzip-header", diff --git a/Cargo.toml b/Cargo.toml index ca32b029df..1ba196d661 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,7 +184,7 @@ rand = "=0.8.5" regex = "^1.7.0" reqwest = { version = "=0.12.5", default-features = false, features = ["rustls-tls", "stream", "gzip", "brotli", "socks", "json", "http2"] } # pinned because of https://github.com/seanmonstar/reqwest/pull/1955 ring = "^0.17.0" -rusqlite = { version = "0.32.0", features = ["unlock_notify", "bundled"] } +rusqlite = { version = "0.32.0", features = ["unlock_notify", "bundled", "session"] } rustls = { version = "0.23.11", default-features = false, features = ["logging", "std", "tls12", "ring"] } rustls-pemfile = "2" rustls-tokio-stream = "=0.3.0" diff --git a/ext/node/lib.rs b/ext/node/lib.rs index 68d16bfd6a..ac68e875b4 100644 --- a/ext/node/lib.rs +++ b/ext/node/lib.rs @@ -443,6 +443,7 @@ deno_core::extension!(deno_node, objects = [ ops::perf_hooks::EldHistogram, ops::sqlite::DatabaseSync, + ops::sqlite::Session, ops::sqlite::StatementSync ], esm_entry_point = "ext:deno_node/02_init.js", diff --git a/ext/node/ops/sqlite/database.rs b/ext/node/ops/sqlite/database.rs index 03ccc7fb53..73063b6276 100644 --- a/ext/node/ops/sqlite/database.rs +++ b/ext/node/ops/sqlite/database.rs @@ -2,6 +2,8 @@ use std::cell::Cell; use std::cell::RefCell; +use std::ffi::CString; +use std::ptr::null; use std::rc::Rc; use deno_core::op2; @@ -10,6 +12,8 @@ use deno_core::OpState; use deno_permissions::PermissionsContainer; use serde::Deserialize; +use super::session::SessionOptions; +use super::Session; use super::SqliteError; use super::StatementSync; @@ -192,4 +196,62 @@ impl DatabaseSync { use_big_ints: Cell::new(false), }) } + + // Creates and attaches a session to the database. + // + // This method is a wrapper around `sqlite3session_create()` and + // `sqlite3session_attach()`. + #[cppgc] + fn create_session( + &self, + #[serde] options: Option, + ) -> Result { + let db = self.conn.borrow(); + let db = db.as_ref().ok_or(SqliteError::AlreadyClosed)?; + + // SAFETY: lifetime of the connection is guaranteed by reference + // counting. + let raw_handle = unsafe { db.handle() }; + + let mut raw_session = std::ptr::null_mut(); + let mut options = options; + + let z_db = options + .as_mut() + .and_then(|options| options.db.take()) + .map(|db| CString::new(db).unwrap()) + .unwrap_or_else(|| CString::new("main").unwrap()); + // SAFETY: `z_db` points to a valid c-string. + let r = unsafe { + libsqlite3_sys::sqlite3session_create( + raw_handle, + z_db.as_ptr() as *const _, + &mut raw_session, + ) + }; + + if r != libsqlite3_sys::SQLITE_OK { + return Err(SqliteError::SessionCreateFailed); + } + + let table = options + .as_mut() + .and_then(|options| options.table.take()) + .map(|table| CString::new(table).unwrap()); + let z_table = table.as_ref().map(|table| table.as_ptr()).unwrap_or(null()); + let r = + // SAFETY: `z_table` points to a valid c-string and `raw_session` + // is a valid session handle. + unsafe { libsqlite3_sys::sqlite3session_attach(raw_session, z_table) }; + + if r != libsqlite3_sys::SQLITE_OK { + return Err(SqliteError::SessionCreateFailed); + } + + Ok(Session { + inner: raw_session, + freed: Cell::new(false), + _db: self.conn.clone(), + }) + } } diff --git a/ext/node/ops/sqlite/mod.rs b/ext/node/ops/sqlite/mod.rs index c53f244cc7..d3c273a663 100644 --- a/ext/node/ops/sqlite/mod.rs +++ b/ext/node/ops/sqlite/mod.rs @@ -1,17 +1,18 @@ // Copyright 2018-2025 the Deno authors. MIT license. mod database; +mod session; mod statement; pub use database::DatabaseSync; -use deno_permissions::PermissionCheckError; +pub use session::Session; pub use statement::StatementSync; #[derive(Debug, thiserror::Error, deno_error::JsError)] pub enum SqliteError { #[class(inherit)] #[error(transparent)] - Permission(#[from] PermissionCheckError), + Permission(#[from] deno_permissions::PermissionCheckError), #[class(generic)] #[error(transparent)] SqliteError(#[from] rusqlite::Error), @@ -40,6 +41,15 @@ pub enum SqliteError { #[error("Failed to prepare statement")] PrepareFailed, #[class(generic)] + #[error("Failed to create session")] + SessionCreateFailed, + #[class(generic)] + #[error("Failed to retrieve changeset")] + SessionChangesetFailed, + #[class(generic)] + #[error("Session is already closed")] + SessionClosed, + #[class(generic)] #[error("Invalid constructor")] InvalidConstructor, #[class(generic)] diff --git a/ext/node/ops/sqlite/session.rs b/ext/node/ops/sqlite/session.rs new file mode 100644 index 0000000000..520904d536 --- /dev/null +++ b/ext/node/ops/sqlite/session.rs @@ -0,0 +1,124 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +use std::cell::Cell; +use std::cell::RefCell; +use std::ffi::c_void; +use std::rc::Rc; + +use deno_core::op2; +use deno_core::GarbageCollected; +use libsqlite3_sys as ffi; +use serde::Deserialize; + +use super::SqliteError; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionOptions { + pub table: Option, + pub db: Option, +} + +pub struct Session { + pub(crate) inner: *mut ffi::sqlite3_session, + pub(crate) freed: Cell, + + // Hold a strong reference to the database. + pub(crate) _db: Rc>>, +} + +impl GarbageCollected for Session {} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.delete(); + } +} + +impl Session { + fn delete(&self) -> Result<(), SqliteError> { + if self.freed.get() { + return Err(SqliteError::SessionClosed); + } + + self.freed.set(true); + // Safety: `self.inner` is a valid session. double free is + // prevented by `freed` flag. + unsafe { + ffi::sqlite3session_delete(self.inner); + } + + Ok(()) + } +} + +#[op2] +impl Session { + // Closes the session. + #[fast] + fn close(&self) -> Result<(), SqliteError> { + self.delete() + } + + // Retrieves a changeset containing all changes since the changeset + // was created. Can be called multiple times. + // + // This method is a wrapper around `sqlite3session_changeset()`. + #[buffer] + fn changeset(&self) -> Result, SqliteError> { + if self.freed.get() { + return Err(SqliteError::SessionClosed); + } + + session_buffer_op(self.inner, ffi::sqlite3session_changeset) + } + + // Similar to the method above, but generates a more compact patchset. + // + // This method is a wrapper around `sqlite3session_patchset()`. + #[buffer] + fn patchset(&self) -> Result, SqliteError> { + if self.freed.get() { + return Err(SqliteError::SessionClosed); + } + + session_buffer_op(self.inner, ffi::sqlite3session_patchset) + } +} + +fn session_buffer_op( + s: *mut ffi::sqlite3_session, + f: unsafe extern "C" fn( + *mut ffi::sqlite3_session, + *mut i32, + *mut *mut c_void, + ) -> i32, +) -> Result, SqliteError> { + let mut n_buffer = 0; + let mut p_buffer = std::ptr::null_mut(); + + // Safety: `s` is a valid session and the buffer is allocated + // by sqlite3 and will be freed later. + let r = unsafe { f(s, &mut n_buffer, &mut p_buffer) }; + if r != ffi::SQLITE_OK { + return Err(SqliteError::SessionChangesetFailed); + } + + if n_buffer == 0 { + return Ok(Default::default()); + } + + // Safety: n_buffer is the size of the buffer. + let buffer = unsafe { + std::slice::from_raw_parts(p_buffer as *const u8, n_buffer as usize) + } + .to_vec() + .into_boxed_slice(); + + // Safety: free sqlite allocated buffer, we copied it into the JS buffer. + unsafe { + ffi::sqlite3_free(p_buffer); + } + + Ok(buffer) +} diff --git a/tests/unit_node/sqlite_test.ts b/tests/unit_node/sqlite_test.ts index 9377d6d34e..ec54780ae9 100644 --- a/tests/unit_node/sqlite_test.ts +++ b/tests/unit_node/sqlite_test.ts @@ -77,6 +77,27 @@ Deno.test("[node/sqlite] StatementSync read bigints are supported", () => { assertEquals(stmt.expandedSQL, "SELECT * FROM data"); }); +Deno.test("[node/sqlite] createSession and changesets", () => { + const db = new DatabaseSync(":memory:"); + const session = db.createSession(); + + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + db.exec("INSERT INTO test (name) VALUES ('foo')"); + + assert(session.changeset() instanceof Uint8Array); + assert(session.patchset() instanceof Uint8Array); + + assert(session.changeset().byteLength > 0); + assert(session.patchset().byteLength > 0); + + session.close(); + + // Use after close shoud throw. + assertThrows(() => session.changeset(), Error, "Session is already closed"); + // Close after close should throw. + assertThrows(() => session.close(), Error, "Session is already closed"); +}); + Deno.test("[node/sqlite] StatementSync integer too large", () => { const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE data(key INTEGER PRIMARY KEY);");