From 8d6dd90dd792cfb082cc75d1c67ef2a739714447 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Jul 2026 17:06:46 +0200 Subject: [PATCH 1/5] Allow calling powersync_init on readonly connections --- crates/core/src/pre_close_vtab.rs | 53 ++++++++++++++++++++++---- crates/core/src/view_admin.rs | 10 +++-- crates/sqlite_nostd/src/capi.rs | 23 ++++++++--- dart/test/sync_test.dart | 24 ++++++++++++ dart/test/utils/native_test_utils.dart | 9 +++-- dart/test/utils/tracking_vfs.dart | 2 +- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/crates/core/src/pre_close_vtab.rs b/crates/core/src/pre_close_vtab.rs index 7f116712..98ed9add 100644 --- a/crates/core/src/pre_close_vtab.rs +++ b/crates/core/src/pre_close_vtab.rs @@ -71,24 +71,61 @@ extern "C" fn update( _argv: *mut *mut sqlite::value, _p_row_id: *mut sqlite::int64, ) -> c_int { - 0 + // This table isn't meant to be written to. + ResultCode::MISUSE as c_int +} + +#[repr(transparent)] +struct EmptyCursor(sqlite::vtab_cursor); + +extern "C" fn best_index(_vtab: *mut sqlite::vtab, _index_info: *mut sqlite::index_info) -> c_int { + // No rows are ever returned, so there's nothing to plan for. + ResultCode::OK as c_int +} + +extern "C" fn open(vtab: *mut sqlite::vtab, cursor: *mut *mut sqlite::vtab_cursor) -> c_int { + let c = Box::into_raw(Box::new(EmptyCursor(sqlite::vtab_cursor { pVtab: vtab }))); + unsafe { *cursor = c.cast::() }; + + ResultCode::OK as c_int +} + +extern "C" fn close(cursor: *mut sqlite::vtab_cursor) -> c_int { + unsafe { + drop(Box::from_raw(cursor as *mut EmptyCursor)); + } + ResultCode::OK as c_int +} + +extern "C" fn filter( + _cursor: *mut sqlite::vtab_cursor, + _idx_num: c_int, + _idx_str: *const c_char, + _argc: c_int, + _argv: *mut *mut sqlite::value, +) -> c_int { + ResultCode::OK as c_int +} + +extern "C" fn eof(_cursor: *mut sqlite::vtab_cursor) -> c_int { + 1 } // Insert-only virtual table. -// The primary functionality here is in update. -// connect and disconnect configures the table and allocates the required resources. +// The primary functionality here is in connect and disconnect - selecting from it is a no-op +// used to trigger those at the right times, and writes are rejected in update. static MODULE: sqlite::module = sqlite::module { iVersion: 0, xCreate: None, xConnect: Some(connect), - xBestIndex: Some(vtab_no_best_index), + xBestIndex: Some(best_index), xDisconnect: Some(disconnect), xDestroy: None, - xOpen: Some(vtab_no_open), - xClose: Some(vtab_no_close), - xFilter: Some(vtab_no_filter), + xOpen: Some(open), + xClose: Some(close), + xFilter: Some(filter), xNext: Some(vtab_no_next), - xEof: Some(vtab_no_eof), + xEof: Some(eof), xColumn: Some(vtab_no_column), xRowid: Some(vtab_no_rowid), xUpdate: Some(update), diff --git a/crates/core/src/view_admin.rs b/crates/core/src/view_admin.rs index 3ef012b6..43da4200 100644 --- a/crates/core/src/view_admin.rs +++ b/crates/core/src/view_admin.rs @@ -36,13 +36,17 @@ fn powersync_init_impl( _args: &[*mut sqlite::value], ) -> Result { let db = ctx.db_handle(); - verify_in_transaction(db)?; - powersync_migrate(ctx, LATEST_VERSION)?; + if let Some(true) = sqlite::db_readonly(db, c"main") { + // Called on readonly connection, don't try to migrate. + } else { + verify_in_transaction(db)?; + powersync_migrate(ctx, LATEST_VERSION)?; + } // Register the powersync_internal_close vtab to implement a "pre-close hook". // See `pre_close_vtab.rs` for more details on how that works. ctx.db_handle() - .exec(c"INSERT INTO powersync_internal_close(_) VALUES (null)")?; + .exec(c"SELECT 1 FROM powersync_internal_close;")?; Ok(String::from("")) } diff --git a/crates/sqlite_nostd/src/capi.rs b/crates/sqlite_nostd/src/capi.rs index df9ad054..9b49893b 100644 --- a/crates/sqlite_nostd/src/capi.rs +++ b/crates/sqlite_nostd/src/capi.rs @@ -49,12 +49,13 @@ mod aliased { sqlite3_column_value as column_value, sqlite3_commit_hook as commit_hook, sqlite3_context_db_handle as context_db_handle, sqlite3_create_function_v2 as create_function_v2, - sqlite3_create_module_v2 as create_module_v2, sqlite3_declare_vtab as declare_vtab, - sqlite3_errcode as errcode, sqlite3_errmsg as errmsg, sqlite3_error_offset as error_offset, - sqlite3_exec as exec, sqlite3_finalize as finalize, sqlite3_free as free, - sqlite3_get_autocommit as get_autocommit, sqlite3_get_auxdata as get_auxdata, - sqlite3_libversion as libversion, sqlite3_libversion_number as libversion_number, - sqlite3_malloc as malloc, sqlite3_malloc64 as malloc64, sqlite3_mutex_alloc as mutex_alloc, + sqlite3_create_module_v2 as create_module_v2, sqlite3_db_readonly as db_readonly, + sqlite3_declare_vtab as declare_vtab, sqlite3_errcode as errcode, sqlite3_errmsg as errmsg, + sqlite3_error_offset as error_offset, sqlite3_exec as exec, sqlite3_finalize as finalize, + sqlite3_free as free, sqlite3_get_autocommit as get_autocommit, + sqlite3_get_auxdata as get_auxdata, sqlite3_libversion as libversion, + sqlite3_libversion_number as libversion_number, sqlite3_malloc as malloc, + sqlite3_malloc64 as malloc64, sqlite3_mutex_alloc as mutex_alloc, sqlite3_mutex_enter as mutex_enter, sqlite3_mutex_free as mutex_free, sqlite3_mutex_leave as mutex_leave, sqlite3_mutex_try as mutex_try, sqlite3_next_stmt as next_stmt, sqlite3_open as open, sqlite3_prepare_v2 as prepare_v2, @@ -148,6 +149,16 @@ pub fn bind_blob( } } +pub fn db_readonly(connection: *mut sqlite3, db: &CStr) -> Option { + let res = unsafe { invoke_sqlite!(db_readonly, connection, db.as_ptr()) }; + + match res { + 1 => Some(true), // Readonly + 0 => Some(false), // Read/write + _ => None, // Not the name of a database on the connection. + } +} + pub fn changes64(db: *mut sqlite3) -> int64 { unsafe { invoke_sqlite!(changes64, db) } } diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index ad93396e..31e68c59 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -2132,6 +2132,30 @@ CREATE TRIGGER users_ref_delete expect(vfs.openFiles, isZero); }); + test('resolving the offline sync status allows closing the database', () { + final vfs = TrackingFileSystem( + parent: InMemoryFileSystem(), name: 'sync-test-resolve-offline'); + sqlite3.registerVirtualFileSystem(vfs); + addTearDown(() => sqlite3.unregisterVirtualFileSystem(vfs)); + + { + db = openTestDatabase(vfs: vfs, fileName: '/test.db') + ..executeInTx('select powersync_init();'); + expect(vfs.openFiles, isNonZero); + db.close(); + expect(vfs.openFiles, isZero); + } + + db = openTestDatabase( + vfs: vfs, fileName: '/test.db', mode: OpenMode.readOnly) + ..executeInTx('select powersync_init();'); + + db.execute('SELECT powersync_offline_sync_status();'); + expect(vfs.openFiles, isNonZero); + db.close(); + expect(vfs.openFiles, isZero); + }); + test('tracks download size', () { invokeControl('start', null); pushCheckpoint(buckets: [bucketDescription('a', count: 2)]); diff --git a/dart/test/utils/native_test_utils.dart b/dart/test/utils/native_test_utils.dart index 3eeca1e3..3839cc2f 100644 --- a/dart/test/utils/native_test_utils.dart +++ b/dart/test/utils/native_test_utils.dart @@ -17,8 +17,11 @@ var didLoadExtension = false; String? testingWithSanitizers = null; -CommonDatabase openTestDatabase( - {VirtualFileSystem? vfs, String fileName = ':memory:'}) { +CommonDatabase openTestDatabase({ + VirtualFileSystem? vfs, + String fileName = ':memory:', + OpenMode mode = OpenMode.readWriteCreate, +}) { if (!didLoadExtension) { loadExtension(); } @@ -35,7 +38,7 @@ CommonDatabase openTestDatabase( vfs = inMemory; } - final db = sqlite3.open(fileName, vfs: vfs?.name); + final db = sqlite3.open(fileName, vfs: vfs?.name, mode: mode); addTearDown(db.close); return db; } diff --git a/dart/test/utils/tracking_vfs.dart b/dart/test/utils/tracking_vfs.dart index a2d411b4..fd66a579 100644 --- a/dart/test/utils/tracking_vfs.dart +++ b/dart/test/utils/tracking_vfs.dart @@ -32,7 +32,7 @@ final class TrackingFileSystem extends BaseVirtualFileSystem { final result = parent.xOpen(path, flags); openFiles++; return ( - outFlags: result.outFlags, + outFlags: flags, file: TrackingFile( result.file, this, flags & SqlFlag.SQLITE_OPEN_DELETEONCLOSE != 0), ); From d6833b65ff137c7ae1f60ce36e47538ee3a797c9 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Jul 2026 17:10:27 +0200 Subject: [PATCH 2/5] deslop comments --- crates/core/src/pre_close_vtab.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/core/src/pre_close_vtab.rs b/crates/core/src/pre_close_vtab.rs index 98ed9add..54513a24 100644 --- a/crates/core/src/pre_close_vtab.rs +++ b/crates/core/src/pre_close_vtab.rs @@ -111,9 +111,7 @@ extern "C" fn eof(_cursor: *mut sqlite::vtab_cursor) -> c_int { 1 } -// Insert-only virtual table. -// The primary functionality here is in connect and disconnect - selecting from it is a no-op -// used to trigger those at the right times, and writes are rejected in update. +// Select-only virtual table, the primary functionality here is in disconnect. static MODULE: sqlite::module = sqlite::module { iVersion: 0, xCreate: None, From 1b8a2a78a71d58c82696e6efde7c0a984583759e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Jul 2026 17:20:00 +0200 Subject: [PATCH 3/5] Simplify changes --- crates/core/src/sync/storage_adapter.rs | 5 +++++ crates/core/src/view_admin.rs | 13 ++----------- crates/sqlite_nostd/src/capi.rs | 23 ++++++----------------- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 76757b96..8ecad48a 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -50,6 +50,11 @@ pub struct StorageAdapter { impl StorageAdapter { pub fn new(db: *mut sqlite::sqlite3) -> Result { + // The cached statements here prevent sqlite3_close to complete. sqlite3_close invokes + // the xDisconnect callback on attached virtual tables, which we use to implement a + // "pre-close hook". See `pre_close_vtab.rs` for more details on how that works. + db.exec(c"SELECT 1 FROM powersync_internal_close;")?; + // language=SQLite let progress = db .prepare_v2("SELECT name, count_at_last, count_since_last FROM ps_buckets") diff --git a/crates/core/src/view_admin.rs b/crates/core/src/view_admin.rs index 43da4200..7c1d517e 100644 --- a/crates/core/src/view_admin.rs +++ b/crates/core/src/view_admin.rs @@ -36,17 +36,8 @@ fn powersync_init_impl( _args: &[*mut sqlite::value], ) -> Result { let db = ctx.db_handle(); - if let Some(true) = sqlite::db_readonly(db, c"main") { - // Called on readonly connection, don't try to migrate. - } else { - verify_in_transaction(db)?; - powersync_migrate(ctx, LATEST_VERSION)?; - } - - // Register the powersync_internal_close vtab to implement a "pre-close hook". - // See `pre_close_vtab.rs` for more details on how that works. - ctx.db_handle() - .exec(c"SELECT 1 FROM powersync_internal_close;")?; + verify_in_transaction(db)?; + powersync_migrate(ctx, LATEST_VERSION)?; Ok(String::from("")) } diff --git a/crates/sqlite_nostd/src/capi.rs b/crates/sqlite_nostd/src/capi.rs index 9b49893b..df9ad054 100644 --- a/crates/sqlite_nostd/src/capi.rs +++ b/crates/sqlite_nostd/src/capi.rs @@ -49,13 +49,12 @@ mod aliased { sqlite3_column_value as column_value, sqlite3_commit_hook as commit_hook, sqlite3_context_db_handle as context_db_handle, sqlite3_create_function_v2 as create_function_v2, - sqlite3_create_module_v2 as create_module_v2, sqlite3_db_readonly as db_readonly, - sqlite3_declare_vtab as declare_vtab, sqlite3_errcode as errcode, sqlite3_errmsg as errmsg, - sqlite3_error_offset as error_offset, sqlite3_exec as exec, sqlite3_finalize as finalize, - sqlite3_free as free, sqlite3_get_autocommit as get_autocommit, - sqlite3_get_auxdata as get_auxdata, sqlite3_libversion as libversion, - sqlite3_libversion_number as libversion_number, sqlite3_malloc as malloc, - sqlite3_malloc64 as malloc64, sqlite3_mutex_alloc as mutex_alloc, + sqlite3_create_module_v2 as create_module_v2, sqlite3_declare_vtab as declare_vtab, + sqlite3_errcode as errcode, sqlite3_errmsg as errmsg, sqlite3_error_offset as error_offset, + sqlite3_exec as exec, sqlite3_finalize as finalize, sqlite3_free as free, + sqlite3_get_autocommit as get_autocommit, sqlite3_get_auxdata as get_auxdata, + sqlite3_libversion as libversion, sqlite3_libversion_number as libversion_number, + sqlite3_malloc as malloc, sqlite3_malloc64 as malloc64, sqlite3_mutex_alloc as mutex_alloc, sqlite3_mutex_enter as mutex_enter, sqlite3_mutex_free as mutex_free, sqlite3_mutex_leave as mutex_leave, sqlite3_mutex_try as mutex_try, sqlite3_next_stmt as next_stmt, sqlite3_open as open, sqlite3_prepare_v2 as prepare_v2, @@ -149,16 +148,6 @@ pub fn bind_blob( } } -pub fn db_readonly(connection: *mut sqlite3, db: &CStr) -> Option { - let res = unsafe { invoke_sqlite!(db_readonly, connection, db.as_ptr()) }; - - match res { - 1 => Some(true), // Readonly - 0 => Some(false), // Read/write - _ => None, // Not the name of a database on the connection. - } -} - pub fn changes64(db: *mut sqlite3) -> int64 { unsafe { invoke_sqlite!(changes64, db) } } From d72a11395bac61bb04b6bbcd504c5ef19e9f1f74 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Jul 2026 17:32:36 +0200 Subject: [PATCH 4/5] Also register for update hooks --- crates/core/src/pre_close_vtab.rs | 5 +++++ crates/core/src/sync/storage_adapter.rs | 3 ++- crates/core/src/update_hooks.rs | 10 +++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/core/src/pre_close_vtab.rs b/crates/core/src/pre_close_vtab.rs index 54513a24..79dfa4ef 100644 --- a/crates/core/src/pre_close_vtab.rs +++ b/crates/core/src/pre_close_vtab.rs @@ -140,6 +140,11 @@ static MODULE: sqlite::module = sqlite::module { xIntegrity: None, }; +pub fn ensure_has_internal_close_vtab(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { + db.exec(c"SELECT 1 FROM powersync_internal_close;")?; + Ok(()) +} + pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<(), ResultCode> { db.create_module_v2( "powersync_internal_close", diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 8ecad48a..d80be80e 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -7,6 +7,7 @@ use serde::Serialize; use crate::{ error::{PSResult, PowerSyncError}, ext::SafeManagedStmt, + pre_close_vtab::ensure_has_internal_close_vtab, schema::Schema, state::DatabaseState, sync::{ @@ -53,7 +54,7 @@ impl StorageAdapter { // The cached statements here prevent sqlite3_close to complete. sqlite3_close invokes // the xDisconnect callback on attached virtual tables, which we use to implement a // "pre-close hook". See `pre_close_vtab.rs` for more details on how that works. - db.exec(c"SELECT 1 FROM powersync_internal_close;")?; + ensure_has_internal_close_vtab(db)?; // language=SQLite let progress = db diff --git a/crates/core/src/update_hooks.rs b/crates/core/src/update_hooks.rs index 1501aa0f..4702cf78 100644 --- a/crates/core/src/update_hooks.rs +++ b/crates/core/src/update_hooks.rs @@ -8,7 +8,10 @@ use powersync_sqlite_nostd::{ self as sqlite, Connection, Context, ResultCode, Value, bindings::SQLITE_RESULT_SUBTYPE, }; -use crate::{constants::SUBTYPE_JSON, error::PowerSyncError, state::DatabaseState}; +use crate::{ + constants::SUBTYPE_JSON, error::PowerSyncError, pre_close_vtab::ensure_has_internal_close_vtab, + state::DatabaseState, +}; /// The `powersync_update_hooks` methods works like this: /// @@ -57,6 +60,11 @@ extern "C" fn powersync_update_hooks( match op { "install" => { + if let Err(e) = ensure_has_internal_close_vtab(db) { + ctx.result_error_code(e); + return; + }; + let state = unsafe { user_data.as_ref().unwrap_unchecked() }; let db_state = &state.state; From f5d43823c87c8cdbcf8713be94baae1fa58b854c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Jul 2026 17:33:39 +0200 Subject: [PATCH 5/5] typo --- crates/core/src/sync/storage_adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index d80be80e..74ef4288 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -51,7 +51,7 @@ pub struct StorageAdapter { impl StorageAdapter { pub fn new(db: *mut sqlite::sqlite3) -> Result { - // The cached statements here prevent sqlite3_close to complete. sqlite3_close invokes + // The cached statements here prevent sqlite3_close from completing. sqlite3_close invokes // the xDisconnect callback on attached virtual tables, which we use to implement a // "pre-close hook". See `pre_close_vtab.rs` for more details on how that works. ensure_has_internal_close_vtab(db)?;