From a304758e2db9a3a88f8c6578e2945c3b1aa82f24 Mon Sep 17 00:00:00 2001 From: Marcus Pousette Date: Sun, 25 Jan 2026 17:53:06 +0100 Subject: [PATCH] perf(sqlite): add placement order_key UDF --- .../src/extension/functions.rs | 20 + .../src/extension/functions/order_key.rs | 410 ++++++++++++++++++ .../src/extension/functions/sqlite_api.rs | 25 ++ .../src/extension/functions/util.rs | 47 ++ .../tests/extension_roundtrip.rs | 221 +++++++++- packages/treecrdt-ts/src/sqlite.ts | 139 +----- 6 files changed, 725 insertions(+), 137 deletions(-) create mode 100644 packages/treecrdt-sqlite-ext/src/extension/functions/order_key.rs diff --git a/packages/treecrdt-sqlite-ext/src/extension/functions.rs b/packages/treecrdt-sqlite-ext/src/extension/functions.rs index 95659822..f3e694ce 100644 --- a/packages/treecrdt-sqlite-ext/src/extension/functions.rs +++ b/packages/treecrdt-sqlite-ext/src/extension/functions.rs @@ -12,6 +12,7 @@ mod node_store; mod op_index; mod op_storage; mod oprefs; +mod order_key; mod ops; mod payload_store; mod schema; @@ -23,6 +24,7 @@ use append::{treecrdt_append_op, treecrdt_append_ops}; use doc_id::{treecrdt_doc_id, treecrdt_set_doc_id}; use materialize::{append_ops_impl, ensure_materialized, treecrdt_ensure_materialized}; use oprefs::{treecrdt_oprefs_all, treecrdt_oprefs_children}; +use order_key::treecrdt_allocate_order_key; use ops::{treecrdt_ops_by_oprefs, treecrdt_ops_since}; use schema::*; use sqlite_api::*; @@ -279,6 +281,21 @@ pub extern "C" fn sqlite3_treecrdt_init( ) }; + let rc_allocate_order_key = { + let name = CString::new("treecrdt_allocate_order_key").expect("static name"); + sqlite_create_function_v2( + db, + name.as_ptr(), + 5, + SQLITE_UTF8 as c_int, + null_mut(), + Some(treecrdt_allocate_order_key), + None, + None, + None, + ) + }; + if rc != SQLITE_OK as c_int || rc_append != SQLITE_OK as c_int || rc_set_doc_id != SQLITE_OK as c_int @@ -289,6 +306,7 @@ pub extern "C" fn sqlite3_treecrdt_init( || rc_subtree_known_state != SQLITE_OK as c_int || rc_ops_by_oprefs != SQLITE_OK as c_int || rc_since != SQLITE_OK as c_int + || rc_allocate_order_key != SQLITE_OK as c_int { unsafe { if !pz_err_msg.is_null() { @@ -315,6 +333,8 @@ pub extern "C" fn sqlite3_treecrdt_init( rc_subtree_known_state } else if rc_ops_by_oprefs != SQLITE_OK as c_int { rc_ops_by_oprefs + } else if rc_allocate_order_key != SQLITE_OK as c_int { + rc_allocate_order_key } else { rc_since }; diff --git a/packages/treecrdt-sqlite-ext/src/extension/functions/order_key.rs b/packages/treecrdt-sqlite-ext/src/extension/functions/order_key.rs new file mode 100644 index 00000000..bcd921d5 --- /dev/null +++ b/packages/treecrdt-sqlite-ext/src/extension/functions/order_key.rs @@ -0,0 +1,410 @@ +use super::materialize::ensure_materialized; +use super::sqlite_api::*; +use super::util::sqlite_result_blob_owned; + +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_void}; +use std::ptr::null_mut; +use std::slice; + +fn is_trash_node(bytes: &[u8; 16]) -> bool { + bytes.iter().all(|b| *b == 0xff) +} + +fn arg_blob16(val: *mut sqlite3_value) -> Result<[u8; 16], &'static [u8]> { + let ptr = unsafe { sqlite_value_blob(val) } as *const u8; + let len = unsafe { sqlite_value_bytes(val) } as usize; + if ptr.is_null() || len != 16 { + return Err(b"expected 16-byte BLOB\0"); + } + let mut out = [0u8; 16]; + out.copy_from_slice(unsafe { slice::from_raw_parts(ptr, len) }); + Ok(out) +} + +fn arg_optional_blob16(val: *mut sqlite3_value) -> Result, &'static [u8]> { + let ty = unsafe { sqlite_value_type(val) }; + if ty == SQLITE_NULL as c_int { + return Ok(None); + } + arg_blob16(val).map(Some) +} + +fn arg_text(val: *mut sqlite3_value) -> Result { + let ptr = unsafe { sqlite_value_text(val) } as *const u8; + if ptr.is_null() { + return Err(b"expected TEXT\0"); + } + let len = unsafe { sqlite_value_bytes(val) } as usize; + let bytes = unsafe { slice::from_raw_parts(ptr, len) }; + Ok(String::from_utf8_lossy(bytes).to_string()) +} + +fn read_blob_column(stmt: *mut sqlite3_stmt, idx: c_int) -> Result, c_int> { + let ty = unsafe { sqlite_column_type(stmt, idx) }; + if ty == SQLITE_NULL as c_int { + return Err(SQLITE_ERROR as c_int); + } + let len = unsafe { sqlite_column_bytes(stmt, idx) } as usize; + if len == 0 { + return Ok(Vec::new()); + } + let ptr = unsafe { sqlite_column_blob(stmt, idx) } as *const u8; + if ptr.is_null() { + return Err(SQLITE_ERROR as c_int); + } + Ok(unsafe { slice::from_raw_parts(ptr, len) }.to_vec()) +} + +fn select_first_child_order_key( + db: *mut sqlite3, + parent: &[u8; 16], + exclude: Option<&[u8; 16]>, +) -> Result>, c_int> { + let sql = if exclude.is_some() { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 AND node <> ?2 \ + ORDER BY order_key, node \ + LIMIT 1" + } else { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 \ + ORDER BY order_key, node \ + LIMIT 1" + }; + let sql = CString::new(sql).expect("first child order_key sql"); + let mut stmt: *mut sqlite3_stmt = null_mut(); + let rc = sqlite_prepare_v2(db, sql.as_ptr(), -1, &mut stmt, null_mut()); + if rc != SQLITE_OK as c_int { + return Err(rc); + } + unsafe { + sqlite_bind_blob( + stmt, + 1, + parent.as_ptr() as *const c_void, + parent.len() as c_int, + None, + ); + if let Some(ex) = exclude { + sqlite_bind_blob( + stmt, + 2, + ex.as_ptr() as *const c_void, + ex.len() as c_int, + None, + ); + } + let step_rc = sqlite_step(stmt); + let out = if step_rc == SQLITE_ROW as c_int { + Some(read_blob_column(stmt, 0)?) + } else if step_rc == SQLITE_DONE as c_int { + None + } else { + sqlite_finalize(stmt); + return Err(step_rc); + }; + sqlite_finalize(stmt); + Ok(out) + } +} + +fn select_last_child_order_key( + db: *mut sqlite3, + parent: &[u8; 16], + exclude: Option<&[u8; 16]>, +) -> Result>, c_int> { + let sql = if exclude.is_some() { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 AND node <> ?2 \ + ORDER BY order_key DESC, node DESC \ + LIMIT 1" + } else { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 \ + ORDER BY order_key DESC, node DESC \ + LIMIT 1" + }; + let sql = CString::new(sql).expect("last child order_key sql"); + let mut stmt: *mut sqlite3_stmt = null_mut(); + let rc = sqlite_prepare_v2(db, sql.as_ptr(), -1, &mut stmt, null_mut()); + if rc != SQLITE_OK as c_int { + return Err(rc); + } + unsafe { + sqlite_bind_blob( + stmt, + 1, + parent.as_ptr() as *const c_void, + parent.len() as c_int, + None, + ); + if let Some(ex) = exclude { + sqlite_bind_blob( + stmt, + 2, + ex.as_ptr() as *const c_void, + ex.len() as c_int, + None, + ); + } + let step_rc = sqlite_step(stmt); + let out = if step_rc == SQLITE_ROW as c_int { + Some(read_blob_column(stmt, 0)?) + } else if step_rc == SQLITE_DONE as c_int { + None + } else { + sqlite_finalize(stmt); + return Err(step_rc); + }; + sqlite_finalize(stmt); + Ok(out) + } +} + +fn select_child_order_key( + db: *mut sqlite3, + parent: &[u8; 16], + node: &[u8; 16], +) -> Result, c_int> { + let sql = CString::new( + "SELECT order_key FROM tree_nodes \ + WHERE node = ?1 AND parent = ?2 AND tombstone = 0 \ + LIMIT 1", + ) + .expect("child order_key sql"); + let mut stmt: *mut sqlite3_stmt = null_mut(); + let rc = sqlite_prepare_v2(db, sql.as_ptr(), -1, &mut stmt, null_mut()); + if rc != SQLITE_OK as c_int { + return Err(rc); + } + unsafe { + sqlite_bind_blob(stmt, 1, node.as_ptr() as *const c_void, node.len() as c_int, None); + sqlite_bind_blob( + stmt, + 2, + parent.as_ptr() as *const c_void, + parent.len() as c_int, + None, + ); + let step_rc = sqlite_step(stmt); + if step_rc != SQLITE_ROW as c_int { + sqlite_finalize(stmt); + return Err(if step_rc == SQLITE_DONE as c_int { + SQLITE_ERROR as c_int + } else { + step_rc + }); + } + let out = read_blob_column(stmt, 0)?; + sqlite_finalize(stmt); + Ok(out) + } +} + +fn select_next_sibling_order_key( + db: *mut sqlite3, + parent: &[u8; 16], + after_order_key: &[u8], + after_node: &[u8; 16], + exclude: Option<&[u8; 16]>, +) -> Result>, c_int> { + let sql = if exclude.is_some() { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 AND node <> ?4 \ + AND (order_key > ?2 OR (order_key = ?2 AND node > ?3)) \ + ORDER BY order_key, node \ + LIMIT 1" + } else { + "SELECT order_key FROM tree_nodes \ + WHERE parent = ?1 AND tombstone = 0 \ + AND (order_key > ?2 OR (order_key = ?2 AND node > ?3)) \ + ORDER BY order_key, node \ + LIMIT 1" + }; + let sql = CString::new(sql).expect("next sibling order_key sql"); + let mut stmt: *mut sqlite3_stmt = null_mut(); + let rc = sqlite_prepare_v2(db, sql.as_ptr(), -1, &mut stmt, null_mut()); + if rc != SQLITE_OK as c_int { + return Err(rc); + } + unsafe { + sqlite_bind_blob( + stmt, + 1, + parent.as_ptr() as *const c_void, + parent.len() as c_int, + None, + ); + sqlite_bind_blob( + stmt, + 2, + after_order_key.as_ptr() as *const c_void, + after_order_key.len() as c_int, + None, + ); + sqlite_bind_blob( + stmt, + 3, + after_node.as_ptr() as *const c_void, + after_node.len() as c_int, + None, + ); + if let Some(ex) = exclude { + sqlite_bind_blob( + stmt, + 4, + ex.as_ptr() as *const c_void, + ex.len() as c_int, + None, + ); + } + let step_rc = sqlite_step(stmt); + let out = if step_rc == SQLITE_ROW as c_int { + Some(read_blob_column(stmt, 0)?) + } else if step_rc == SQLITE_DONE as c_int { + None + } else { + sqlite_finalize(stmt); + return Err(step_rc); + }; + sqlite_finalize(stmt); + Ok(out) + } +} + +pub(super) unsafe extern "C" fn treecrdt_allocate_order_key( + ctx: *mut sqlite3_context, + argc: c_int, + argv: *mut *mut sqlite3_value, +) { + if argc != 5 { + sqlite_result_error( + ctx, + b"treecrdt_allocate_order_key expects 5 args (parent, placement, after, exclude, seed)\0" + .as_ptr() as *const c_char, + ); + return; + } + + let args = unsafe { slice::from_raw_parts(argv, argc as usize) }; + let parent = match arg_blob16(args[0]) { + Ok(v) => v, + Err(msg) => { + sqlite_result_error(ctx, msg.as_ptr() as *const c_char); + return; + } + }; + let placement = match arg_text(args[1]) { + Ok(v) => v, + Err(msg) => { + sqlite_result_error(ctx, msg.as_ptr() as *const c_char); + return; + } + }; + let after = match arg_optional_blob16(args[2]) { + Ok(v) => v, + Err(msg) => { + sqlite_result_error(ctx, msg.as_ptr() as *const c_char); + return; + } + }; + let exclude = match arg_optional_blob16(args[3]) { + Ok(v) => v, + Err(msg) => { + sqlite_result_error(ctx, msg.as_ptr() as *const c_char); + return; + } + }; + let seed_ptr = unsafe { sqlite_value_blob(args[4]) } as *const u8; + let seed_len = unsafe { sqlite_value_bytes(args[4]) } as usize; + if seed_ptr.is_null() && seed_len != 0 { + sqlite_result_error(ctx, b"seed must be a BLOB\0".as_ptr() as *const c_char); + return; + } + let seed = if seed_len == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(seed_ptr, seed_len) } + }; + + if is_trash_node(&parent) { + sqlite_result_blob_owned(ctx, &[]); + return; + } + + let db = sqlite_context_db_handle(ctx); + if let Err(rc) = ensure_materialized(db) { + sqlite_result_error_code(ctx, rc); + return; + } + + let exclude_ref = exclude.as_ref(); + let (left, right) = match placement.as_str() { + "first" => match select_first_child_order_key(db, &parent, exclude_ref) { + Ok(r) => (None, r), + Err(rc) => { + sqlite_result_error_code(ctx, rc); + return; + } + }, + "last" => match select_last_child_order_key(db, &parent, exclude_ref) { + Ok(l) => (l, None), + Err(rc) => { + sqlite_result_error_code(ctx, rc); + return; + } + }, + "after" => { + let Some(after_node) = after else { + sqlite_result_error(ctx, b"after placement requires after node\0".as_ptr() as *const c_char); + return; + }; + if exclude_ref.map_or(false, |ex| ex == &after_node) { + sqlite_result_error( + ctx, + b"placement.after must not equal excluded node\0".as_ptr() as *const c_char, + ); + return; + } + let left_key = match select_child_order_key(db, &parent, &after_node) { + Ok(v) => v, + Err(rc) => { + sqlite_result_error_code(ctx, rc); + return; + } + }; + let right_key = match select_next_sibling_order_key( + db, + &parent, + &left_key, + &after_node, + exclude_ref, + ) { + Ok(v) => v, + Err(rc) => { + sqlite_result_error_code(ctx, rc); + return; + } + }; + (Some(left_key), right_key) + } + _ => { + sqlite_result_error( + ctx, + b"placement must be one of: first | last | after\0".as_ptr() as *const c_char, + ); + return; + } + }; + + let order_key = match treecrdt_core::order_key::allocate_between(left.as_deref(), right.as_deref(), seed) { + Ok(v) => v, + Err(_) => { + sqlite_result_error_code(ctx, SQLITE_ERROR as c_int); + return; + } + }; + + sqlite_result_blob_owned(ctx, &order_key); +} diff --git a/packages/treecrdt-sqlite-ext/src/extension/functions/sqlite_api.rs b/packages/treecrdt-sqlite-ext/src/extension/functions/sqlite_api.rs index 149863fe..11434b77 100644 --- a/packages/treecrdt-sqlite-ext/src/extension/functions/sqlite_api.rs +++ b/packages/treecrdt-sqlite-ext/src/extension/functions/sqlite_api.rs @@ -110,6 +110,12 @@ mod ffi { n: c_int, destructor: Option, ); + pub fn sqlite3_result_blob( + ctx: *mut sqlite3_context, + val: *const c_void, + n: c_int, + destructor: Option, + ); pub fn sqlite3_result_error_code(ctx: *mut sqlite3_context, code: c_int); pub fn sqlite3_result_int(ctx: *mut sqlite3_context, value: c_int); pub fn sqlite3_result_int64(ctx: *mut sqlite3_context, value: i64); @@ -440,6 +446,25 @@ pub(super) fn sqlite_result_text( } } +pub(super) fn sqlite_result_blob( + ctx: *mut sqlite3_context, + val: *const c_void, + len: c_int, + destructor: Option, +) { + #[cfg(feature = "ext-sqlite")] + { + let api = api().expect("api table"); + unsafe { + (api.result_blob.unwrap())(ctx, val, len, destructor); + } + } + #[cfg(feature = "static-link")] + unsafe { + ffi::sqlite3_result_blob(ctx, val, len, destructor); + } +} + pub(super) fn sqlite_result_error_code(ctx: *mut sqlite3_context, code: c_int) { #[cfg(feature = "ext-sqlite")] { diff --git a/packages/treecrdt-sqlite-ext/src/extension/functions/util.rs b/packages/treecrdt-sqlite-ext/src/extension/functions/util.rs index 6d0ae5c9..e4fef5f2 100644 --- a/packages/treecrdt-sqlite-ext/src/extension/functions/util.rs +++ b/packages/treecrdt-sqlite-ext/src/extension/functions/util.rs @@ -1,7 +1,9 @@ use super::sqlite_api::*; +use std::alloc::{alloc, dealloc, Layout}; use std::ffi::CString; use std::os::raw::{c_char, c_int, c_void}; +use std::ptr; pub(super) fn sqlite_result_json(ctx: *mut sqlite3_context, value: &T) where @@ -34,3 +36,48 @@ pub(super) unsafe extern "C" fn drop_cstring(ptr: *mut c_void) { } } } + +const USIZE_BYTES: usize = std::mem::size_of::(); + +pub(super) fn sqlite_result_blob_owned(ctx: *mut sqlite3_context, bytes: &[u8]) { + let total_len = USIZE_BYTES + .checked_add(bytes.len()) + .expect("blob allocation size overflow"); + let layout = + Layout::from_size_align(total_len, std::mem::align_of::()).expect("blob layout"); + let base = unsafe { alloc(layout) }; + if base.is_null() { + sqlite_result_error_code(ctx, SQLITE_ERROR as c_int); + return; + } + unsafe { + (base as *mut usize).write(bytes.len()); + let data_ptr = base.add(USIZE_BYTES); + if !bytes.is_empty() { + ptr::copy_nonoverlapping(bytes.as_ptr(), data_ptr, bytes.len()); + } + sqlite_result_blob( + ctx, + data_ptr as *const c_void, + bytes.len() as c_int, + Some(drop_allocated_blob), + ); + } +} + +pub(super) unsafe extern "C" fn drop_allocated_blob(ptr: *mut c_void) { + if ptr.is_null() { + return; + } + unsafe { + let data_ptr = ptr as *mut u8; + let base = data_ptr.sub(USIZE_BYTES); + let len = (base as *const usize).read(); + let total_len = USIZE_BYTES + .checked_add(len) + .expect("blob deallocation size overflow"); + let layout = + Layout::from_size_align(total_len, std::mem::align_of::()).expect("blob layout"); + dealloc(base, layout); + } +} diff --git a/packages/treecrdt-sqlite-ext/tests/extension_roundtrip.rs b/packages/treecrdt-sqlite-ext/tests/extension_roundtrip.rs index 9cb21bc2..d68182e8 100644 --- a/packages/treecrdt-sqlite-ext/tests/extension_roundtrip.rs +++ b/packages/treecrdt-sqlite-ext/tests/extension_roundtrip.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use rusqlite::Connection; use serde::Deserialize; +use treecrdt_core::order_key::allocate_between; #[derive(Deserialize)] struct JsonOp { @@ -19,18 +20,7 @@ struct JsonOp { #[test] fn append_and_fetch_ops_via_extension() { - let ext_path = find_extension().expect("extension dylib path"); - let conn = Connection::open_in_memory().unwrap(); - unsafe { - conn.load_extension_enable().unwrap(); - conn.load_extension(ext_path, Some("sqlite3_treecrdt_init")).unwrap(); - } - conn.query_row( - "SELECT treecrdt_set_doc_id('treecrdt-sqlite-ext-test')", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(); + let conn = setup_conn(); let replica = b"r1".to_vec(); let parent = node_bytes(0); @@ -101,6 +91,213 @@ fn append_and_fetch_ops_via_extension() { assert_eq!(filtered.len(), 2); } +#[test] +fn allocate_order_key_after_is_deterministic_for_single_gap() { + let conn = setup_conn(); + + let parent = node_bytes(0); + let node_a = node_bytes(1); + let node_b = node_bytes(2); + + let key_a = (1u16).to_be_bytes().to_vec(); + let key_b = (3u16).to_be_bytes().to_vec(); + + // A(1), B(3) + for (counter, (node, order_key)) in [(1i64, (&node_a, &key_a)), (2i64, (&node_b, &key_b))] { + let _: i64 = conn + .query_row( + "SELECT treecrdt_append_op(?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL)", + rusqlite::params![b"r1".to_vec(), counter, counter, "insert", parent, node, order_key], + |row| row.get(0), + ) + .unwrap(); + } + + // Ensure tree_nodes is available for boundary lookup. + let _: i64 = conn + .query_row("SELECT treecrdt_ensure_materialized()", [], |row| row.get(0)) + .unwrap(); + + // after(A) between 1 and 3 => 2 (deterministic) + let seed = b"seed".to_vec(); + let allocated_after: Vec = conn + .query_row( + "SELECT treecrdt_allocate_order_key(?1, 'after', ?2, NULL, ?3)", + rusqlite::params![parent, node_a, seed.clone()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(allocated_after, (2u16).to_be_bytes().to_vec()); +} + +#[test] +fn allocate_order_key_first_is_deterministic_for_single_gap() { + let conn = setup_conn(); + + let parent = node_bytes(0); + let node_a = node_bytes(1); + let node_b = node_bytes(2); + let key_a = (2u16).to_be_bytes().to_vec(); + let key_b = (4u16).to_be_bytes().to_vec(); + for (counter, (node, order_key)) in [(1i64, (&node_a, &key_a)), (2i64, (&node_b, &key_b))] { + let _: i64 = conn + .query_row( + "SELECT treecrdt_append_op(?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL)", + rusqlite::params![b"r1".to_vec(), counter, counter, "insert", parent, node, order_key], + |row| row.get(0), + ) + .unwrap(); + } + let _: i64 = conn + .query_row("SELECT treecrdt_ensure_materialized()", [], |row| row.get(0)) + .unwrap(); + let seed = b"seed".to_vec(); + let allocated_first: Vec = conn + .query_row( + "SELECT treecrdt_allocate_order_key(?1, 'first', NULL, NULL, ?2)", + rusqlite::params![parent, seed], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(allocated_first, (1u16).to_be_bytes().to_vec()); +} + +#[test] +fn allocate_order_key_last_is_deterministic_for_single_gap() { + let conn = setup_conn(); + + let parent = node_bytes(0); + let node_a = node_bytes(1); + let key_a = (0xfffdu16).to_be_bytes().to_vec(); + let _: i64 = conn + .query_row( + "SELECT treecrdt_append_op(?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL)", + rusqlite::params![b"r1".to_vec(), 1i64, 1i64, "insert", parent, node_a, key_a], + |row| row.get(0), + ) + .unwrap(); + let _: i64 = conn + .query_row("SELECT treecrdt_ensure_materialized()", [], |row| row.get(0)) + .unwrap(); + let allocated_last: Vec = conn + .query_row( + "SELECT treecrdt_allocate_order_key(?1, 'last', NULL, NULL, ?2)", + rusqlite::params![parent, b"seed".to_vec()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(allocated_last, (0xfffeu16).to_be_bytes().to_vec()); +} + +#[test] +fn allocate_order_key_after_excludes_a_node() { + let conn = setup_conn(); + + let parent = node_bytes(0); + let node_a = node_bytes(1); + let node_b = node_bytes(2); + let node_c = node_bytes(3); + + let key_a = (1u16).to_be_bytes().to_vec(); + let key_b = (3u16).to_be_bytes().to_vec(); + let key_c = (5u16).to_be_bytes().to_vec(); + + // A(1), B(3), C(5) + for (counter, (node, order_key)) in [ + (1i64, (&node_a, &key_a)), + (2i64, (&node_b, &key_b)), + (3i64, (&node_c, &key_c)), + ] { + let _: i64 = conn + .query_row( + "SELECT treecrdt_append_op(?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL)", + rusqlite::params![b"r1".to_vec(), counter, counter, "insert", parent, node, order_key], + |row| row.get(0), + ) + .unwrap(); + } + let _: i64 = conn + .query_row("SELECT treecrdt_ensure_materialized()", [], |row| row.get(0)) + .unwrap(); + + // exclude() should skip B when placing after A. + let seed = b"seed".to_vec(); + let expected_excluding_b = + allocate_between(Some(&key_a), Some(&key_c), &seed).expect("allocate_between"); + let allocated_excluding_b: Vec = conn + .query_row( + "SELECT treecrdt_allocate_order_key(?1, 'after', ?2, ?3, ?4)", + rusqlite::params![parent, node_a, node_b, seed.clone()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(allocated_excluding_b, expected_excluding_b); +} + +#[test] +fn allocate_order_key_after_rejects_excluding_the_after_node() { + let conn = setup_conn(); + + let parent = node_bytes(0); + let node_a = node_bytes(1); + let node_b = node_bytes(2); + let key_a = (1u16).to_be_bytes().to_vec(); + let key_b = (3u16).to_be_bytes().to_vec(); + for (counter, (node, order_key)) in [(1i64, (&node_a, &key_a)), (2i64, (&node_b, &key_b))] { + let _: i64 = conn + .query_row( + "SELECT treecrdt_append_op(?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL)", + rusqlite::params![b"r1".to_vec(), counter, counter, "insert", parent, node, order_key], + |row| row.get(0), + ) + .unwrap(); + } + let _: i64 = conn + .query_row("SELECT treecrdt_ensure_materialized()", [], |row| row.get(0)) + .unwrap(); + + // after == exclude should error. + let seed = b"seed".to_vec(); + let bad: rusqlite::Result> = conn.query_row( + "SELECT treecrdt_allocate_order_key(?1, 'after', ?2, ?2, ?3)", + rusqlite::params![parent, node_a, seed.clone()], + |row| row.get(0), + ); + assert!(bad.is_err()); +} + +#[test] +fn allocate_order_key_trash_parent_returns_empty() { + let conn = setup_conn(); + + // TRASH parent returns empty order_key. + let trash = node_bytes(u128::MAX); + let allocated_trash: Vec = conn + .query_row( + "SELECT treecrdt_allocate_order_key(?1, 'first', NULL, NULL, ?2)", + rusqlite::params![trash, b"seed".to_vec()], + |row| row.get(0), + ) + .unwrap(); + assert!(allocated_trash.is_empty()); +} + +fn setup_conn() -> Connection { + let ext_path = find_extension().expect("extension dylib path"); + let conn = Connection::open_in_memory().unwrap(); + unsafe { + conn.load_extension_enable().unwrap(); + conn.load_extension(ext_path, Some("sqlite3_treecrdt_init")).unwrap(); + } + conn.query_row( + "SELECT treecrdt_set_doc_id('treecrdt-sqlite-ext-test')", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(); + conn +} + fn node_bytes(id: u128) -> Vec { id.to_be_bytes().to_vec() } diff --git a/packages/treecrdt-ts/src/sqlite.ts b/packages/treecrdt-ts/src/sqlite.ts index bfb90679..fe3a7b26 100644 --- a/packages/treecrdt-ts/src/sqlite.ts +++ b/packages/treecrdt-ts/src/sqlite.ts @@ -1,16 +1,14 @@ import type { SerializeNodeId, SerializeReplica, TreecrdtAdapter } from "./adapter.js"; import { - bytesToHex, decodeNodeId, decodeReplicaId, hexToBytes, nodeIdToBytes16, ROOT_NODE_ID_HEX, - TRASH_NODE_ID_HEX, replicaIdToBytes, } from "./ids.js"; import type { Operation, OperationKind, ReplicaId } from "./index.js"; -import { allocateBetween, makeOrderKeySeed } from "./order_key.js"; +import { makeOrderKeySeed } from "./order_key.js"; export type SqlCall = { sql: string; @@ -460,95 +458,20 @@ async function treecrdtSubtreeKnownState(runner: SqliteRunner, node: Uint8Array) return new TextEncoder().encode(json); } -async function treecrdtSelectChildOrderKeyHex(runner: SqliteRunner, parent: Uint8Array, node: Uint8Array): Promise { - const key = await runner.getText( - "SELECT CASE WHEN order_key IS NULL THEN NULL ELSE lower(hex(order_key)) END \ - FROM tree_nodes \ - WHERE node = ?1 AND parent = ?2 AND tombstone = 0 \ - LIMIT 1", - [node, parent] - ); - if (!key) throw new Error("missing order_key for child node"); - return key; -} - -async function treecrdtSelectFirstChildOrderKeyHex( - runner: SqliteRunner, - parent: Uint8Array, - excludeNode: Uint8Array | null -): Promise { - if (excludeNode) { - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 AND node <> ?2 \ - ORDER BY order_key, node \ - LIMIT 1", - [parent, excludeNode] - ); - } - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 \ - ORDER BY order_key, node \ - LIMIT 1", - [parent] - ); -} - -async function treecrdtSelectLastChildOrderKeyHex( - runner: SqliteRunner, - parent: Uint8Array, - excludeNode: Uint8Array | null -): Promise { - if (excludeNode) { - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 AND node <> ?2 \ - ORDER BY order_key DESC, node DESC \ - LIMIT 1", - [parent, excludeNode] - ); - } - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 \ - ORDER BY order_key DESC, node DESC \ - LIMIT 1", - [parent] - ); -} - -async function treecrdtSelectNextSiblingOrderKeyHex( +async function treecrdtAllocateOrderKey( runner: SqliteRunner, parent: Uint8Array, - afterOrderKey: Uint8Array, - afterNode: Uint8Array, - excludeNode: Uint8Array | null -): Promise { - if (excludeNode) { - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 AND node <> ?4 \ - AND (order_key > ?2 OR (order_key = ?2 AND node > ?3)) \ - ORDER BY order_key, node \ - LIMIT 1", - [parent, afterOrderKey, afterNode, excludeNode] - ); - } - return runner.getText( - "SELECT lower(hex(order_key)) \ - FROM tree_nodes \ - WHERE parent = ?1 AND tombstone = 0 \ - AND (order_key > ?2 OR (order_key = ?2 AND node > ?3)) \ - ORDER BY order_key, node \ - LIMIT 1", - [parent, afterOrderKey, afterNode] + placement: TreecrdtSqlitePlacement, + excludeNode: Uint8Array | null, + seed: Uint8Array +): Promise { + const afterNode = placement.type === "after" ? nodeIdToBytes16(placement.after) : null; + const hex = await runner.getText( + "SELECT lower(hex(treecrdt_allocate_order_key(?1, ?2, ?3, ?4, ?5)))", + [parent, placement.type, afterNode, excludeNode, seed] ); + if (hex === null) throw new Error("treecrdt_allocate_order_key returned NULL"); + return hexToBytes(hex); } export function createTreecrdtSqliteWriter(runner: SqliteRunner, opts: { replica: ReplicaId }): TreecrdtSqliteWriter { @@ -577,42 +500,11 @@ export function createTreecrdtSqliteWriter(runner: SqliteRunner, opts: { replica return { id: { replica, counter }, lamport }; }; - const orderKeyBoundaries = async (parentId: string, placement: TreecrdtSqlitePlacement, excludeNodeId: string | null) => { - if (parentId === TRASH_NODE_ID_HEX) return { left: null as Uint8Array | null, right: null as Uint8Array | null }; - - await treecrdtEnsureMaterialized(runner); - - const parent = nodeIdToBytes16(parentId); - const excludeNode = excludeNodeId ? nodeIdToBytes16(excludeNodeId) : null; - - if (placement.type === "first") { - const rightHex = await treecrdtSelectFirstChildOrderKeyHex(runner, parent, excludeNode); - return { left: null, right: rightHex ? hexToBytes(rightHex) : null }; - } - - if (placement.type === "last") { - const leftHex = await treecrdtSelectLastChildOrderKeyHex(runner, parent, excludeNode); - return { left: leftHex ? hexToBytes(leftHex) : null, right: null }; - } - - const afterNode = nodeIdToBytes16(placement.after); - if (excludeNode && bytesToHex(afterNode) === bytesToHex(excludeNode)) { - throw new Error("placement.after must not equal excluded node"); - } - const leftHex = await treecrdtSelectChildOrderKeyHex(runner, parent, afterNode); - const left = hexToBytes(leftHex); - const rightHex = await treecrdtSelectNextSiblingOrderKeyHex(runner, parent, left, afterNode, excludeNode); - const right = rightHex ? hexToBytes(rightHex) : null; - return { left, right }; - }; - const insert = async (parent: string, node: string, placement: TreecrdtSqlitePlacement, o: { payload?: Uint8Array } = {}) => { const meta = await nextMeta(); const payload = o.payload; const seed = makeOrderKeySeed(replica, meta.id.counter); - const boundaries = parent === TRASH_NODE_ID_HEX ? null : await orderKeyBoundaries(parent, placement, null); - const orderKey = - parent === TRASH_NODE_ID_HEX ? new Uint8Array() : allocateBetween(boundaries!.left, boundaries!.right, seed); + const orderKey = await treecrdtAllocateOrderKey(runner, nodeIdToBytes16(parent), placement, null, seed); const kind: OperationKind = payload !== undefined ? { type: "insert", parent, node, orderKey, payload } @@ -625,10 +517,7 @@ export function createTreecrdtSqliteWriter(runner: SqliteRunner, opts: { replica const move = async (node: string, newParent: string, placement: TreecrdtSqlitePlacement) => { const meta = await nextMeta(); const seed = makeOrderKeySeed(replica, meta.id.counter); - const boundaries = - newParent === TRASH_NODE_ID_HEX ? null : await orderKeyBoundaries(newParent, placement, node); - const orderKey = - newParent === TRASH_NODE_ID_HEX ? new Uint8Array() : allocateBetween(boundaries!.left, boundaries!.right, seed); + const orderKey = await treecrdtAllocateOrderKey(runner, nodeIdToBytes16(newParent), placement, nodeIdToBytes16(node), seed); const op: Operation = { meta, kind: { type: "move", node, newParent, orderKey } }; await treecrdtAppendOp(runner, op, nodeIdToBytes16, replicaIdToBytes); return op;