Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
f40b599
Add MetadataCollection design document
oguzkocer Dec 9, 2025
6ef8927
Add sync module with `SyncableEntity` trait and `EntityMetadata` struct
oguzkocer Dec 9, 2025
5101f37
Add `ListItem<T, Id>` enum with loaded/loading/failed states
oguzkocer Dec 9, 2025
a0c55ca
Add `KvStore` trait and `InMemoryKvStore` implementation
oguzkocer Dec 9, 2025
97fb450
Add `fetch_posts_metadata()` to PostService
oguzkocer Dec 9, 2025
1a620b0
Add `fetch_posts_by_ids()` to PostService
oguzkocer Dec 9, 2025
63d1d04
Add `MetadataCollection<T, Id>` for metadata-first sync strategy
oguzkocer Dec 9, 2025
29d80c8
Apply formatting and clippy fixes
oguzkocer Dec 9, 2025
66a1048
Update design doc with implementation status
oguzkocer Dec 9, 2025
d037288
Add v3 design document for MetadataCollection
oguzkocer Dec 10, 2025
ac85768
Add core types for MetadataCollection (v3 design)
oguzkocer Dec 10, 2025
2928776
Add EntityStateStore and ListMetadataStore
oguzkocer Dec 10, 2025
2316fa4
Update implementation plan with Phase 2 completion
oguzkocer Dec 10, 2025
d5be8f9
Add MetadataFetcher trait and MetadataCollection
oguzkocer Dec 10, 2025
0e58e73
Update implementation plan with Phase 3 completion
oguzkocer Dec 10, 2025
935543c
Integrate MetadataCollection stores into PostService
oguzkocer Dec 10, 2025
7e64119
Add PostMetadataCollectionWithEditContext for UniFFI export
oguzkocer Dec 10, 2025
bc0259e
Add ObservableMetadataCollection Kotlin wrapper
oguzkocer Dec 10, 2025
07c5024
Add PostMetadataCollectionScreen example app
oguzkocer Dec 10, 2025
98c9ee4
Fix fetch_posts_by_ids to include all post statuses
oguzkocer Dec 10, 2025
d7a49dd
Add debug logs for metadata collection implementation for prototype t…
oguzkocer Dec 10, 2025
14b01d8
Implement stale detection by comparing modified_gmt timestamps
oguzkocer Dec 10, 2025
cafd9ff
Add MetadataService design document
oguzkocer Dec 11, 2025
51a9c5e
Add MetadataService implementation plan
oguzkocer Dec 11, 2025
e47cec8
Add database foundation for MetadataService (Phase 1)
oguzkocer Dec 11, 2025
2440a13
Add list metadata repository concurrency helpers
oguzkocer Dec 11, 2025
87b555b
Add MetadataService for database-backed list metadata
oguzkocer Dec 11, 2025
8d26f93
Integrate MetadataService into PostService
oguzkocer Dec 11, 2025
8fbd067
Update MetadataService implementation plan with progress
oguzkocer Dec 11, 2025
cc0c8a5
Reset stale fetching states on app launch
oguzkocer Dec 11, 2025
d8c836b
Update PostMetadataCollection to use database-backed storage
oguzkocer Dec 11, 2025
d64142f
Split collection observers for data vs state updates
oguzkocer Dec 11, 2025
64f4707
Update documentation for Phase 4 observer split
oguzkocer Dec 11, 2025
452aa9a
Complete Phase 4 & 5: Split observers, async methods, and UI improvem…
oguzkocer Dec 11, 2025
95f1f26
Remove deprecated in-memory metadata store (Phase 3.4)
oguzkocer Dec 11, 2025
2ab7c35
Clean up debug prints for better readability
oguzkocer Dec 11, 2025
07ff144
Fix state persistence when switching filters
oguzkocer Dec 11, 2025
f915b8e
Update documentation: MetadataService implementation complete
oguzkocer Dec 11, 2025
fe7435c
make fmt-rust
oguzkocer Dec 12, 2025
c73ed4d
Fix tests and detekt issues for new migration
oguzkocer Dec 12, 2025
be6a809
Consolidate design docs into single metadata_collection.md
oguzkocer Dec 12, 2025
6979ea7
wp_mobile/docs/design/metadata_collection_flow.txt
oguzkocer Dec 12, 2025
34b111e
Fix load_items() to load cached data independent of EntityState
oguzkocer Dec 15, 2025
cdcbb4c
Refactor PostMetadataCollectionItem to use type-safe PostItemState enum
oguzkocer Dec 15, 2025
41a1069
Add wp_mobile_item_state! macro for generic item state enums
oguzkocer Dec 15, 2025
b4b5de7
Replace AnyPostFilter with PostListParams in metadata collection
oguzkocer Dec 15, 2025
6819155
Update Kotlin example to use PostItemState enum
oguzkocer Dec 15, 2025
bcea14e
Fix race condition in ViewModel syncing state
oguzkocer Dec 15, 2025
d93a5fb
Add PostEndpointType parameter to metadata collection infrastructure
oguzkocer Dec 16, 2025
2918b33
Add parent and menu_order fields to list metadata items
oguzkocer Dec 16, 2025
25f88a4
Rename last_updated_at to last_fetched_at in list metadata
oguzkocer Dec 16, 2025
4237fb6
Add get_total_items and get_per_page to ListMetadataReader trait
oguzkocer Dec 16, 2025
8806422
Remove duplicate PaginationState from MetadataCollection
oguzkocer Dec 16, 2025
7d3a844
Remove dead update hook relevance checking code
oguzkocer Dec 16, 2025
ec970ff
Remove default implementations from ListMetadataReader trait
oguzkocer Dec 16, 2025
1d709e7
Simplify ListMetadataReader trait with combined ListInfo query
oguzkocer Dec 16, 2025
2e2048b
Expose ListInfo via UniFFI and rename state observers to listInfo obs…
oguzkocer Dec 16, 2025
a2eddd1
Expose parent and menu_order on PostMetadataCollectionItem
oguzkocer Dec 16, 2025
9e8a7b6
Introduce PostListFilter for metadata collection API
oguzkocer Dec 17, 2025
5880f40
make fmt-rust
oguzkocer Dec 17, 2025
497b6ad
Remove unused update hook helper functions
oguzkocer Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ class WordPressApiCacheTest {

@Test
fun testThatMigrationsWork() = runTest {
assertEquals(6, WordPressApiCache().performMigrations())
assertEquals(7, WordPressApiCache().performMigrations())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import java.util.concurrent.CopyOnWriteArraySet
* the observable's is_relevant_update closure handles all the matching logic in Rust.
*
* **Lifecycle Management**: Observables are registered when created and should be closed
* when no longer needed. [ObservableEntity] and [ObservableCollection] implement [AutoCloseable]
* and will automatically unregister when closed. Use `.use { }` blocks for automatic cleanup,
* or call `.close()` manually.
* when no longer needed. [ObservableEntity], [ObservableCollection], and [ObservableMetadataCollection]
* implement [AutoCloseable] and will automatically unregister when closed. Use `.use { }` blocks
* for automatic cleanup, or call `.close()` manually.
*/
object DatabaseChangeNotifier : DatabaseDelegate {
private val observableEntities = CopyOnWriteArraySet<ObservableEntity<*>>()
private val observableCollections = CopyOnWriteArraySet<ObservableCollection<*>>()
private val observableMetadataCollections = CopyOnWriteArraySet<ObservableMetadataCollection>()

/**
* Register an ObservableEntity to receive database change notifications.
Expand Down Expand Up @@ -57,14 +58,32 @@ object DatabaseChangeNotifier : DatabaseDelegate {
observableCollections.remove(collection)
}

/**
* Register an ObservableMetadataCollection to receive database change notifications.
*
* The collection will be notified of all database updates and can decide internally
* whether the update is relevant to it.
*/
fun register(collection: ObservableMetadataCollection) {
observableMetadataCollections.add(collection)
}

/**
* Unregister an ObservableMetadataCollection from receiving database change notifications.
*/
fun unregister(collection: ObservableMetadataCollection) {
observableMetadataCollections.remove(collection)
}

/**
* Called by WpApiCache when a database update occurs.
*
* Notifies all registered observables (entities and collections), which will check if the update
* is relevant to them using their is_relevant_update() methods.
* Notifies all registered observables (entities, collections, and metadata collections),
* which will check if the update is relevant to them using their is_relevant_update() methods.
*/
override fun didUpdate(updateHook: UpdateHook) {
observableEntities.forEach { it.notifyIfRelevant(updateHook) }
observableCollections.forEach { it.notifyIfRelevant(updateHook) }
observableMetadataCollections.forEach { it.notifyIfRelevant(updateHook) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package rs.wordpress.cache.kotlin

import uniffi.wp_mobile.ListInfo
import uniffi.wp_mobile.PostMetadataCollectionItem
import uniffi.wp_mobile.PostMetadataCollectionWithEditContext
import uniffi.wp_mobile.SyncResult
import uniffi.wp_mobile_cache.ListState
import uniffi.wp_mobile_cache.UpdateHook
import java.util.concurrent.CopyOnWriteArrayList

/**
* Check if there are more pages to load.
*
* Returns `true` if:
* - Total pages is unknown (assume more)
* - Current page is less than total pages
*/
val ListInfo.hasMorePages: Boolean
get() = totalPages?.let { currentPage < it } ?: true

/**
* Check if a sync operation is in progress.
*/
val ListInfo.isSyncing: Boolean
get() = state == ListState.FETCHING_FIRST_PAGE || state == ListState.FETCHING_NEXT_PAGE

/**
* Create an observable metadata collection that notifies observers when data changes.
*
* This helper automatically registers the collection with [DatabaseChangeNotifier].
* Use service extension functions (e.g., `getObservablePostMetadataCollectionWithEditContext`)
* instead of calling this directly.
*
* **Lifecycle Management**: Collections implement [AutoCloseable] and should be closed when
* no longer needed to prevent memory accumulation. In ViewModels, call `.close()` in
* `onCleared()`. For short-lived usage, use `.use { }` blocks. For app-lifecycle-scoped
* observables, explicit cleanup may not be necessary.
*
* Example (ViewModel):
* ```
* class MyViewModel : ViewModel() {
* private val observableCollection = postService.getObservablePostMetadataCollectionWithEditContext(filter)
*
* init {
* observableCollection.addObserver { /* update UI */ }
* viewModelScope.launch { observableCollection.refresh() }
* }
*
* override fun onCleared() {
* super.onCleared()
* observableCollection.close()
* }
* }
* ```
*/
fun createObservableMetadataCollection(
collection: PostMetadataCollectionWithEditContext
): ObservableMetadataCollection = ObservableMetadataCollection(
collection = collection
).also {
DatabaseChangeNotifier.register(it)
}

/**
* Observable wrapper around a metadata collection that notifies observers when changes occur.
*
* This is similar to [ObservableCollection] but designed for the "metadata-first" sync strategy:
* - Items include fetch state (Missing, Fetching, Cached, Stale, Failed)
* - Sync operations (refresh, loadNextPage) are exposed for explicit control
* - Data is optional per item (present only when Cached)
*
* The metadata collection uses a two-phase sync:
* 1. Fetch lightweight metadata (id + modified_gmt) to define list structure
* 2. Selectively fetch full data for missing or stale items
*
* This allows showing cached items immediately while loading only what's needed.
*
* Create instances using [createObservableMetadataCollection] or service extension functions
* rather than the constructor directly.
*
* Implements [AutoCloseable] to support cleanup. Call [close] when done (typically in
* ViewModel.onCleared()) to unregister from [DatabaseChangeNotifier].
*/
@Suppress("TooManyFunctions") // Observer pattern requires multiple add/remove/notify methods
class ObservableMetadataCollection(
private val collection: PostMetadataCollectionWithEditContext
) : AutoCloseable {
private val dataObservers = CopyOnWriteArrayList<() -> Unit>()
private val listInfoObservers = CopyOnWriteArrayList<() -> Unit>()

/**
* Add an observer for data changes (list contents changed).
*
* Data observers are notified when:
* - Entity data changes (posts updated, deleted, etc.)
* - List metadata items change (list structure changed)
*
* Use this for refreshing list contents in the UI.
*/
fun addDataObserver(observer: () -> Unit) {
dataObservers.add(observer)
}

/**
* Add an observer for list info changes (pagination or sync state changed).
*
* ListInfo observers are notified when:
* - Pagination info changes (current page, total pages updated after fetch)
* - Sync state changes (Idle -> FetchingFirstPage, etc.)
*
* Use this for updating pagination display and loading indicators in the UI.
*/
fun addListInfoObserver(observer: () -> Unit) {
listInfoObservers.add(observer)
}

/**
* Add an observer for both data and list info changes.
*
* This is a convenience method that registers the observer for both
* data and list info updates. Use this when you want to refresh the entire
* UI on any change.
*/
fun addObserver(observer: () -> Unit) {
dataObservers.add(observer)
listInfoObservers.add(observer)
}

/**
* Remove a data observer.
*/
fun removeDataObserver(observer: () -> Unit) {
dataObservers.remove(observer)
}

/**
* Remove a list info observer.
*/
fun removeListInfoObserver(observer: () -> Unit) {
listInfoObservers.remove(observer)
}

/**
* Remove an observer from both data and list info lists.
*/
fun removeObserver(observer: () -> Unit) {
dataObservers.remove(observer)
listInfoObservers.remove(observer)
}

/**
* Load all items with their current states and data.
*
* Returns items in list order with type-safe state representation.
* Each item's `state` is a [PostItemState] sealed class that encodes both
* sync status and data availability:
*
* - [PostItemState.Cached]: Fresh data, no fetch needed
* - [PostItemState.Stale]: Outdated data, could benefit from refresh
* - [PostItemState.FetchingWithData]: Refresh in progress, showing cached data
* - [PostItemState.FailedWithData]: Fetch failed, showing last known data
* - [PostItemState.Missing]: Needs fetch, no cached data
* - [PostItemState.Fetching]: Fetch in progress, no cached data
* - [PostItemState.Failed]: Fetch failed, no cached data
*
* This is a suspend function that reads from cache on a background thread.
*/
suspend fun loadItems(): List<PostMetadataCollectionItem> = collection.loadItems()

/**
* Refresh the collection (fetch page 1, replace metadata).
*
* This:
* 1. Fetches metadata from the network (page 1)
* 2. Replaces existing metadata in the store
* 3. Fetches missing/stale entities
*
* Returns sync statistics including counts and pagination info.
*
* This is a suspend function and should be called from a coroutine or background thread.
*/
suspend fun refresh(): SyncResult = collection.refresh()

/**
* Load the next page of items.
*
* This:
* 1. Fetches metadata for the next page
* 2. Appends to existing metadata in the store
* 3. Fetches missing/stale entities from the new page
*
* Returns a no-op result if already on the last page.
*
* This is a suspend function and should be called from a coroutine or background thread.
*/
suspend fun loadNextPage(): SyncResult = collection.loadNextPage()

/**
* Get combined list info (pagination + sync state) in a single query.
*
* Returns `null` if the list hasn't been created yet.
*
* The returned [ListInfo] contains:
* - `state`: Current sync state (IDLE, FETCHING_FIRST_PAGE, FETCHING_NEXT_PAGE, ERROR)
* - `errorMessage`: Error message if state is ERROR
* - `currentPage`: Current page number (0 = not loaded yet)
* - `totalPages`: Total pages if known
* - `totalItems`: Total items if known
* - `perPage`: Items per page setting
*
* Use [ListInfo.hasMorePages] extension to check if more pages are available.
*/
fun listInfo(): ListInfo? = collection.listInfo()

/**
* Internal method called by DatabaseChangeNotifier when a database update occurs.
*
* Checks relevance and notifies appropriate observers:
* - Data updates -> dataObservers
* - List info updates -> listInfoObservers
*/
internal fun notifyIfRelevant(hook: UpdateHook) {
val isDataRelevant = collection.isRelevantDataUpdate(hook)
val isListInfoRelevant = collection.isRelevantListInfoUpdate(hook)
if (isDataRelevant) {
dataObservers.forEach { it() }
}
if (isListInfoRelevant) {
listInfoObservers.forEach { it() }
}
}

/**
* Unregister this collection from receiving database change notifications.
*
* Call this when the collection is no longer needed, or use `.use { }` for automatic cleanup.
* After calling close(), the collection will no longer notify observers of database changes.
*/
override fun close() {
DatabaseChangeNotifier.unregister(this)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package rs.wordpress.cache.kotlin

import uniffi.wp_api.PostEndpointType
import uniffi.wp_mobile.AnyPostFilter
import uniffi.wp_mobile.FullEntityAnyPostWithEditContext
import uniffi.wp_mobile.PostListFilter
import uniffi.wp_mobile.PostService
import uniffi.wp_mobile_cache.EntityId

Expand Down Expand Up @@ -34,3 +36,28 @@ fun PostService.getObservablePostCollectionWithEditContext(
isRelevantUpdate = collection::isRelevantUpdate
)
}

/**
* Create an observable metadata collection for posts with edit context.
*
* This uses the "metadata-first" sync strategy:
* 1. Fetch lightweight metadata (id + modified_gmt) to define list structure
* 2. Selectively fetch full data for missing or stale items
*
* Unlike [getObservablePostCollectionWithEditContext] which fetches full data for all items,
* this collection shows cached items immediately and fetches only what's needed.
*
* Items include fetch state (Missing, Fetching, Cached, Stale, Failed) so the UI
* can show appropriate feedback for each item.
*
* @param endpointType The post endpoint type (Posts, Pages, or Custom)
* @param filter Filter parameters (status, author, categories, etc.)
* @return Observable metadata collection that notifies on database changes
*/
fun PostService.getObservablePostMetadataCollectionWithEditContext(
endpointType: PostEndpointType,
filter: PostListFilter
): ObservableMetadataCollection {
val collection = this.createPostMetadataCollectionWithEditContext(endpointType, filter)
return createObservableMetadataCollection(collection)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import rs.wordpress.example.shared.ui.login.LoginScreen
import rs.wordpress.example.shared.ui.plugins.PluginListScreen
import rs.wordpress.example.shared.ui.plugins.PluginListViewModel
import rs.wordpress.example.shared.ui.postcollection.PostCollectionScreen
import rs.wordpress.example.shared.ui.postmetadatacollection.PostMetadataCollectionScreen
import rs.wordpress.example.shared.ui.site.SiteScreen
import rs.wordpress.example.shared.ui.stresstest.StressTestScreen
import rs.wordpress.example.shared.ui.users.UserListScreen
Expand Down Expand Up @@ -57,6 +58,9 @@ fun App(authenticationEnabled: Boolean, authenticateSite: (String) -> Unit) {
},
onPostCollectionClicked = {
navController.navigate("postcollection")
},
onPostMetadataCollectionClicked = {
navController.navigate("postmetadatacollection")
}
)
}
Expand All @@ -70,7 +74,14 @@ fun App(authenticationEnabled: Boolean, authenticateSite: (String) -> Unit) {
StressTestScreen()
}
composable("postcollection") {
PostCollectionScreen()
PostCollectionScreen(
onBackClicked = { navController.popBackStack() }
)
}
composable("postmetadatacollection") {
PostMetadataCollectionScreen(
onBackClicked = { navController.popBackStack() }
)
}
}
}
Expand Down
Loading