Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
0ee0bb8
Add MetadataCollection design document
oguzkocer Dec 9, 2025
e92e96f
Add sync module with `SyncableEntity` trait and `EntityMetadata` struct
oguzkocer Dec 9, 2025
bf6f4bf
Add `ListItem<T, Id>` enum with loaded/loading/failed states
oguzkocer Dec 9, 2025
641ed05
Add `KvStore` trait and `InMemoryKvStore` implementation
oguzkocer Dec 9, 2025
d4ca06a
Add `fetch_posts_metadata()` to PostService
oguzkocer Dec 9, 2025
f1cb839
Add `fetch_posts_by_ids()` to PostService
oguzkocer Dec 9, 2025
aded0fe
Add `MetadataCollection<T, Id>` for metadata-first sync strategy
oguzkocer Dec 9, 2025
6b73e52
Apply formatting and clippy fixes
oguzkocer Dec 9, 2025
aa22202
Update design doc with implementation status
oguzkocer Dec 9, 2025
1f404f1
Add v3 design document for MetadataCollection
oguzkocer Dec 10, 2025
4555e2e
Add core types for MetadataCollection (v3 design)
oguzkocer Dec 10, 2025
cfa8e85
Add EntityStateStore and ListMetadataStore
oguzkocer Dec 10, 2025
a0fa4b2
Update implementation plan with Phase 2 completion
oguzkocer Dec 10, 2025
07868c8
Add MetadataFetcher trait and MetadataCollection
oguzkocer Dec 10, 2025
d66b2a0
Update implementation plan with Phase 3 completion
oguzkocer Dec 10, 2025
aec1b26
Integrate MetadataCollection stores into PostService
oguzkocer Dec 10, 2025
987e38f
Add PostMetadataCollectionWithEditContext for UniFFI export
oguzkocer Dec 10, 2025
53591c8
Add ObservableMetadataCollection Kotlin wrapper
oguzkocer Dec 10, 2025
7d036ea
Add PostMetadataCollectionScreen example app
oguzkocer Dec 10, 2025
0d94d66
Fix fetch_posts_by_ids to include all post statuses
oguzkocer Dec 10, 2025
c114932
Add debug logs for metadata collection implementation for prototype t…
oguzkocer Dec 10, 2025
8b19cc4
Implement stale detection by comparing modified_gmt timestamps
oguzkocer Dec 10, 2025
1b73572
Add MetadataService design document
oguzkocer Dec 11, 2025
7f7ca2e
Add MetadataService implementation plan
oguzkocer Dec 11, 2025
9ec546f
Add database foundation for MetadataService (Phase 1)
oguzkocer Dec 11, 2025
05cc1d8
Add list metadata repository concurrency helpers
oguzkocer Dec 11, 2025
a8de338
Add MetadataService for database-backed list metadata
oguzkocer Dec 11, 2025
e2fce48
Integrate MetadataService into PostService
oguzkocer Dec 11, 2025
ff4425b
Update MetadataService implementation plan with progress
oguzkocer Dec 11, 2025
6db3c96
Reset stale fetching states on app launch
oguzkocer Dec 11, 2025
11128b3
Update PostMetadataCollection to use database-backed storage
oguzkocer Dec 11, 2025
b677ec6
Split collection observers for data vs state updates
oguzkocer Dec 11, 2025
7da761f
Update documentation for Phase 4 observer split
oguzkocer Dec 11, 2025
be62715
Complete Phase 4 & 5: Split observers, async methods, and UI improvem…
oguzkocer Dec 11, 2025
b59e835
Remove deprecated in-memory metadata store (Phase 3.4)
oguzkocer Dec 11, 2025
d5e6931
Clean up debug prints for better readability
oguzkocer Dec 11, 2025
dd7cd75
Fix state persistence when switching filters
oguzkocer Dec 11, 2025
724ddf1
Update documentation: MetadataService implementation complete
oguzkocer Dec 11, 2025
18703b6
make fmt-rust
oguzkocer Dec 12, 2025
817e5f7
Fix tests and detekt issues for new migration
oguzkocer Dec 12, 2025
90c6b87
Consolidate design docs into single metadata_collection.md
oguzkocer Dec 12, 2025
1c53cc8
wp_mobile/docs/design/metadata_collection_flow.txt
oguzkocer Dec 12, 2025
e35a5ee
Fix load_items() to load cached data independent of EntityState
oguzkocer Dec 15, 2025
9081b8e
Refactor PostMetadataCollectionItem to use type-safe PostItemState enum
oguzkocer Dec 15, 2025
4ff7bf9
Add wp_mobile_item_state! macro for generic item state enums
oguzkocer Dec 15, 2025
9f682f8
Replace AnyPostFilter with PostListParams in metadata collection
oguzkocer Dec 15, 2025
19d214e
Update Kotlin example to use PostItemState enum
oguzkocer Dec 15, 2025
6aa9579
Fix race condition in ViewModel syncing state
oguzkocer Dec 15, 2025
689a180
Add PostEndpointType parameter to metadata collection infrastructure
oguzkocer Dec 16, 2025
dfeb693
Add parent and menu_order fields to list metadata items
oguzkocer Dec 16, 2025
4f22346
Rename last_updated_at to last_fetched_at in list metadata
oguzkocer Dec 16, 2025
16d7303
Add get_total_items and get_per_page to ListMetadataReader trait
oguzkocer Dec 16, 2025
ca12594
Remove duplicate PaginationState from MetadataCollection
oguzkocer Dec 16, 2025
d526ee2
Remove dead update hook relevance checking code
oguzkocer Dec 16, 2025
65ffe81
Remove default implementations from ListMetadataReader trait
oguzkocer Dec 16, 2025
ee8d376
Simplify ListMetadataReader trait with combined ListInfo query
oguzkocer Dec 16, 2025
2cf206d
Expose ListInfo via UniFFI and rename state observers to listInfo obs…
oguzkocer Dec 16, 2025
c29a166
Expose parent and menu_order on PostMetadataCollectionItem
oguzkocer Dec 16, 2025
bff928d
Introduce PostListFilter for metadata collection API
oguzkocer Dec 17, 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