-
Notifications
You must be signed in to change notification settings - Fork 3
Add wp_mobile service layer with observable entity pattern
#1024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
oguzkocer
wants to merge
71
commits into
trunk
Choose a base branch
from
wp_mobile_initial_design
base: trunk
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This commit introduces the foundational architecture for wp_mobile, implementing the Entity pattern discussed in DESIGN.md. The goal is to provide a unified business layer that bridges network (wp_api) and cache (wp_mobile_cache) layers with observable, lightweight entity handles. Changes: - Add Entity<T> generic type as lightweight ID-based handle - Add wp_mobile_entity! macro to create uniffi-compatible concrete types - Add WpSelfHostedService to coordinate site-specific services - Add PostService with get_entity() method - Add WpApiCache::connection() to expose DB access - Add EntityAnyPostWithEditContext as first concrete entity type The Entity<T> design: - Stores only ID and site reference (lightweight) - Multiple instances with same ID are equal - Data and state read from global stores (trait-based for future optimization) - Not directly constructible by clients (internal to service layer) Next steps: Implement data() method, state management, and observers.
This implements the core data reading functionality for Entity pattern, allowing entities to fetch data from the cache via service-controlled logic. Changes: - Add EntityError type for entity-layer errors (hides DB implementation details) - Add Entity::data() method that returns Result<Option<T>, EntityError> - Entity now holds closure provided by service for data reading - Remove db_site field from Entity (captured in closure instead) - Add PostService::read_post_from_db_internal() for centralized DB reading - Add PostService::read_post_from_db() (private, can be made public later) - PostService::get_entity() provides closure that calls internal reader Design decisions: - Entity holds Box<dyn Fn() -> Result<Option<T>, EntityError>> - Service controls how data is read via closure - Closure captures cache, db_site, and ID (all Arc references) - No PartialEq/Eq/Clone due to closure field - Actual reading logic centralized in PostService for reusability Next steps: Test data reading, implement state management, add observers.
The method name 'data()' looked like a cheap property getter, but it actually performs a database read on each call. Renaming to 'load_data()' makes it clear that: 1. This is an expensive operation (hits database) 2. Results may differ on subsequent calls 3. It's loading from storage, not just accessing a field This better matches mobile platform conventions where 'load' implies an operation with cost, helping developers understand performance implications.
Implement reusable test fixtures and add initial unit test for `read_post_from_db` to verify the service layer correctly reads posts from the cache. Changes: - Add `test-helpers` feature to `wp_mobile_cache` to expose test fixtures - Implement `From<Connection>` for `WpApiCache` (idiomatic trait) - Create `test_fixtures` module in `wp_mobile` with rstest fixtures - Add `mock_api_client` fixture returning `Arc<WpApiClient>` - Add `post_service_ctx` fixture for complete PostService setup - Add first unit test `test_read_post_from_db_returns_cached_post` - Make `read_post_from_db` `pub(crate)` for testing
Create TestPost helper struct to encapsulate test data with its assertion logic, keeping expected values with their creation. Changes: - Add TestPost struct with assert_matches() method - Refactor insert_test_post() to return TestPost - Replace all unwraps with expect() and clear error messages - Add test_get_entity_load_data_returns_cached_post() Both tests now demonstrate that entity.load_data() works identically to service.read_post_from_db(), validating the entity abstraction. The TestPost helper ensures test data and assertions stay in sync.
Changes: - `EntityError::DatabaseError` field renamed - `WpServiceError::DatabaseError` field renamed
Implemented infrastructure to support platform-specific observable patterns by allowing entities to determine if a database update is relevant to them. This enables the Kotlin/Swift wrappers to filter database change notifications without exposing internal database details (table names, rowids) to application code. Changes: - Added `is_relevant_update` closure to `Entity<T>` constructor - Added `is_relevant_update()` method to `Entity<T>` and generated entity types - Updated `PostService::get_entity()` to capture table name and rowid for filtering - Made `UpdateHook` fields public for platform-side access - Made `PostRepository::table_name()` public for entity creation
Implemented temporary helper methods to insert and update posts directly in the cache database for testing the observer pattern without requiring the full API client stack. These methods are intentionally marked as TEMPORARY and should be removed once the full CRUD implementation is complete. Changes: - Added `create_temp_post()` - Creates a minimal test post - Added `insert_mock_post_for_testing()` - Inserts a post into cache - Added `update_mock_post_for_testing()` - Updates an existing post in cache
Implemented platform-specific observable pattern for Kotlin/Android using a lightweight wrapper that bridges Rust entities to Kotlin observers. This design keeps database implementation details (table names, rowids) hidden from application code while enabling reactive UI updates when entity data changes. Changes: - Added `ObservableEntity` wrapper with observer registration - Added `DatabaseChangeNotifier` singleton to coordinate change notifications - Added `PostServiceExtensions.kt` with `getObservableEntity()` helper - Simplified `WordPressApiCache` to auto-register `DatabaseChangeNotifier` - Removed old `WordPressApiCacheDelegate` and related classes - Removed old delegate test from `WordPressApiCacheTest`
Implemented comprehensive tests validating the platform-specific observable pattern works correctly across the Rust/Kotlin boundary. Tests verify that observers are notified when relevant entities change, multiple observers can be registered, and updates are properly filtered. Changes: - Added `ObservableEntityTest.kt` with comprehensive Kotlin integration tests - Added `createSelfHostedService()` helper to `IntegrationTestHelpers.kt` - Added `test_entity_is_relevant_update_matches_correct_updates()` Rust test - Minor formatting improvements in test fixtures
Changed PostService storage to Arc to enable shared access across multiple consumers, particularly for the Kotlin integration tests that need to access the same service instance. This pattern will extend to other services (CommentService, etc.) as they are added. Changes: - Changed `posts` field from `PostService` to `Arc<PostService>` - Added `posts()` accessor method returning `Arc<PostService>` - Fixed connection guard scoping to ensure proper cleanup - Minor formatting improvements
Clarified that PostService methods operate on edit context by adding context suffix to method names. This makes the API more explicit and prepares for future addition of other context methods (view, embed). This approach (context in method names) is more flexible than using generics on the service itself, as it allows one service instance to provide entities in different contexts. Changes: - Renamed `get_entity()` to `get_entity_with_edit_context()` in Rust - Renamed `getObservableEntity()` to `getObservableEntityWithEditContext()` in Kotlin - Updated all test usages - Updated documentation to clarify edit context usage
Introduced EntityId as an opaque database identity handle that encapsulates the complete identity of a cached entity (site_id, table_name, rowid). This prototype explores moving entity identity management into the cache layer where it naturally belongs, as it represents "which row in which table in which site's database." Changes: - Added `EntityId` struct as uniffi::Object with opaque identity - Added `is_same_entity()` method for identity comparison - Added `FullEntity<T>` wrapper pairing data with EntityId - Extended `PostContext` trait with accessor methods for DbPost fields - Added prototype `select_by_post_id_with_entity_id()` method Benefits of this approach: - Encapsulates database complexity (rowid, table_name) in single type - Enables creating entities without database lookups (pass EntityId) - Solves non-existent entity bug (EntityId created with data) - Provides foundation for cleaner UpdateHook (use EntityId instead of fields) - Natural fit: cache owns database, so cache should own database identity Next steps: - Update existing methods to return FullEntity instead of DbPost - Consider moving Entity<T> to cache layer (aligns with EntityId/FullEntity) - Update UpdateHook to use EntityId
Changed the return types of `select_by_post_id()`, `select_by_rowid()`, and `select_all()` to return `FullEntity<C::DbPost>` instead of just `C::DbPost`. This change pairs cached post data with its database identity (`EntityId`) at the repository layer, enabling: - Entity creation without additional database lookups - Database change notification filtering - Identity-based entity comparison The `FullEntity` wrapper doesn't change what data is returned - it just adds the `entity_id` field alongside the existing `DbPost` type. Tests now access post data via `.data.post`, cache metadata via `.data.last_fetched_at`, etc. Changes: - Updated `PostRepository::select_by_post_id()` to return `FullEntity<C::DbPost>` - Updated `PostRepository::select_by_rowid()` to return `FullEntity<C::DbPost>` - Updated `PostRepository::select_all()` to return `Vec<FullEntity<C::DbPost>>` - Removed `select_by_post_id_with_entity_id()` prototype method (replaced by updated `select_by_post_id()`) - Updated all test assertions to use `.data` prefix for accessing wrapped fields - Added `Arc` import to `posts.rs` for `EntityId` creation
Applied the same FullEntity pattern to SiteRepository for consistency with PostRepository. Changed return types of `select_self_hosted_site()` and `select_self_hosted_site_by_url()` to return FullEntity wrappers that pair the site data with its database identity (EntityId). For sites, the EntityId encapsulates: - db_site_id: The DbSite.row_id (from the db_sites table) - table_name: "self_hosted_sites" - rowid: The DbSelfHostedSite.row_id (from the self_hosted_sites table) Changes: - Updated `select_self_hosted_site()` to return `FullEntity<DbSelfHostedSite>` - Updated `select_self_hosted_site_by_url()` to return `FullEntity<(DbSite, DbSelfHostedSite)>` - Updated `delete_self_hosted_site_by_url()` to destructure FullEntity - Updated all test assertions to use `.data` prefix for accessing wrapped fields - Added `Arc` import for EntityId creation
Updated EntityId to improve its design and type safety: 1. **Store full DbSite instead of just site_id**: This provides more context and enables load_data() to work without additional site lookups. The DbSite contains site_type and mapped_site_id which are useful for filtering and queries. Memory impact is minimal (~24 bytes) and amortized by Arc-sharing. 2. **Use RowId type instead of i64**: Changed the rowid field from i64 to RowId for type consistency with the rest of the codebase. 3. **Added test documentation**: Added a doc comment to the test helper `make_db_site()` explaining that using the same value for both row_id and mapped_site_id is just for test convenience (in real data they differ). Changes: - Changed `EntityId::new()` signature from `(i64, String, i64)` to `(DbSite, String, RowId)` - Changed `EntityId::rowid()` return type from `i64` to `RowId` - Renamed `EntityId::site_id()` to `EntityId::db_site()` returning `&DbSite` - Removed `PostContext::get_db_site_id()` trait method (no longer needed) - Updated all EntityId creation sites to pass DbSite and RowId - Updated test cases to use RowId type - Added documentation to test helper explaining row_id vs mapped_site_id
Changed EntityId::table_name from String to &'static str to eliminate heap allocation and reduce memory footprint (from 24 bytes to 8 bytes per EntityId). Since all table names are compile-time constants, this change: - Avoids String allocation/deallocation overhead - Reduces EntityId memory size by 16 bytes - Keeps the simple uniform type (no generics needed) - Works seamlessly with UniFFI (private field) Implementation approach: - Added PostContext::TABLE_NAME associated const to each context impl - Updated EntityId::new() to accept &'static str instead of String - Updated EntityId::table_name() getter to return &'static str - Modified all call sites to use const table names (C::TABLE_NAME) - Updated test cases to use string literals directly Table names are now compile-time constants: - "posts_edit_context" - "posts_view_context" - "posts_embed_context" - "self_hosted_sites" Changes: - Changed EntityId::table_name field from String to &'static str - Added PostContext::TABLE_NAME associated const - Implemented TABLE_NAME for EditContext, ViewContext, EmbedContext - Updated all EntityId::new() call sites to pass &'static str - Removed .to_string() calls from repository code - Updated tests to use string literals
Move the `Entity<T>` type from `wp_mobile` to `wp_mobile_cache` to enable creating entities directly from repository results without additional database lookups. Changes: - Move `Entity<T>` struct to `wp_mobile_cache/src/entity.rs` - Change `Entity<T>` to use `SqliteDbError` instead of `EntityError` - Change `Entity::new()` from `pub(crate)` to `pub` visibility - Update `wp_mobile_entity!` macro to convert `SqliteDbError` to `EntityError` - Move macro from `entity.rs` to `wp_mobile/src/lib.rs` - Update `read_post_from_db_internal()` to return `SqliteDbError` - Fix `.data` access in service layer for `FullEntity` wrapper - Export `Entity` from `wp_mobile_cache` alongside `EntityId` This change maintains proper layering where `wp_mobile_cache` provides core database types, while `wp_mobile` adds UniFFI compatibility through the `wp_mobile_entity!` macro with automatic error conversion.
Change `Entity::load_data()` to return `FullEntity<T>` instead of just `T` for consistency with repository methods. This ensures callers get both the refreshed data and its EntityId. Changes: - Update `Entity<T>` to store `Result<Option<FullEntity<T>>, SqliteDbError>` closure - Change `Entity::new()` signature to accept FullEntity closure - Update `Entity::load_data()` to return `Result<Option<FullEntity<T>>, SqliteDbError>` - Update `wp_mobile_entity!` macro to extract `.data` from FullEntity - Update `read_post_from_db_internal()` to return `FullEntity<AnyPostWithEditContext>` - Update `read_post_from_db()` to extract `.data` for public API The wp_mobile layer maintains backward compatibility by extracting just the data through the macro, while the underlying Entity now provides full context.
Update the `wp_mobile_entity!` macro to generate both an Entity wrapper and a FullEntity wrapper type. This ensures Kotlin/Swift clients have access to the EntityId, not just the data. Changes: - Add `FullEntityAnyPostWithEditContext` wrapper type - Generate `#[derive(uniffi::Record)]` struct with `entity_id` and `data` fields - Update `load_data()` to return `Option<FullEntityAnyPostWithEditContext>` - Implement `From<wp_mobile_cache::FullEntity<T>>` for conversion - Update macro signature to accept 3 parameters: entity, full_entity, type - Update test to access `.data` from the FullEntity result Before this change, `load_data()` returned just the data type (e.g., `AnyPostWithEditContext`), stripping away the EntityId. Now it returns the full FullEntity wrapper, allowing Kotlin/Swift to: - Access the EntityId for identity comparison - Filter database update notifications - Access both data and metadata together
Use the `paste` crate to automatically generate the FullEntity type name
by prepending "Full" to the Entity name, reducing the macro signature
from 3 parameters to 2.
Changes:
- Add `paste` workspace dependency to wp_mobile
- Update macro to use `paste::paste!` with `[<Full $id_type>]` syntax
- Simplify macro invocation to only require entity name and data type
- Update Kotlin test to access `.data` field from FullEntity
Before:
```rust
wp_mobile_entity!(
EntityAnyPostWithEditContext,
FullEntityAnyPostWithEditContext, // redundant
wp_api::posts::AnyPostWithEditContext
);
```
After:
```rust
wp_mobile_entity!(
EntityAnyPostWithEditContext,
wp_api::posts::AnyPostWithEditContext
);
```
The macro now automatically generates `FullEntityAnyPostWithEditContext`.
Move EntityId, FullEntity, and Entity into a single entity.rs module
and remove the re-exports from lib.rs. Code now uses explicit paths
like `wp_mobile_cache::entity::EntityId`.
Changes:
- Consolidate EntityId from entity_id.rs into entity.rs
- Move FullEntity definition from lib.rs into entity.rs
- Keep Entity in entity.rs (already there)
- Remove `pub use entity::{Entity, EntityId, FullEntity}` re-exports
- Update all imports to use `crate::entity::*` or `wp_mobile_cache::entity::*`
- Delete wp_mobile_cache/src/entity_id.rs
This reduces scattered type definitions and makes the module structure
clearer - all entity-related types live in the entity module.
Add an async version of load_data() that UniFFI can generate platform-native async bindings for (Kotlin suspend functions, Swift async functions). Changes: - Add `async fn load_data_async()` to wp_mobile_entity! macro - Expose async version through ObservableEntity wrapper - Update Kotlin test to use `loadDataAsync()` How it works: - Rust async fn wraps the sync call (no actual async work) - UniFFI generates `suspend fun` in Kotlin with `uniffiRustCallAsync()` - Platform handles threading (Kotlin coroutines move off main thread) - No need for spawn_blocking - UniFFI handles it Both sync and async versions are now available: - `load_data()` - sync, for internal Rust use - `load_data_async()` - async, for platform bindings (default in mobile UX)
Change ObservableEntity from hardcoded EntityAnyPostWithEditContext to
generic over the data type, allowing it to work with any entity type.
Changes:
- Make `ObservableEntity<D>` generic over data type
- Accept function references for loadData, loadDataAsync, id, isRelevantUpdate
- Update `PostServiceExtensions.getObservableEntityWithEditContext()` to return
`ObservableEntity<FullEntityAnyPostWithEditContext>`
- Update `DatabaseChangeNotifier` to accept `ObservableEntity<*>`
This approach uses function references since we don't have a common trait/interface
that all entity types implement yet. Future improvement could add a Rust trait
or Kotlin interface to make this cleaner.
Before:
```kotlin
class ObservableEntity(private val entity: EntityAnyPostWithEditContext)
```
After:
```kotlin
class ObservableEntity<D>(
private val loadDataFn: () -> D?,
private val loadDataAsyncFn: suspend () -> D?,
// ...
)
```
Update repository APIs to return `EntityId` instead of `RowId` and accept `EntityId` for select operations, improving type safety and encapsulation. Changes: - Add `TableNameMismatch` error variant to `SqliteDbError` - Add `validate_table_name()` method to `EntityId` for validation - Make `EntityId` fields public and implement `Copy` trait - Update `PostRepository::upsert()` to return `EntityId` instead of `RowId` - Update `PostRepository::upsert_batch()` to return `Vec<EntityId>` - Rename `select_by_rowid()` to `select_by_entity_id()` with validation - Update `SiteRepository::upsert_self_hosted_site()` to return `EntityId` - Update `SiteRepository::select_self_hosted_site()` to accept `EntityId` - Update all tests to use new API with `EntityId`
Adapt service layer to work with EntityId returned from repository methods. Changes: - Update `wp_mobile_entity!` macro to return `Arc<EntityId>` from `id()` - Update `add_site()` to extract `DbSite` from returned `EntityId` - Change `insert_mock_post_for_testing()` to return `EntityId` - Change `get_entity_with_edit_context()` to accept `Arc<EntityId>` - Remove `read_post_from_db_internal()` and `read_post_from_db()` helpers - Update tests to fetch `EntityId` from repository before creating entities
Adapt Kotlin wrapper layer to work with EntityId returned from Rust. Changes: - Add `createObservableEntity()` helper that auto-registers with notifier - Update `ObservableEntity.id()` to return `EntityId` instead of `Long` - Update `getObservableEntityWithEditContext()` to accept `EntityId` - Update all tests to capture `EntityId` from `insertMockPostForTesting()` - Simplify observable creation using method references
Move testing utilities out of PostService into a dedicated MockPostService that can be easily removed once proper data insertion is available. Changes: - Create `MockPostService` that wraps `Arc<PostService>` - Move `create_temp_post()`, `insert_mock_post()`, and `update_mock_post()` - Add `cache()` and `db_site()` accessors to `PostService` for internal use - Add `mock_posts()` method to `WpSelfHostedService` - Update Kotlin tests to use `service.mockPosts()` instead of `postService` - Rename methods: `insertMockPostForTesting` → `insertMockPost`
Add methods to support stress testing of Kotlin observables with large numbers of posts. This enables performance testing of the observable entity pattern under load. Changes: - Add `generate_and_insert_posts` to `MockPostService` for bulk test data generation - Add `count_edit_context` to `PostService` to query total post count - Add Kotlin integration test verifying bulk insert and count functionality
Introduces foundational types for network fetch operations in the service layer: - `FetchError`: Wraps `WpApiError` and adds `Database` variant for cache operation failures. ViewModels can match on the wrapped `WpApiError` to handle authentication, rate limiting, etc. - `FetchResult`: Contains entity IDs of fetched items plus pagination metadata from API responses. ViewModels use this to track pagination state without collections being stateful. Changes: - Add `FetchError` enum with `Api` and `Database` variants - Add `FetchResult` struct with entity IDs and pagination fields - Implement `From` conversions for `WpApiError` and `SqliteDbError` - Create `collection` module for networking types - Re-export types from `wp_mobile`
Introduces minimal post filter type with only status field to start. More filter fields can be added as UI requirements emerge. The `to_list_params()` helper method converts the domain filter to API parameters. This is intentionally not a `From` trait impl to signal the one-way transformation. Filter will be passed to repository as individual parameters for now. When filters grow, we can introduce a cache-layer parameter struct to avoid unwieldy method signatures. Changes: - Add `AnyPostFilter` struct with `status` field - Implement `to_list_params()` conversion to `PostListParams` - Create `filters` module for domain filter types - Re-export `AnyPostFilter` from `wp_mobile`
Adds ability to filter posts by status when querying from the database. This follows the same two-pass approach as select_all (fetch IDs, batch load terms, construct posts) for efficiency. The method accepts individual filter parameters rather than a filter struct to keep wp_mobile_cache free of domain abstractions. When more filters are added, a cache-layer parameter struct can be introduced if method signatures become unwieldy. Changes: - Add `select_by_filter()` method with status parameter - Build dynamic WHERE clause based on provided filters - Add test verifying filtering by status works correctly - Test also verifies no filter (None) returns all posts
Eliminates code duplication by making `select_all` call `select_by_filter` with no filters (None). This makes `select_by_filter` the canonical implementation with `select_all` as a convenience wrapper. Changes: - Replace `select_all` implementation with call to `select_by_filter(None)` - All existing tests continue to pass
Implements the core networking function that fetches posts from the WordPress REST API and saves them to the local cache. The function: 1. Converts AnyPostFilter to PostListParams 2. Makes async network request via WpApiClient 3. Upserts fetched posts to database 4. Returns FetchResult with entity IDs and pagination metadata This is the foundational primitive that collections and ViewModels will use to load posts from the network. The function is async because network operations are async - platform wrappers will handle the async/sync bridge as needed. Changes: - Add `fetch_posts_page()` async method to `PostService` - Takes filter, page number, and per_page as parameters - Returns `FetchResult` with entity IDs and pagination info - Extracts pagination metadata from response headers - Converts API response data to entity IDs via database upsert
Implements generic PostCollection<T> that wraps NaiveCollection and adds network fetching capabilities for filtered post queries. Instead of using closures for the networking layer, PostCollection holds a direct reference to Arc<PostService>. This is simpler and more idiomatic than the closure-based approach, as PostCollection is a service-layer abstraction that naturally depends on the service. Key components: - `PostCollection<T>`: Generic collection over post type (e.g., AnyPostWithEditContext) - `fetch_page()`: Async method that fetches from network via PostService - `load_data()`: Sync method that loads cached posts via NaiveCollection - `is_relevant_update()`: Delegates to NaiveCollection for update monitoring - `create_post_collection_with_edit_context()`: Factory method on PostService Changes: - Add PostCollection<T> generic type with Arc<PostService> dependency - Add PostCollectionWithEditContext type alias - Add create_post_collection_with_edit_context() to PostService - PostService.create_post_collection_with_edit_context() requires &Arc<Self> receiver
Create macro to generate UniFFI-compatible wrapper types for PostCollection<T> generic, enabling cross-platform use of filtered post collections with network fetching. The macro is defined in lib.rs where FullEntity types are accessible and generates concrete wrapper structs that can be exported through UniFFI. Currently generates wrapper for EditContext, with ViewContext and EmbedContext to be added later. Key design decisions: - Macro lives in lib.rs (not post_collection.rs) to access FullEntity* types - PostCollection<T> generic type stays private (not FFI-safe) - Wrapper structs (e.g., PostCollectionWithEditContext) are FFI-exported - Service method returns wrapper type via Into trait Changes: - Add wp_mobile_post_collection! macro in lib.rs - Generate PostCollectionWithEditContext wrapper via macro - Remove PostCollection from public exports (keep generic internal) - Update PostService imports to use collection::post_collection::PostCollection - Update create_post_collection_with_edit_context() to return wrapper via .into()
…tlin wrapper Expose the filtered PostCollection creation method through UniFFI and create observable wrapper for use in ViewModels. Rust changes: - Move create_post_collection_with_edit_context to #[uniffi::export] impl block - Change parameter from &AnyPostFilter to AnyPostFilter (owned, UniFFI compatible) - Update documentation with Kotlin usage example Kotlin changes: - Add getObservablePostCollectionWithEditContext(filter) extension function - Returns ObservableCollection<FullEntityAnyPostWithEditContext> - Wraps PostCollectionWithEditContext with database change notification support This enables ViewModels to create filtered, observable post collections that support both network fetching and cache loading with automatic UI updates.
Replace the direct `getObservableAllPostsWithEditContext()` call with the new `getObservablePostCollectionWithEditContext(AnyPostFilter())` to utilize the filtered collection infrastructure. Changes: - Replace `getObservableAllPostsWithEditContext` import with `getObservablePostCollectionWithEditContext` - Add `AnyPostFilter` import - Update collection creation to use filtered collection with empty filter The empty `AnyPostFilter()` matches all posts, maintaining the same behavior as the previous implementation while using the new collection architecture that supports filtering and network fetching. Verified with stress test showing comparable performance metrics: - Initial load: 11ms (1000 posts) - Average load times: 16-27ms - Database change notifications working correctly
Implemented a comprehensive demo screen for `PostCollection` that showcases filtering, network fetching, and progressive pagination capabilities. Changes: - Add `parse_post_status()` UniFFI helper function in `wp_api/src/posts.rs` to parse strings to `PostStatus` enum (works around UniFFI limitation that traits cannot be exported) - Extract `PostCard` component for reuse across screens - Implement `PostCollectionViewModel` with `CollectionState` data class for managing collection state, filtering, and pagination - Add `PostCollectionScreen` with filter controls (All/Drafts/Published) and progressive page fetching UI - Fix API URL construction in `AppModule` to use correct `/wp-json` endpoint - Add proper authentication using `AuthenticationRepository` instead of no-auth - Hide "Fetch Page" button when all pages have been fetched - Wire up navigation to PostCollection screen The filter functionality now works correctly by using the new `parsePostStatus()` function to convert UI strings to proper `PostStatus` enum values.
Remove DESIGN.md and DESIGN_REVIEW.md as they were working documents used during development that may not be completely up to date with the final implementation. The commit history provides the detailed story of the design evolution. Changes: - Remove wp_mobile/DESIGN.md - Remove wp_mobile/DESIGN_REVIEW.md
EntityId was previously a uniffi::Object which cannot be used as HashMap keys in Kotlin/Swift. This required an opaque EntityKey wrapper type that obscured the actual data structure. By converting EntityId to a Record (value type), it can be used directly as HashMap keys across FFI boundaries. This simplifies the API and makes the type system more honest - EntityId is now transparently a value type containing DbSite, DbTable, and RowId. Changes: - Convert `EntityId` from `uniffi::Object` to `uniffi::Record` - Remove `EntityKey` type entirely (no longer needed) - Add `uniffi::Record` derive to `DbSite` - Add `uniffi::Enum` derive to `DbSiteType` - Add `uniffi::custom_newtype!(RowId, u64)` for FFI compatibility - Remove all `Arc<EntityId>` wrappers in wp_mobile (Records don't need Arc) - Update `FetchResult` to use `Vec<EntityId>` instead of `Vec<Arc<EntityId>>` - Update wp_mobile macros to work with value-type EntityId
Mark DbTable as non_exhaustive to ensure future table additions (e.g., Comments, Media, Users) don't silently break Kotlin/Swift code. This forces client code to handle unknown variants explicitly, preventing runtime errors when new database tables are introduced. Changes: - Add #[non_exhaustive] attribute to DbTable enum
Move term relationship loading outside the row-processing closure in select_by_post_id() and select_by_entity_id() to match the pattern used in select_by_filter(). This change improves consistency across all PostRepository query methods and prevents potential misuse if the lazy-loading pattern were copied to multi-row queries (which would create N+1 query problems). Changes: - Pre-load term relationships before query_row() in select_by_post_id() - Pre-load term relationships before query_row() in select_by_entity_id() - Closure now only performs HashMap lookup instead of executing queries
Address minor code quality improvements identified during PR review: Changes: - Ignore _migrations table in update hook to prevent "unknown table" warnings - Rename PostContext::get_rowid() to rowid() for Rust naming consistency - Add documentation to Entity::read_data field explaining why it can't be cloned
The previous implementation incorrectly attempted to extract site URLs from `ApiUrlResolver`, resulting in both `site_url` and `api_root` having the same value. This refactoring makes the API more explicit and moves site creation logic to the appropriate location. Changes: - Add `site_url` and `api_root` parameters to `WpSelfHostedService::new()` - Move `get_or_create_self_hosted_site()` to `SiteService` as an associated function - Mark site creation function as `pub(crate)` (internal only, not exposed to UniFFI) - Update Kotlin clients to pass site URLs explicitly - Remove broken URL extraction logic from resolver
Use `WpServiceError::SiteNotFound` instead of a generic database error when the site doesn't exist. This provides better type safety and allows clients to specifically handle the "site not found" case. This is important because entities (posts, comments, etc.) are bound to sites, and without CASCADE DELETE, there could be legitimate cases where a site is missing but entities still reference it. Changes: - Return `WpServiceError` instead of `SqliteDbError` from `get_current_site_info()` - Use `WpServiceError::SiteNotFound` when site is not in database - Improve documentation explaining when each error variant occurs - Simplify error handling logic for better readability
Restructure the crate to group related functionality into cohesive modules with clear boundaries. This improves code discoverability and maintainability. Changes: - Move entity-related files into `entity/` module - `entity_error.rs` → `entity/entity_error.rs` - Created `entity/mod.rs` with entity wrapper macros - Move collection-related files into `collection/` module - `collection_error.rs` → `collection/collection_error.rs` - `naive_collection.rs` → `collection/naive_collection.rs` - Moved collection macros into `collection/mod.rs` - Move test utilities into `testing/` module - `test_fixtures.rs` → `testing/test_fixtures.rs` - Created `testing/mod.rs` - Simplify `lib.rs` to just module declarations and macro invocations - Make all modules public for external access - Update import paths throughout the crate - Remove unused `cache()` and `db_site()` methods from `PostService` - Fix clippy warning for useless conversion
The test function was used to verify basic UniFFI bindings work. Now that we have real functionality, it's no longer needed. Changes: - Remove wp_mobile_crate_works function from wp_mobile/src/lib.rs - Remove corresponding test from Kotlin integration tests
Updated type name to better reflect that the collection doesn't maintain state between operations. Also updated all comments referring to "naive behavior" to "stateless behavior" for consistency. Changes: - Renamed NaiveCollection → StatelessCollection - Renamed naive_collection.rs → stateless_collection.rs - Renamed wp_mobile_naive_collection! → wp_mobile_stateless_collection! - Updated all comments from "naive behavior" to "stateless behavior"
Removed the sync `load_data()` method from UniFFI exports and renamed `load_data_async()` to just `load_data()`. This creates a clearer API where client platforms only see one async method, encouraging proper background thread usage. The underlying Rust implementation remains synchronous since rusqlite doesn't support async operations. The async function name is more honest - it doesn't imply the Rust DB operation is async (which would be misleading), but generates proper suspend/async functions in Kotlin/Swift that will execute on background threads. Changes: - Renamed `load_data_async()` to `load_data()` in entity and collection macros - Removed sync `load_data()` from UniFFI exports - Updated Rust test to use internal `entity.0.load_data()` for sync access - Simplified Kotlin `ObservableEntity` and `ObservableCollection` to only expose `suspend fun loadData()` - Updated `PostServiceExtensions.kt` to use single `loadData` method - Updated `ObservableEntityTest.kt` to use `loadData()` instead of `loadDataAsync()` - Enhanced Kotlin documentation with performance warnings
Implemented AutoCloseable on ObservableEntity and ObservableCollection
to properly manage DatabaseChangeNotifier registration lifecycle. This
prevents memory accumulation when creating many short-lived observables.
ViewModels should call `.close()` in `onCleared()` to unregister from
the notifier. The AutoCloseable pattern provides both manual cleanup
and automatic cleanup via `.use { }` blocks for short-lived usage.
Changes:
- Implement AutoCloseable on ObservableEntity and ObservableCollection
- Add close() method that calls DatabaseChangeNotifier.unregister()
- Update documentation with ViewModel lifecycle examples
- Update StressTestViewModel and PostCollectionViewModel to call close() in onCleared()
- Fix PostCollectionViewModel.setFilter() to close old observable before creating new one
- Make StressTestViewModel.reloadPostsAndMeasure() suspend to match loadData() signature
- Fix PostServiceExtensions line length formatting
This file was used for local performance tracking and doesn't need to be in version control. It's available in git history if needed.
The failing doc tests required rstest fixtures and database connections that aren't available in doc test context. Marking them as 'ignore' keeps the examples in the documentation while skipping compilation during doc tests.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This PR introduces
wp_mobile, a mobile-friendly service layer that provides observable entities and collections with automatic cache management. The design enables reactive UIs on iOS/Android by bridging Rust database updates to platform-native observers.1.
wp_mobile_cache: Repository Pattern & Change NotificationsKey Changes:
DbSite + table_name + rowid)EntityIdwith entity data for consistent API returnsEntityIdfrom upsert operationsDatabaseDelegatetrait allows platforms to observe all database updates viaUpdateHookDesign rationale:
EntityIdprovides a stable, UniFFI-compatible reference to cached data. The delegate pattern enables platform-specific reactive patterns without coupling the cache to any particular UI framework.2.
wp_mobile: Service Layer & Observable EntitiesArchitecture:
Key Components:
Entity Pattern:
Entity<T>: Lightweight handle to cached data withload_data()andis_relevant_update()methodswp_mobile_entity!macro generates UniFFI-compatible wrappers for each context (Edit/View/Embed)Collection Pattern:
StatelessCollection: Generic collection with closure-based filtering and multi-table observationPostCollection: Domain-specific collection with filtering (AnyPostFilter) and pagination (fetch_page())load_data()to query all matching items from cacheServices:
PostService: Factory for post entities and collections, coordinates network fetch + cacheSiteService: Manages site lookup and creation for self-hosted sitesDesign decisions:
load_data()is exported asasync fnto encourage background thread usage in Kotlin/Swift (Rust implementation is sync sincerusqlitedoesn't support async)is_relevant_update()allows platforms to implement their own observer patterns3. Kotlin: Observable Wrappers with Lifecycle Management
New Components:
DatabaseChangeNotifier(singleton):DatabaseDelegateto receive all Rust database updatesObservableEntityandObservableCollectioninstancesObservableEntity<D>/ObservableCollection<D>:addObserver()/removeObserver()for reactive UI updatesAutoCloseableimplementation for proper lifecycle managementpostService.getObservableEntityWithEditContext(entityId)WordPressApiCache:WpApiCachethat auto-registersDatabaseChangeNotifierLifecycle pattern:
Design rationale:
AutoCloseableprovides idiomatic Kotlin lifecycle management without forcing weak references or complex cleanup logic. The observer pattern is familiar to Android/Compose developers.4. Kotlin Example App: Reference Implementation
Two demo screens showcase the observable pattern:
Stress Test Screen:
Post Collection Screen:
fetch_page()PostCollectionwith filtering and network operationsDI Setup (Koin):
MockPostServicefor generating test dataWpSelfHostedServicewith authenticationViewModels as singletons (acceptable for example app, not production pattern)Purpose: Serves as reference for integrating
wp_mobileinto real apps. Shows proper lifecycle management, reactive UI patterns, and how to use entities/collections.Summary
This PR establishes the foundation for mobile-native WordPress data access with:
AutoCloseablelifecycle managementThe design prioritizes simplicity (stateless), performance (no memory caching), and platform flexibility (observers via
is_relevant_update, not prescriptive state management).