From c910a9dddc49ca845a4188dbb1b70c6b7cb306ad Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Mon, 27 Jul 2026 08:52:02 +0200 Subject: [PATCH 1/7] fix(sqlite): complete Node 26 parity --- .../src/lower_call/native_table/databases.rs | 18 ++ crates/perry-runtime/src/array/iter_object.rs | 78 ++++++++- .../perry-stdlib/src/common/dispatch/init.rs | 4 + .../src/common/dispatch/method_dispatch.rs | 2 + crates/perry-stdlib/src/sqlite.rs | 3 +- crates/perry-stdlib/src/sqlite/bind.rs | 80 +++++++-- crates/perry-stdlib/src/sqlite/connection.rs | 2 + crates/perry-stdlib/src/sqlite/dispatch.rs | 33 ++++ crates/perry-stdlib/src/sqlite/node_db.rs | 160 +++++++++++++++++- .../src/sqlite/node_stmt_session.rs | 8 +- crates/perry-stdlib/src/sqlite/options.rs | 15 +- 11 files changed, 365 insertions(+), 38 deletions(-) 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 63cf314eed..eff2c09489 100644 --- a/crates/perry-codegen/src/lower_call/native_table/databases.rs +++ b/crates/perry-codegen/src/lower_call/native_table/databases.rs @@ -808,6 +808,24 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ args: &[NA_F64, NA_F64], ret: NR_PTR, }, + NativeModSig { + module: "sqlite", + has_receiver: true, + method: "serialize", + class_filter: None, + runtime: "js_node_sqlite_database_sync_serialize", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "sqlite", + has_receiver: true, + method: "deserialize", + class_filter: None, + runtime: "js_node_sqlite_database_sync_deserialize", + args: &[NA_F64], + ret: NR_VOID, + }, NativeModSig { module: "sqlite", has_receiver: true, diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 6019a4730c..9f6ef39baf 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -79,12 +79,33 @@ pub fn array_values_iter(arr_f64: f64) -> f64 { /// Values iterator whose done-result carries `value: null` and whose /// `return()` terminates it — the `node:sqlite` `iterate()` protocol /// (#6561). See [`KIND_VALUES_NULL_DONE`]. -pub fn array_values_iter_null_done(arr_f64: f64) -> f64 { +pub fn array_values_iter_null_done( + arr_f64: f64, + iteration_epoch: &std::sync::atomic::AtomicU64, + epoch: u64, +) -> f64 { let arr_ptr = unbox_array_ptr(arr_f64); if arr_ptr.is_null() { return f64::from_bits(TAG_UNDEFINED); } - unsafe { alloc_iterator(arr_ptr, KIND_VALUES_NULL_DONE) } + unsafe { + let obj = js_object_alloc(ARRAY_ITERATOR_CLASS_ID, 5); + js_object_set_field( + obj, + 0, + JSValue::from_bits(js_nanbox_pointer(arr_ptr as i64).to_bits()), + ); + js_object_set_field(obj, 1, JSValue::number(0.0)); + js_object_set_field(obj, 2, JSValue::number(KIND_VALUES_NULL_DONE as f64)); + js_object_set_field( + obj, + 3, + JSValue::pointer(iteration_epoch as *const _ as *const u8), + ); + js_object_set_field(obj, 4, JSValue::number(epoch as f64)); + crate::object::attach_iterator_prototype(obj, ARRAY_ITERATOR_CLASS_ID); + js_nanbox_pointer(obj as i64) + } } /// `arr.keys()` iterator — yields each index `0..length`. @@ -517,6 +538,19 @@ unsafe fn make_iter_result(value: JSValue, done: bool) -> f64 { js_nanbox_pointer(obj as i64) } +unsafe fn make_sqlite_iter_result(value: JSValue, done: bool) -> f64 { + let obj = js_object_alloc(0, 2); + let done_key = crate::string::js_string_from_bytes(b"done".as_ptr(), 4); + let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); + let keys = crate::array::js_array_alloc(2); + crate::array::js_array_push(keys, JSValue::string_ptr(done_key)); + crate::array::js_array_push(keys, JSValue::string_ptr(value_key)); + crate::object::js_object_set_keys(obj, keys); + js_object_set_field(obj, 0, JSValue::bool(done)); + js_object_set_field(obj, 1, value); + js_nanbox_pointer(obj as i64) +} + unsafe fn make_pair_array(idx: u32, value: f64) -> f64 { let pair = crate::array::js_array_alloc(2); (*pair).length = 2; @@ -547,15 +581,29 @@ pub unsafe fn dispatch_array_iterator_method( }; match method_name { "next" => { + if kind == KIND_VALUES_NULL_DONE { + let epoch_ptr = + js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj, 3).bits())) + as *const std::sync::atomic::AtomicU64; + let expected = f64::from_bits(js_object_get_field(iter_obj, 4).bits()) as u64; + if epoch_ptr.is_null() + || (*epoch_ptr).load(std::sync::atomic::Ordering::Relaxed) != expected + { + crate::fs::validate::throw_error_with_code( + "Statement iterator has been invalidated", + "ERR_INVALID_STATE", + ); + } + } // Field 0: backing array pointer (NaN-boxed). let backing_field = js_object_get_field(iter_obj, 0); let backing_f64 = f64::from_bits(backing_field.bits()); - // Once the iterator is exhausted the backing array is cleared to - // `undefined` (spec: `[[IteratedArrayLike]]` set to undefined), so a - // later `.next()` stays done even if the array grew after exhaustion - // (test262 Array/prototype/{values,keys,entries}/iteration-mutable: - // pushing AFTER the iterator reported done must not resurface). + // Array iterators clear their backing array at exhaustion. SQLite's + // statement iterator restarts a completed execution on the next call. if JSValue::from_bits(backing_f64.to_bits()).is_undefined() { + if kind == KIND_VALUES_NULL_DONE { + return make_sqlite_iter_result(done_value(), true); + } return make_iter_result(done_value(), true); } let arr_ptr = js_nanbox_get_pointer(backing_f64) as *const ArrayHeader; @@ -570,6 +618,10 @@ pub unsafe fn dispatch_array_iterator_method( }; if idx >= len { + if kind == KIND_VALUES_NULL_DONE { + js_object_set_field(iter_obj, 1, JSValue::number(0.0)); + return make_sqlite_iter_result(done_value(), true); + } js_object_set_field(iter_obj, 0, JSValue::undefined()); return make_iter_result(done_value(), true); } @@ -593,7 +645,11 @@ pub unsafe fn dispatch_array_iterator_method( } _ => JSValue::undefined(), }; - make_iter_result(value, false) + if kind == KIND_VALUES_NULL_DONE { + make_sqlite_iter_result(value, false) + } else { + make_iter_result(value, false) + } } // Iterators are themselves iterable — `[Symbol.iterator]()` on one // returns the same iterator (matches Node, and lets `js_get_iterator` @@ -609,7 +665,11 @@ pub unsafe fn dispatch_array_iterator_method( if kind == KIND_VALUES_NULL_DONE { js_object_set_field(iter_obj, 0, JSValue::undefined()); } - make_iter_result(done_value(), true) + if kind == KIND_VALUES_NULL_DONE { + make_sqlite_iter_result(done_value(), true) + } else { + make_iter_result(done_value(), true) + } } _ => f64::from_bits(TAG_UNDEFINED), } diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 5686a5d4d3..54dde91066 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -164,6 +164,10 @@ pub unsafe extern "C" fn js_handle_property_set_dispatch( #[no_mangle] pub unsafe extern "C" fn js_handle_own_property_names_dispatch(handle: i64) -> f64 { + #[cfg(feature = "database-sqlite")] + if let Some(names) = crate::sqlite::dispatch_node_sqlite_own_property_names(handle) { + return names; + } if crate::string_decoder::is_string_decoder_handle(handle) { return crate::string_decoder::string_decoder_own_property_names(handle); } diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index d78df68326..7c3d5f57c5 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -108,6 +108,8 @@ pub unsafe extern "C" fn js_handle_method_dispatch( | "close" | "exec" | "prepare" + | "serialize" + | "deserialize" // `function`/`aggregate`/`enableDefensive`/`setAuthorizer` were // missing from this gate (#6561): an any-typed // `db.function(...)` / `db.aggregate(...)` fell through the diff --git a/crates/perry-stdlib/src/sqlite.rs b/crates/perry-stdlib/src/sqlite.rs index d35ee308ca..2e65bdc48e 100644 --- a/crates/perry-stdlib/src/sqlite.rs +++ b/crates/perry-stdlib/src/sqlite.rs @@ -6,7 +6,7 @@ use crate::common::{for_each_handle_mut_of, Handle}; use rusqlite::Connection; use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, Once, OnceLock}; mod backup; @@ -142,6 +142,7 @@ pub struct NodeSqliteStmtHandle { pub db_handle: Handle, pub sql: String, pub finalized: AtomicBool, + pub iteration_epoch: AtomicU64, pub read_bigints: AtomicBool, pub return_arrays: AtomicBool, pub allow_bare_named_parameters: AtomicBool, diff --git a/crates/perry-stdlib/src/sqlite/bind.rs b/crates/perry-stdlib/src/sqlite/bind.rs index 239178cbf4..3f8c28ef89 100644 --- a/crates/perry-stdlib/src/sqlite/bind.rs +++ b/crates/perry-stdlib/src/sqlite/bind.rs @@ -91,7 +91,7 @@ pub(crate) unsafe fn sqlite_error_message(conn: &Connection) -> String { pub(crate) unsafe fn prepare_node_raw_statement(conn: &Connection, sql: &str) -> RawNodeStatement { let c_sql = CString::new(sql) - .unwrap_or_else(|_| throw_type("The \"sql\" argument must not contain null bytes")); + .unwrap_or_else(|_| throw_sqlite_error("SQL string must not contain null bytes")); let mut raw = std::ptr::null_mut(); let rc = ffi::sqlite3_prepare_v2( conn.handle(), @@ -183,7 +183,31 @@ pub(crate) unsafe fn bind_node_sqlite_value( ffi::sqlite3_bind_double(raw_stmt, index, js.as_number()) } else { let raw = raw_addr_from_value(value); - if raw != 0 && is_registered_buffer(raw) { + if perry_runtime::typedarray::lookup_typed_array_kind(raw).is_some() { + let typed_array = raw as *const perry_runtime::typedarray::TypedArrayHeader; + let Some(bytes) = perry_runtime::typedarray::typed_array_bytes(typed_array) else { + throw_type(&format!( + "Provided value cannot be bound to SQLite parameter {}.", + index + )); + }; + let data_ptr = if bytes.is_empty() { + std::ptr::null() + } else { + bytes.as_ptr() as *const c_void + }; + if bytes.is_empty() { + ffi::sqlite3_bind_zeroblob(raw_stmt, index, 0) + } else { + ffi::sqlite3_bind_blob( + raw_stmt, + index, + data_ptr, + bytes.len() as c_int, + ffi::SQLITE_TRANSIENT(), + ) + } + } else if raw != 0 && is_registered_buffer(raw) { let buffer = raw as *const BufferHeader; let len = (*buffer).length as usize; let data_ptr = if len == 0 { @@ -191,13 +215,17 @@ pub(crate) unsafe fn bind_node_sqlite_value( } else { buffer_data(buffer) as *const c_void }; - ffi::sqlite3_bind_blob( - raw_stmt, - index, - data_ptr, - len as c_int, - ffi::SQLITE_TRANSIENT(), - ) + if len == 0 { + ffi::sqlite3_bind_zeroblob(raw_stmt, index, 0) + } else { + ffi::sqlite3_bind_blob( + raw_stmt, + index, + data_ptr, + len as c_int, + ffi::SQLITE_TRANSIENT(), + ) + } } else { throw_type(&format!( "Provided value cannot be bound to SQLite parameter {}.", @@ -226,7 +254,9 @@ pub(crate) fn is_named_parameter_object(value: f64) -> bool { return false; } let raw = raw_addr_from_value(value); - raw >= 0x1000 && !is_registered_buffer(raw) + raw >= 0x1000 + && !is_registered_buffer(raw) + && unsafe { perry_runtime::symbol::js_is_symbol(value) == 0 } } pub(crate) unsafe fn string_key_from_js_value(value: JSValue) -> Option { @@ -328,13 +358,23 @@ pub(crate) unsafe fn bind_node_sqlite_params( } let positional_count = args.len().saturating_sub(positional_start); - if positional_count > anonymous_indices.len() { + let positional_indices: Vec = if named_params.is_some() { + anonymous_indices + } else if named_indices + .keys() + .any(|name| has_sqlite_parameter_prefix(name)) + { + Vec::new() + } else { + (1..=param_count).collect() + }; + if positional_count > positional_indices.len() { // Node raises ERR_SQLITE_ERROR with errcode 25 (SQLITE_RANGE) when // more anonymous values are supplied than the statement has // anonymous parameters (#6561). throw_sqlite_error_ext("column index out of range", ffi::SQLITE_RANGE); } - for (offset, index) in anonymous_indices.into_iter().enumerate() { + for (offset, index) in positional_indices.into_iter().enumerate() { if let Some(value) = args.get(positional_start + offset).copied() { bind_node_sqlite_value(conn, raw_stmt, index, value); } @@ -730,7 +770,9 @@ pub(crate) unsafe fn node_sqlite_aggregate_emit(ctx: *mut ffi::sqlite3_context, let Some(state) = node_sqlite_aggregate_state(ctx, &*aggregate, true) else { return; }; - let value = if let Some(result) = (*aggregate).result { + let value = if finalize && (*aggregate).inverse.is_some() { + (*state).state + } else if let Some(result) = (*aggregate).result { node_sqlite_call_closure(result, &[(*state).state]) } else { (*state).state @@ -826,6 +868,7 @@ where if stmt.finalized.load(Ordering::Relaxed) { throw_invalid_state("statement has been finalized"); } + stmt.iteration_epoch.fetch_add(1, Ordering::Relaxed); let db = get_handle::(stmt.db_handle) .unwrap_or_else(|| throw_invalid_state("database is not open")); let conn_ptr = { @@ -904,3 +947,14 @@ pub(crate) fn build_packed_keys(column_names: &[String]) -> (Vec, u32) { shape_id = shape_id.wrapping_add(column_names.len() as u32); (packed, shape_id) } + +#[cfg(test)] +mod tests { + use super::is_named_parameter_object; + + #[test] + fn symbols_are_not_named_parameter_objects() { + let symbol = unsafe { perry_runtime::symbol::js_symbol_new_empty() }; + assert!(!is_named_parameter_object(symbol)); + } +} diff --git a/crates/perry-stdlib/src/sqlite/connection.rs b/crates/perry-stdlib/src/sqlite/connection.rs index f8c88aaf8c..9e29b25531 100644 --- a/crates/perry-stdlib/src/sqlite/connection.rs +++ b/crates/perry-stdlib/src/sqlite/connection.rs @@ -155,6 +155,7 @@ pub(crate) unsafe fn finalize_node_sqlite_statements(db: &NodeSqliteDbHandle) { for handle in handles { if let Some(stmt) = get_handle::(handle) { stmt.finalized.store(true, Ordering::Relaxed); + stmt.iteration_epoch.fetch_add(1, Ordering::Relaxed); } } } @@ -164,6 +165,7 @@ pub(crate) unsafe fn finalize_node_sqlite_statement_handle(stmt_handle: Handle) return; }; stmt.finalized.store(true, Ordering::Relaxed); + stmt.iteration_epoch.fetch_add(1, Ordering::Relaxed); if let Some(db) = get_handle::(stmt.db_handle) { if let Ok(mut statements) = db.statements.lock() { statements.remove(&stmt_handle); diff --git a/crates/perry-stdlib/src/sqlite/dispatch.rs b/crates/perry-stdlib/src/sqlite/dispatch.rs index e98092d73c..d4fa097d50 100644 --- a/crates/perry-stdlib/src/sqlite/dispatch.rs +++ b/crates/perry-stdlib/src/sqlite/dispatch.rs @@ -37,6 +37,13 @@ pub unsafe fn dispatch_node_sqlite_database_method( let stmt = js_node_sqlite_database_sync_prepare(handle, arg0, arg1); Some(js_nanbox_pointer(stmt)) } + "serialize" => Some(js_nanbox_pointer( + js_node_sqlite_database_sync_serialize(handle, arg0) as i64, + )), + "deserialize" => { + js_node_sqlite_database_sync_deserialize(handle, arg0); + Some(undefined_f64()) + } "function" => { js_node_sqlite_database_sync_function(handle, arg0, arg1, arg2); Some(undefined_f64()) @@ -94,6 +101,8 @@ pub unsafe fn dispatch_node_sqlite_database_property( | "close" | "exec" | "prepare" + | "serialize" + | "deserialize" | "function" | "aggregate" | "enableDefensive" @@ -387,6 +396,30 @@ pub unsafe fn dispatch_node_sqlite_limits_set( true } +pub unsafe fn dispatch_node_sqlite_own_property_names(handle: Handle) -> Option { + if js_node_sqlite_is_limits_handle(handle) == 0 { + return None; + } + let mut names = js_array_alloc(0); + for name in [ + "length", + "sqlLength", + "column", + "exprDepth", + "compoundSelect", + "vdbeOp", + "functionArg", + "attach", + "likePatternLength", + "variableNumber", + "triggerDepth", + ] { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + names = js_array_push_f64(names, f64_from_jsvalue(JSValue::string_ptr(key))); + } + Some(js_nanbox_pointer(names as i64)) +} + #[no_mangle] pub unsafe extern "C" fn js_node_sqlite_is_database_sync_handle(handle: Handle) -> i32 { if get_handle::(handle).is_some() { diff --git a/crates/perry-stdlib/src/sqlite/node_db.rs b/crates/perry-stdlib/src/sqlite/node_db.rs index 646a10f077..b67b9eb910 100644 --- a/crates/perry-stdlib/src/sqlite/node_db.rs +++ b/crates/perry-stdlib/src/sqlite/node_db.rs @@ -1,6 +1,10 @@ use super::*; use crate::common::{get_handle, register_handle, Handle}; use perry_runtime::{ + buffer::{ + buffer_alloc, buffer_data, buffer_data_mut, is_any_array_buffer, is_data_view, + is_registered_buffer, is_uint8array_buffer, mark_as_uint8array, BufferHeader, + }, js_get_string_pointer_unified, js_nanbox_pointer, js_promise_rejected, js_promise_resolved, JSValue, Promise, StringHeader, }; @@ -100,8 +104,8 @@ pub unsafe extern "C" fn js_node_sqlite_backup( } } -/// Validate the `DatabaseSync` `path` argument per Node: a string, -/// Uint8Array/Buffer, or `file:` URL, none of which may contain null +/// Validate the `DatabaseSync` `path` argument per Node: a string or `file:` URL, +/// neither of which may contain null /// bytes — with Node's exact `ERR_INVALID_ARG_TYPE` message (#6561). pub(crate) unsafe fn node_sqlite_database_path(value: f64) -> String { const PATH_TYPE_MSG: &str = @@ -110,7 +114,7 @@ pub(crate) unsafe fn node_sqlite_database_path(value: f64) -> String { let path = if js.is_any_string() { let ptr = js_get_string_pointer_unified(value) as *const StringHeader; string_from_header(ptr).unwrap_or_else(|| throw_type(PATH_TYPE_MSG)) - } else if let Some(bytes) = bytes_from_path_like(value) { + } else if let Some(bytes) = node_sqlite_path_bytes(value) { if bytes.contains(&0) { throw_type(PATH_TYPE_MSG); } @@ -133,6 +137,28 @@ pub(crate) unsafe fn node_sqlite_database_path(value: f64) -> String { path } +unsafe fn node_sqlite_path_bytes(value: f64) -> Option> { + let raw = raw_addr_from_value(value); + if raw < 0x1000 { + return None; + } + if is_registered_buffer(raw) && is_uint8array_buffer(raw) { + let buffer = raw as *const BufferHeader; + return Some( + std::slice::from_raw_parts(buffer_data(buffer), (*buffer).length as usize).to_vec(), + ); + } + if perry_runtime::typedarray::lookup_typed_array_kind(raw) + == Some(perry_runtime::typedarray::KIND_UINT8) + { + return perry_runtime::typedarray::typed_array_bytes( + raw as *const perry_runtime::typedarray::TypedArrayHeader, + ) + .map(ToOwned::to_owned); + } + None +} + #[no_mangle] pub unsafe extern "C" fn js_node_sqlite_database_sync_new( path_value: f64, @@ -161,6 +187,13 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_new( sessions: Mutex::new(HashSet::new()), statements: Mutex::new(HashSet::new()), }); + let type_symbol = + perry_runtime::symbol::js_symbol_for(f64_from_jsvalue(string_value("sqlite-type"))); + perry_runtime::symbol::js_object_set_symbol_property( + js_nanbox_pointer(handle), + type_symbol, + f64_from_jsvalue(string_value("node:sqlite")), + ); if open { js_node_sqlite_database_sync_open(handle); } @@ -193,6 +226,9 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_open(db_handle: Handle) -> { throw_sqlite_error(&err); } + if let Err(err) = configure_node_sqlite_dqs(&opened, db.enable_dqs) { + throw_sqlite_error(&err); + } if let Err(err) = configure_node_sqlite_load_extension( &opened, db.enable_load_extension.load(Ordering::Relaxed), @@ -317,7 +353,15 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_prepare( options_value: f64, ) -> Handle { ensure_open_node_database(db_handle); - let sql = string_from_value(sql_value, "sql"); + let sql = { + let js = value_from_f64(sql_value); + if !js.is_any_string() { + throw_type("The \"sql\" argument must be of type string"); + } + let ptr = js_get_string_pointer_unified(sql_value) as *const StringHeader; + string_from_header(ptr) + .unwrap_or_else(|| throw_type("The \"sql\" argument must be of type string")) + }; let db = get_handle::(db_handle) .unwrap_or_else(|| throw_invalid_state("database is not open")); let options = parse_statement_options(db, options_value); @@ -338,6 +382,7 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_prepare( db_handle, sql, finalized: AtomicBool::new(false), + iteration_epoch: std::sync::atomic::AtomicU64::new(0), read_bigints: AtomicBool::new(options.read_bigints), return_arrays: AtomicBool::new(options.return_arrays), allow_bare_named_parameters: AtomicBool::new(options.allow_bare_named_parameters), @@ -350,6 +395,88 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_prepare( handle } +#[no_mangle] +pub unsafe extern "C" fn js_node_sqlite_database_sync_serialize( + db_handle: Handle, + schema_value: f64, +) -> *mut BufferHeader { + ensure_open_node_database(db_handle); + let schema = if value_from_f64(schema_value).is_undefined() { + "main".to_string() + } else { + string_from_value(schema_value, "attachedDb") + }; + let schema = CString::new(schema) + .unwrap_or_else(|_| throw_type("The \"attachedDb\" argument must be a string")); + with_open_node_connection(db_handle, |conn| { + let mut size = 0; + let image = ffi::sqlite3_serialize(conn.handle(), schema.as_ptr(), &mut size, 0); + if image.is_null() || size < 0 { + throw_sqlite_error_from_conn(conn); + } + let len = size as usize; + let buffer = buffer_alloc(len as u32); + (*buffer).length = len as u32; + mark_as_uint8array(buffer as usize); + if len > 0 { + std::ptr::copy_nonoverlapping(image, buffer_data_mut(buffer), len); + } + ffi::sqlite3_free(image.cast()); + buffer + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_node_sqlite_database_sync_deserialize( + db_handle: Handle, + image_value: f64, +) -> i32 { + ensure_open_node_database(db_handle); + let raw = raw_addr_from_value(image_value); + let bytes = if perry_runtime::typedarray::lookup_typed_array_kind(raw) + == Some(perry_runtime::typedarray::KIND_UINT8) + { + perry_runtime::typedarray::typed_array_bytes( + raw as *const perry_runtime::typedarray::TypedArrayHeader, + ) + .map(ToOwned::to_owned) + } else if raw >= 0x1000 + && is_registered_buffer(raw) + && !is_any_array_buffer(raw) + && !is_data_view(raw) + { + let buffer = raw as *const BufferHeader; + Some(std::slice::from_raw_parts(buffer_data(buffer), (*buffer).length as usize).to_vec()) + } else { + None + } + .unwrap_or_else(|| throw_type("The \"data\" argument must be a Uint8Array.")); + if bytes.is_empty() { + throw_arg_value("The \"data\" argument must not be empty."); + } + with_open_node_connection(db_handle, |conn| { + let allocation = ffi::sqlite3_malloc64(bytes.len() as u64).cast::(); + if allocation.is_null() { + throw_sqlite_error("out of memory"); + } + std::ptr::copy_nonoverlapping(bytes.as_ptr(), allocation, bytes.len()); + let schema = b"main\0"; + let rc = ffi::sqlite3_deserialize( + conn.handle(), + schema.as_ptr().cast(), + allocation, + bytes.len() as i64, + bytes.len() as i64, + ffi::SQLITE_DESERIALIZE_FREEONCLOSE | ffi::SQLITE_DESERIALIZE_RESIZEABLE, + ); + if rc != ffi::SQLITE_OK { + ffi::sqlite3_free(allocation.cast()); + throw_sqlite_error_from_conn(conn); + } + }); + 1 +} + pub(crate) fn sqlite_function_name(name: String) -> CString { let bytes = name.as_bytes(); let end = bytes @@ -444,6 +571,10 @@ pub unsafe extern "C" fn js_node_sqlite_database_sync_aggregate( ensure_open_node_database(db_handle); let name = sqlite_function_name(string_from_value(name_value, "name")); + if value_from_f64(options_value).is_null() || !is_object_like(options_value) { + throw_plain_type("The \"options\" argument must be an object."); + } + let start = object_field(options_value, "start"); if start.is_undefined() { throw_type("The \"options.start\" argument must be a function or a primitive value."); @@ -535,6 +666,27 @@ pub(crate) unsafe fn configure_node_sqlite_defensive( .into_owned()) } +pub(crate) unsafe fn configure_node_sqlite_dqs( + conn: &Connection, + enabled: bool, +) -> Result<(), String> { + for option in [ffi::SQLITE_DBCONFIG_DQS_DDL, ffi::SQLITE_DBCONFIG_DQS_DML] { + let mut current = 0; + let rc = ffi::sqlite3_db_config( + conn.handle(), + option, + if enabled { 1 } else { 0 }, + &mut current, + ); + if rc != ffi::SQLITE_OK { + return Err(CStr::from_ptr(ffi::sqlite3_errmsg(conn.handle())) + .to_string_lossy() + .into_owned()); + } + } + Ok(()) +} + #[no_mangle] pub unsafe extern "C" fn js_node_sqlite_database_sync_enable_defensive( db_handle: Handle, diff --git a/crates/perry-stdlib/src/sqlite/node_stmt_session.rs b/crates/perry-stdlib/src/sqlite/node_stmt_session.rs index ed36d84187..b454d6f7e5 100644 --- a/crates/perry-stdlib/src/sqlite/node_stmt_session.rs +++ b/crates/perry-stdlib/src/sqlite/node_stmt_session.rs @@ -116,7 +116,13 @@ pub unsafe extern "C" fn js_node_sqlite_statement_sync_iterate( // Node (#6561): exhaustion and `return()` produce // `{ done: true, value: null }`, and `return()` terminates iteration. let rows = js_node_sqlite_statement_sync_all(stmt_handle, params_arr); - perry_runtime::array::array_values_iter_null_done(f64_from_jsvalue(JSValue::array_ptr(rows))) + let stmt = get_handle::(stmt_handle) + .unwrap_or_else(|| throw_invalid_state("statement has been finalized")); + perry_runtime::array::array_values_iter_null_done( + f64_from_jsvalue(JSValue::array_ptr(rows)), + &stmt.iteration_epoch, + stmt.iteration_epoch.load(Ordering::Relaxed), + ) } #[no_mangle] diff --git a/crates/perry-stdlib/src/sqlite/options.rs b/crates/perry-stdlib/src/sqlite/options.rs index 1a06540848..d76f290ef5 100644 --- a/crates/perry-stdlib/src/sqlite/options.rs +++ b/crates/perry-stdlib/src/sqlite/options.rs @@ -246,7 +246,10 @@ pub(crate) fn non_negative_i32_value(value: JSValue, name: &str, allow_infinity: if allow_infinity && number == f64::INFINITY { return i32::MAX; } - if !number.is_finite() || number < 0.0 || number.fract() != 0.0 || number > i32::MAX as f64 { + if !number.is_finite() || number.fract() != 0.0 { + throw_type(&format!("The \"{}\" option must be an integer", name)); + } + if number < 0.0 || number > i32::MAX as f64 { throw_range(&format!( "The value of \"{}\" is out of range. It must be a non-negative integer.", name @@ -255,14 +258,6 @@ pub(crate) fn non_negative_i32_value(value: JSValue, name: &str, allow_infinity: number as i32 } -pub(crate) unsafe fn non_negative_i32_option(options_value: f64, name: &str, default: i32) -> i32 { - let value = object_field(options_value, name); - if value.is_undefined() { - return default; - } - non_negative_i32_value(value, name, false) -} - pub(crate) fn node_sqlite_limit(name: &str) -> Option<(usize, Limit)> { match name { "length" => Some((0, Limit::SQLITE_LIMIT_LENGTH)), @@ -302,7 +297,7 @@ pub(crate) unsafe fn parse_node_sqlite_options(options_value: f64) -> NodeSqlite "enableDoubleQuotedStringLiterals", options.enable_dqs, ); - options.timeout_ms = non_negative_i32_option(options_value, "timeout", options.timeout_ms); + options.timeout_ms = int32_option(options_value, "timeout", options.timeout_ms); options.read_bigints = bool_option(options_value, "readBigInts", options.read_bigints); options.return_arrays = bool_option(options_value, "returnArrays", options.return_arrays); options.allow_bare_named_parameters = bool_option( From 1aa613f9eb53dc30172e7ddae42f44a720b006de Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Wed, 29 Jul 2026 22:20:16 +0200 Subject: [PATCH 2/7] docs(changelog): add PR fragment --- changelog.d/6896-sqlite-node26-parity.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6896-sqlite-node26-parity.md diff --git a/changelog.d/6896-sqlite-node26-parity.md b/changelog.d/6896-sqlite-node26-parity.md new file mode 100644 index 0000000000..f27f9e1f3d --- /dev/null +++ b/changelog.d/6896-sqlite-node26-parity.md @@ -0,0 +1 @@ +**SQLite:** Complete Node.js 26 compatibility for database configuration, parameter binding, serialization, limits, aggregates, sessions, and statement iteration. From cd6d4e8d7c4c7ca9d4c1e174730378dea8a379e5 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Wed, 29 Jul 2026 23:33:51 +0200 Subject: [PATCH 3/7] fix(sqlite): classify typed arrays as positional values --- crates/perry-stdlib/src/sqlite/bind.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/perry-stdlib/src/sqlite/bind.rs b/crates/perry-stdlib/src/sqlite/bind.rs index 3f8c28ef89..515fffc0be 100644 --- a/crates/perry-stdlib/src/sqlite/bind.rs +++ b/crates/perry-stdlib/src/sqlite/bind.rs @@ -256,6 +256,7 @@ pub(crate) fn is_named_parameter_object(value: f64) -> bool { let raw = raw_addr_from_value(value); raw >= 0x1000 && !is_registered_buffer(raw) + && perry_runtime::typedarray::lookup_typed_array_kind(raw).is_none() && unsafe { perry_runtime::symbol::js_is_symbol(value) == 0 } } @@ -957,4 +958,14 @@ mod tests { let symbol = unsafe { perry_runtime::symbol::js_symbol_new_empty() }; assert!(!is_named_parameter_object(symbol)); } + + #[test] + fn typed_arrays_are_not_named_parameter_objects() { + let typed_array = perry_runtime::typedarray::js_typed_array_new_empty( + perry_runtime::typedarray::KIND_UINT8 as i32, + 1, + ); + let value = perry_runtime::value::js_nanbox_pointer(typed_array as i64); + assert!(!is_named_parameter_object(value)); + } } From 60fa3c7932460a7ddeb4682c708322b5236ee3e9 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 30 Jul 2026 00:00:43 +0200 Subject: [PATCH 4/7] style(codegen): format loop purity match arm --- crates/perry-codegen/src/loop_purity.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/loop_purity.rs b/crates/perry-codegen/src/loop_purity.rs index aeace93d48..364d34cb3b 100644 --- a/crates/perry-codegen/src/loop_purity.rs +++ b/crates/perry-codegen/src/loop_purity.rs @@ -121,9 +121,7 @@ fn expr_alloc_free(e: &Expr) -> bool { // Element READS never allocate — they return an existing element / a // number. Recurse so the object and index are themselves alloc-free. Expr::IndexGet { object, index } => expr_alloc_free(object) && expr_alloc_free(index), - Expr::BufferIndexGet { buffer, index } => { - expr_alloc_free(buffer) && expr_alloc_free(index) - } + Expr::BufferIndexGet { buffer, index } => expr_alloc_free(buffer) && expr_alloc_free(index), Expr::Uint8ArrayGet { array, index } => expr_alloc_free(array) && expr_alloc_free(index), // `arr[i]++` / `--`: read-modify-write of an existing numeric slot, no // growth, no allocation. From 1891acebebb36b351215a978108b2a3b6b5a4efe Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 30 Jul 2026 08:01:25 +0200 Subject: [PATCH 5/7] fix(sqlite): merge main and preserve GC safepoints --- crates/perry-codegen/src/loop_purity.rs | 76 ++++++++++++++++++------- crates/perry-codegen/src/stmt/loops.rs | 21 ++++--- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/crates/perry-codegen/src/loop_purity.rs b/crates/perry-codegen/src/loop_purity.rs index 364d34cb3b..57faed9853 100644 --- a/crates/perry-codegen/src/loop_purity.rs +++ b/crates/perry-codegen/src/loop_purity.rs @@ -33,7 +33,7 @@ //! (`for (;;) {}`, `while (cond) {}`) — the #74 repro case — as the only //! class that still receives the barrier. -use perry_hir::{Expr, Stmt}; +use perry_hir::{CompareOp, Expr, Stmt, UnaryOp}; use std::collections::HashSet; /// True when the body needs an `asm sideeffect` barrier inserted. This is @@ -62,8 +62,8 @@ pub(crate) fn body_needs_asm_barrier(body: &[Stmt]) -> bool { /// the poll. A spurious poll costs a little vectorization; a missing one only /// delays a deferred minor to the next safepoint (bounded by the moving-GC hard /// cap) — never a correctness or UAF hazard. -pub(crate) fn body_may_allocate(body: &[Stmt]) -> bool { - !body.iter().all(stmt_alloc_free) +pub(crate) fn loop_may_allocate(body: &[Stmt], controls: &[&Expr]) -> bool { + !body.iter().all(stmt_alloc_free) || controls.iter().any(|expr| !expr_alloc_free(expr)) } /// Like `stmt_is_pure`, but the question is narrower — "can this allocate (or @@ -113,21 +113,25 @@ fn stmt_alloc_free(s: &Stmt) -> bool { } fn expr_alloc_free(e: &Expr) -> bool { - // Everything LLVM-pure is allocation-free (literals, reads, arithmetic). - if expr_is_pure(e) { - return true; - } match e { - // Element READS never allocate — they return an existing element / a - // number. Recurse so the object and index are themselves alloc-free. - Expr::IndexGet { object, index } => expr_alloc_free(object) && expr_alloc_free(index), + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::BigInt(_) + | Expr::String(_) + | Expr::This + | Expr::LocalGet(_) + | Expr::GlobalGet(_) + | Expr::FuncRef(_) + | Expr::ClassRef(_) + | Expr::EnumMember { .. } => true, + // Typed/buffer reads are fixed-layout numeric loads. Generic + // IndexGet/IndexUpdate are deliberately excluded: proxies, accessors, + // and coercion hooks can run user code and allocate. Expr::BufferIndexGet { buffer, index } => expr_alloc_free(buffer) && expr_alloc_free(index), Expr::Uint8ArrayGet { array, index } => expr_alloc_free(array) && expr_alloc_free(index), - // `arr[i]++` / `--`: read-modify-write of an existing numeric slot, no - // growth, no allocation. - Expr::IndexUpdate { object, index, .. } => { - expr_alloc_free(object) && expr_alloc_free(index) - } // Typed-array element WRITES store into a fixed-size backing buffer that // never grows/reallocates. (Generic `IndexSet` is deliberately absent — // a plain JS-array write can grow the array and allocate.) @@ -141,15 +145,19 @@ fn expr_alloc_free(e: &Expr) -> bool { index, value, } => expr_alloc_free(array) && expr_alloc_free(index) && expr_alloc_free(value), - // Re-handle the composite arithmetic/assign forms so an alloc-free (but - // not LLVM-pure) operand — e.g. a `BufferIndexGet` — propagates through. Expr::LocalSet(_, val) => expr_alloc_free(val), - Expr::Binary { left, right, .. } - | Expr::Compare { left, right, .. } + Expr::Compare { + op: CompareOp::Eq | CompareOp::Ne, + left, + right, + } | Expr::Logical { left, right, .. } => expr_alloc_free(left) && expr_alloc_free(right), - Expr::Unary { operand, .. } | Expr::TypeOf(operand) | Expr::Void(operand) => { - expr_alloc_free(operand) + Expr::Unary { + op: UnaryOp::Not, + operand, } + | Expr::TypeOf(operand) + | Expr::Void(operand) => expr_alloc_free(operand), Expr::Conditional { condition, then_expr, @@ -159,6 +167,32 @@ fn expr_alloc_free(e: &Expr) -> bool { } } +#[cfg(test)] +mod allocation_tests { + use super::*; + use perry_hir::BinaryOp; + + #[test] + fn generic_index_update_keeps_the_loop_safepoint() { + let update = Expr::IndexUpdate { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Integer(0)), + op: BinaryOp::Add, + prefix: false, + }; + assert!(loop_may_allocate(&[Stmt::Expr(update)], &[])); + } + + #[test] + fn allocating_loop_control_keeps_the_loop_safepoint() { + let condition = Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Integer(0)), + }; + assert!(loop_may_allocate(&[], &[&condition])); + } +} + fn stmt_is_pure(s: &Stmt) -> bool { match s { Stmt::Expr(e) => expr_is_pure(e), diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 39f13d6fe9..edcb5b4ea8 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2875,8 +2875,6 @@ fn lower_object_array_write_versioned_for( extra_guards.push((g_packed, g_box)); } let preheader_idx = ctx.current_block; - let preheader_label = ctx.block().label.clone(); - // Emit the fallback first. Besides preserving the original semantics, this // creates the ordinary local slots for the nested counter, allowing the // fast completion block to synchronize loop variables before the merge. @@ -5141,7 +5139,8 @@ fn lower_for_after_init_with_i32_bound( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx, body); + let controls: Vec<&perry_hir::Expr> = condition.into_iter().chain(update).collect(); + emit_gc_loop_safepoint(ctx, body, &controls); ctx.block().br(&update_label); } @@ -5226,7 +5225,7 @@ fn moving_safepoint_polls_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); // DEFAULT ON (moving-nursery flip): emit the back-edge poll, but ONLY for - // allocating loop bodies (see the `body_may_allocate` gate in + // allocating loops (see the `loop_may_allocate` gate in // `emit_gc_loop_safepoint`) so numeric/vectorizable loops stay call-free. // Kill switch: PERRY_GC_MOVING_LOOP_POLLS=0/off/false. Must match the runtime // `gc_moving_loop_polls_enabled` (same env) so deferrals always have a drain. @@ -5251,15 +5250,19 @@ fn moving_safepoint_polls_enabled() -> bool { /// loop that takes one of those paths won't drain a deferred moving minor until /// the next event-loop safepoint. Adding the poll to every back-edge across /// those paths is the remaining Phase 2 codegen work. -pub(crate) fn emit_gc_loop_safepoint(ctx: &mut FnCtx<'_>, body: &[Stmt]) { +pub(crate) fn emit_gc_loop_safepoint( + ctx: &mut FnCtx<'_>, + body: &[Stmt], + controls: &[&perry_hir::Expr], +) { if !moving_safepoint_polls_enabled() || ctx.block().is_terminated() { return; } // Only an ALLOCATING loop body can defer a collection to this poll; skip the // poll for pure (non-allocating) bodies so numeric/vectorizable loops stay // call-free (a poll defeats LLVM auto-vectorization — measured ~2x on a tight - // scalar reduction). See `body_may_allocate` for the safe-direction rationale. - if !crate::loop_purity::body_may_allocate(body) { + // scalar reduction). See `loop_may_allocate` for the safe-direction rationale. + if !crate::loop_purity::loop_may_allocate(body, controls) { return; } ctx.block().call_void("js_gc_loop_safepoint", &[]); @@ -7042,7 +7045,7 @@ pub(crate) fn lower_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx, body); + emit_gc_loop_safepoint(ctx, body, &[condition]); ctx.block().br(&cond_label); } ctx.active_region_id = previous_region_id; @@ -7100,7 +7103,7 @@ pub(crate) fn lower_do_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx, body); + emit_gc_loop_safepoint(ctx, body, &[condition]); ctx.block().br(&cond_label); } From e3140ceb9869953a6b4b8d10dcc415a03065e3a7 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 30 Jul 2026 08:25:06 +0200 Subject: [PATCH 6/7] fix(gc): poll after allocating loop controls --- crates/perry-codegen/src/stmt/loops.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index edcb5b4ea8..9d0b1c492f 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5078,6 +5078,7 @@ fn lower_for_after_init_with_i32_bound( if let Some(cond_expr) = condition { let cv = lower_expr(ctx, cond_expr)?; let i1 = lower_truthy(ctx, &cv, cond_expr); + emit_gc_loop_safepoint(ctx, &[], &[cond_expr]); ctx.block().cond_br(&i1, &body_label, &exit_label); } else { ctx.block().br(&body_label); @@ -5090,6 +5091,7 @@ fn lower_for_after_init_with_i32_bound( if let Some(cond_expr) = condition { let cv = lower_expr(ctx, cond_expr)?; let i1 = lower_truthy(ctx, &cv, cond_expr); + emit_gc_loop_safepoint(ctx, &[], &[cond_expr]); ctx.block().cond_br(&i1, &body_label, &exit_label); } else { // `for (;;)` — unconditional jump into the body. May be an @@ -5139,8 +5141,7 @@ fn lower_for_after_init_with_i32_bound( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - let controls: Vec<&perry_hir::Expr> = condition.into_iter().chain(update).collect(); - emit_gc_loop_safepoint(ctx, body, &controls); + emit_gc_loop_safepoint(ctx, body, &[]); ctx.block().br(&update_label); } @@ -5148,6 +5149,7 @@ fn lower_for_after_init_with_i32_bound( ctx.current_block = update_idx; if let Some(update_expr) = update { let _ = lower_expr(ctx, update_expr)?; + emit_gc_loop_safepoint(ctx, &[], &[update_expr]); } // #6072: a loop-private i32 counter is invisible to the `Update` lowering // (it is not in `ctx.i32_counter_slots`), so advance it here. The classifier @@ -5237,11 +5239,12 @@ fn moving_safepoint_polls_enabled() -> bool { }) } -/// Emit a `js_gc_loop_safepoint()` poll at a loop back-edge. Call this AFTER -/// `clear_loop_body_shadow_slots` and only where the block is not terminated: -/// at that point the loop-body expression has completed, so every live heap -/// value is a named local on the shadow stack (no unspilled register temps) — -/// a precise-root safepoint where a deferred copying minor can MOVE survivors. +/// Emit a `js_gc_loop_safepoint()` after an allocating loop segment has +/// completed and only where the block is not terminated. Body calls must run +/// after `clear_loop_body_shadow_slots`; control calls run after their result +/// has been reduced or discarded. At either point every live heap value is a +/// named local on the shadow stack (no unspilled register temps) — a precise +/// root safepoint where a deferred copying minor can MOVE survivors. /// /// COVERAGE (Phase 2, follow-up): currently wired into the generic `while`, /// `do..while`, and `for` back-edges. The specialized/versioned `for`-loop @@ -7008,6 +7011,7 @@ pub(crate) fn lower_while( ctx.current_block = cond_idx; let cv = lower_expr(ctx, condition)?; let i1 = lower_truthy(ctx, &cv, condition); + emit_gc_loop_safepoint(ctx, &[], &[condition]); ctx.block().cond_br(&i1, &body_label, &exit_label); // For while-loops, continue jumps back to the cond block. @@ -7045,7 +7049,7 @@ pub(crate) fn lower_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx, body, &[condition]); + emit_gc_loop_safepoint(ctx, body, &[]); ctx.block().br(&cond_label); } ctx.active_region_id = previous_region_id; @@ -7103,13 +7107,14 @@ pub(crate) fn lower_do_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx, body, &[condition]); + emit_gc_loop_safepoint(ctx, body, &[]); ctx.block().br(&cond_label); } ctx.current_block = cond_idx; let cv = lower_expr(ctx, condition)?; let i1 = lower_truthy(ctx, &cv, condition); + emit_gc_loop_safepoint(ctx, &[], &[condition]); ctx.block().cond_br(&i1, &body_label, &exit_label); ctx.active_region_id = previous_region_id; From 6f841ba7b9fd5cec9ea0509245d57612ae5fd62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 08:25:29 +0200 Subject: [PATCH 7/7] style(codegen): preserve loop formatting after sqlite rebase --- crates/perry-codegen/src/stmt/loops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index edcb5b4ea8..990ca06872 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2875,6 +2875,7 @@ fn lower_object_array_write_versioned_for( extra_guards.push((g_packed, g_box)); } let preheader_idx = ctx.current_block; + // Emit the fallback first. Besides preserving the original semantics, this // creates the ordinary local slots for the nested counter, allowing the // fast completion block to synchronize loop variables before the merge.