From 720fb28536e0cbf8240943d407f34d51b506560c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:53:44 -0600 Subject: [PATCH 1/7] Get basic sqlite working with updates --- .buildkite/swift-test.sh | 2 +- .gitignore | 5 +- Cargo.lock | 56 +++++ Makefile | 2 +- Package.resolved | 2 +- Package.swift | 19 ++ native/kotlin/api/android/build.gradle.kts | 3 + .../kotlin/WordPressApiCacheTest.kt | 16 ++ .../cache/kotlin/WordPressApiCache.kt | 47 ++++ .../WordPressApiCache.swift | 58 +++++ .../WordPressApiCacheTests.swift | 17 ++ scripts/swift-bindings.sh | 5 + scripts/xcodebuild-test.sh | 2 +- wp_api/Cargo.toml | 2 + .../migrations/0001-create-posts-table.sql | 22 ++ .../migrations/0002-create-users-table.sql | 14 ++ wp_api/src/cache/mod.rs | 1 + wp_api/src/cache/wp_api_cache.rs | 226 ++++++++++++++++++ wp_api/src/lib.rs | 1 + wp_api/uniffi.toml | 2 +- 20 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt create mode 100644 native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt create mode 100644 native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift create mode 100644 native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift create mode 100644 wp_api/src/cache/migrations/0001-create-posts-table.sql create mode 100644 wp_api/src/cache/migrations/0002-create-users-table.sql create mode 100644 wp_api/src/cache/mod.rs create mode 100644 wp_api/src/cache/wp_api_cache.rs diff --git a/.buildkite/swift-test.sh b/.buildkite/swift-test.sh index 2155606e7..1b12f4af1 100755 --- a/.buildkite/swift-test.sh +++ b/.buildkite/swift-test.sh @@ -18,7 +18,7 @@ function build_for_real_device() { echo "--- :swift: Building for $platform device" export NSUnbufferedIO=YES xcodebuild -destination "generic/platform=$platform" \ - -scheme WordPressAPI \ + -scheme WordPressAPI-Package \ -derivedDataPath DerivedData \ -skipPackagePluginValidation \ build | xcbeautify diff --git a/.gitignore b/.gitignore index 5a213c67c..313f21887 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ /docs /docs.tar.gz +# Test DB Files +kotlin.db +swift.db + # Ignore Gradle project-specific cache directory .gradle @@ -33,7 +37,6 @@ fastlane/report.xml libwordPressFFI.xcframework* /swift-docs.tar.gz - # Auto-generated Swift Files native/swift/Sources/wordpress-api-wrapper/*.swift diff --git a/Cargo.lock b/Cargo.lock index b12a2c701..998e588b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1004,6 +1016,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1332,6 +1350,18 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -2044,6 +2074,17 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -3096,6 +3137,20 @@ dependencies = [ "syn", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.8.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -5000,6 +5055,7 @@ dependencies = [ "roxmltree", "rstest", "rstest_reuse", + "rusqlite", "rustls", "scraper", "serde", diff --git a/Makefile b/Makefile index 729deb4fa..bc49d24cd 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,7 @@ _build-apple-%-tvos _build-apple-%-tvos-sim _build-apple-%-watchos _build-apple- # Build the library for a specific target _build-apple-%: - cargo $(CARGO_OPTS) $(cargo_config_library) build --target $* --package wp_api --profile $(CARGO_PROFILE) + cargo $(CARGO_OPTS) $(cargo_config_library) build --target $* --package wp_api --profile $(CARGO_PROFILE) --no-default-features ./scripts/swift-bindings.sh target/$*/$(CARGO_PROFILE_DIRNAME)/libwp_api.a # Build the library for one single platform, including real device and simulator. diff --git a/Package.resolved b/Package.resolved index 9bd433af1..e0098449e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c800e108ef4eae436d8444968bc4e6f1d076a0d019136719caee3dcce13fe09a", + "originHash" : "6791ea51c1f6a770231d8e16e3b9310037ce94ea7a924534fab9039ed08061cd", "pins" : [ { "identity" : "collectionconcurrencykit", diff --git a/Package.swift b/Package.swift index 9f3f548c9..8fe6d4bb1 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,10 @@ var package = Package( .library( name: "WordPressAPI", targets: ["WordPressAPI"] + ), + .library( + name: "WordPressApiCache", + targets: ["WordPressApiCache"] ) ], dependencies: [ @@ -56,6 +60,13 @@ var package = Package( .swiftLanguageMode(.v5) ] ), + .target( + name: "WordPressApiCache", + dependencies: [ + .target(name: "WordPressAPIInternal") + ], + path: "native/swift/Sources/wordpress-api-cache" + ), libwordpressFFI, .testTarget( name: "WordPressAPITests", @@ -68,6 +79,14 @@ var package = Package( swiftSettings: [ .define("PROGRESS_REPORTING_ENABLED", .when(platforms: [.iOS, .macOS, .tvOS, .watchOS])) ] + ), + .testTarget( + name: "WordPressApiCacheTests", + dependencies: [ + .target(name: "WordPressApiCache"), + .target(name: "WordPressAPIInternal") + ], + path: "native/swift/Tests/wordpress-api-cache" ) ].addingIntegrationTests() ) diff --git a/native/kotlin/api/android/build.gradle.kts b/native/kotlin/api/android/build.gradle.kts index 4da0cdc15..9fb07316b 100644 --- a/native/kotlin/api/android/build.gradle.kts +++ b/native/kotlin/api/android/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.kotlin.konan.target.linker +import kotlin.system.exitProcess + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt new file mode 100644 index 000000000..9a2da0716 --- /dev/null +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -0,0 +1,16 @@ +package rs.wordpress.api.cache.kotlin + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import rs.wordpress.cache.kotlin.WordPressApiCache +import kotlin.test.assertEquals + +@Execution(ExecutionMode.CONCURRENT) +class WordPressApiCacheTest { + + @Test + fun testThatMigrationsWork() { + assertEquals(2, WordPressApiCache().performMigrations()) + } +} diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt new file mode 100644 index 000000000..f907312c2 --- /dev/null +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt @@ -0,0 +1,47 @@ +package rs.wordpress.cache.kotlin + +import kotlinx.coroutines.asCoroutineDispatcher +import uniffi.wp_api.DatabaseDelegate +import uniffi.wp_api.UpdateHook +import uniffi.wp_api.WpApiCache +import uniffi.wp_api.setGlobalDelegate +import java.nio.file.Path +import java.util.concurrent.Executors + +class WordPressApiCacheDelegate : DatabaseDelegate { + override fun didUpdate(updateHook: UpdateHook) { + println("Received update: $updateHook") + } +} + +class WordPressApiCache { + private val cache: WpApiCache + private val internalDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val delegate: DatabaseDelegate = WordPressApiCacheDelegate() + + // Creates a new in-memory cache + constructor() : this(":memory:") + + // Creates a new cache at the specified file system URL + constructor(path: Path) : this(path.toString()) + + // Creates a new cache at the specified path + constructor(string: String) { + this.cache = WpApiCache(string) + } + + fun performMigrations(): Int { + internalDispatcher.run { + return this@WordPressApiCache.cache.performMigrations().toInt() + } + } + + fun startListeningForUpdates() { + setGlobalDelegate(delegate) + this.cache.startListeningForUpdates() + } + + fun stopListeningForUpdates() { + this.cache.stopListeningForUpdates() + } +} diff --git a/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift new file mode 100644 index 000000000..2762c63ca --- /dev/null +++ b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift @@ -0,0 +1,58 @@ +import Foundation +import WordPressAPIInternal + +public actor WordPressApiCache { + + private let cache: WpApiCache + private let delegate: any DatabaseDelegate + + public struct Notifications { + public static let cacheDidUpdate = Notification.Name("WordPressApiCache.cacheDidUpdate") + + public static func name(for table: String) -> Notification.Name { + Notification.Name(rawValue: "WordPressApiCachce.cacheDidUpdate.\(table)") + } + } + + final public class ApiCacheDelegate: DatabaseDelegate { + public init() {} + + public func didUpdate(updateHook: WordPressAPIInternal.UpdateHook) { + let name = Notifications.name(for: updateHook.tableName) + NotificationCenter.default.post(name: name, object: updateHook) + } + } + + /// Creates a new in-memory cache + public init(delegate: DatabaseDelegate = ApiCacheDelegate()) throws { + try self.init(path: ":memory:", delegate: delegate) + } + + /// Creates a new cache at the specified file system URL + public init(url: URL, delegate: DatabaseDelegate = ApiCacheDelegate()) throws { + try self.init(path: url.absoluteString, delegate: delegate) + } + + /// Creates a new cache at the specified path + public init(path: String, delegate: DatabaseDelegate = ApiCacheDelegate()) throws { + self.cache = try WpApiCache(path: path) + self.delegate = delegate + } + + public func performMigrations() async throws -> Int { + return Int(try self.cache.performMigrations()) + } + + public func startListeningForUpdates() { + setGlobalDelegate(delegate: delegate) + self.cache.startListeningForUpdates() + } + + public func stopListeningForUpdates() { + self.cache.stopListeningForUpdates() + } + + deinit { + self.cache.stopListeningForUpdates() + } +} diff --git a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift new file mode 100644 index 000000000..ba07eb495 --- /dev/null +++ b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing +import WordPressApiCache + +struct Test { + + private var cache: WordPressApiCache! + + init() throws { + self.cache = try WordPressApiCache() + } + + @Test func testMigrationsWork() async throws { + let migrationsPerformed = try await self.cache.performMigrations() + #expect(migrationsPerformed == 2) + } +} diff --git a/scripts/swift-bindings.sh b/scripts/swift-bindings.sh index 79bfd9442..27574aac9 100755 --- a/scripts/swift-bindings.sh +++ b/scripts/swift-bindings.sh @@ -32,6 +32,11 @@ extension $error_type: LocalizedError { } EOF done + + # Use sed to replace `import SQLite3` with the wrapped version + sed -i.bak 's/^import SQLite3$/#if canImport(SQLite3)\ +import SQLite3\ +#endif/' $swift_binding } for swift_binding in "$output_dir"/*.swift; do diff --git a/scripts/xcodebuild-test.sh b/scripts/xcodebuild-test.sh index 1afeb8d5f..b48af587f 100755 --- a/scripts/xcodebuild-test.sh +++ b/scripts/xcodebuild-test.sh @@ -11,7 +11,7 @@ device_id=$(xcrun simctl list --json devices available | jq -re ".devices.\"com. export NSUnbufferedIO=YES xcodebuild \ - -scheme WordPressAPI \ + -scheme WordPressAPI-Package \ -derivedDataPath DerivedData \ -destination "id=${device_id}" \ -skipPackagePluginValidation \ diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 8b67c2536..86779c086 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [features] +default = ["rusqlite/bundled"] integration-tests = [] reqwest-request-executor = ["dep:reqwest", "dep:tokio", "dep:hyper-util", "dep:rustls", "dep:hickory-resolver", "dep:hyper", "dep:h2"] @@ -47,6 +48,7 @@ wp_localization = { path = "../wp_localization" } wp_localization_macro = { path = "../wp_localization_macro" } wp_serde_helper = { path = "../wp_serde_helper" } x509-cert = { workspace = true } +rusqlite = { version = "0.37.0", features = ["hooks"] } [dev-dependencies] rstest = { workspace = true } diff --git a/wp_api/src/cache/migrations/0001-create-posts-table.sql b/wp_api/src/cache/migrations/0001-create-posts-table.sql new file mode 100644 index 000000000..07a74c91c --- /dev/null +++ b/wp_api/src/cache/migrations/0001-create-posts-table.sql @@ -0,0 +1,22 @@ +CREATE TABLE `posts` ( + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + `post_id` INTEGER NOT NULL, + `context` TEXT COLLATE NOCASE NOT NULL, + `post_author` INTEGER NOT NULL, + `post_date` TEXT COLLATE NOCASE NOT NULL, + `post_content` TEXT COLLATE NOCASE NOT NULL, + `post_title` TEXT COLLATE NOCASE NOT NULL, + `post_excerpt` TEXT COLLATE NOCASE NOT NULL, + `post_status` TEXT COLLATE NOCASE NOT NULL, + `comment_status` TEXT COLLATE NOCASE NOT NULL, + `ping_status` TEXT COLLATE NOCASE NOT NULL, + `post_password` TEXT COLLATE NOCASE DEFAULT NULL, + `post_modified` TEXT COLLATE NOCASE NOT NULL, + `post_parent` INTEGER, + `guid` TEXT COLLATE NOCASE NOT NULL, + `menu_order` INTEGER NOT NULL DEFAULT '0', + `post_type` TEXT COLLATE NOCASE NOT NULL, + `comment_count` INTEGER NOT NULL +) STRICT; + +CREATE UNIQUE INDEX idx_posts_have_unique_post_id_and_context ON posts(post_id, context); diff --git a/wp_api/src/cache/migrations/0002-create-users-table.sql b/wp_api/src/cache/migrations/0002-create-users-table.sql new file mode 100644 index 000000000..b5b68af95 --- /dev/null +++ b/wp_api/src/cache/migrations/0002-create-users-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE `users` ( + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + `user_id` INTEGER NOT NULL, + `context` TEXT COLLATE NOCASE NOT NULL, + `user_login` TEXT COLLATE NOCASE NOT NULL, + `user_nicename` TEXT COLLATE NOCASE NOT NULL, + `user_email` TEXT COLLATE NOCASE NOT NULL, + `user_url` TEXT COLLATE NOCASE NOT NULL, + `user_registered` TEXT COLLATE NOCASE NOT NULL, + `user_status` INTEGER NOT NULL, + `display_name` TEXT COLLATE NOCASE NOT NULL +) STRICT; + +CREATE UNIQUE INDEX idx_users_have_unique_user_id_and_context ON users(user_id, context); diff --git a/wp_api/src/cache/mod.rs b/wp_api/src/cache/mod.rs new file mode 100644 index 000000000..dd46670a4 --- /dev/null +++ b/wp_api/src/cache/mod.rs @@ -0,0 +1 @@ +pub mod wp_api_cache; diff --git a/wp_api/src/cache/wp_api_cache.rs b/wp_api/src/cache/wp_api_cache.rs new file mode 100644 index 000000000..aa2ce2b1a --- /dev/null +++ b/wp_api/src/cache/wp_api_cache.rs @@ -0,0 +1,226 @@ +use rusqlite::hooks::Action; +use rusqlite::{Connection, Result as SqliteResult, params}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum SqliteDbError { + SqliteError(String), +} + +impl std::fmt::Display for SqliteDbError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SqliteDbError::SqliteError(message) => write!(f, "SqliteDbError: message={}", message), + } + } +} + +impl From for SqliteDbError { + fn from(err: rusqlite::Error) -> Self { + SqliteDbError::SqliteError(err.to_string()) + } +} + +#[derive(uniffi::Object)] +pub struct WpApiCache { + inner: DBManager, +} + +unsafe impl Sync for WpApiCache {} +unsafe impl Send for WpApiCache {} + +#[uniffi::export] +impl WpApiCache { + #[uniffi::constructor] + pub fn new(path: Option) -> Result { + Ok(Self { + inner: DBManager::new(&path)?, + }) + } + + pub fn perform_migrations(&self) -> Result { + Ok(MigrationManager::new(&self.inner.connection)?.perform_migrations()?) + } + + pub fn flush(&self) -> Result<(), SqliteDbError> { + self.inner.connection.execute("commit", ())?; + Ok(()) + } + + pub fn start_listening_for_updates(&self) { + self.inner.connection.update_hook(Some( + |action: Action, db_name: &str, table_name: &str, row_id: i64| { + let hook_data = UpdateHook { + action: action.into(), + db_name: db_name.to_string(), + table_name: table_name.to_string(), + row_id, + }; + + if let Some(delegate) = GLOBAL_QUERY_MONITOR.get_delegate() { + delegate.did_update(hook_data); + } + }, + )); + } + + pub fn stop_listening_for_updates(&self) { + self.inner + .connection + .update_hook(None::) + } +} + +static MIGRATION_QUERIES: [&str; 2] = [ + include_str!("migrations/0001-create-posts-table.sql"), + include_str!("migrations/0002-create-users-table.sql"), +]; + +pub struct MigrationManager<'a> { + connection: &'a Connection, +} + +impl<'a> MigrationManager<'a> { + pub fn new(connection: &'a Connection) -> Result { + Ok(Self { connection }) + } + + pub fn has_migrations_table(&self) -> SqliteResult { + let mut statement: rusqlite::Statement<'_> = self.connection.prepare( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_migrations'", + )?; + + let result = statement.query_row([], |row| row.get::<_, i32>(0))?; + + Ok(result > 0) + } + + pub fn perform_migrations(&mut self) -> SqliteResult { + if !self.has_migrations_table()? { + self.create_migrations_table()?; + } + + let next_migration_id = self.get_next_migration_index()?; + for (index, migration) in MIGRATION_QUERIES[next_migration_id..].iter().enumerate() { + for query in migration + .split(";") + .filter(|query| !query.trim().is_empty()) + { + self.connection.execute(query, ())?; + } + + self.insert_migration((index + 1) as u64)?; + } + + Ok(MIGRATION_QUERIES[next_migration_id..].len() as u64) + } + + pub fn create_migrations_table(&self) -> SqliteResult<()> { + self.connection.execute( + "CREATE TABLE _migrations (migration_id INTEGER PRIMARY KEY)", + (), + )?; + Ok(()) + } + + pub fn insert_migration(&mut self, migration_id: u64) -> SqliteResult<()> { + let mut insert_migration_query = self + .connection + .prepare("INSERT INTO _migrations (migration_id) VALUES (?)")?; + insert_migration_query.execute(params![migration_id])?; + Ok(()) + } + + /// Returns the index of the next migration to run in the `MIGRATION_QUERIES` array. + /// Note that this is *not* the same as the migration ID. + pub fn get_next_migration_index(&self) -> SqliteResult { + let mut statement = self + .connection + .prepare("SELECT MAX(migration_id) FROM _migrations")?; + let result = statement.query_row([], |row| row.get::<_, Option>(0))?; + Ok(result.unwrap_or(0)) + } +} + +#[uniffi::export(with_foreign)] +pub trait DatabaseDelegate: Send + Sync { + fn did_update(&self, update_hook: UpdateHook); +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct User { + id: i64, + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct UpdateHook { + action: HookAction, + db_name: String, + table_name: String, + row_id: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum HookAction { + Insert, + Update, + Delete, +} + +impl From for HookAction { + fn from(action: Action) -> Self { + match action { + Action::SQLITE_INSERT => HookAction::Insert, + Action::SQLITE_UPDATE => HookAction::Update, + Action::SQLITE_DELETE => HookAction::Delete, + _ => panic!("Invalid action: {:?}", action), + } + } +} + +struct GlobalQueryMonitor { + rw_lock: Mutex>>, +} + +impl GlobalQueryMonitor { + fn get_delegate(&self) -> Option> { + self.rw_lock.lock().unwrap().clone() + } + + fn set_delegate(&self, delegate: Arc) { + self.rw_lock.lock().unwrap().replace(delegate); + } +} + +static GLOBAL_QUERY_MONITOR: GlobalQueryMonitor = GlobalQueryMonitor { + rw_lock: Mutex::new(None), +}; + +#[uniffi::export] +fn get_global_query_monitor() -> Option> { + GLOBAL_QUERY_MONITOR.get_delegate() +} + +#[uniffi::export] +fn set_global_delegate(delegate: Arc) { + GLOBAL_QUERY_MONITOR.set_delegate(delegate); +} + +struct DBManager { + connection: Connection, +} + +impl DBManager { + pub fn new(path: &Option) -> Result { + let connection: Connection; + + if let Some(path) = path.clone() { + connection = Connection::open(path)?; + } else { + connection = Connection::open_in_memory()?; + } + + Ok(Self { connection }) + } +} diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 490a2af15..6ade5c71d 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -6,6 +6,7 @@ use users::*; use wp_localization::{MessageBundle, WpMessages, WpSupportsLocalization}; use wp_localization_macro::WpDeriveLocalizable; +pub mod cache; pub mod jetpack; pub mod wp_com; diff --git a/wp_api/uniffi.toml b/wp_api/uniffi.toml index 214bc4f86..71ca20d4a 100644 --- a/wp_api/uniffi.toml +++ b/wp_api/uniffi.toml @@ -12,7 +12,7 @@ from_custom = "{}.toInstant().epochSecond" [bindings.swift.custom_types.WpGmtDateTime] type_name = "Date" -imports = ["Foundation"] +imports = ["SQLite3"] into_custom = "Date(timeIntervalSince1970: TimeInterval({}))" from_custom = "Int64({}.timeIntervalSince1970)" From e7563dfbe77e5f83d3a3da58fdc105b7cca63e63 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:56:53 -0600 Subject: [PATCH 2/7] Genuine Sync+Send, and injected delegate --- .../WordPressApiCache.swift | 3 +- .../WordPressApiCacheTests.swift | 10 +++ wp_api/src/cache/wp_api_cache.rs | 61 +++++-------------- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift index 2762c63ca..3ef66603d 100644 --- a/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift +++ b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift @@ -44,8 +44,7 @@ public actor WordPressApiCache { } public func startListeningForUpdates() { - setGlobalDelegate(delegate: delegate) - self.cache.startListeningForUpdates() + self.cache.startListeningForUpdates(delegate: self.delegate) } public func stopListeningForUpdates() { diff --git a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift index ba07eb495..364389d70 100644 --- a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift +++ b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift @@ -14,4 +14,14 @@ struct Test { let migrationsPerformed = try await self.cache.performMigrations() #expect(migrationsPerformed == 2) } + + @Test func testBackgroundUpdateNotificationsWork() async throws { + var migrationNotificationReceived = 0 + NotificationCenter.default.addObserver(forName: WordPressApiCache.Notifications.name(for: "_migrations"), object: nil, queue: nil, using: { notification in + migrationNotificationReceived += 1 + }) + await self.cache.startListeningForUpdates() + _ = try await self.cache.performMigrations() + #expect(migrationNotificationReceived == 2) + } } diff --git a/wp_api/src/cache/wp_api_cache.rs b/wp_api/src/cache/wp_api_cache.rs index aa2ce2b1a..b4b2a74ae 100644 --- a/wp_api/src/cache/wp_api_cache.rs +++ b/wp_api/src/cache/wp_api_cache.rs @@ -23,33 +23,33 @@ impl From for SqliteDbError { #[derive(uniffi::Object)] pub struct WpApiCache { - inner: DBManager, + inner: DBManager } -unsafe impl Sync for WpApiCache {} -unsafe impl Send for WpApiCache {} - #[uniffi::export] impl WpApiCache { #[uniffi::constructor] pub fn new(path: Option) -> Result { Ok(Self { - inner: DBManager::new(&path)?, + inner: DBManager::new(&path)? }) } pub fn perform_migrations(&self) -> Result { - Ok(MigrationManager::new(&self.inner.connection)?.perform_migrations()?) + let connection: &Connection = &self.inner.connection.lock().unwrap(); + Ok(MigrationManager::new(connection)?.perform_migrations()?) } pub fn flush(&self) -> Result<(), SqliteDbError> { - self.inner.connection.execute("commit", ())?; + let connection: &Connection = &self.inner.connection.lock().unwrap(); + connection.execute("commit", ())?; Ok(()) } - pub fn start_listening_for_updates(&self) { - self.inner.connection.update_hook(Some( - |action: Action, db_name: &str, table_name: &str, row_id: i64| { + pub fn start_listening_for_updates(&self, delegate: Arc) { + let connection: &Connection = &self.inner.connection.lock().unwrap(); + connection.update_hook(Some( + move |action: Action, db_name: &str, table_name: &str, row_id: i64| { let hook_data = UpdateHook { action: action.into(), db_name: db_name.to_string(), @@ -57,17 +57,14 @@ impl WpApiCache { row_id, }; - if let Some(delegate) = GLOBAL_QUERY_MONITOR.get_delegate() { - delegate.did_update(hook_data); - } + delegate.did_update(hook_data); }, )); } pub fn stop_listening_for_updates(&self) { - self.inner - .connection - .update_hook(None::) + let connection: &Connection = &self.inner.connection.lock().unwrap(); + connection.update_hook(None::); } } @@ -179,36 +176,8 @@ impl From for HookAction { } } -struct GlobalQueryMonitor { - rw_lock: Mutex>>, -} - -impl GlobalQueryMonitor { - fn get_delegate(&self) -> Option> { - self.rw_lock.lock().unwrap().clone() - } - - fn set_delegate(&self, delegate: Arc) { - self.rw_lock.lock().unwrap().replace(delegate); - } -} - -static GLOBAL_QUERY_MONITOR: GlobalQueryMonitor = GlobalQueryMonitor { - rw_lock: Mutex::new(None), -}; - -#[uniffi::export] -fn get_global_query_monitor() -> Option> { - GLOBAL_QUERY_MONITOR.get_delegate() -} - -#[uniffi::export] -fn set_global_delegate(delegate: Arc) { - GLOBAL_QUERY_MONITOR.set_delegate(delegate); -} - struct DBManager { - connection: Connection, + connection: Mutex, } impl DBManager { @@ -221,6 +190,6 @@ impl DBManager { connection = Connection::open_in_memory()?; } - Ok(Self { connection }) + Ok(Self { connection: Mutex::new(connection) }) } } From 54254978dc91e7480f19f996e5ebfc6adc8d21de Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:53:34 -0600 Subject: [PATCH 3/7] Fix Swift test warning --- .../WordPressApiCache.swift | 2 +- .../WordPressApiCacheTests.swift | 28 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift index 3ef66603d..8c29e73eb 100644 --- a/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift +++ b/native/swift/Sources/wordpress-api-cache/WordPressApiCache.swift @@ -10,7 +10,7 @@ public actor WordPressApiCache { public static let cacheDidUpdate = Notification.Name("WordPressApiCache.cacheDidUpdate") public static func name(for table: String) -> Notification.Name { - Notification.Name(rawValue: "WordPressApiCachce.cacheDidUpdate.\(table)") + Notification.Name(rawValue: "WordPressApiCache.cacheDidUpdate.\(table)") } } diff --git a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift index 364389d70..00d4eb891 100644 --- a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift +++ b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift @@ -2,9 +2,10 @@ import Foundation import Testing import WordPressApiCache -struct Test { +actor Test { private var cache: WordPressApiCache! + private var changeCount = 0 init() throws { self.cache = try WordPressApiCache() @@ -16,12 +17,25 @@ struct Test { } @Test func testBackgroundUpdateNotificationsWork() async throws { - var migrationNotificationReceived = 0 - NotificationCenter.default.addObserver(forName: WordPressApiCache.Notifications.name(for: "_migrations"), object: nil, queue: nil, using: { notification in - migrationNotificationReceived += 1 - }) + let name = WordPressApiCache.Notifications.name(for: "_migrations") + + let handle = Task { + for await _ in NotificationCenter.default.notifications(named: name) { + self.incrementChangeCount() + } + } + await self.cache.startListeningForUpdates() - _ = try await self.cache.performMigrations() - #expect(migrationNotificationReceived == 2) + let migrationCount = try await self.cache.performMigrations() + + // Wait for NotificationCenter to finish delivery + try await Task.sleep(nanoseconds: 10 * NSEC_PER_MSEC) + + #expect(migrationCount == self.changeCount) + handle.cancel() + } + + func incrementChangeCount() { + self.changeCount += 1 } } From 60409f379b7cc8ee235711f3706c399d65e764a1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:09:16 -0600 Subject: [PATCH 4/7] Add Kotlin background update notifications test --- .../kotlin/WordPressApiCacheTest.kt | 20 ++++++++++- .../cache/kotlin/WordPressApiCache.kt | 33 +++++++++++-------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt index 9a2da0716..ee1ac1ad8 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -1,16 +1,34 @@ package rs.wordpress.api.cache.kotlin +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode import rs.wordpress.cache.kotlin.WordPressApiCache +import rs.wordpress.cache.kotlin.WordPressApiCacheDelegate import kotlin.test.assertEquals @Execution(ExecutionMode.CONCURRENT) class WordPressApiCacheTest { @Test - fun testThatMigrationsWork() { + fun testThatMigrationsWork() = runBlocking { assertEquals(2, WordPressApiCache().performMigrations()) } + + @Test + fun testBackgroundUpdateNotificationsWork() = runBlocking { + var updateCount = 0 + val delegate = WordPressApiCacheDelegate( + callback = { updateHook -> + updateCount += 1 + } + ) + + val cache = WordPressApiCache(delegate = delegate) + cache.startListeningForUpdates() + + val migrationCount = cache.performMigrations() + assertEquals(updateCount, migrationCount) + } } diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt index f907312c2..e8117e37a 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/WordPressApiCache.kt @@ -1,44 +1,51 @@ package rs.wordpress.cache.kotlin import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext import uniffi.wp_api.DatabaseDelegate import uniffi.wp_api.UpdateHook import uniffi.wp_api.WpApiCache -import uniffi.wp_api.setGlobalDelegate import java.nio.file.Path import java.util.concurrent.Executors -class WordPressApiCacheDelegate : DatabaseDelegate { +class WordPressApiCacheLoggingDelegate: DatabaseDelegate { override fun didUpdate(updateHook: UpdateHook) { println("Received update: $updateHook") } } +class WordPressApiCacheDelegate( + private val callback: (updateHook: UpdateHook) -> Unit +) : DatabaseDelegate { + + override fun didUpdate(updateHook: UpdateHook) { + callback(updateHook) + } +} class WordPressApiCache { private val cache: WpApiCache private val internalDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val delegate: DatabaseDelegate = WordPressApiCacheDelegate() + private val delegate: DatabaseDelegate? // Creates a new in-memory cache - constructor() : this(":memory:") + constructor(delegate: WordPressApiCacheDelegate? = null) : this(":memory:", delegate) // Creates a new cache at the specified file system URL - constructor(path: Path) : this(path.toString()) + constructor(path: Path, delegate: WordPressApiCacheDelegate? = null) : this(path.toString(), delegate) // Creates a new cache at the specified path - constructor(string: String) { + constructor(string: String, delegate: WordPressApiCacheDelegate? = null) { this.cache = WpApiCache(string) + this.delegate = delegate } - fun performMigrations(): Int { - internalDispatcher.run { - return this@WordPressApiCache.cache.performMigrations().toInt() - } + suspend fun performMigrations(): Int = withContext(internalDispatcher) { + cache.performMigrations().toInt() } - fun startListeningForUpdates() { - setGlobalDelegate(delegate) - this.cache.startListeningForUpdates() + if (this.delegate != null) { + this.cache.startListeningForUpdates(this.delegate) + } } fun stopListeningForUpdates() { From 9acf4efa620475ebe9757b3ffb835e45c23821d7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:10:28 -0600 Subject: [PATCH 5/7] Use `runTest` --- .../src/integrationTest/kotlin/WordPressApiCacheTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt index ee1ac1ad8..9d399959e 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -1,6 +1,6 @@ package rs.wordpress.api.cache.kotlin -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode @@ -12,12 +12,12 @@ import kotlin.test.assertEquals class WordPressApiCacheTest { @Test - fun testThatMigrationsWork() = runBlocking { + fun testThatMigrationsWork() = runTest { assertEquals(2, WordPressApiCache().performMigrations()) } @Test - fun testBackgroundUpdateNotificationsWork() = runBlocking { + fun testBackgroundUpdateNotificationsWork() = runTest { var updateCount = 0 val delegate = WordPressApiCacheDelegate( callback = { updateHook -> From 921ce91cf51024c5ec2ceb6f275a4ec2c00d1da4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:12:38 -0600 Subject: [PATCH 6/7] Update native/kotlin/api/android/build.gradle.kts Co-authored-by: Oguz Kocer --- native/kotlin/api/android/build.gradle.kts | 3 --- 1 file changed, 3 deletions(-) diff --git a/native/kotlin/api/android/build.gradle.kts b/native/kotlin/api/android/build.gradle.kts index 9fb07316b..4da0cdc15 100644 --- a/native/kotlin/api/android/build.gradle.kts +++ b/native/kotlin/api/android/build.gradle.kts @@ -1,6 +1,3 @@ -import org.jetbrains.kotlin.konan.target.linker -import kotlin.system.exitProcess - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") From 0bedb9cd63237c528a19272b1e2cf56480958184 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:13:06 -0600 Subject: [PATCH 7/7] Update native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt Co-authored-by: Oguz Kocer --- .../kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt index 9d399959e..d70f56ee5 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -1,5 +1,3 @@ -package rs.wordpress.api.cache.kotlin - import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution