From 0e0cb5098d1a396622a6b38e4ec6202f260c7cf4 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 8 Apr 2026 17:11:38 +0200 Subject: [PATCH 01/23] Replace rusqlite with powersync_sqlite_nostd --- .github/workflows/ci.yml | 10 +- Cargo.lock | 2 + powersync/Cargo.toml | 7 +- powersync/src/db/connection.rs | 216 ++++++++++++++++++ powersync/src/db/core_extension.rs | 23 +- powersync/src/db/crud.rs | 20 +- powersync/src/db/internal.rs | 67 +++--- powersync/src/db/mod.rs | 21 +- powersync/src/db/pool.rs | 118 ++++++---- powersync/src/db/streams.rs | 14 +- powersync/src/env.rs | 21 +- powersync/src/error.rs | 27 ++- powersync/src/sync/download/actor.rs | 2 +- powersync/src/sync/download/sync_iteration.rs | 58 +++-- powersync/src/sync/status.rs | 14 +- powersync/src/sync/upload.rs | 63 ++--- 16 files changed, 483 insertions(+), 200 deletions(-) create mode 100644 powersync/src/db/connection.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42739d4..65549f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,13 @@ env: jobs: rust: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) name: Build and test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - name: Cache - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) uses: actions/cache@v4 with: path: | @@ -27,15 +26,14 @@ jobs: target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: rustup update stable && rustup default stable && rustup component add clippy - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - run: cargo build --verbose name: Building project - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - run: cargo clippy - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - run: cargo test --verbose name: Testing project - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) + + - name: Build without rusqlite + run: cargo build --no-default-features diff --git a/Cargo.lock b/Cargo.lock index e03c179..df3ea95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3099,8 +3099,10 @@ dependencies = [ "futures-lite", "futures-test", "log", + "num-traits", "pin-project-lite", "powersync_core", + "powersync_sqlite_nostd", "powersync_test_utils", "reqwest", "rusqlite", diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index ffbcadc..e6367f6 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -15,9 +15,12 @@ description = "Experimental PowerSync SDK for Rust applications." crate-type = ["lib"] [features] +default = ["rusqlite"] + tokio = ["dep:tokio"] smol = ["dep:async-io"] reqwest = ["dep:reqwest"] +rusqlite = ["dep:rusqlite"] ffi = [] [dependencies] @@ -33,7 +36,7 @@ reqwest = { version = "0.13.2", optional = true, features = ["stream"] } bytes = "1" log = "0.4.28" pin-project-lite = "0.2.16" -rusqlite = { version = "0.39.0", features = ["load_extension"] } +rusqlite = { version = "0.39.0", optional = true, features = ["load_extension"] } scopeguard = "1.2.0" serde = { version = "1.0.219", features = ["derive", "rc"] } serde_json = { version = "1.0.143", features = ["raw_value"] } @@ -42,6 +45,8 @@ tokio = { version = "1", features = ["time", "rt"], optional = true } url = "2.5.7" serde_with = "3.15.0" powersync_core = { version = "=0.4.12", features = ["static"] } +powersync_sqlite_nostd = { version = "=0.4.12", features = ["static"] } +num-traits = "0.2.19" [dev-dependencies] async-executor = "1.13.3" diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs new file mode 100644 index 0000000..7245c9a --- /dev/null +++ b/powersync/src/db/connection.rs @@ -0,0 +1,216 @@ +use crate::error::{PowerSyncError, RawPowerSyncError}; +use num_traits::cast::FromPrimitive; +use powersync_sqlite_nostd::bindings::sqlite3_open_v2; +use powersync_sqlite_nostd::{Connection, ManagedConnection, ManagedStmt, ResultCode, sqlite3}; +use std::ffi::{CStr, CString, c_int}; +use std::mem::MaybeUninit; +use std::path::Path; +use std::ptr::null; + +/// The SQLite connection used by the PowerSync Rust SDK. +/// +/// When the `rusqlite` feature is enabled, we use rusqlite connections. +/// Without that feature, we use raw `*mut sqlite3` pointers. Disabling that +/// feature can be useful when a custom SQLite build (e.g. `sqlite3mc`) needs +/// to be used with the SDK. +pub struct SqliteConnection { + #[cfg(not(feature = "rusqlite"))] + raw: RawSqliteConnection, + #[cfg(feature = "rusqlite")] + inner: rusqlite::Connection, +} + +impl SqliteConnection { + /// Returns the `*mut sqlite3` pointer from the inner connection. + /// + /// This method is unsafe since the pointer could be used to transform the connection + /// into an unexpected state. + #[cfg(feature = "rusqlite")] + pub unsafe fn handle(&self) -> *mut sqlite3 { + unsafe { self.inner.handle() }.cast() + } + + #[cfg(not(feature = "rusqlite"))] + pub unsafe fn handle(&self) -> *mut sqlite3 { + self.raw.0.db + } + + #[cfg(feature = "rusqlite")] + pub fn rusqlite_connection(&self) -> &rusqlite::Connection { + &self.inner + } + + #[cfg(feature = "rusqlite")] + pub fn rusqlite_connection_mut(&mut self) -> &mut rusqlite::Connection { + &mut self.inner + } + + /// Executes a SQL statement without parameters. + pub fn exec(&self, stmt: &CStr) -> Result<(), PowerSyncError> { + unsafe { + // Safety: We know the stmt is null-terminated. + self.handle().exec(stmt.as_ptr()) + } + .map_err(|rc| RawPowerSyncError::RawSqlite { + code: rc, + context: format!("Could not run {}", stmt.to_string_lossy()), + })?; + + Ok(()) + } + + pub fn prepare(&self, stmt: &str) -> Result { + unsafe { + // Safety: We're not doing anything that could close the connection. + self.handle() + } + .prepare_v2(stmt) + .map_err(|rc| { + RawPowerSyncError::RawSqlite { + code: rc, + context: format!("Could not prepare {stmt}"), + } + .into() + }) + } +} + +/// Utility for running a block in a transaction. +pub struct TransactionGuard<'a> { + pub inner: &'a mut SqliteConnection, + active: bool, +} + +impl<'a> TransactionGuard<'a> { + pub fn new(connection: &'a mut SqliteConnection) -> Result { + if !unsafe { connection.handle().get_autocommit() } { + return Err(PowerSyncError::argument_error( + "Connection already in transaction", + )); + } + + connection.exec(c"BEGIN")?; + Ok(TransactionGuard { + inner: connection, + active: true, + }) + } + + pub fn commit(mut self) -> Result<(), PowerSyncError> { + self.active = false; + self.inner.exec(c"COMMIT") + } + + fn rollback_internal(&mut self) -> Result<(), PowerSyncError> { + self.inner.exec(c"ROLLBACK") + } +} + +impl Drop for TransactionGuard<'_> { + fn drop(&mut self) { + if self.active { + // Rollback if the transaction hasn't explicitly been committed. + let _ = self.rollback_internal(); + } + } +} + +#[cfg(feature = "rusqlite")] +impl From for SqliteConnection { + fn from(value: rusqlite::Connection) -> Self { + Self { inner: value } + } +} + +#[cfg(not(feature = "rusqlite"))] +impl From for SqliteConnection { + fn from(value: RawSqliteConnection) -> Self { + Self { raw: value } + } +} + +#[cfg(feature = "rusqlite")] +impl From for SqliteConnection { + fn from(value: RawSqliteConnection) -> Self { + let conn = value.0.db; + + // Don't call sqlite3_close_v2, we want to transfer ownership. + let _ = std::mem::ManuallyDrop::new(value.0); + + Self { + inner: unsafe { + // Safety: The never dropped ManuallyDrop transfers ownership from the + // RawSqliteConnection to rusqlite. + rusqlite::Connection::from_handle_owned(conn.cast()) + } + .unwrap(), + } + } +} + +pub struct RawSqliteConnection(ManagedConnection); + +unsafe impl Send for RawSqliteConnection {} + +impl RawSqliteConnection { + pub fn open(path: &CStr, flags: u32) -> Result { + let mut db = MaybeUninit::<*mut sqlite3>::uninit(); + let rc = ResultCode::from_i32(unsafe { + sqlite3_open_v2(path.as_ptr(), db.as_mut_ptr(), flags as c_int, null()) + }) + .unwrap(); + + if rc == ResultCode::OK { + Ok(Self(ManagedConnection { + db: unsafe { + // sqlite3_open_v2 returned 0, so SQLite will have written the pointer. + db.assume_init() + }, + })) + } else { + Err(RawPowerSyncError::RawSqlite { + code: rc, + context: format!("Could not open database {}", path.to_string_lossy()), + } + .into()) + } + } + + pub fn open_path>(path: P, flags: u32) -> Result { + Self::open(path_to_cstring(path.as_ref())?.as_ref(), flags) + } +} + +pub fn exec_stmt(stmt: ManagedStmt) -> Result<(), PowerSyncError> { + loop { + return match stmt.step() { + Err(e) => Err(RawPowerSyncError::RawSqlite { + code: e, + context: format!("Stepping through {}", stmt.sql().unwrap_or("unknown SQL")), + } + .into()), + Ok(ResultCode::ROW) => continue, + _ => Ok(()), + }; + } +} + +#[cfg(unix)] +fn path_to_cstring(p: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + Ok( + CString::new(p.as_os_str().as_bytes()).map_err(|_| RawPowerSyncError::ArgumentError { + desc: format!("Invalid path: {p:?}").into(), + })?, + ) +} + +#[cfg(not(unix))] +fn path_to_cstring(p: &Path) -> Result { + let s = p.to_str().ok_or_else(|| Error::InvalidPath(p.to_owned()))?; + Ok( + CString::new(s).map_err(|_| RawPowerSyncError::ArgumentError { + desc: format!("Invalid path: {p:?}").into(), + })?, + ) +} diff --git a/powersync/src/db/core_extension.rs b/powersync/src/db/core_extension.rs index ccc9a19..28d051e 100644 --- a/powersync/src/db/core_extension.rs +++ b/powersync/src/db/core_extension.rs @@ -1,8 +1,7 @@ -use std::{fmt::Display, str::FromStr}; - -use rusqlite::{Connection, params}; - +use crate::db::connection::SqliteConnection; use crate::error::{PowerSyncError, RawPowerSyncError}; +use powersync_sqlite_nostd::ResultCode; +use std::{fmt::Display, str::FromStr}; #[derive(Clone, PartialEq, PartialOrd, Eq, Ord)] pub struct CoreExtensionVersion { @@ -35,17 +34,13 @@ impl CoreExtensionVersion { } } - pub(crate) fn check_from_db(conn: &Connection) -> Result { - let version = - conn.prepare("SELECT powersync_rs_version()")? - .query_row(params![], |row| { - let value = row.get_ref(0)?; - value - .as_str()? - .parse::() - .map_err(|_| rusqlite::Error::InvalidQuery) - })?; + pub(crate) fn check_from_db(conn: &SqliteConnection) -> Result { + let stmt = conn.prepare("SELECT powersync_rs_version()")?; + let ResultCode::ROW = stmt.step()? else { + panic!("Expected row") + }; + let version = stmt.column_text(0)?.parse::()?; version.validate()?; Ok(version) } diff --git a/powersync/src/db/crud.rs b/powersync/src/db/crud.rs index 8a3e7b2..763c4bd 100644 --- a/powersync/src/db/crud.rs +++ b/powersync/src/db/crud.rs @@ -3,12 +3,12 @@ use std::task::{Context, Poll}; use futures_lite::{FutureExt, Stream, ready}; use pin_project_lite::pin_project; -use rusqlite::params; +use powersync_sqlite_nostd::ResultCode; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::PowerSyncDatabase; -use crate::error::{PowerSyncError, RawPowerSyncError}; +use crate::error::PowerSyncError; /// All local writes that were made in a single SQLite transaction. pub struct CrudTransaction<'a> { @@ -147,17 +147,19 @@ impl<'a> CrudTransactionStream<'a> { ) -> Result)>, PowerSyncError> { let last = last.unwrap_or(-1); let reader = db.reader().await?; - let mut stmt = reader.prepare_cached(Self::SQL)?; - let mut rows = stmt.query(params![last])?; + let conn = reader.sqlite_connection(); + let stmt = conn.prepare(Self::SQL)?; + stmt.bind_int64(1, last)?; + let mut crud_entries = vec![]; let mut last = None::<(i64, i64)>; - while let Some(row) = rows.next()? { - let id: i64 = row.get(0)?; - let tx_id: i64 = row.get(1)?; - let data = row.get_ref(2)?.as_str().map_err(RawPowerSyncError::from)?; - last = Some((id, tx_id)); + while let ResultCode::ROW = stmt.step()? { + let id = stmt.column_int64(0); + let tx_id = stmt.column_int64(1); + let data = stmt.column_text(2)?; + last = Some((id, tx_id)); crud_entries.push(CrudEntry::parse(id, tx_id, data)?); } diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index e9207b7..49bd198 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -1,14 +1,4 @@ -use event_listener::EventListener; -use futures_lite::{FutureExt, Stream, StreamExt, ready}; -use rusqlite::{Connection, params}; -use std::sync::{Mutex, Weak}; -use std::time::Duration; -use std::{ - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; - +use crate::db::connection::{SqliteConnection, TransactionGuard, exec_stmt}; use crate::schema::SchemaOrCustom; use crate::{ db::{ @@ -19,6 +9,16 @@ use crate::{ sync::{MAX_OP_ID, coordinator::SyncCoordinator, status::SyncStatus, status::SyncStatusData}, util::SharedFuture, }; +use event_listener::EventListener; +use futures_lite::{FutureExt, Stream, StreamExt, ready}; +use powersync_sqlite_nostd::{Destructor, ResultCode}; +use std::sync::{Mutex, Weak}; +use std::time::Duration; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; pub struct InnerPowerSyncState { /// External dependencies (timers and HTTP clients) used to implement SDK functionality. @@ -62,13 +62,13 @@ impl InnerPowerSyncState { self.did_initialize .run(|| async { let conn = pool.writer().await; - CoreExtensionVersion::check_from_db(&conn)?; + let conn = conn.sqlite_connection(); + CoreExtensionVersion::check_from_db(conn)?; - conn.prepare("SELECT powersync_init()")? - .query_row(params![], |_| Ok(()))?; + conn.exec(c"SELECT powersync_init()")?; - self.update_schema_internal(&conn)?; - self.status.update(|old| old.resolve_offline_state(&conn))?; + self.update_schema_internal(conn)?; + self.status.update(|old| old.resolve_offline_state(conn))?; Ok(()) }) @@ -76,14 +76,16 @@ impl InnerPowerSyncState { .clone() } - fn update_schema_internal(&self, conn: &Connection) -> Result<(), PowerSyncError> { + fn update_schema_internal(&self, conn: &SqliteConnection) -> Result<(), PowerSyncError> { if let SchemaOrCustom::Schema(schema) = self.schema.as_ref() { schema.validate()?; }; let serialized_schema = serde_json::to_string(&self.schema)?; - conn.prepare("SELECT powersync_replace_schema(?)")? - .query_one(params![serialized_schema], |_| Ok(()))?; + let stmt = conn.prepare("SELECT powersync_replace_schema(?)")?; + stmt.bind_text(1, &serialized_schema, Destructor::STATIC)?; + exec_stmt(stmt)?; + // TODO: Update readers? Should be fine at the moment because we're only doing this during // initialization. Ok(()) @@ -97,25 +99,32 @@ impl InnerPowerSyncState { write_checkpoint: Option, ) -> Result<(), PowerSyncError> { let mut writer = self.writer().await?; - let writer = writer.transaction()?; + let writer = TransactionGuard::new(writer.sqlite_connection_mut())?; + + { + let stmt = writer.inner.prepare("DELETE FROM ps_crud WHERE id <= ?")?; + stmt.bind_int64(1, last_client_id)?; + exec_stmt(stmt)?; + } - writer.execute("DELETE FROM ps_crud WHERE id <= ?", params![last_client_id])?; let mut target_op: i64 = MAX_OP_ID; if let Some(write_checkpoint) = write_checkpoint { // If there are no remaining crud items we can set the target op to the checkpoint. - let mut stmt = writer.prepare("SELECT 1 FROM ps_crud LIMIT 1")?; - if stmt.query(params![])?.next()?.is_none() { + let stmt = writer.inner.prepare("SELECT 1 FROM ps_crud LIMIT 1")?; + if let ResultCode::OK = stmt.step()? { target_op = write_checkpoint; } } - writer.execute( - "UPDATE ps_buckets SET target_op = ? WHERE name = ?", - params![target_op, "$local"], - )?; - writer.commit()?; + Self::set_local_target_op(writer.inner, target_op)?; + writer.commit() + } - Ok(()) + pub fn set_local_target_op(writer: &SqliteConnection, op: i64) -> Result<(), PowerSyncError> { + let stmt = writer.prepare("UPDATE ps_buckets SET target_op = ? WHERE name = ?")?; + stmt.bind_int64(1, op)?; + stmt.bind_text(2, "$local", Destructor::STATIC)?; + exec_stmt(stmt) } pub async fn reader(&self) -> Result { diff --git a/powersync/src/db/mod.rs b/powersync/src/db/mod.rs index 4f843a3..bae02a9 100644 --- a/powersync/src/db/mod.rs +++ b/powersync/src/db/mod.rs @@ -17,11 +17,10 @@ use crate::{ error::PowerSyncError, sync::{download::DownloadActor, status::SyncStatusData, upload::UploadActor}, }; -use futures_lite::stream::{once, once_future}; use futures_lite::{FutureExt, Stream, StreamExt}; -use rusqlite::{Params, Statement, params}; mod async_support; +pub(crate) mod connection; pub mod core_extension; pub mod crud; pub(crate) mod internal; @@ -147,14 +146,16 @@ impl PowerSyncDatabase { /// This method is a core building block for reactive applications with PowerSync - since it /// updates automatically, all writes (regardless of whether they're local or due to synced /// writes from your backend) are reflected. - pub fn watch_statement( + #[cfg(feature = "rusqlite")] + pub fn watch_statement( &self, sql: String, params: P, read: F, ) -> impl Stream> + 'static where - for<'a> F: (Fn(&'a mut Statement, P) -> Result) + 'static + Clone, + for<'a> F: + (Fn(&'a mut rusqlite::Statement, P) -> Result) + 'static + Clone, { // Find and watch referenced tables. We assume the set of read tables is fixed for a given // SQL query and parameters. We also want this to emit initially without an update so that @@ -186,14 +187,15 @@ impl PowerSyncDatabase { }) } + #[cfg(feature = "rusqlite")] fn emit_on_statement_changes( &self, emit_initially: bool, sql: String, - params: impl Params + 'static, + params: impl rusqlite::Params + 'static, ) -> impl Stream> + 'static { // Stream emitting referenced tables once. - let tables = once_future(self.clone().find_tables(sql, params)); + let tables = futures_lite::stream::once_future(self.clone().find_tables(sql, params)); // Stream emitting updates, or a single error if we couldn't resolve tables. let db = self.clone(); @@ -202,14 +204,15 @@ impl PowerSyncDatabase { .watch_tables(emit_initially, referenced_tables) .map(Ok) .boxed(), - Err(e) => once(Err(e)).boxed(), + Err(e) => futures_lite::stream::once(Err(e)).boxed(), }) } /// Finds all tables that are used in a given select statement. /// /// This can be used together with [watch_tables] to build an auto-updating stream of queries. - async fn find_tables( + #[cfg(feature = "rusqlite")] + async fn find_tables( self, sql: impl Into>, params: P, @@ -231,7 +234,7 @@ impl PowerSyncDatabase { && matches!(p3.as_i64(), Ok(0)) && let Ok(page) = p2.as_i64() { - let mut found_table = find_table_stmt.query(params![page])?; + let mut found_table = find_table_stmt.query(rusqlite::params![page])?; if let Some(found_table) = found_table.next()? { let table_name: String = found_table.get(0)?; found_tables.insert(table_name); diff --git a/powersync/src/db/pool.rs b/powersync/src/db/pool.rs index 6e5c52c..763c51f 100644 --- a/powersync/src/db/pool.rs +++ b/powersync/src/db/pool.rs @@ -1,16 +1,16 @@ -use std::{ - collections::HashSet, - mem::MaybeUninit, - ops::{Deref, DerefMut}, - path::Path, - sync::Arc, -}; +#[cfg(feature = "rusqlite")] +use std::ops::{Deref, DerefMut}; +use std::{collections::HashSet, mem::MaybeUninit, path::Path, sync::Arc}; use async_channel::{Receiver, Sender}; use async_lock::{Mutex, MutexGuardArc}; -use rusqlite::{Connection, Error, params}; +use powersync_sqlite_nostd::ResultCode; +use powersync_sqlite_nostd::bindings::{ + SQLITE_OPEN_CREATE, SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, +}; use serde::Deserialize; +use crate::db::connection::{RawSqliteConnection, SqliteConnection}; use crate::{db::watch::TableNotifiers, error::PowerSyncError}; /// A raw connection pool, giving out both synchronous and asynchronous leases to SQLite @@ -21,45 +21,49 @@ pub struct ConnectionPool { } impl ConnectionPool { - fn prepare_writer(connection: Connection) -> Arc> { + fn prepare_writer(connection: SqliteConnection) -> Arc> { connection - .prepare("SELECT powersync_update_hooks('install');") - .expect("should prepare statement for update hooks") - .query_one(params![], |_| Ok(())) + .exec(c"SELECT powersync_update_hooks('install');") .expect("could not install update hook"); Arc::new(Mutex::new(connection)) } pub fn open>(path: P) -> Result { - let writer = Connection::open(&path)?; + let writer = SqliteConnection::from(RawSqliteConnection::open_path( + &path, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + )?); - writer.pragma_update(None, "journal_mode", "WAL")?; - writer.pragma_update(None, "journal_size_limit", 6 * 1024 * 1024)?; - writer.pragma_update(None, "busy_timeout", 30_000)?; - writer.pragma_update(None, "cache_size", 50 * 1024)?; + writer.exec(c"PRAGMA journal_mode = WAL")?; + writer.exec(c"PRAGMA journal_size_limit = 6291456")?; // 6 * 1024 * 1024 + writer.exec(c"PRAGMA busy_timeout = 30000")?; + writer.exec(c"PRAGMA cache_size = -51200")?; // -(50 * 1024) let mut readers = vec![]; for _ in 0..5 { - let reader = Connection::open(&path)?; - reader.pragma_update(None, "query_only", true)?; + let reader = SqliteConnection::from(RawSqliteConnection::open_path( + &path, + SQLITE_OPEN_READONLY, + )?); + reader.exec(c"PRAGMA query_only = 1")?; readers.push(reader); } Ok(Self::wrap_connections(writer, readers)) } - /// Creates a pool backed by a single write ad multiple reader connections. + /// Creates a pool backed by a single write and multiple reader connections. /// /// Connections will be configured to use WAL mode. pub fn wrap_connections( - writer: Connection, - readers: impl IntoIterator, + writer: impl Into, + readers: impl IntoIterator>, ) -> Self { - let writer = Self::prepare_writer(writer); - let (release, consume) = async_channel::unbounded::(); + let writer = Self::prepare_writer(writer.into()); + let (release, consume) = async_channel::unbounded::(); for reader in readers { - release.send_blocking(reader).unwrap(); + release.send_blocking(reader.into()).unwrap(); } Self { @@ -75,10 +79,10 @@ impl ConnectionPool { } /// Creates a connection pool backed by a single sqlite connection. - pub fn single_connection(conn: Connection) -> Self { + pub fn single_connection(conn: impl Into) -> Self { Self { state: Arc::new(PoolState { - writer: Self::prepare_writer(conn), + writer: Self::prepare_writer(conn.into()), readers: None, table_notifiers: Default::default(), }), @@ -115,19 +119,23 @@ impl ConnectionPool { fn take_update_notifications( &self, - writer: &Connection, - ) -> Result { - let mut stmt = writer.prepare_cached("SELECT powersync_update_hooks('get');")?; - let rows: String = stmt.query_one(params![], |row| row.get(0))?; + writer: &SqliteConnection, + ) -> Result { + let stmt = writer.prepare("SELECT powersync_update_hooks('get');")?; - let updates = serde_json::from_str::(&rows) - .map_err(|_| Error::InvalidQuery)?; + match stmt.step()? { + ResultCode::ROW => { + let updates = + serde_json::from_str::(stmt.column_text(0)?)?; - if !updates.tables.is_empty() { - self.state.table_notifiers.notify_updates(&updates.tables); - } + if !updates.tables.is_empty() { + self.state.table_notifiers.notify_updates(&updates.tables); + } - Ok(updates) + Ok(updates) + } + code => Err(code.into()), + } } async fn take_connection_async(&self, writer: bool) -> LeasedConnection { @@ -179,23 +187,23 @@ pub struct SqliteUpdateNotification { } struct PoolState { - writer: Arc>, + writer: Arc>, readers: Option, table_notifiers: Arc, } struct PoolReaders { - take_reader: Receiver, - release_reader: Sender, + take_reader: Receiver, + release_reader: Sender, } enum OwnedConnectionLease { Writer { - connection: MutexGuardArc, + connection: MutexGuardArc, pool: ConnectionPool, }, Reader { - connection: MaybeUninit, + connection: MaybeUninit, pool: ConnectionPool, }, } @@ -233,10 +241,8 @@ pub struct LeasedConnection { inner: OwnedConnectionLease, } -impl Deref for LeasedConnection { - type Target = Connection; - - fn deref(&self) -> &Self::Target { +impl LeasedConnection { + pub(crate) fn sqlite_connection(&self) -> &SqliteConnection { match &self.inner { OwnedConnectionLease::Writer { connection, .. } => connection, OwnedConnectionLease::Reader { connection, .. } => unsafe { @@ -245,10 +251,8 @@ impl Deref for LeasedConnection { }, } } -} -impl DerefMut for LeasedConnection { - fn deref_mut(&mut self) -> &mut Self::Target { + pub(crate) fn sqlite_connection_mut(&mut self) -> &mut SqliteConnection { match &mut self.inner { OwnedConnectionLease::Writer { connection, .. } => connection, OwnedConnectionLease::Reader { connection, .. } => unsafe { @@ -258,3 +262,19 @@ impl DerefMut for LeasedConnection { } } } + +#[cfg(feature = "rusqlite")] +impl Deref for LeasedConnection { + type Target = rusqlite::Connection; + + fn deref(&self) -> &Self::Target { + self.sqlite_connection().rusqlite_connection() + } +} + +#[cfg(feature = "rusqlite")] +impl DerefMut for LeasedConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + self.sqlite_connection_mut().rusqlite_connection_mut() + } +} diff --git a/powersync/src/db/streams.rs b/powersync/src/db/streams.rs index 2d4d178..474835f 100644 --- a/powersync/src/db/streams.rs +++ b/powersync/src/db/streams.rs @@ -1,3 +1,4 @@ +use crate::db::connection::{TransactionGuard, exec_stmt}; use crate::{ PowerSyncDatabase, StreamPriority, db::internal::InnerPowerSyncState, @@ -8,7 +9,7 @@ use crate::{ }, util::SerializedJsonObject, }; -use rusqlite::params; +use powersync_sqlite_nostd::Destructor; use std::{ cell::Cell, collections::HashMap, @@ -85,14 +86,13 @@ impl<'a> SyncStream<'a> { let serialized = serde_json::to_string(cmd)?; let mut writer = self.db.writer().await?; - let writer = writer.transaction()?; + let writer = TransactionGuard::new(writer.sqlite_connection_mut())?; { - let mut stmt = writer.prepare_cached("SELECT powersync_control(?, ?)")?; - let mut rows = stmt.query(params!["subscriptions", serialized])?; - - // Ignore results. - while rows.next()?.is_some() {} + let stmt = writer.inner.prepare("SELECT powersync_control(?, ?)")?; + stmt.bind_text(1, "subscriptions", Destructor::STATIC)?; + stmt.bind_text(2, &serialized, Destructor::STATIC)?; + exec_stmt(stmt)?; } writer.commit()?; diff --git a/powersync/src/env.rs b/powersync/src/env.rs index daa8e74..edc5d60 100644 --- a/powersync/src/env.rs +++ b/powersync/src/env.rs @@ -1,10 +1,10 @@ -use std::{pin::Pin, time::Duration}; - -use powersync_core::powersync_init_static; - use super::db::pool::ConnectionPool; -use crate::error::PowerSyncError; +use crate::error::{PowerSyncError, RawPowerSyncError}; use crate::http::HttpClient; +use num_traits::FromPrimitive; +use powersync_core::powersync_init_static; +use powersync_sqlite_nostd::ResultCode; +use std::{pin::Pin, time::Duration}; /// All external dependencies required for the PowerSync SDK. /// @@ -38,13 +38,12 @@ impl PowerSyncEnvironment { /// This needs to be invoked before using the PowerSync SDK. It can safely be called multiple /// times. pub fn powersync_auto_extension() -> Result<(), PowerSyncError> { - let rc = powersync_init_static(); - match rc { + match powersync_init_static() { 0 => Ok(()), - _ => Err(rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rc), - Some("Loading PowerSync core extension failed".into()), - ) + code => Err(RawPowerSyncError::RawSqlite { + code: ResultCode::from_i32(code).unwrap(), + context: "Loading PowerSync core extension failed".into(), + } .into()), } } diff --git a/powersync/src/error.rs b/powersync/src/error.rs index 3f5e280..35065bc 100644 --- a/powersync/src/error.rs +++ b/powersync/src/error.rs @@ -1,10 +1,8 @@ +use powersync_sqlite_nostd::ResultCode; use std::error::Error; use std::io; use std::sync::Arc; use std::{borrow::Cow, fmt::Display}; - -use rusqlite::Error as SqliteError; -use rusqlite::types::FromSqlError; use thiserror::Error; pub type Result = std::result::Result; @@ -24,8 +22,9 @@ impl PowerSyncError { } } -impl From for PowerSyncError { - fn from(value: SqliteError) -> Self { +#[cfg(feature = "rusqlite")] +impl From for PowerSyncError { + fn from(value: rusqlite::Error) -> Self { RawPowerSyncError::Sqlite { inner: value }.into() } } @@ -68,13 +67,17 @@ pub(crate) enum RawPowerSyncError { #[error("invalid argument: {desc}")] ArgumentError { desc: Cow<'static, str> }, /// An inner SQLite call failed. + #[cfg(feature = "rusqlite")] #[error("SQLite: {inner}")] - Sqlite { inner: SqliteError }, + Sqlite { inner: rusqlite::Error }, + #[error("SQLite: {context} failed with {code}")] + RawSqlite { code: ResultCode, context: String }, /// Reading a value from SQLite failed. + #[cfg(feature = "rusqlite")] #[error("Reading from SQLite: {inner}")] FromSql { #[from] - inner: FromSqlError, + inner: rusqlite::types::FromSqlError, }, /// The version of the core extension linked into the application is unexpected. /// @@ -106,3 +109,13 @@ pub(crate) enum RawPowerSyncError { #[error("Unexpected HTTP status code from PowerSync service: {code}")] UnexpectedStatusCode { code: u16 }, } + +impl From for PowerSyncError { + fn from(value: ResultCode) -> Self { + RawPowerSyncError::RawSqlite { + code: value, + context: String::new(), + } + .into() + } +} diff --git a/powersync/src/sync/download/actor.rs b/powersync/src/sync/download/actor.rs index 5a57235..ba31b09 100644 --- a/powersync/src/sync/download/actor.rs +++ b/powersync/src/sync/download/actor.rs @@ -96,7 +96,7 @@ impl DownloadActor { let writer = self.db.writer().await?; self.db .status - .update(|s| s.resolve_offline_state(&writer))?; + .update(|s| s.resolve_offline_state(writer.sqlite_connection()))?; Ok::<(), PowerSyncError>(()) } diff --git a/powersync/src/sync/download/sync_iteration.rs b/powersync/src/sync/download/sync_iteration.rs index 264a077..ac90837 100644 --- a/powersync/src/sync/download/sync_iteration.rs +++ b/powersync/src/sync/download/sync_iteration.rs @@ -2,13 +2,11 @@ use std::sync::Arc; use futures_lite::{StreamExt, future, stream::Boxed as BoxedStream}; use log::{debug, info, trace, warn}; -use rusqlite::{ - Connection, ToSql, params, - types::{ToSqlOutput, ValueRef}, -}; +use powersync_sqlite_nostd::{Destructor, ManagedStmt, ResultCode}; use serde::Serialize; use serde_json::value::RawValue; +use crate::db::connection::{SqliteConnection, TransactionGuard}; use crate::schema::SchemaOrCustom; use crate::{ SyncOptions, @@ -55,7 +53,7 @@ impl DownloadClient { trace!("Handling event {event:?}"); let mut conn = self.db.writer().await?; - for instr in event.invoke_control(&mut conn)? { + for instr in event.invoke_control(conn.sqlite_connection_mut())? { trace!("Handling instruction {instr:?}"); match instr { @@ -181,23 +179,28 @@ impl DownloadEvent { /// Forwards the event to the core extension, and returns instructions that the SDK needs to /// perform. - pub fn invoke_control(self, conn: &mut Connection) -> Result, PowerSyncError> { - let tx = conn.transaction()?; + pub fn invoke_control( + self, + conn: &mut SqliteConnection, + ) -> Result, PowerSyncError> { + let tx = TransactionGuard::new(conn)?; let instructions = { - let mut stmt = tx.prepare_cached("SELECT powersync_control(?, ?)")?; + let stmt = tx.inner.prepare("SELECT powersync_control(?, ?)")?; let (op, arg) = self.into_powersync_control_argument(); - let mut rows = stmt.query(params![op, arg])?; - let Some(row) = rows.next()? else { - return Err(rusqlite::Error::QueryReturnedNoRows)?; - }; + stmt.bind_text(1, op, Destructor::STATIC)?; + arg.bind_to(&stmt, 2)?; - let instructions = row.get_ref(0)?.as_str().map_err(|_| { - PowerSyncError::argument_error("Could not read powersync_control instructions") - })?; + if let ResultCode::ROW = stmt.step()? { + let instructions = stmt.column_text(0).map_err(|_| { + PowerSyncError::argument_error("Could not read powersync_control instructions") + })?; - serde_json::from_str(instructions)? + serde_json::from_str(instructions)? + } else { + panic!("Statement should have returned a row") + } }; tx.commit()?; @@ -212,14 +215,21 @@ enum PowerSyncControlArgument { Bytes(Vec), } -impl ToSql for PowerSyncControlArgument { - fn to_sql(&self) -> rusqlite::Result> { - Ok(ToSqlOutput::Borrowed(match self { - PowerSyncControlArgument::Null => ValueRef::Null, - PowerSyncControlArgument::StaticString(str) => ValueRef::Text(str.as_bytes()), - PowerSyncControlArgument::String(str) => ValueRef::Text(str.as_bytes()), - PowerSyncControlArgument::Bytes(items) => ValueRef::Blob(items), - })) +impl PowerSyncControlArgument { + fn bind_to(&self, stmt: &ManagedStmt, index: i32) -> Result<(), ResultCode> { + match self { + PowerSyncControlArgument::Null => stmt.bind_null(index), + PowerSyncControlArgument::StaticString(str) => { + stmt.bind_text(index, str, Destructor::STATIC) + } + PowerSyncControlArgument::String(str) => { + stmt.bind_text(index, &str, Destructor::STATIC) + } + PowerSyncControlArgument::Bytes(bytes) => { + stmt.bind_blob(index, &bytes, Destructor::STATIC) + } + }?; + Ok(()) } } diff --git a/powersync/src/sync/status.rs b/powersync/src/sync/status.rs index 16b2a43..b3a7575 100644 --- a/powersync/src/sync/status.rs +++ b/powersync/src/sync/status.rs @@ -7,8 +7,9 @@ use std::{ }; use event_listener::{Event, EventListener}; -use rusqlite::{Connection, params}; +use powersync_sqlite_nostd::ResultCode; +use crate::db::connection::SqliteConnection; use crate::{ error::PowerSyncError, sync::{ @@ -170,12 +171,15 @@ impl SyncStatusData { pub(crate) fn resolve_offline_state( &mut self, - conn: &Connection, + conn: &SqliteConnection, ) -> Result<(), PowerSyncError> { - let mut stmt = conn.prepare_cached("SELECT powersync_offline_sync_status()")?; - let raw_status: String = stmt.query_row(params![], |row| row.get(0))?; + let stmt = conn.prepare("SELECT powersync_offline_sync_status()")?; + let ResultCode::ROW = stmt.step()? else { + panic!("Expected row"); + }; - self.update_from_core(serde_json::from_str(&raw_status)?); + let raw_status = stmt.column_text(0)?; + self.update_from_core(serde_json::from_str(raw_status)?); Ok(()) } diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 57cd674..ef547f7 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -5,8 +5,9 @@ use futures_lite::{ future::{self, Boxed}, }; use log::{debug, info, warn}; -use rusqlite::{Connection, params}; +use powersync_sqlite_nostd::{Destructor, ResultCode}; +use crate::db::connection::{SqliteConnection, TransactionGuard}; use crate::db::watch::ListenerConfiguration; use crate::sync::coordinator::SyncCoordinator; use crate::{ @@ -291,53 +292,62 @@ impl<'a> CrudUpload<'a> { async fn oldest_crud_item_id(&self) -> Result, PowerSyncError> { let reader = self.db.reader().await?; - Self::read_oldest_crud_item_id(&reader) + Self::read_oldest_crud_item_id(reader.sqlite_connection()) } async fn get_write_checkpoint(&self) -> Result { let client_id = { let reader = self.db.reader().await?; - let mut stmt = reader.prepare("SELECT powersync_client_id()")?; - let id: String = stmt.query_one(params![], |row| row.get(0))?; - id + + let stmt = reader + .sqlite_connection() + .prepare("SELECT powersync_client_id()")?; + let ResultCode::ROW = stmt.step()? else { + panic!("Expected row"); + }; + + stmt.column_text(0)?.to_string() }; let credentials = self.connector.fetch_credentials().await?; write_checkpoint(&self.db, &client_id, credentials).await } - fn read_oldest_crud_item_id(conn: &Connection) -> Result, PowerSyncError> { - let mut stmt = conn.prepare("SELECT id FROM ps_crud ORDER BY id LIMIT 1")?; - let mut rows = stmt.query(params![])?; + fn read_oldest_crud_item_id(conn: &SqliteConnection) -> Result, PowerSyncError> { + let stmt = conn.prepare("SELECT id FROM ps_crud ORDER BY id LIMIT 1")?; - Ok(match rows.next()? { - None => None, - Some(row) => Some(row.get(0)?), + Ok(match stmt.step()? { + ResultCode::ROW => Some(stmt.column_int64(0)), + _ => None, }) } - fn ps_crud_sequence(conn: &Connection) -> Result, PowerSyncError> { - let mut seq_before = conn.prepare("SELECT seq FROM main.sqlite_sequence WHERE name = ?")?; - let mut seq_before = seq_before.query(params!["ps_crud"])?; - let Some(row) = seq_before.next()? else { + fn ps_crud_sequence(conn: &SqliteConnection) -> Result, PowerSyncError> { + let seq_before = conn.prepare("SELECT seq FROM main.sqlite_sequence WHERE name = ?")?; + seq_before.bind_text(0, "ps_crud", Destructor::STATIC)?; + + let ResultCode::ROW = seq_before.step()? else { return Ok(None); }; - Ok(row.get(0)?) + Ok(Some(seq_before.column_int64(0))) } async fn sequence_for_checkpoint( &self, ) -> Result, PowerSyncError> { let reader = self.db.reader().await?; + let reader = reader.sqlite_connection(); { - let mut stmt = + let stmt = reader.prepare("SELECT 1 FROM ps_buckets WHERE name = ? AND target_op = ?")?; - let mut rows = stmt.query(params!["$local", MAX_OP_ID])?; - if rows.next()?.is_none() { + stmt.bind_text(1, "$local", Destructor::STATIC)?; + stmt.bind_int64(2, MAX_OP_ID)?; + + let ResultCode::ROW = stmt.step()? else { // Nothing to update. return Ok(None); - } + }; } let seq_before = Self::ps_crud_sequence(&reader)?; @@ -366,15 +376,15 @@ impl PendingCheckpointRequest { info!("Updating target to checkpoint {}", self.crud_sequence); let mut writer = db.writer().await?; - let writer = writer.transaction()?; + let writer = TransactionGuard::new(writer.sqlite_connection_mut())?; - if CrudUpload::read_oldest_crud_item_id(&writer)?.is_some() { + if CrudUpload::read_oldest_crud_item_id(writer.inner)?.is_some() { warn!("ps_crud is not empty, won't advance target"); return Ok(()); } - let seq_after = - CrudUpload::ps_crud_sequence(&writer)?.expect("sqlite sequence should not be empty"); + let seq_after = CrudUpload::ps_crud_sequence(writer.inner)? + .expect("sqlite sequence should not be empty"); if seq_after != self.crud_sequence { debug!( @@ -384,10 +394,7 @@ impl PendingCheckpointRequest { return Ok(()); } - writer.execute( - "UPDATE ps_buckets SET target_op = ? WHERE name = ?", - params![op_id, "$local"], - )?; + InnerPowerSyncState::set_local_target_op(writer.inner, op_id)?; writer.commit()?; Ok(()) From a1019c09ad68c172bb40f0d5c3503a983cec4fa9 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 8 Apr 2026 17:12:00 +0200 Subject: [PATCH 02/23] Fix clippy lints --- powersync/src/sync/download/sync_iteration.rs | 4 ++-- powersync/src/sync/upload.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/powersync/src/sync/download/sync_iteration.rs b/powersync/src/sync/download/sync_iteration.rs index ac90837..707aaeb 100644 --- a/powersync/src/sync/download/sync_iteration.rs +++ b/powersync/src/sync/download/sync_iteration.rs @@ -223,10 +223,10 @@ impl PowerSyncControlArgument { stmt.bind_text(index, str, Destructor::STATIC) } PowerSyncControlArgument::String(str) => { - stmt.bind_text(index, &str, Destructor::STATIC) + stmt.bind_text(index, str, Destructor::STATIC) } PowerSyncControlArgument::Bytes(bytes) => { - stmt.bind_blob(index, &bytes, Destructor::STATIC) + stmt.bind_blob(index, bytes, Destructor::STATIC) } }?; Ok(()) diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index ef547f7..11f2cbe 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -350,7 +350,7 @@ impl<'a> CrudUpload<'a> { }; } - let seq_before = Self::ps_crud_sequence(&reader)?; + let seq_before = Self::ps_crud_sequence(reader)?; Ok(seq_before.map(|seq_before| PendingCheckpointRequest { crud_sequence: seq_before, })) From 2fc180eb2225f8fd10e1e6ec85e11684a41722e3 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 8 Apr 2026 17:46:46 +0200 Subject: [PATCH 03/23] AI feedback --- powersync/src/db/connection.rs | 17 +++++++---------- powersync/src/db/core_extension.rs | 2 +- powersync/src/db/internal.rs | 1 + powersync/src/db/streams.rs | 1 + powersync/src/sync/download/sync_iteration.rs | 8 ++++---- powersync/src/sync/status.rs | 2 +- powersync/src/sync/upload.rs | 4 ++-- 7 files changed, 17 insertions(+), 18 deletions(-) diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index 7245c9a..4c3ff1a 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -182,17 +182,14 @@ impl RawSqliteConnection { } pub fn exec_stmt(stmt: ManagedStmt) -> Result<(), PowerSyncError> { - loop { - return match stmt.step() { - Err(e) => Err(RawPowerSyncError::RawSqlite { - code: e, - context: format!("Stepping through {}", stmt.sql().unwrap_or("unknown SQL")), - } - .into()), - Ok(ResultCode::ROW) => continue, - _ => Ok(()), - }; + while let ResultCode::ROW = stmt.step().map_err(|e| RawPowerSyncError::RawSqlite { + code: e, + context: format!("Stepping through {}", stmt.sql().unwrap_or("unknown SQL")), + })? { + // Keep stepping through statement. } + + Ok(()) } #[cfg(unix)] diff --git a/powersync/src/db/core_extension.rs b/powersync/src/db/core_extension.rs index 28d051e..15c543f 100644 --- a/powersync/src/db/core_extension.rs +++ b/powersync/src/db/core_extension.rs @@ -37,7 +37,7 @@ impl CoreExtensionVersion { pub(crate) fn check_from_db(conn: &SqliteConnection) -> Result { let stmt = conn.prepare("SELECT powersync_rs_version()")?; let ResultCode::ROW = stmt.step()? else { - panic!("Expected row") + panic!("Expected row") // Can't happen, scalar select }; let version = stmt.column_text(0)?.parse::()?; diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index 49bd198..37a5029 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -83,6 +83,7 @@ impl InnerPowerSyncState { let serialized_schema = serde_json::to_string(&self.schema)?; let stmt = conn.prepare("SELECT powersync_replace_schema(?)")?; + // Fine because we drop the statement before the serialized schema stmt.bind_text(1, &serialized_schema, Destructor::STATIC)?; exec_stmt(stmt)?; diff --git a/powersync/src/db/streams.rs b/powersync/src/db/streams.rs index 474835f..c31dc78 100644 --- a/powersync/src/db/streams.rs +++ b/powersync/src/db/streams.rs @@ -91,6 +91,7 @@ impl<'a> SyncStream<'a> { { let stmt = writer.inner.prepare("SELECT powersync_control(?, ?)")?; stmt.bind_text(1, "subscriptions", Destructor::STATIC)?; + // Fine because we drop the statement before serialized stmt.bind_text(2, &serialized, Destructor::STATIC)?; exec_stmt(stmt)?; } diff --git a/powersync/src/sync/download/sync_iteration.rs b/powersync/src/sync/download/sync_iteration.rs index 707aaeb..6d69ce4 100644 --- a/powersync/src/sync/download/sync_iteration.rs +++ b/powersync/src/sync/download/sync_iteration.rs @@ -199,7 +199,7 @@ impl DownloadEvent { serde_json::from_str(instructions)? } else { - panic!("Statement should have returned a row") + panic!("Expected a row") // Can't happen, scalar select } }; @@ -217,14 +217,14 @@ enum PowerSyncControlArgument { impl PowerSyncControlArgument { fn bind_to(&self, stmt: &ManagedStmt, index: i32) -> Result<(), ResultCode> { + // We use Destructor::STATIC here which is technically not safe, but fine since we'll always + // drop the statement before the control argument. match self { PowerSyncControlArgument::Null => stmt.bind_null(index), PowerSyncControlArgument::StaticString(str) => { stmt.bind_text(index, str, Destructor::STATIC) } - PowerSyncControlArgument::String(str) => { - stmt.bind_text(index, str, Destructor::STATIC) - } + PowerSyncControlArgument::String(str) => stmt.bind_text(index, str, Destructor::STATIC), PowerSyncControlArgument::Bytes(bytes) => { stmt.bind_blob(index, bytes, Destructor::STATIC) } diff --git a/powersync/src/sync/status.rs b/powersync/src/sync/status.rs index b3a7575..20eecb0 100644 --- a/powersync/src/sync/status.rs +++ b/powersync/src/sync/status.rs @@ -175,7 +175,7 @@ impl SyncStatusData { ) -> Result<(), PowerSyncError> { let stmt = conn.prepare("SELECT powersync_offline_sync_status()")?; let ResultCode::ROW = stmt.step()? else { - panic!("Expected row"); + panic!("Expected row"); // Can't happen, scalar select }; let raw_status = stmt.column_text(0)?; diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 11f2cbe..22b2e82 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -303,7 +303,7 @@ impl<'a> CrudUpload<'a> { .sqlite_connection() .prepare("SELECT powersync_client_id()")?; let ResultCode::ROW = stmt.step()? else { - panic!("Expected row"); + panic!("Expected row"); // Can't happen, scalar select }; stmt.column_text(0)?.to_string() @@ -324,7 +324,7 @@ impl<'a> CrudUpload<'a> { fn ps_crud_sequence(conn: &SqliteConnection) -> Result, PowerSyncError> { let seq_before = conn.prepare("SELECT seq FROM main.sqlite_sequence WHERE name = ?")?; - seq_before.bind_text(0, "ps_crud", Destructor::STATIC)?; + seq_before.bind_text(1, "ps_crud", Destructor::STATIC)?; let ResultCode::ROW = seq_before.step()? else { return Ok(None); From 0536ef3234e6ef118c013b63ee251b0bbdfecd54 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 22 Jul 2026 11:06:25 +0200 Subject: [PATCH 04/23] Retry on failed crud uploads --- CHANGELOG.md | 5 +- README.md | 4 +- powersync/src/db/internal.rs | 7 +- powersync/src/error.rs | 14 ++++ powersync/src/sync/upload.rs | 130 ++++++++++++++++------------------- powersync/tests/sync_test.rs | 93 ++++++++++++++++++++++++- 6 files changed, 175 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ed656..c2cdfdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -## 0.0.6 (unreleased) +## 0.0.6 - Skip creating `ps_crud` entries when clearing raw tables. +- Call `upload_data` repeatedly if an upload fails. +- Add `PowerSyncError::upload_error`, which can be used to convert any error into PowerSync errors for + `upload_data` callbacks. ## 0.0.5 diff --git a/README.md b/README.md index 78b4812..40dd71e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To start an example: 3. Compile and run an example here: `cargo run -p egui_todolist`. ```yaml -# Sync-rule docs: https://docs.powersync.com/usage/sync-rules +# Sync Streams docs: https://docs.powersync.com/sync/streams/overview streams: lists: query: SELECT * FROM lists #WHERE owner_id = auth.user_id() @@ -32,5 +32,5 @@ streams: query: SELECT * FROM todos WHERE list_id = subscription.parameter('list') #AND list_id IN (SELECT id FROM lists WHERE owner_id = auth.user_id()) config: - edition: 2 + edition: 3 ``` diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index 37a5029..71ff231 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -10,6 +10,7 @@ use crate::{ util::SharedFuture, }; use event_listener::EventListener; +use futures_lite::future::yield_now; use futures_lite::{FutureExt, Stream, StreamExt, ready}; use powersync_sqlite_nostd::{Destructor, ResultCode}; use std::sync::{Mutex, Weak}; @@ -144,8 +145,12 @@ impl InnerPowerSyncState { *guard }; - if let Some(delay) = delay { + if let Some(delay) = delay + && delay > Duration::ZERO + { self.env.timer.delay_once(delay).await + } else { + yield_now().await } } diff --git a/powersync/src/error.rs b/powersync/src/error.rs index 35065bc..f15f817 100644 --- a/powersync/src/error.rs +++ b/powersync/src/error.rs @@ -17,6 +17,15 @@ pub struct PowerSyncError { } impl PowerSyncError { + /// Wrap any error as a PowerSync error to indicate an error in a + /// [crate::BackendConnector::upload_data] implementation. + pub fn upload_error(inner: impl Error + Send + Sync + 'static) -> Self { + RawPowerSyncError::UploadError { + source: Box::new(inner), + } + .into() + } + pub(crate) fn argument_error(desc: impl Into>) -> Self { RawPowerSyncError::ArgumentError { desc: desc.into() }.into() } @@ -108,6 +117,11 @@ pub(crate) enum RawPowerSyncError { InvalidCredentials, #[error("Unexpected HTTP status code from PowerSync service: {code}")] UnexpectedStatusCode { code: u16 }, + #[error("Error in upload_data: {source}")] + UploadError { + #[source] + source: Box, + }, } impl From for PowerSyncError { diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 22b2e82..6dd9691 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, sync::Arc}; +use std::{collections::HashSet, ops::ControlFlow, sync::Arc}; use futures_lite::{ FutureExt, StreamExt, @@ -159,55 +159,16 @@ impl UploadActor { Self::state_transition_from_command_while_uploading(&self.commands, &self.db); let upload_done = async { - let (result, state) = result.await; - - match result { - Ok(_) => { - // It's possible that pending CRUD uploads were preventing data from - // syncing. So now that that's completed, notify the download client in - // case it needs to retry. - if let Some(sync) = self.db.sync.upgrade() { - sync.mark_crud_uploads_completed().await; - } - - // Apart from that, the upload is done and we transition back into the - // ready connected state to start the next iteration when needed. - Some(UploadActorState::Connected(state)) - } - Err(e) => { - warn!("CRUD uploads failed, will retry, {e}"); - self.db - .status - .update(|s| s.set_upload_state(UploadStatus::Error(e))); - let db = self.db.clone(); - - Some(UploadActorState::WaitingForReconnect { - timeout: async move { - db.sync_iteration_delay().await; - state - } - .boxed(), - }) - } - } - }; - - future::race(request, upload_done) - .await - .unwrap_or(old_state) - } - UploadActorState::WaitingForReconnect { ref mut timeout } => { - // Either the timeout expires, in which case we reconnect, or a disconnect is - // requested. - let request = - Self::state_transition_from_command_while_uploading(&self.commands, &self.db); + let state = result.await; + self.db + .status + .update(|s| s.set_upload_state(UploadStatus::Idle)); - let timeout_expired = async { - let state = timeout.await; + // The upload is done and we transition back into the ready connected state to start the next iteration when needed. Some(UploadActorState::Connected(state)) }; - future::race(request, timeout_expired) + future::race(request, upload_done) .await .unwrap_or(old_state) } @@ -223,9 +184,9 @@ impl UploadActor { connector: state.connector.as_ref(), db, }; - let result = upload.run().await; + upload.run().await; - (result, state) + state } .boxed(), } @@ -235,12 +196,7 @@ impl UploadActor { enum UploadActorState { Idle, Connected(ConnectedUploadActor), - RunningUpload { - result: Boxed<(Result<(), PowerSyncError>, ConnectedUploadActor)>, - }, - WaitingForReconnect { - timeout: Boxed, - }, + RunningUpload { result: Boxed }, Stopped, } @@ -263,31 +219,61 @@ struct CrudUpload<'a> { } impl<'a> CrudUpload<'a> { - pub async fn run(&mut self) -> Result<(), PowerSyncError> { + pub async fn run(&mut self) { let mut last_item_id = None::; - while let Some(item) = self.oldest_crud_item_id().await? { - if last_item_id == Some(item) { - warn!("{}", Self::DUPLICATE_ITEM_WARNING); - return Err(PowerSyncError::argument_error( - "Delaying due to previously encountered CRUD item.", - )); + // Invoke upload method on connector until there are no remaining CRUD items to upload. + loop { + match self.upload_step(&mut last_item_id).await { + Ok(ControlFlow::Break(_)) => break, + Ok(ControlFlow::Continue(_)) => continue, + Err(e) => { + last_item_id = None; + info!("CRUD uploads failed, will retry, {e}"); + + self.db + .status + .update(|data| data.set_upload_state(UploadStatus::Error(e))); + self.db.sync_iteration_delay().await; + } } - - last_item_id = Some(item); - self.db - .status - .update(|data| data.set_upload_state(UploadStatus::Uploading)); - self.connector.upload_data().await?; } + } + + async fn upload_step( + &mut self, + last_item_id: &mut Option, + ) -> Result, PowerSyncError> { + let Some(item) = self.oldest_crud_item_id().await? else { + // Uploading is completed, advance write checkpoint. + if let Some(advance_target) = self.sequence_for_checkpoint().await? { + let write_checkpoint = self.get_write_checkpoint().await?; + advance_target.complete(write_checkpoint, &self.db).await?; + } - // Uploading is completed, advance write checkpoint. - if let Some(advance_target) = self.sequence_for_checkpoint().await? { - let write_checkpoint = self.get_write_checkpoint().await?; - advance_target.complete(write_checkpoint, &self.db).await?; + // It's possible that pending CRUD uploads were preventing data from syncing. So now + // that that's completed, notify the download client in case it needs to retry. + if let Some(sync) = self.db.sync.upgrade() { + sync.mark_crud_uploads_completed().await; + } + + return Ok(ControlFlow::Break(())); + }; + + self.db + .status + .update(|data| data.set_upload_state(UploadStatus::Uploading)); + if matches!(*last_item_id, Some(x) if x == item) { + warn!("{}", Self::DUPLICATE_ITEM_WARNING); + return Err(PowerSyncError::argument_error( + "Delaying due to previously encountered CRUD item.", + )); } - Ok(()) + *last_item_id = Some(item); + self.connector.upload_data().await?; + + Ok(ControlFlow::Continue(())) } async fn oldest_crud_item_id(&self) -> Result, PowerSyncError> { diff --git a/powersync/tests/sync_test.rs b/powersync/tests/sync_test.rs index dbdc59e..4d9cf34 100644 --- a/powersync/tests/sync_test.rs +++ b/powersync/tests/sync_test.rs @@ -1,15 +1,27 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + use async_task::Task; +use async_trait::async_trait; +use event_listener::Event; use futures_lite::{StreamExt, future}; use powersync::{ - PowerSyncDatabase, StreamPriority, StreamSubscription, StreamSubscriptionOptions, SyncOptions, - SyncStatusData, error::PowerSyncError, + BackendConnector, PowerSyncCredentials, PowerSyncDatabase, StreamPriority, StreamSubscription, + StreamSubscriptionOptions, SyncOptions, SyncStatusData, error::PowerSyncError, }; use powersync_test_utils::{ DatabaseTest, mock_sync_service::TestConnector, sync_line::{Checkpoint, SyncLine}, }; +use rusqlite::params; use serde_json::json; +use thiserror::Error; struct SyncStreamTest { test: DatabaseTest, @@ -333,3 +345,80 @@ fn progress_without_priorities() { sync.wait_for_status(|s| !s.is_downloading()).await; }); } + +#[test] +fn upload_retry() { + struct FailOnFirstUpload { + db: PowerSyncDatabase, + counter: Arc, + completed_second: Arc, + } + + #[derive(Error, Debug)] + #[error("Deliberate failure on first upload")] + struct FirstUploadFailure; + + #[async_trait] + impl BackendConnector for FailOnFirstUpload { + async fn fetch_credentials(&self) -> Result { + Ok(PowerSyncCredentials { + endpoint: "https://rust.unit.test.powersync.com/".to_string(), + token: "token".to_string(), + }) + } + + async fn upload_data(&self) -> Result<(), PowerSyncError> { + let Some(tx) = self.db.next_crud_transaction().await? else { + return Ok(()); + }; + + let old_count = self.counter.fetch_add(1, Ordering::SeqCst); + if old_count == 0 { + return Err(PowerSyncError::upload_error(FirstUploadFailure)); + } + + tx.complete().await?; + self.completed_second.notify(usize::MAX); + Ok(()) + } + } + + let sync = SyncStreamTest::new(); + let upload_counter = Arc::new(AtomicUsize::default()); + let event = Arc::new(Event::new()); + let mut options = SyncOptions::new(FailOnFirstUpload { + db: sync.db.clone(), + counter: upload_counter.clone(), + completed_second: event.clone(), + }); + options.with_retry_delay(Duration::ZERO); // We can't use timers in tests + sync.run(sync.db.connect(options)); + + sync.run(async { + sync.wait_for_status(|s| s.is_connected()).await; + + // Trigger a crud upload. + { + let writer = sync.db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO users (id, name) VALUES (uuid(), 'local user')", + params![], + ) + .unwrap(); + } + + // Wait for the second upload to finish. + loop { + let listener = event.listen(); + if upload_counter.load(Ordering::SeqCst) == 2 { + break; + }; + + listener.await + } + + sync.wait_for_status(|s| s.upload_error().is_none() && !s.is_uploading()) + .await; + }); +} From 404321a53a09ff2958edf219185de91c3c354206 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 22 Jul 2026 11:29:06 +0200 Subject: [PATCH 05/23] Properly prepare release --- Cargo.lock | 2 +- powersync/Cargo.toml | 2 +- powersync/tests/sync_test.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df3ea95..15d6c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3084,7 +3084,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "powersync" -version = "0.0.5" +version = "0.0.6" dependencies = [ "async-channel", "async-executor", diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index e6367f6..1654ffa 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "powersync" -version = "0.0.5" +version = "0.0.6" edition = "2024" license = "Apache-2.0" diff --git a/powersync/tests/sync_test.rs b/powersync/tests/sync_test.rs index 4d9cf34..0f9637e 100644 --- a/powersync/tests/sync_test.rs +++ b/powersync/tests/sync_test.rs @@ -420,5 +420,7 @@ fn upload_retry() { sync.wait_for_status(|s| s.upload_error().is_none() && !s.is_uploading()) .await; + + assert!(sync.db.next_crud_transaction().await.unwrap().is_none()); }); } From 9b1b62600f9688aa100cb587e55c756a8fda33b9 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Jul 2026 10:49:31 +0200 Subject: [PATCH 06/23] Update core extension to 0.5.1 --- CHANGELOG.md | 4 +++ Cargo.lock | 8 ++--- powersync/Cargo.toml | 4 +-- powersync/src/db/connection.rs | 2 +- powersync/src/db/core_extension.rs | 4 +-- powersync/src/db/internal.rs | 47 ++++++++++++++++++++---------- powersync/src/sync/upload.rs | 34 ++++++++++----------- powersync/tests/crud_test.rs | 6 +++- 8 files changed, 66 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2cdfdd..ae7370f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.7 (unreleased) + +- Update PowerSync core extension to version 0.5.1. + ## 0.0.6 - Skip creating `ps_crud` entries when clearing raw tables. diff --git a/Cargo.lock b/Cargo.lock index 15d6c4e..b68f5c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3117,9 +3117,9 @@ dependencies = [ [[package]] name = "powersync_core" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f9c7f0117bda7f68ca872528e0c2c78de94d34f81a6e167e2046e8578bd080" +checksum = "ca826497b4096dc869569970712ad77c96108de46ad8237e671b61a937a47536" dependencies = [ "bytes", "const_format", @@ -3137,9 +3137,9 @@ dependencies = [ [[package]] name = "powersync_sqlite_nostd" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a5a95198e2ab901965138fced0315621073ef557a04b5552a51774658abf13" +checksum = "38c81cb5ccc5d718ed7d304eb102198af2e5b6a707fed188dffed00f5efcee35" dependencies = [ "bindgen", "num-derive 0.4.2", diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index 1654ffa..8903b49 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -44,8 +44,8 @@ thiserror = "2.0.16" tokio = { version = "1", features = ["time", "rt"], optional = true } url = "2.5.7" serde_with = "3.15.0" -powersync_core = { version = "=0.4.12", features = ["static"] } -powersync_sqlite_nostd = { version = "=0.4.12", features = ["static"] } +powersync_core = { version = "=0.5.1", features = ["static"] } +powersync_sqlite_nostd = { version = "=0.5.1", features = ["static"] } num-traits = "0.2.19" [dev-dependencies] diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index 4c3ff1a..0f72521 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -49,7 +49,7 @@ impl SqliteConnection { pub fn exec(&self, stmt: &CStr) -> Result<(), PowerSyncError> { unsafe { // Safety: We know the stmt is null-terminated. - self.handle().exec(stmt.as_ptr()) + self.handle().exec(stmt) } .map_err(|rc| RawPowerSyncError::RawSqlite { code: rc, diff --git a/powersync/src/db/core_extension.rs b/powersync/src/db/core_extension.rs index 15c543f..f35e66e 100644 --- a/powersync/src/db/core_extension.rs +++ b/powersync/src/db/core_extension.rs @@ -12,8 +12,8 @@ pub struct CoreExtensionVersion { impl CoreExtensionVersion { /// The minimum version of the core extension supported by the native SDK. - pub const MINIMUM: Self = Self::new(0, 4, 7); - pub const MAXIMUM_EXCLUSIVE: Self = Self::new(0, 5, 0); + pub const MINIMUM: Self = Self::new(0, 5, 1); + pub const MAXIMUM_EXCLUSIVE: Self = Self::new(0, 6, 0); pub const fn new(major: u32, minor: u32, patch: u32) -> Self { Self { diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index 71ff231..153ff06 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -1,4 +1,4 @@ -use crate::db::connection::{SqliteConnection, TransactionGuard, exec_stmt}; +use crate::db::connection::{TransactionGuard, exec_stmt}; use crate::schema::SchemaOrCustom; use crate::{ db::{ @@ -12,7 +12,7 @@ use crate::{ use event_listener::EventListener; use futures_lite::future::yield_now; use futures_lite::{FutureExt, Stream, StreamExt, ready}; -use powersync_sqlite_nostd::{Destructor, ResultCode}; +use powersync_sqlite_nostd::{ColumnType, Destructor, ResultCode}; use std::sync::{Mutex, Weak}; use std::time::Duration; use std::{ @@ -62,14 +62,17 @@ impl InnerPowerSyncState { let pool = &self.env.pool; self.did_initialize .run(|| async { - let conn = pool.writer().await; - let conn = conn.sqlite_connection(); + let mut conn = pool.writer().await; + let conn = conn.sqlite_connection_mut(); CoreExtensionVersion::check_from_db(conn)?; - conn.exec(c"SELECT powersync_init()")?; + let tx = TransactionGuard::new(conn)?; + tx.inner.exec(c"SELECT powersync_init()")?; - self.update_schema_internal(conn)?; - self.status.update(|old| old.resolve_offline_state(conn))?; + self.update_schema_internal(&tx)?; + self.status + .update(|old| old.resolve_offline_state(tx.inner))?; + tx.commit()?; Ok(()) }) @@ -77,13 +80,13 @@ impl InnerPowerSyncState { .clone() } - fn update_schema_internal(&self, conn: &SqliteConnection) -> Result<(), PowerSyncError> { + fn update_schema_internal(&self, conn: &TransactionGuard) -> Result<(), PowerSyncError> { if let SchemaOrCustom::Schema(schema) = self.schema.as_ref() { schema.validate()?; }; let serialized_schema = serde_json::to_string(&self.schema)?; - let stmt = conn.prepare("SELECT powersync_replace_schema(?)")?; + let stmt = conn.inner.prepare("SELECT powersync_replace_schema(?)")?; // Fine because we drop the statement before the serialized schema stmt.bind_text(1, &serialized_schema, Destructor::STATIC)?; exec_stmt(stmt)?; @@ -118,15 +121,29 @@ impl InnerPowerSyncState { } } - Self::set_local_target_op(writer.inner, target_op)?; + Self::target_checkpoint_request_id(&writer, Some(target_op))?; writer.commit() } - pub fn set_local_target_op(writer: &SqliteConnection, op: i64) -> Result<(), PowerSyncError> { - let stmt = writer.prepare("UPDATE ps_buckets SET target_op = ? WHERE name = ?")?; - stmt.bind_int64(1, op)?; - stmt.bind_text(2, "$local", Destructor::STATIC)?; - exec_stmt(stmt) + pub fn target_checkpoint_request_id( + writer: &TransactionGuard, + update: Option, + ) -> Result, PowerSyncError> { + let stmt = writer.inner.prepare("SELECT powersync_control(?, ?);")?; + stmt.bind_text(1, "target_checkpoint_request_id", Destructor::STATIC)?; + if let Some(update) = update { + stmt.bind_int64(2, update)?; + } else { + stmt.bind_null(2)?; + } + let ResultCode::ROW = stmt.step()? else { + panic!("Scalar statement not return a row") + }; + + Ok(match stmt.column_type(0)? { + ColumnType::Integer => Some(stmt.column_int64(0)), + _ => None, + }) } pub async fn reader(&self) -> Result { diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 6dd9691..8c9dc9e 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -308,8 +308,10 @@ impl<'a> CrudUpload<'a> { }) } - fn ps_crud_sequence(conn: &SqliteConnection) -> Result, PowerSyncError> { - let seq_before = conn.prepare("SELECT seq FROM main.sqlite_sequence WHERE name = ?")?; + fn ps_crud_sequence(tx: &TransactionGuard) -> Result, PowerSyncError> { + let seq_before = tx + .inner + .prepare("SELECT seq FROM main.sqlite_sequence WHERE name = ?")?; seq_before.bind_text(1, "ps_crud", Destructor::STATIC)?; let ResultCode::ROW = seq_before.step()? else { @@ -322,21 +324,17 @@ impl<'a> CrudUpload<'a> { async fn sequence_for_checkpoint( &self, ) -> Result, PowerSyncError> { - let reader = self.db.reader().await?; - let reader = reader.sqlite_connection(); - { - let stmt = - reader.prepare("SELECT 1 FROM ps_buckets WHERE name = ? AND target_op = ?")?; - stmt.bind_text(1, "$local", Destructor::STATIC)?; - stmt.bind_int64(2, MAX_OP_ID)?; + let mut reader = self.db.reader().await?; + let reader = reader.sqlite_connection_mut(); + let read_tx = TransactionGuard::new(reader)?; - let ResultCode::ROW = stmt.step()? else { - // Nothing to update. - return Ok(None); - }; - } + let Some(MAX_OP_ID) = InnerPowerSyncState::target_checkpoint_request_id(&read_tx, None)? + else { + // Nothing to update. + return Ok(None); + }; - let seq_before = Self::ps_crud_sequence(reader)?; + let seq_before = Self::ps_crud_sequence(&read_tx)?; Ok(seq_before.map(|seq_before| PendingCheckpointRequest { crud_sequence: seq_before, })) @@ -369,8 +367,8 @@ impl PendingCheckpointRequest { return Ok(()); } - let seq_after = CrudUpload::ps_crud_sequence(writer.inner)? - .expect("sqlite sequence should not be empty"); + let seq_after = + CrudUpload::ps_crud_sequence(&writer)?.expect("sqlite sequence should not be empty"); if seq_after != self.crud_sequence { debug!( @@ -380,7 +378,7 @@ impl PendingCheckpointRequest { return Ok(()); } - InnerPowerSyncState::set_local_target_op(writer.inner, op_id)?; + InnerPowerSyncState::target_checkpoint_request_id(&writer, Some(op_id))?; writer.commit()?; Ok(()) diff --git a/powersync/tests/crud_test.rs b/powersync/tests/crud_test.rs index bfb1f0b..523d380 100644 --- a/powersync/tests/crud_test.rs +++ b/powersync/tests/crud_test.rs @@ -303,9 +303,13 @@ fn raw_table_clear() { // Running powersync_clear should delete from users { - let writer = db.writer().await.unwrap(); + let mut writer = db.writer().await.unwrap(); + let writer = writer.transaction().unwrap(); + let mut stmt = writer.prepare("SELECT powersync_clear(0)").unwrap(); stmt.query_one(params![], |_| Ok(())).unwrap(); + drop(stmt); + writer.commit().unwrap(); } assert_eq!( From 460125099073533e1f710b419fe52c63fa8fb0d5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Jul 2026 10:51:15 +0200 Subject: [PATCH 07/23] Fix outdated comment --- powersync/src/db/connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index 0f72521..e0f286e 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -48,7 +48,7 @@ impl SqliteConnection { /// Executes a SQL statement without parameters. pub fn exec(&self, stmt: &CStr) -> Result<(), PowerSyncError> { unsafe { - // Safety: We know the stmt is null-terminated. + // Safety: We're not doing anything that could close the connection. self.handle().exec(stmt) } .map_err(|rc| RawPowerSyncError::RawSqlite { From cfe4f82d0cd7d61f97f57466e97aec55dded8094 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Jul 2026 10:59:56 +0200 Subject: [PATCH 08/23] typo --- powersync/src/db/internal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index 153ff06..ad0fb33 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -137,7 +137,7 @@ impl InnerPowerSyncState { stmt.bind_null(2)?; } let ResultCode::ROW = stmt.step()? else { - panic!("Scalar statement not return a row") + panic!("Scalar statement did not return a row") }; Ok(match stmt.column_type(0)? { From 4d1854023561e79d498fbf1a8b6bf1789a9153b0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Jul 2026 11:10:36 +0200 Subject: [PATCH 09/23] AI review --- powersync/src/sync/upload.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 8c9dc9e..2b0000e 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -328,11 +328,11 @@ impl<'a> CrudUpload<'a> { let reader = reader.sqlite_connection_mut(); let read_tx = TransactionGuard::new(reader)?; - let Some(MAX_OP_ID) = InnerPowerSyncState::target_checkpoint_request_id(&read_tx, None)? - else { + let current_target = InnerPowerSyncState::target_checkpoint_request_id(&read_tx, None)?; + if current_target != Some(MAX_OP_ID) { // Nothing to update. return Ok(None); - }; + } let seq_before = Self::ps_crud_sequence(&read_tx)?; Ok(seq_before.map(|seq_before| PendingCheckpointRequest { From 654bac2a861fc86baaa4f08bae6a501441bbe021 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Jul 2026 14:03:25 +0200 Subject: [PATCH 10/23] Fix decoding time stamps from core extension --- powersync/src/sync/instruction.rs | 2 +- powersync/tests/sync_test.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/powersync/src/sync/instruction.rs b/powersync/src/sync/instruction.rs index 9c6785d..6ae570b 100644 --- a/powersync/src/sync/instruction.rs +++ b/powersync/src/sync/instruction.rs @@ -89,7 +89,7 @@ pub struct Timestamp(pub i64); impl From for SystemTime { fn from(val: Timestamp) -> Self { - let since_epoch = Duration::from_secs(val.0 as u64); + let since_epoch = Duration::from_micros(val.0 as u64); SystemTime::UNIX_EPOCH + since_epoch } } diff --git a/powersync/tests/sync_test.rs b/powersync/tests/sync_test.rs index 0f9637e..57b9112 100644 --- a/powersync/tests/sync_test.rs +++ b/powersync/tests/sync_test.rs @@ -3,7 +3,7 @@ use std::{ Arc, atomic::{AtomicUsize, Ordering}, }, - time::Duration, + time::{Duration, SystemTime}, }; use async_task::Task; @@ -424,3 +424,32 @@ fn upload_retry() { assert!(sync.db.next_crud_transaction().await.unwrap().is_none()); }); } + +#[test] +fn reports_correct_times() { + let sync = SyncStreamTest::new(); + sync.connect(); + + sync.run(async { + let request = sync.test.http.receive_requests.recv().await.unwrap(); + sync.wait_for_status(|s| s.is_connected()).await; + + request + .send_checkpoint(Checkpoint::single_bucket("a", 0, None)) + .await; + request.send_checkpoint_complete(0, None).await; + sync.wait_for_status(|s| !s.is_downloading()).await; + + let stream = sync.db.sync_stream("a", None); + let status = sync.db.status(); + let status = status + .for_stream(&stream) + .expect("should have stream status"); + let last_synced_at = status + .subscription + .last_synced_at() + .expect("should have last synced at"); + let delta = SystemTime::now().duration_since(last_synced_at).unwrap(); + assert!(delta < Duration::from_secs(5)); + }); +} From fdaaec810f72581ff27e926b53cdcb194e67c126 Mon Sep 17 00:00:00 2001 From: Daniel Vacic Date: Thu, 30 Jul 2026 02:24:18 +1000 Subject: [PATCH 11/23] fix(windows): compile non-Unix database paths --- .github/workflows/ci.yml | 12 ++++++++++++ powersync/src/db/connection.rs | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65549f2..7326612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,15 @@ jobs: - name: Build without rusqlite run: cargo build --no-default-features + + windows: + name: Check Windows build + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install stable Rust + run: rustup update stable + + - name: Check PowerSync crate + run: cargo check -p powersync --all-targets diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index e0f286e..25fe90e 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -204,7 +204,9 @@ fn path_to_cstring(p: &Path) -> Result { #[cfg(not(unix))] fn path_to_cstring(p: &Path) -> Result { - let s = p.to_str().ok_or_else(|| Error::InvalidPath(p.to_owned()))?; + let s = p.to_str().ok_or_else(|| RawPowerSyncError::ArgumentError { + desc: format!("Invalid path: {p:?}").into(), + })?; Ok( CString::new(s).map_err(|_| RawPowerSyncError::ArgumentError { desc: format!("Invalid path: {p:?}").into(), From 20decdfb9632240b78daafdfd24c39f51e364668 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 3 Aug 2026 12:42:35 +0200 Subject: [PATCH 12/23] Update core extension to 0.5.2 --- CHANGELOG.md | 4 ++-- Cargo.lock | 10 +++++----- powersync/Cargo.toml | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7370f..4656d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -## 0.0.7 (unreleased) +## 0.0.7 -- Update PowerSync core extension to version 0.5.1. +- Update PowerSync core extension to version 0.5.2. ## 0.0.6 diff --git a/Cargo.lock b/Cargo.lock index b68f5c8..f39632f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3084,7 +3084,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "powersync" -version = "0.0.6" +version = "0.0.7" dependencies = [ "async-channel", "async-executor", @@ -3117,9 +3117,9 @@ dependencies = [ [[package]] name = "powersync_core" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca826497b4096dc869569970712ad77c96108de46ad8237e671b61a937a47536" +checksum = "32ab437bc28b8007e789d4d4b39861a7dbd1eca102d4c453f3ba12d320b0de7a" dependencies = [ "bytes", "const_format", @@ -3137,9 +3137,9 @@ dependencies = [ [[package]] name = "powersync_sqlite_nostd" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c81cb5ccc5d718ed7d304eb102198af2e5b6a707fed188dffed00f5efcee35" +checksum = "f4e318c28daeba1a83f93eb42917972b243a5def587eb27a7c7e5da938df3b67" dependencies = [ "bindgen", "num-derive 0.4.2", diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index 8903b49..33ddfa7 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "powersync" -version = "0.0.6" +version = "0.0.7" edition = "2024" license = "Apache-2.0" @@ -44,8 +44,8 @@ thiserror = "2.0.16" tokio = { version = "1", features = ["time", "rt"], optional = true } url = "2.5.7" serde_with = "3.15.0" -powersync_core = { version = "=0.5.1", features = ["static"] } -powersync_sqlite_nostd = { version = "=0.5.1", features = ["static"] } +powersync_core = { version = "=0.5.2", features = ["static"] } +powersync_sqlite_nostd = { version = "=0.5.2", features = ["static"] } num-traits = "0.2.19" [dev-dependencies] From a4d16b4b7e5f5f5a5678b5ec245f62a2506b2b1a Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:13:23 +0800 Subject: [PATCH 13/23] fix(sync): scan queued crud on connect --- powersync/src/sync/upload.rs | 2 +- powersync/tests/sync_test.rs | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/powersync/src/sync/upload.rs b/powersync/src/sync/upload.rs index 2b0000e..955ea39 100644 --- a/powersync/src/sync/upload.rs +++ b/powersync/src/sync/upload.rs @@ -60,7 +60,7 @@ impl UploadActor { .env .pool .update_notifiers() - .listen(ListenerConfiguration::if_matches(tables, false)); + .listen(ListenerConfiguration::if_matches(tables, true)); ConnectedUploadActor { connector, crud_stream: stream.map(|_| ()).boxed(), diff --git a/powersync/tests/sync_test.rs b/powersync/tests/sync_test.rs index 57b9112..3484891 100644 --- a/powersync/tests/sync_test.rs +++ b/powersync/tests/sync_test.rs @@ -425,6 +425,60 @@ fn upload_retry() { }); } +#[test] +fn connect_uploads_crud_that_was_already_queued() { + struct CompleteQueuedUpload { + db: PowerSyncDatabase, + counter: Arc, + } + + #[async_trait] + impl BackendConnector for CompleteQueuedUpload { + async fn fetch_credentials(&self) -> Result { + Ok(PowerSyncCredentials { + endpoint: "https://rust.unit.test.powersync.com/".to_string(), + token: "token".to_string(), + }) + } + + async fn upload_data(&self) -> Result<(), PowerSyncError> { + let Some(transaction) = self.db.next_crud_transaction().await? else { + return Ok(()); + }; + self.counter.fetch_add(1, Ordering::SeqCst); + transaction.complete().await + } + } + + let sync = SyncStreamTest::new(); + sync.run(async { + let writer = sync.db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO users (id, name) VALUES (uuid(), 'queued before connect')", + params![], + ) + .unwrap(); + }); + let upload_counter = Arc::new(AtomicUsize::default()); + sync.run(sync.db.connect(SyncOptions::new(CompleteQueuedUpload { + db: sync.db.clone(), + counter: upload_counter.clone(), + }))); + + sync.run(async { + for _ in 0..100 { + if upload_counter.load(Ordering::SeqCst) != 0 { + break; + } + future::yield_now().await; + } + + assert_eq!(upload_counter.load(Ordering::SeqCst), 1); + assert!(sync.db.next_crud_transaction().await.unwrap().is_none()); + }); +} + #[test] fn reports_correct_times() { let sync = SyncStreamTest::new(); From 5a27c423a2382b24f551a5f3278078fa13832888 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:14:55 +0800 Subject: [PATCH 14/23] fix(sync): release writer before async work --- powersync/src/sync/download/sync_iteration.rs | 7 ++- powersync/tests/sync_test.rs | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/powersync/src/sync/download/sync_iteration.rs b/powersync/src/sync/download/sync_iteration.rs index 6d69ce4..20d6785 100644 --- a/powersync/src/sync/download/sync_iteration.rs +++ b/powersync/src/sync/download/sync_iteration.rs @@ -51,9 +51,12 @@ impl DownloadClient { }?; trace!("Handling event {event:?}"); - let mut conn = self.db.writer().await?; + let instructions = { + let mut conn = self.db.writer().await?; + event.invoke_control(conn.sqlite_connection_mut())? + }; - for instr in event.invoke_control(conn.sqlite_connection_mut())? { + for instr in instructions { trace!("Handling instruction {instr:?}"); match instr { diff --git a/powersync/tests/sync_test.rs b/powersync/tests/sync_test.rs index 3484891..c4e555a 100644 --- a/powersync/tests/sync_test.rs +++ b/powersync/tests/sync_test.rs @@ -479,6 +479,49 @@ fn connect_uploads_crud_that_was_already_queued() { }); } +#[test] +fn fetching_credentials_does_not_hold_the_download_writer_lease() { + struct WriterUsingConnector { + entered: async_channel::Sender<()>, + release: async_channel::Receiver<()>, + } + + #[async_trait] + impl BackendConnector for WriterUsingConnector { + async fn fetch_credentials(&self) -> Result { + self.entered.send(()).await.unwrap(); + self.release.recv().await.unwrap(); + Ok(PowerSyncCredentials { + endpoint: "https://rust.unit.test.powersync.com/".to_string(), + token: "token".to_string(), + }) + } + + async fn upload_data(&self) -> Result<(), PowerSyncError> { + Ok(()) + } + } + + let sync = SyncStreamTest::new(); + let (entered_tx, entered_rx) = async_channel::bounded(1); + let (release_tx, release_rx) = async_channel::bounded(1); + sync.run(sync.db.connect(SyncOptions::new(WriterUsingConnector { + entered: entered_tx, + release: release_rx, + }))); + + sync.run(async { + entered_rx.recv().await.unwrap(); + let writer = future::poll_once(sync.db.writer()).await; + assert!( + writer.is_some(), + "download retained the writer while awaiting credentials" + ); + drop(writer); + release_tx.send(()).await.unwrap(); + }); +} + #[test] fn reports_correct_times() { let sync = SyncStreamTest::new(); From 6d6eaf9196ef16ee27c11d841b7f1abf86d10407 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:15:21 +0800 Subject: [PATCH 15/23] fix(sync): report connection after successful response --- powersync/src/sync/download/http.rs | 98 +++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/powersync/src/sync/download/http.rs b/powersync/src/sync/download/http.rs index b553847..1619885 100644 --- a/powersync/src/sync/download/http.rs +++ b/powersync/src/sync/download/http.rs @@ -46,10 +46,11 @@ pub fn sync_stream( let stream = stream::once_future(response); - StreamExt::flat_map(stream, |response| { - let items = response_to_lines(response); - - stream::once(Ok(DownloadEvent::ConnectionEstablished)).chain(items) + StreamExt::flat_map(stream, |response| match response { + Err(error) => stream::once(Err(error)).boxed(), + Ok(response) => stream::once(Ok(DownloadEvent::ConnectionEstablished)) + .chain(response_to_lines(Ok(response))) + .boxed(), }) } @@ -138,3 +139,92 @@ fn response_to_lines( .boxed() } } + +#[cfg(test)] +mod tests { + use std::{pin::Pin, sync::Arc, time::Duration}; + + use async_trait::async_trait; + use futures_lite::{StreamExt, future}; + use rusqlite::Connection; + + use super::*; + use crate::{ + db::{internal::InnerPowerSyncState, pool::ConnectionPool}, + env::{PowerSyncEnvironment, Timer}, + http::{HttpClient, Request, ResponseBody}, + schema::Schema, + sync::coordinator::SyncCoordinator, + }; + + struct FailingClient; + + #[async_trait] + impl HttpClient for FailingClient { + async fn send(&self, _request: Request) -> Result { + Err(PowerSyncError::argument_error("offline")) + } + } + + struct StatusClient(u16); + + #[async_trait] + impl HttpClient for StatusClient { + async fn send(&self, _request: Request) -> Result { + Ok(Response { + status: self.0, + content_type: Some("application/x-ndjson".to_string()), + body: ResponseBody { + reader: stream::empty().boxed(), + length: Some(0), + }, + }) + } + } + + struct UnusedTimer; + + impl Timer for UnusedTimer { + fn delay_once(&self, _duration: Duration) -> Pin + Send>> { + Box::pin(future::pending()) + } + } + + fn first_event(client: impl HttpClient) -> Result, PowerSyncError> { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::single_connection(Connection::open_in_memory().unwrap()); + let environment = PowerSyncEnvironment::custom(client, pool, &UnusedTimer); + let coordinator = Arc::new(SyncCoordinator::default()); + let db = Arc::new(InnerPowerSyncState::new( + environment, + Schema::default().into(), + &coordinator, + )); + let credentials = PowerSyncCredentials { + endpoint: "https://rust.unit.test.powersync.com/".to_string(), + token: "token".to_string(), + }; + let mut events = Box::pin(sync_stream(db, credentials, "{}".to_string())); + + future::block_on(events.as_mut().try_next()) + } + + #[test] + fn transport_error_does_not_report_connection_established() { + assert!(first_event(FailingClient).is_err()); + } + + #[test] + fn unsuccessful_status_does_not_report_connection_established() { + assert!(first_event(StatusClient(401)).is_err()); + assert!(first_event(StatusClient(500)).is_err()); + } + + #[test] + fn successful_response_reports_connection_before_stream_events() { + assert!(matches!( + first_event(StatusClient(200)), + Ok(Some(DownloadEvent::ConnectionEstablished)) + )); + } +} From 1321851dea383e03401bba6e57e119dcca79ac5c Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:15:48 +0800 Subject: [PATCH 16/23] fix(sync): accept crlf stream framing --- powersync/src/util/line_split.rs | 38 +++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/powersync/src/util/line_split.rs b/powersync/src/util/line_split.rs index 9bb68de..956a74d 100644 --- a/powersync/src/util/line_split.rs +++ b/powersync/src/util/line_split.rs @@ -41,8 +41,11 @@ impl Stream for LineSplitter { // Split into line including the \n, and the rest let remainder = this.unfinished_line.split_off(idx + 1); let mut completed_line = mem::replace(&mut this.unfinished_line, remainder); - // Remove \n from the completed line. + // Remove \n, then strip the optional \r from CRLF input. completed_line.pop(); + if completed_line.last() == Some(&b'\r') { + completed_line.pop(); + } return Self::emit_line(completed_line); } @@ -91,6 +94,39 @@ mod test { assert!(next.is_none()); } + #[test] + fn splits_crlf_lines_without_retaining_carriage_returns() { + let bytes = Bytes::copy_from_slice(b"hello\r\nworld\r\n"); + let mut lines = LineSplitter::from(stream::once(Ok(bytes)).boxed()); + + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert_eq!(next.unwrap(), "hello"); + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert_eq!(next.unwrap(), "world"); + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert!(next.is_none()); + } + + #[test] + fn splits_crlf_across_chunks_and_preserves_other_carriage_returns() { + let mut lines = LineSplitter::from( + stream::iter(vec![ + Ok(Bytes::from_static(b"first\r")), + Ok(Bytes::from_static(b"\nsecond\nlast\r")), + ]) + .boxed(), + ); + + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert_eq!(next.unwrap(), "first"); + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert_eq!(next.unwrap(), "second"); + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert_eq!(next.unwrap(), "last\r"); + let next = future::block_on(async { lines.try_next().await }).unwrap(); + assert!(next.is_none()); + } + #[test] fn utf8_split_across_chunks() { // "é" is two bytes: 0xC3 0xA9, split across chunk boundary. This verifies we don't try to From 9a29df80479c6fcdd1e9e53f1cae9377270f3d39 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:16:13 +0800 Subject: [PATCH 17/23] fix(http): use rustls without native tls defaults --- Cargo.lock | 18 +++++++++--------- powersync/Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f39632f..24a94d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,9 +490,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -500,14 +500,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1287,11 +1288,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3686,9 +3686,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index 33ddfa7..e484442 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -32,7 +32,7 @@ async-oneshot = "0.5.9" atomic_enum = "0.3.0" event-listener = "5.4.1" futures-lite = "2.6.1" -reqwest = { version = "0.13.2", optional = true, features = ["stream"] } +reqwest = { version = "0.13.2", default-features = false, optional = true, features = ["stream", "rustls"] } bytes = "1" log = "0.4.28" pin-project-lite = "0.2.16" From 4af5094954808d111be87ac28f758596d2aaab66 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 13:17:31 +0800 Subject: [PATCH 18/23] chore(powersync): sync fork with upstream 0.0.7 --- .github/workflows/ci.yml | 32 ++++++++++----- .github/workflows/pr.yml | 58 +++++++++++++++++++++++++++ CHANGELOG.md | 9 +++++ README.md | 3 ++ deny.toml | 60 ++++++++++++++++++++++++++++ docs/guion-patches.md | 33 +++++++++++++++ lefthook.yml | 13 ++++++ powersync/src/sync/coordinator.rs | 4 +- powersync/src/sync/download/actor.rs | 2 +- powersync/src/sync/download/mod.rs | 3 +- powersync/src/sync/streams.rs | 2 +- 11 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/pr.yml create mode 100644 deny.toml create mode 100644 docs/guion-patches.md create mode 100644 lefthook.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7326612..ac99e05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: Build and test on: push: + branches: + - main pull_request: branches: - main @@ -11,7 +13,6 @@ env: jobs: rust: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) name: Build and test runs-on: ubuntu-latest steps: @@ -27,16 +28,29 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: rustup update stable && rustup default stable && rustup component add clippy - - run: cargo build --verbose - name: Building project + - name: Build all features + run: cargo build --workspace --all-features --verbose + + - name: Test all features + run: cargo test --workspace --all-features --verbose - - run: cargo clippy + - name: Check without rusqlite + run: cargo check -p powersync --no-default-features - - run: cargo test --verbose - name: Testing project + - name: Check reqwest without rusqlite + run: cargo check -p powersync --no-default-features --features tokio,reqwest - - name: Build without rusqlite - run: cargo build --no-default-features + musl: + name: Check musl release features + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install musl target + run: rustup target add x86_64-unknown-linux-musl + + - name: Check PowerSync crate + run: cargo check -p powersync --target x86_64-unknown-linux-musl --no-default-features --features tokio,reqwest,rusqlite windows: name: Check Windows build @@ -48,4 +62,4 @@ jobs: run: rustup update stable - name: Check PowerSync crate - run: cargo check -p powersync --all-targets + run: cargo check -p powersync --all-targets --all-features diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..a16aa4f --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,58 @@ +name: PR + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install cargo-deny + uses: taiki-e/install-action@cargo-deny + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all --check + + - name: Lint all targets and features + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Test all features + run: cargo test --workspace --all-features + + - name: Check dependencies + run: cargo deny check + + - name: Build all features + run: cargo build --workspace --all-features + + osv-scan: + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.5 + permissions: + actions: read + contents: read + security-events: write + pull-requests: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 4656d68..005ffc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v0.0.7-guion.1 (unreleased) + +- Rebase the Guion fork on upstream PowerSync Native v0.0.7. +- Scan queued CRUD immediately when the upload actor connects. +- Release the download writer before awaiting connector or network work. +- Report `ConnectionEstablished` only after a successful HTTP response. +- Accept CRLF framing in JSON sync streams. +- Use reqwest with rustls and without native TLS defaults. + ## 0.0.7 - Update PowerSync core extension to version 0.5.2. diff --git a/README.md b/README.md index 40dd71e..b6f33d3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ _[PowerSync](https://www.powersync.com) is a sync engine for building local-firs This repository contains code used to build a PowerSync SDK for native development. PowerSync is available as a Rust crate in `powersync/`, and on crates.io as the `powersync` crate. +Guion release tags add a small, tested compatibility layer for Guion consumers. See +[`docs/guion-patches.md`](docs/guion-patches.md) for the exact upstream delta and patch policy. + ## Running the examples To start an example: diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..2bc230f --- /dev/null +++ b/deny.toml @@ -0,0 +1,60 @@ +[graph] +targets = [] +all-features = false + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Zlib", + "Unicode-3.0", + "CDLA-Permissive-2.0", + "CC0-1.0", + "OFL-1.1", + "BSL-1.0", + "OpenSSL", + "Ubuntu-font-1.0", +] + +[[licenses.clarify]] +crate = "powersync" +expression = "Apache-2.0" +license-files = [] + +[[licenses.clarify]] +crate = "powersync_test_utils" +expression = "Apache-2.0" +license-files = [] + +[[licenses.clarify]] +crate = "egui_todolist" +expression = "Apache-2.0" +license-files = [] + +[[licenses.clarify]] +crate = "aws-lc-sys" +expression = "ISC AND (Apache-2.0 OR ISC) AND OpenSSL" +license-files = [] + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[advisories] +ignore = [ + "RUSTSEC-2023-0018", # remove_dir_all is a test-only tempdir dependency + "RUSTSEC-2018-0017", # tempdir is test-only and inherited from upstream + "RUSTSEC-2026-0195", # quick-xml NsReader is build-only via Wayland scanner on trusted XML + "RUSTSEC-2026-0194", # quick-xml is used only by upstream example desktop build tooling + "RUSTSEC-2026-0192", # ttf-parser is used only by the upstream egui example +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [] diff --git a/docs/guion-patches.md b/docs/guion-patches.md new file mode 100644 index 0000000..2760a50 --- /dev/null +++ b/docs/guion-patches.md @@ -0,0 +1,33 @@ +# Guion fork patch inventory + +The Guion fork is rebuilt from upstream PowerSync Native v0.0.7. A patch stays in a Guion +release only when its current failure mode is covered by a deterministic regression test or it is +an explicit Guion build policy. + +## Retained delta + +| Patch | Failure mode | Regression evidence | Upstream v0.0.7 | Disposition | +| --- | --- | --- | --- | --- | +| Connect-time CRUD scan | CRUD queued before `connect()` can miss the early notification and remain stranded | `connect_uploads_crud_that_was_already_queued` | Missing | Retain; upstream candidate | +| Download writer scope | Connector credential/network awaits can retain the only writer and deadlock other writes | `fetching_credentials_does_not_hold_the_download_writer_lease` | Missing | Retain; upstream candidate | +| Connection status ordering | Transport and non-2xx errors can emit `ConnectionEstablished` before the error | `sync::download::http::tests` | Missing | Retain; upstream candidate | +| CRLF framing | JSON lines ending in CRLF expose a trailing `\r` to the parser | `util::line_split::test` | Missing | Retain; upstream candidate | +| rustls-only reqwest | Guion musl builds must not depend on native TLS/OpenSSL defaults | musl CI and dependency graph check | Policy differs | Retain as Guion build policy | + +Upstream v0.0.7 already retries a failed `upload_data` call in the same upload cycle. Its +`upload_retry` test remains the source of truth; the fork does not add another retry worker. + +## Dropped legacy patches + +| Legacy patch | Why it is absent from v0.0.7-guion.1 | +| --- | --- | +| rusqlite 0.32 alignment and API adaptations | The SQLx SQLite consumer is being removed; use upstream optional rusqlite 0.39 and `powersync_sqlite_nostd` 0.5.2. | +| Reader `busy_timeout` | No deterministic failure remains with one PowerSync-owned pool. The upstream writer keeps its 30-second timeout. | +| Extra `BEGIN IMMEDIATE` sites | The PowerSync writer mutex serializes SDK writes; no current `BUSY_SNAPSHOT` regression justifies broader locking. | +| Reader lease release sender | The upstream lease can only be constructed when `PoolReaders` exists and returns through the same shared pool state. | +| `From` for `PowerSyncError` | No SDK or Guion consumer path uses this public conversion. | +| Broad Clippy allows | The private-interface warning is fixed by narrowing internal subscription command visibility. | + +Do not restore a dropped patch based on suspicion or an intermittent stress failure. First add a +minimal deterministic test that identifies the failing invariant, then retain only the smallest +fix for that test. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..3157a57 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,13 @@ +pre-commit: + parallel: true + commands: + cargo-fmt: + run: cargo fmt --all --check + +pre-push: + parallel: true + commands: + cargo-clippy: + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo-deny: + run: cargo deny check diff --git a/powersync/src/sync/coordinator.rs b/powersync/src/sync/coordinator.rs index f5b5f12..5a4b47e 100644 --- a/powersync/src/sync/coordinator.rs +++ b/powersync/src/sync/coordinator.rs @@ -88,7 +88,7 @@ impl SyncCoordinator { /// Handle the set of active sync stream subscriptions changing. /// /// This is a no-op if not connected. - pub async fn handle_subscriptions_changed(&self, update: ChangedSyncSubscriptions) { + pub(crate) async fn handle_subscriptions_changed(&self, update: ChangedSyncSubscriptions) { self.download_actor_request(DownloadActorCommand::SubscriptionsChanged(update)) .await; } @@ -118,7 +118,7 @@ impl SyncCoordinator { slot.clone() } - pub fn receive_download_commands(&self) -> Receiver> { + pub(crate) fn receive_download_commands(&self) -> Receiver> { Self::install_actor_channel(&self.control_downloads) } diff --git a/powersync/src/sync/download/actor.rs b/powersync/src/sync/download/actor.rs index ba31b09..8d167ff 100644 --- a/powersync/src/sync/download/actor.rs +++ b/powersync/src/sync/download/actor.rs @@ -21,7 +21,7 @@ use crate::{ }; /// A command sent from a database to the download actor. -pub enum DownloadActorCommand { +pub(crate) enum DownloadActorCommand { Connect(SyncOptions), Disconnect, ResolveOfflineSyncStatusIfNotConnected, diff --git a/powersync/src/sync/download/mod.rs b/powersync/src/sync/download/mod.rs index cd95fb1..496b198 100644 --- a/powersync/src/sync/download/mod.rs +++ b/powersync/src/sync/download/mod.rs @@ -2,4 +2,5 @@ mod actor; pub mod http; mod sync_iteration; -pub use actor::{DownloadActor, DownloadActorCommand}; +pub use actor::DownloadActor; +pub(crate) use actor::DownloadActorCommand; diff --git a/powersync/src/sync/streams.rs b/powersync/src/sync/streams.rs index fc262c0..8c04f5f 100644 --- a/powersync/src/sync/streams.rs +++ b/powersync/src/sync/streams.rs @@ -124,4 +124,4 @@ impl<'a> From<&'a StreamSubscriptionDescription<'a>> for StreamDescription<'a> { val.description() } } -pub struct ChangedSyncSubscriptions(pub Vec); +pub(crate) struct ChangedSyncSubscriptions(pub(crate) Vec); From d51a2bc8ad59c8947672c9fa47399044e1f3a4d7 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 17:39:49 +0800 Subject: [PATCH 19/23] fix(powersync): repair SQLite cleanup and binding ownership --- powersync/src/db/connection.rs | 108 ++++++++++++++++-- powersync/src/sync/download/sync_iteration.rs | 33 +++++- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index 25fe90e..8c0d3c7 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -1,11 +1,10 @@ use crate::error::{PowerSyncError, RawPowerSyncError}; use num_traits::cast::FromPrimitive; -use powersync_sqlite_nostd::bindings::sqlite3_open_v2; +use powersync_sqlite_nostd::bindings::{sqlite3_close_v2, sqlite3_open_v2}; use powersync_sqlite_nostd::{Connection, ManagedConnection, ManagedStmt, ResultCode, sqlite3}; use std::ffi::{CStr, CString, c_int}; -use std::mem::MaybeUninit; use std::path::Path; -use std::ptr::null; +use std::ptr::{null, null_mut}; /// The SQLite connection used by the PowerSync Rust SDK. /// @@ -97,8 +96,9 @@ impl<'a> TransactionGuard<'a> { } pub fn commit(mut self) -> Result<(), PowerSyncError> { + self.inner.exec(c"COMMIT")?; self.active = false; - self.inner.exec(c"COMMIT") + Ok(()) } fn rollback_internal(&mut self) -> Result<(), PowerSyncError> { @@ -154,20 +154,22 @@ unsafe impl Send for RawSqliteConnection {} impl RawSqliteConnection { pub fn open(path: &CStr, flags: u32) -> Result { - let mut db = MaybeUninit::<*mut sqlite3>::uninit(); + let mut db = null_mut(); let rc = ResultCode::from_i32(unsafe { - sqlite3_open_v2(path.as_ptr(), db.as_mut_ptr(), flags as c_int, null()) + sqlite3_open_v2(path.as_ptr(), &mut db, flags as c_int, null()) }) .unwrap(); if rc == ResultCode::OK { - Ok(Self(ManagedConnection { - db: unsafe { - // sqlite3_open_v2 returned 0, so SQLite will have written the pointer. - db.assume_init() - }, - })) + Ok(Self(ManagedConnection { db })) } else { + if !db.is_null() { + // SQLite may allocate an error-bearing handle even when open fails. + // No statements can exist yet, so closing it here releases all resources. + unsafe { + sqlite3_close_v2(db); + } + } Err(RawPowerSyncError::RawSqlite { code: rc, context: format!("Could not open database {}", path.to_string_lossy()), @@ -202,6 +204,88 @@ fn path_to_cstring(p: &Path) -> Result { ) } +#[cfg(all(test, feature = "rusqlite"))] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use powersync_sqlite_nostd::bindings::{ + SQLITE_OPEN_CREATE, SQLITE_OPEN_READWRITE, sqlite3_memory_used, + }; + + use super::*; + + static NEXT_TEST_DATABASE: AtomicUsize = AtomicUsize::new(0); + + fn test_database_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "powersync-{name}-{}-{}.sqlite", + std::process::id(), + NEXT_TEST_DATABASE.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn failed_commit_rolls_back_before_returning_connection() { + let path = test_database_path("commit-rollback"); + let setup = rusqlite::Connection::open(&path).unwrap(); + setup + .execute_batch( + "PRAGMA journal_mode = DELETE; + CREATE TABLE values_table (value INTEGER NOT NULL); + INSERT INTO values_table VALUES (1);", + ) + .unwrap(); + + let reader = rusqlite::Connection::open(&path).unwrap(); + reader.execute_batch("BEGIN").unwrap(); + let _: i64 = reader + .query_row("SELECT value FROM values_table", [], |row| row.get(0)) + .unwrap(); + + let mut writer = SqliteConnection::from(rusqlite::Connection::open(&path).unwrap()); + let tx = TransactionGuard::new(&mut writer).unwrap(); + tx.inner.exec(c"UPDATE values_table SET value = 2").unwrap(); + + assert!(tx.commit().is_err()); + assert!(unsafe { writer.handle().get_autocommit() }); + + reader.execute_batch("ROLLBACK").unwrap(); + let value: i64 = writer + .rusqlite_connection() + .query_row("SELECT value FROM values_table", [], |row| row.get(0)) + .unwrap(); + assert_eq!(value, 1); + + drop(reader); + drop(setup); + drop(writer); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn repeated_open_failures_do_not_leak_sqlite_handles() { + let path = test_database_path("missing-parent").join("database.sqlite"); + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + + // Warm SQLite's process-global caches before measuring per-open resources. + for _ in 0..128 { + assert!(RawSqliteConnection::open_path(&path, flags).is_err()); + } + let memory_before = unsafe { sqlite3_memory_used() }; + + for _ in 0..128 { + assert!(RawSqliteConnection::open_path(&path, flags).is_err()); + } + + let memory_after = unsafe { sqlite3_memory_used() }; + assert!( + memory_after - memory_before < 1024, + "failed opens leaked {} SQLite bytes", + memory_after - memory_before + ); + } +} + #[cfg(not(unix))] fn path_to_cstring(p: &Path) -> Result { let s = p.to_str().ok_or_else(|| RawPowerSyncError::ArgumentError { diff --git a/powersync/src/sync/download/sync_iteration.rs b/powersync/src/sync/download/sync_iteration.rs index 20d6785..2d240f5 100644 --- a/powersync/src/sync/download/sync_iteration.rs +++ b/powersync/src/sync/download/sync_iteration.rs @@ -220,22 +220,47 @@ enum PowerSyncControlArgument { impl PowerSyncControlArgument { fn bind_to(&self, stmt: &ManagedStmt, index: i32) -> Result<(), ResultCode> { - // We use Destructor::STATIC here which is technically not safe, but fine since we'll always - // drop the statement before the control argument. match self { PowerSyncControlArgument::Null => stmt.bind_null(index), PowerSyncControlArgument::StaticString(str) => { stmt.bind_text(index, str, Destructor::STATIC) } - PowerSyncControlArgument::String(str) => stmt.bind_text(index, str, Destructor::STATIC), + PowerSyncControlArgument::String(str) => { + stmt.bind_text(index, str, Destructor::TRANSIENT) + } PowerSyncControlArgument::Bytes(bytes) => { - stmt.bind_blob(index, bytes, Destructor::STATIC) + stmt.bind_blob(index, bytes, Destructor::TRANSIENT) } }?; Ok(()) } } +#[cfg(all(test, feature = "rusqlite"))] +mod tests { + use std::hint::black_box; + + use super::*; + + #[test] + fn binding_dynamic_control_arguments_copies_the_payload() { + let connection = rusqlite::Connection::open_in_memory().unwrap(); + let connection = SqliteConnection::from(connection); + let stmt = connection.prepare("SELECT ?").unwrap(); + let expected = "a".repeat(4096); + + PowerSyncControlArgument::String(expected.clone()) + .bind_to(&stmt, 1) + .unwrap(); + + let overwrite = "b".repeat(4096); + black_box(&overwrite); + + assert_eq!(stmt.step().unwrap(), ResultCode::ROW); + assert_eq!(stmt.column_text(0).unwrap(), expected); + } +} + #[derive(Debug, Serialize)] pub struct StartDownloadIteration { pub parameters: serde_json::Value, From 411dea6029fd708cf02136fdddee384283e1d0a9 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 17:40:01 +0800 Subject: [PATCH 20/23] chore(powersync): align checks with supported targets --- .github/workflows/ci.yml | 24 +++--------------------- CHANGELOG.md | 1 - docs/guion-patches.md | 1 - powersync/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac99e05..f44a836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,27 +34,9 @@ jobs: - name: Test all features run: cargo test --workspace --all-features --verbose - - name: Check without rusqlite - run: cargo check -p powersync --no-default-features - - - name: Check reqwest without rusqlite - run: cargo check -p powersync --no-default-features --features tokio,reqwest - - musl: - name: Check musl release features - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install musl target - run: rustup target add x86_64-unknown-linux-musl - - - name: Check PowerSync crate - run: cargo check -p powersync --target x86_64-unknown-linux-musl --no-default-features --features tokio,reqwest,rusqlite - - windows: - name: Check Windows build - runs-on: windows-latest + macos: + name: Check macOS ARM64 build + runs-on: macos-14 steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 005ffc7..c5c0b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ - Release the download writer before awaiting connector or network work. - Report `ConnectionEstablished` only after a successful HTTP response. - Accept CRLF framing in JSON sync streams. -- Use reqwest with rustls and without native TLS defaults. ## 0.0.7 diff --git a/docs/guion-patches.md b/docs/guion-patches.md index 2760a50..aed0c37 100644 --- a/docs/guion-patches.md +++ b/docs/guion-patches.md @@ -12,7 +12,6 @@ an explicit Guion build policy. | Download writer scope | Connector credential/network awaits can retain the only writer and deadlock other writes | `fetching_credentials_does_not_hold_the_download_writer_lease` | Missing | Retain; upstream candidate | | Connection status ordering | Transport and non-2xx errors can emit `ConnectionEstablished` before the error | `sync::download::http::tests` | Missing | Retain; upstream candidate | | CRLF framing | JSON lines ending in CRLF expose a trailing `\r` to the parser | `util::line_split::test` | Missing | Retain; upstream candidate | -| rustls-only reqwest | Guion musl builds must not depend on native TLS/OpenSSL defaults | musl CI and dependency graph check | Policy differs | Retain as Guion build policy | Upstream v0.0.7 already retries a failed `upload_data` call in the same upload cycle. Its `upload_retry` test remains the source of truth; the fork does not add another retry worker. diff --git a/powersync/Cargo.toml b/powersync/Cargo.toml index e484442..33ddfa7 100644 --- a/powersync/Cargo.toml +++ b/powersync/Cargo.toml @@ -32,7 +32,7 @@ async-oneshot = "0.5.9" atomic_enum = "0.3.0" event-listener = "5.4.1" futures-lite = "2.6.1" -reqwest = { version = "0.13.2", default-features = false, optional = true, features = ["stream", "rustls"] } +reqwest = { version = "0.13.2", optional = true, features = ["stream"] } bytes = "1" log = "0.4.28" pin-project-lite = "0.2.16" From 8b17f022df499c929fdc04fd93d213c905ec7882 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 17:49:00 +0800 Subject: [PATCH 21/23] fix(sync): apply checkpoint after draining crud --- powersync/src/db/internal.rs | 2 +- powersync/tests/crud_test.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/powersync/src/db/internal.rs b/powersync/src/db/internal.rs index ad0fb33..2eafeab 100644 --- a/powersync/src/db/internal.rs +++ b/powersync/src/db/internal.rs @@ -116,7 +116,7 @@ impl InnerPowerSyncState { if let Some(write_checkpoint) = write_checkpoint { // If there are no remaining crud items we can set the target op to the checkpoint. let stmt = writer.inner.prepare("SELECT 1 FROM ps_crud LIMIT 1")?; - if let ResultCode::OK = stmt.step()? { + if let ResultCode::DONE = stmt.step()? { target_op = write_checkpoint; } } diff --git a/powersync/tests/crud_test.rs b/powersync/tests/crud_test.rs index 523d380..be72f6b 100644 --- a/powersync/tests/crud_test.rs +++ b/powersync/tests/crud_test.rs @@ -222,6 +222,35 @@ fn insert() { }); } +#[test] +fn applies_checkpoint_after_draining_crud_queue() { + future::block_on(async move { + let test = DatabaseTest::new(); + let db = test.in_memory_database(); + + execute( + &db, + "INSERT INTO users (id, name) VALUES (?, ?)", + params!["test", "name"], + ) + .await; + + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + transaction.complete_with_checkpoint(42).await.unwrap(); + + let mut reader = db.reader().await.unwrap(); + let reader = reader.transaction().unwrap(); + let checkpoint: i64 = reader + .query_one( + "SELECT powersync_control('target_checkpoint_request_id', NULL)", + params![], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(checkpoint, 42); + }); +} + #[test] fn crud_transactions() { async fn create_transaction(db: &PowerSyncDatabase, amount: usize) { From 1482501c664869e95a26b6710fb7671f1f5df23c Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 17:53:36 +0800 Subject: [PATCH 22/23] fix(sync): serialize SQLite leak checks --- powersync/src/db/connection.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index 8c0d3c7..b0c40d0 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -206,7 +206,10 @@ fn path_to_cstring(p: &Path) -> Result { #[cfg(all(test, feature = "rusqlite"))] mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, + }; use powersync_sqlite_nostd::bindings::{ SQLITE_OPEN_CREATE, SQLITE_OPEN_READWRITE, sqlite3_memory_used, @@ -215,6 +218,7 @@ mod tests { use super::*; static NEXT_TEST_DATABASE: AtomicUsize = AtomicUsize::new(0); + static SQLITE_TEST_LOCK: Mutex<()> = Mutex::new(()); fn test_database_path(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( @@ -226,6 +230,7 @@ mod tests { #[test] fn failed_commit_rolls_back_before_returning_connection() { + let _lock = SQLITE_TEST_LOCK.lock().unwrap(); let path = test_database_path("commit-rollback"); let setup = rusqlite::Connection::open(&path).unwrap(); setup @@ -264,6 +269,7 @@ mod tests { #[test] fn repeated_open_failures_do_not_leak_sqlite_handles() { + let _lock = SQLITE_TEST_LOCK.lock().unwrap(); let path = test_database_path("missing-parent").join("database.sqlite"); let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; From 133f7eabacfbd49b3240f8313807105eab0ef315 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 18:06:33 +0800 Subject: [PATCH 23/23] fix(ci): isolate SQLite test and restrict workflow access --- .github/workflows/ci.yml | 7 +++++++ powersync/src/db/connection.rs | 26 +++++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44a836..d50c113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: branches: - main +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -17,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Cache uses: actions/cache@v4 @@ -39,6 +44,8 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install stable Rust run: rustup update stable diff --git a/powersync/src/db/connection.rs b/powersync/src/db/connection.rs index b0c40d0..849da56 100644 --- a/powersync/src/db/connection.rs +++ b/powersync/src/db/connection.rs @@ -206,10 +206,8 @@ fn path_to_cstring(p: &Path) -> Result { #[cfg(all(test, feature = "rusqlite"))] mod tests { - use std::sync::{ - Mutex, - atomic::{AtomicUsize, Ordering}, - }; + use std::process::Command; + use std::sync::atomic::{AtomicUsize, Ordering}; use powersync_sqlite_nostd::bindings::{ SQLITE_OPEN_CREATE, SQLITE_OPEN_READWRITE, sqlite3_memory_used, @@ -218,7 +216,9 @@ mod tests { use super::*; static NEXT_TEST_DATABASE: AtomicUsize = AtomicUsize::new(0); - static SQLITE_TEST_LOCK: Mutex<()> = Mutex::new(()); + const SQLITE_MEMORY_TEST: &str = + "db::connection::tests::repeated_open_failures_do_not_leak_sqlite_handles"; + const SQLITE_MEMORY_TEST_CHILD: &str = "POWERSYNC_SQLITE_MEMORY_TEST_CHILD"; fn test_database_path(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( @@ -230,7 +230,6 @@ mod tests { #[test] fn failed_commit_rolls_back_before_returning_connection() { - let _lock = SQLITE_TEST_LOCK.lock().unwrap(); let path = test_database_path("commit-rollback"); let setup = rusqlite::Connection::open(&path).unwrap(); setup @@ -269,7 +268,20 @@ mod tests { #[test] fn repeated_open_failures_do_not_leak_sqlite_handles() { - let _lock = SQLITE_TEST_LOCK.lock().unwrap(); + if std::env::var_os(SQLITE_MEMORY_TEST_CHILD).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .arg(SQLITE_MEMORY_TEST) + .arg("--exact") + .env(SQLITE_MEMORY_TEST_CHILD, "1") + .status() + .unwrap(); + assert!( + status.success(), + "SQLite memory test subprocess failed: {status}" + ); + return; + } + let path = test_database_path("missing-parent").join("database.sqlite"); let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;