Skip to content

Conversation

@oguzkocer
Copy link
Contributor

@oguzkocer oguzkocer commented Nov 16, 2025

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 Notifications

Key Changes:

  • EntityId system: Universal identifier for cached entities across language boundaries (DbSite + table_name + rowid)
  • FullEntity wrapper: Combines EntityId with entity data for consistent API returns
  • Repository pattern: All repositories now return EntityId from upsert operations
  • Database change hooks: DatabaseDelegate trait allows platforms to observe all database updates via UpdateHook
  • DbTable enum: Single source of truth for table names, prevents string typos

Design rationale: EntityId provides 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 Entities

Architecture:

┌─────────────────────────────────────────────────────┐
│  Platform (iOS/Android)                             │
│  ┌──────────────────────────────────────────────┐   │
│  │  ViewModel observes Entity/Collection        │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
                      ▲
                      │ UniFFI bindings
                      ▼
┌─────────────────────────────────────────────────────┐
│  wp_mobile (Rust)                                   │
│  ┌──────────────────┐  ┌──────────────────────────┐ │
│  │ Entity<T>        │  │ PostCollection<T>        │ │
│  │ - load_data()    │  │ - filter()               │ │
│  │ - is_relevant()  │  │ - fetch_page()           │ │
│  └──────────────────┘  │ - load_data()            │ │
│                        └──────────────────────────┘ │
│  ┌──────────────────────────────────────────────┐   │
│  │ PostService / SiteService                    │   │
│  │ - Create entities/collections                │   │
│  │ - Coordinate network + cache                 │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
                      ▲
                      │ Uses repositories
                      ▼
┌─────────────────────────────────────────────────────┐
│  wp_mobile_cache                                    │
│  - PostRepository, SiteRepository                   │
│  - SQLite storage                                   │
└─────────────────────────────────────────────────────┘

Key Components:

Entity Pattern:

  • Entity<T>: Lightweight handle to cached data with load_data() and is_relevant_update() methods
  • Stateless design: no cached data in memory, always reads from DB
  • wp_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 observation
  • PostCollection: Domain-specific collection with filtering (AnyPostFilter) and pagination (fetch_page())
  • Both support load_data() to query all matching items from cache

Services:

  • PostService: Factory for post entities and collections, coordinates network fetch + cache
  • SiteService: Manages site lookup and creation for self-hosted sites

Design decisions:

  • Stateless by design: Entities don't cache data to avoid stale state and memory overhead
  • Async-only API for UniFFI: load_data() is exported as async fn to encourage background thread usage in Kotlin/Swift (Rust implementation is sync since rusqlite doesn't support async)
  • Observable without coupling: is_relevant_update() allows platforms to implement their own observer patterns

3. Kotlin: Observable Wrappers with Lifecycle Management

New Components:

DatabaseChangeNotifier (singleton):

  • Implements DatabaseDelegate to receive all Rust database updates
  • Broadcasts updates to registered ObservableEntity and ObservableCollection instances

ObservableEntity<D> / ObservableCollection<D>:

  • Platform-native observer pattern wrapping Rust entities/collections
  • addObserver() / removeObserver() for reactive UI updates
  • AutoCloseable implementation for proper lifecycle management
  • Created via service extensions: postService.getObservableEntityWithEditContext(entityId)

WordPressApiCache:

  • Kotlin wrapper for WpApiCache that auto-registers DatabaseChangeNotifier
  • Provides convenient constructors (in-memory, path-based)

Lifecycle pattern:

class MyViewModel : ViewModel() {
    private val observable = postService.getObservableEntityWithEditContext(postId)

    init {
        observable.addObserver { /* update UI */ }
    }

    override fun onCleared() {
        observable.close() // Unregisters from DatabaseChangeNotifier
        super.onCleared()
    }
}

Design rationale: AutoCloseable provides 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:

  • Generates 1000 posts with background batch updates (1-20 posts per batch, variable timing)
  • Displays real-time performance metrics (avg/min/max load times, total latency)
  • Demonstrates scalability of observable pattern with frequent updates

Post Collection Screen:

  • Interactive filtering (All / Drafts / Published)
  • Pagination with fetch_page()
  • Shows cached post count, pages fetched, total items
  • Demonstrates practical usage of PostCollection with filtering and network operations

DI Setup (Koin):

  • Disk-based cache with temp file (auto-deletes on exit)
  • MockPostService for generating test data
  • WpSelfHostedService with authentication
  • ViewModels as singletons (acceptable for example app, not production pattern)

Purpose: Serves as reference for integrating wp_mobile into 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:

  • Rust core: Stateless entity/collection pattern with database change notifications
  • Kotlin bindings: Observable wrappers with AutoCloseable lifecycle management
  • Reference app: Working examples of filtering, pagination, and real-time updates

The design prioritizes simplicity (stateless), performance (no memory caching), and platform flexibility (observers via is_relevant_update, not prescriptive state management).

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.
@oguzkocer oguzkocer added this to the 0.2 milestone Nov 16, 2025
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.
@oguzkocer oguzkocer requested a review from jkmassel November 17, 2025 03:55
@oguzkocer oguzkocer marked this pull request as ready for review November 17, 2025 03:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants