diff --git a/Cargo.lock b/Cargo.lock index e0794b38e..b11f6a81d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5295,7 +5295,9 @@ dependencies = [ "rstest", "rusqlite", "thiserror 2.0.17", + "tokio", "uniffi", + "url", "wp_api", "wp_mobile_cache", ] diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/DatabaseChangeNotifier.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/DatabaseChangeNotifier.kt index 1b05a6410..9f2ce6715 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/DatabaseChangeNotifier.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/DatabaseChangeNotifier.kt @@ -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>() private val observableCollections = CopyOnWriteArraySet>() + private val observableMetadataCollections = CopyOnWriteArraySet() /** * Register an ObservableEntity to receive database change notifications. @@ -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) } } } diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/ObservableMetadataCollection.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/ObservableMetadataCollection.kt new file mode 100644 index 000000000..c38980889 --- /dev/null +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/ObservableMetadataCollection.kt @@ -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 = 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) + } +} diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/PostServiceExtensions.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/PostServiceExtensions.kt index 70acfd48a..b3a5c3ad1 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/PostServiceExtensions.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/PostServiceExtensions.kt @@ -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 @@ -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) +} diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/App.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/App.kt index b0a051ac8..cbef973fb 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/App.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/App.kt @@ -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 @@ -57,6 +58,9 @@ fun App(authenticationEnabled: Boolean, authenticateSite: (String) -> Unit) { }, onPostCollectionClicked = { navController.navigate("postcollection") + }, + onPostMetadataCollectionClicked = { + navController.navigate("postmetadatacollection") } ) } @@ -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() } + ) } } } diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt index 5e22a6b6f..2d22951ed 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt @@ -9,6 +9,7 @@ import rs.wordpress.example.shared.localTestSiteUrl import rs.wordpress.example.shared.repository.AuthenticationRepository import rs.wordpress.example.shared.ui.plugins.PluginListViewModel import rs.wordpress.example.shared.ui.postcollection.PostCollectionViewModel +import rs.wordpress.example.shared.ui.postmetadatacollection.PostMetadataCollectionViewModel import rs.wordpress.example.shared.ui.stresstest.StressTestViewModel import rs.wordpress.example.shared.ui.users.UserListViewModel import rs.wordpress.example.shared.ui.welcome.WelcomeViewModel @@ -105,6 +106,7 @@ val viewModelModule = module { single { WelcomeViewModel(get()) } single { StressTestViewModel(get(), get(), get()) } single { PostCollectionViewModel(get()) } + single { PostMetadataCollectionViewModel(get()) } } fun commonModules() = listOf( diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionScreen.kt index 830b48f56..03085d461 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionScreen.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionScreen.kt @@ -31,7 +31,10 @@ import rs.wordpress.example.shared.ui.components.PostCard @Composable @Preview -fun PostCollectionScreen(viewModel: PostCollectionViewModel = koinInject()) { +fun PostCollectionScreen( + viewModel: PostCollectionViewModel = koinInject(), + onBackClicked: (() -> Unit)? = null +) { val state by viewModel.state.collectAsState() val posts by viewModel.posts.collectAsState() val listState = rememberLazyListState() @@ -41,6 +44,19 @@ fun PostCollectionScreen(viewModel: PostCollectionViewModel = koinInject()) { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize().padding(16.dp), ) { + // Back button (for desktop) + if (onBackClicked != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + TextButton(onClick = onBackClicked) { + Text("← Back") + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + // Filter controls FilterControls( currentFilter = state.filterStatusString, diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionScreen.kt new file mode 100644 index 000000000..bd4e356ee --- /dev/null +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionScreen.kt @@ -0,0 +1,418 @@ +package rs.wordpress.example.shared.ui.postmetadatacollection + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.koinInject +import uniffi.wp_mobile.PostItemState +import uniffi.wp_mobile_cache.ListState + +@Composable +@Preview +fun PostMetadataCollectionScreen( + viewModel: PostMetadataCollectionViewModel = koinInject(), + onBackClicked: (() -> Unit)? = null +) { + val state by viewModel.state.collectAsState() + val items by viewModel.items.collectAsState() + val listState = rememberLazyListState() + + MaterialTheme { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) { + // Back button (for desktop) + if (onBackClicked != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + TextButton(onClick = onBackClicked) { + Text("← Back") + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Filter controls + FilterControls( + currentFilter = state.filterStatusString, + onFilterChange = { viewModel.setFilter(it) } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Info card with refresh button + InfoCard( + state = state, + itemCount = items.size, + onRefreshClick = { viewModel.refresh() } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Items list with load more card at the end + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items) { item -> + PostItemCard(item) + } + + // Load next page card at the end + item { + LoadNextPageCard( + state = state, + onLoadClick = { viewModel.loadNextPage() } + ) + } + } + } + } +} + +@Composable +fun FilterControls( + currentFilter: String?, + onFilterChange: (String?) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = 2.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Filter", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterButton( + text = "All", + isSelected = currentFilter == null, + onClick = { onFilterChange(null) } + ) + FilterButton( + text = "Drafts", + isSelected = currentFilter?.contains("draft", ignoreCase = true) == true, + onClick = { onFilterChange("draft") } + ) + FilterButton( + text = "Published", + isSelected = currentFilter?.contains("publish", ignoreCase = true) == true, + onClick = { onFilterChange("publish") } + ) + } + } + } +} + +@Composable +fun RowScope.FilterButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + if (isSelected) { + Button(onClick = onClick, modifier = Modifier.weight(1f)) { + Text(text) + } + } else { + TextButton(onClick = onClick, modifier = Modifier.weight(1f)) { + Text(text) + } + } +} + +@Composable +fun InfoCard( + state: PostMetadataCollectionState, + itemCount: Int, + onRefreshClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = 2.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Metadata Collection", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + Button( + onClick = onRefreshClick, + enabled = !state.isSyncing + ) { + if (state.isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Refresh") + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Filter: ${state.filterDisplayName}") + Text(text = "Items: $itemCount") + Text(text = "Page: ${state.currentPage}" + (state.totalPages?.let { " / $it" } ?: "")) + + // Show sync state from database + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = "Sync State:") + SyncStateIndicator(state.syncState) + Text( + text = syncStateDisplayName(state.syncState), + style = MaterialTheme.typography.body2, + color = syncStateColor(state.syncState) + ) + } + + // Show last sync result + state.lastSyncResult?.let { result -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Last sync: ${result.fetchedCount} fetched, ${result.failedCount} failed", + style = MaterialTheme.typography.caption + ) + } + + // Show error if any + state.lastError?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Error: $error", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.error + ) + } + } + } +} + +@Composable +fun PostItemCard(item: PostItemDisplayData) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = 2.dp + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // State indicator + StateIndicator(state = item.state) + + Spacer(modifier = Modifier.width(12.dp)) + + // Content + Column(modifier = Modifier.weight(1f)) { + when { + item.isLoading -> { + Text( + text = "Loading post ${item.id}...", + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + item.errorMessage != null -> { + Text( + text = "Post ${item.id}", + style = MaterialTheme.typography.subtitle2, + fontWeight = FontWeight.Bold + ) + Text( + text = "Error: ${item.errorMessage}", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.error + ) + } + item.title != null -> { + Text( + text = item.title, + style = MaterialTheme.typography.subtitle2, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + item.contentPreview?.let { preview -> + Text( + text = preview, + style = MaterialTheme.typography.caption, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + ) + } + item.status?.let { status -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = status, + style = MaterialTheme.typography.overline, + color = MaterialTheme.colors.primary + ) + } + } + else -> { + Text( + text = "Post ${item.id} (${stateDisplayName(item.state)})", + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } + } + + // ID badge + Text( + text = "#${item.id}", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f) + ) + } + } +} + +@Composable +fun StateIndicator(state: PostItemState) { + val color = when (state) { + is PostItemState.Missing -> Color.Gray + is PostItemState.Fetching -> Color.Blue + is PostItemState.FetchingWithData -> Color.Blue + is PostItemState.Cached -> Color.Green + is PostItemState.Stale -> Color.Yellow + is PostItemState.Failed -> Color.Red + is PostItemState.FailedWithData -> Color.Red + } + + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(color) + ) +} + +fun stateDisplayName(state: PostItemState): String = when (state) { + is PostItemState.Missing -> "missing" + is PostItemState.Fetching -> "fetching" + is PostItemState.FetchingWithData -> "fetching" + is PostItemState.Cached -> "cached" + is PostItemState.Stale -> "stale" + is PostItemState.Failed -> "failed" + is PostItemState.FailedWithData -> "failed" +} + +@Composable +fun SyncStateIndicator(state: ListState) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(syncStateColor(state)) + ) +} + +fun syncStateDisplayName(state: ListState): String = when (state) { + ListState.IDLE -> "Idle" + ListState.FETCHING_FIRST_PAGE -> "Fetching First Page" + ListState.FETCHING_NEXT_PAGE -> "Fetching Next Page" + ListState.ERROR -> "Error" +} + +@Composable +fun syncStateColor(state: ListState): Color = when (state) { + ListState.IDLE -> Color(0xFF2E7D32) // Dark green + ListState.FETCHING_FIRST_PAGE -> Color.Blue + ListState.FETCHING_NEXT_PAGE -> Color.Cyan + ListState.ERROR -> Color.Red +} + +@Composable +fun LoadNextPageCard( + state: PostMetadataCollectionState, + onLoadClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = 4.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.isSyncing) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Syncing...") + } else if (state.hasMorePages) { + Button( + onClick = onLoadClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Load Next Page") + } + } else if (state.currentPage > 0L) { + Text( + text = "All pages loaded", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary + ) + } else { + Text( + text = "Click Refresh to load posts", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } + } +} diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionViewModel.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionViewModel.kt new file mode 100644 index 000000000..1c32a557a --- /dev/null +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionViewModel.kt @@ -0,0 +1,271 @@ +package rs.wordpress.example.shared.ui.postmetadatacollection + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import rs.wordpress.cache.kotlin.ObservableMetadataCollection +import rs.wordpress.cache.kotlin.getObservablePostMetadataCollectionWithEditContext +import rs.wordpress.cache.kotlin.hasMorePages +import rs.wordpress.cache.kotlin.isSyncing +import uniffi.wp_api.PostEndpointType +import uniffi.wp_mobile.ListInfo +import uniffi.wp_mobile.PostItemState +import uniffi.wp_mobile.PostListFilter +import uniffi.wp_mobile.PostMetadataCollectionItem +import uniffi.wp_mobile.SyncResult +import uniffi.wp_mobile.WpSelfHostedService +import uniffi.wp_mobile_cache.ListState + +/** + * UI state for the post metadata collection screen + */ +data class PostMetadataCollectionState( + val currentFilter: PostListFilter, + val listInfo: ListInfo? = null, + val lastSyncResult: SyncResult? = null, + val lastError: String? = null +) { + /** + * Whether a sync operation is in progress. + * Derived from listInfo.state - the single source of truth from the database. + */ + val isSyncing: Boolean + get() = listInfo?.isSyncing ?: false + + val hasMorePages: Boolean + get() = listInfo?.hasMorePages ?: true + + val currentPage: Long + get() = listInfo?.currentPage ?: 0L + + val totalPages: Long? + get() = listInfo?.totalPages + + val syncState: ListState + get() = listInfo?.state ?: ListState.IDLE + + val filterDisplayName: String + get() { + val statuses = currentFilter.status + return when { + statuses.isEmpty() -> "All Posts" + statuses.any { it.toString().contains("draft", ignoreCase = true) } -> "Drafts" + statuses.any { it.toString().contains("publish", ignoreCase = true) } -> "Published" + else -> statuses.firstOrNull()?.toString() ?: "All Posts" + } + } + + val filterStatusString: String? + get() = currentFilter.status.firstOrNull()?.toString()?.lowercase() +} + +/** + * Display data for a post item with its fetch state + */ +data class PostItemDisplayData( + val id: Long, + val state: PostItemState, + val title: String?, + val contentPreview: String?, + val status: String?, + val isLoading: Boolean, + val errorMessage: String? +) { + companion object { + fun fromCollectionItem(item: PostMetadataCollectionItem): PostItemDisplayData { + // Extract data from state variants that carry data + val data = when (val s = item.state) { + is PostItemState.Cached -> s.data + is PostItemState.Stale -> s.data + is PostItemState.FetchingWithData -> s.data + is PostItemState.FailedWithData -> s.data + else -> null + } + + val isLoading = item.state is PostItemState.Fetching || + item.state is PostItemState.FetchingWithData + + val errorMessage = when (val s = item.state) { + is PostItemState.Failed -> s.error + is PostItemState.FailedWithData -> s.error + else -> null + } + + return PostItemDisplayData( + id = item.id, + state = item.state, + title = data?.data?.title?.rendered, + contentPreview = data?.data?.content?.rendered?.take(100), + status = data?.data?.status?.toString(), + isLoading = isLoading, + errorMessage = errorMessage + ) + } + } +} + +class PostMetadataCollectionViewModel( + private val selfHostedService: WpSelfHostedService +) { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(PostMetadataCollectionState(currentFilter = PostListFilter())) + val state: StateFlow = _state.asStateFlow() + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private var observableCollection: ObservableMetadataCollection? = null + + init { + createObservableCollection(_state.value.currentFilter) + loadItemsFromCollection() + } + + /** + * Change the filter and load persisted state from database + */ + fun setFilter(status: String?) { + val postStatus = status?.let { uniffi.wp_api.parsePostStatus(it) } + val newFilter = PostListFilter( + status = if (postStatus != null) listOf(postStatus) else emptyList() + ) + + observableCollection?.close() + createObservableCollection(newFilter) + + // Read persisted state from database (single query) + _state.value = PostMetadataCollectionState( + currentFilter = newFilter, + listInfo = observableCollection?.listInfo(), + lastSyncResult = null, + lastError = null + ) + + // Load items (async) + loadItemsFromCollection() + } + + /** + * Refresh the collection (fetch page 1, sync missing/stale) + * + * Note: syncState is managed by the database and observed via state observer. + * We don't manually toggle isSyncing - it's derived from listInfo.state. + */ + fun refresh() { + if (_state.value.isSyncing) return + + _state.value = _state.value.copy(lastError = null) + + viewModelScope.launch(Dispatchers.IO) { + try { + val collection = observableCollection ?: return@launch + val result = collection.refresh() + + _state.value = _state.value.copy( + listInfo = collection.listInfo(), + lastSyncResult = result, + lastError = null + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + lastError = e.message ?: "Unknown error" + ) + } + } + } + + /** + * Load the next page of items + * + * Note: syncState is managed by the database and observed via state observer. + * We don't manually toggle isSyncing - it's derived from listInfo.state. + */ + fun loadNextPage() { + if (_state.value.isSyncing) return + if (!_state.value.hasMorePages) return + + // If no pages have been loaded yet, do a refresh instead + if (_state.value.currentPage == 0L) { + refresh() + return + } + + _state.value = _state.value.copy(lastError = null) + + viewModelScope.launch(Dispatchers.IO) { + try { + val collection = observableCollection ?: return@launch + val result = collection.loadNextPage() + + _state.value = _state.value.copy( + listInfo = collection.listInfo(), + lastSyncResult = result, + lastError = null + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + lastError = e.message ?: "Unknown error" + ) + } + } + } + + private fun createObservableCollection(filter: PostListFilter) { + val postService = selfHostedService.posts() + val observable = postService.getObservablePostMetadataCollectionWithEditContext( + PostEndpointType.Posts, + filter + ) + + // Data observer: refresh list contents when data changes + observable.addDataObserver { + viewModelScope.launch(Dispatchers.Default) { + loadItemsFromCollectionInternal() + } + } + + // ListInfo observer: update listInfo when pagination or sync state changes + observable.addListInfoObserver { + viewModelScope.launch(Dispatchers.Default) { + updateListInfo() + } + } + + observableCollection = observable + } + + private fun updateListInfo() { + val newListInfo = observableCollection?.listInfo() + println("[ViewModel] updateListInfo: new state = ${newListInfo?.state}") + _state.value = _state.value.copy(listInfo = newListInfo) + } + + private suspend fun loadItemsFromCollectionInternal() { + try { + val collection = observableCollection ?: return + val rawItems = collection.loadItems() + _items.value = rawItems.map { PostItemDisplayData.fromCollectionItem(it) } + } catch (e: Exception) { + println("Error loading items from collection: ${e.message}") + _items.value = emptyList() + } + } + + private fun loadItemsFromCollection() { + viewModelScope.launch(Dispatchers.Default) { + loadItemsFromCollectionInternal() + } + } + + fun onCleared() { + observableCollection?.close() + observableCollection = null + viewModelScope.cancel() + } +} diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/site/SiteScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/site/SiteScreen.kt index 76b86d7fd..9a641879c 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/site/SiteScreen.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/site/SiteScreen.kt @@ -17,7 +17,8 @@ fun SiteScreen( onUsersClicked: () -> Unit, onPluginsClicked: () -> Unit, onStressTestClicked: () -> Unit, - onPostCollectionClicked: () -> Unit + onPostCollectionClicked: () -> Unit, + onPostMetadataCollectionClicked: () -> Unit ) { MaterialTheme { Column( @@ -52,6 +53,11 @@ fun SiteScreen( Text("Post Collection") } } + Column { + Button(onClick = onPostMetadataCollectionClicked) { + Text("Post Metadata Collection") + } + } } } } diff --git a/wp_api/src/date.rs b/wp_api/src/date.rs index 463215d4a..cff6d0c7d 100644 --- a/wp_api/src/date.rs +++ b/wp_api/src/date.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::{fmt::Display, str::FromStr}; use wp_serde_helper::wp_utc_date_format; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct WpGmtDateTime(#[serde(with = "wp_utc_date_format")] pub DateTime); impl WpGmtDateTime { diff --git a/wp_api/src/posts.rs b/wp_api/src/posts.rs index 440f23019..4abd0ae5f 100644 --- a/wp_api/src/posts.rs +++ b/wp_api/src/posts.rs @@ -67,7 +67,7 @@ pub enum WpApiParamPostsSearchColumn { impl_as_query_value_from_to_string!(WpApiParamPostsSearchColumn); -#[derive(Debug, Default, PartialEq, Eq, uniffi::Record, WpDeriveParamsField)] +#[derive(Debug, Default, Clone, PartialEq, Eq, uniffi::Record, WpDeriveParamsField)] #[supports_pagination(true)] pub struct PostListParams { /// Current page of the collection. diff --git a/wp_api/src/wp_content_macros.rs b/wp_api/src/wp_content_macros.rs index 9e911009b..c1444ba09 100644 --- a/wp_api/src/wp_content_macros.rs +++ b/wp_api/src/wp_content_macros.rs @@ -18,7 +18,9 @@ macro_rules! wp_content_i64_id { ($id_type:ident) => { $crate::impl_as_query_value_for_new_type!($id_type); ::uniffi::custom_newtype!($id_type, i64); - #[derive(Debug, Clone, Copy, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)] + #[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize, + )] pub struct $id_type(pub i64); impl ::core::str::FromStr for $id_type { @@ -60,7 +62,9 @@ macro_rules! wp_content_u64_id { ($id_type:ident) => { $crate::impl_as_query_value_for_new_type!($id_type); ::uniffi::custom_newtype!($id_type, u64); - #[derive(Debug, Clone, Copy, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)] + #[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize, + )] pub struct $id_type(pub u64); impl ::core::str::FromStr for $id_type { diff --git a/wp_mobile/Cargo.toml b/wp_mobile/Cargo.toml index c141e572c..04bb94913 100644 --- a/wp_mobile/Cargo.toml +++ b/wp_mobile/Cargo.toml @@ -16,6 +16,7 @@ paste = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } uniffi = { workspace = true } +url = { workspace = true } wp_api = { path = "../wp_api" } wp_mobile_cache = { path = "../wp_mobile_cache" } @@ -23,6 +24,7 @@ wp_mobile_cache = { path = "../wp_mobile_cache" } async-trait = { workspace = true } rstest = { workspace = true } rusqlite = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } wp_mobile_cache = { path = "../wp_mobile_cache", features = ["test-helpers"] } [build-dependencies] diff --git a/wp_mobile/docs/design/load_items_state_fix.md b/wp_mobile/docs/design/load_items_state_fix.md new file mode 100644 index 000000000..47b8457e5 --- /dev/null +++ b/wp_mobile/docs/design/load_items_state_fix.md @@ -0,0 +1,288 @@ +# Fix: `load_items()` Should Load Data Independent of EntityState + +## Problem Statement + +When reopening a posts page after initial load, posts display as "Post 12107" (ID-only +placeholder) instead of their actual titles, even though the data exists in the cache database. + +### Observed Behavior + +1. **First open**: User opens posts page, taps refresh +2. Posts load correctly with titles, excerpts, etc. +3. User navigates away or closes app +4. **Second open**: User opens posts page again +5. Posts show as "Post 12107", "Post 12105", etc. (placeholders) +6. User must tap refresh to see actual data + +### Expected Behavior + +On second open, posts should display their cached data immediately without requiring +a refresh. + +--- + +## Root Cause Analysis + +### The Bug: Conflating Fetch State with Cache Availability + +The current implementation uses `EntityState` to determine whether to load data from +the cache. This conflates two independent concepts: + +| Concept | Description | +|---------|-------------| +| **Fetch State** | Is a network fetch needed/in progress/completed/failed? | +| **Cache Availability** | Does data exist in the database for this entity? | + +### Architecture Issue: Memory-Only State Store + +From `entity_state_store.rs`: +```rust +/// Maps entity IDs to their current fetch state (Missing, Fetching, Cached, etc.). +/// This is a memory-only store - state resets on app restart. +pub struct EntityStateStore { + states: RwLock>, +} +``` + +The `EntityStateStore` is a **memory-only HashMap**. When the app restarts: +- Metadata (post IDs) persists in the database +- Entity states reset to `Missing` (the default) + +### Code Path Trace + +``` +App Restart → ViewModel.init() → createObservableCollection() → loadItemsFromCollection() + ↓ +PostMetadataCollectionWithEditContext::load_items() + ↓ + let cached_ids: Vec = items + .iter() + .filter(|item| item.state.is_cached()) // ← State is Missing! Returns false + .map(|item| item.id()) + .collect(); // cached_ids is empty! + ↓ + // No posts loaded from DB because cached_ids is empty + // Result: items have state=Missing, data=None +``` + +### In `post_metadata_collection.rs` (lines 110-153) + +```rust +pub async fn load_items(&self) -> Result, CollectionError> { + let items = self.collection.items(); + + // BUG: Only loads data for items where state.is_cached() is true + // After app restart, state resets to Missing, so nothing is loaded + let cached_ids: Vec = items + .iter() + .filter(|item| item.state.is_cached()) // ← Problem is here + .map(|item| item.id()) + .collect(); + + let cached_posts = if cached_ids.is_empty() { + Vec::new() + } else { + self.post_service.read_posts_by_ids_from_db(&cached_ids)? + }; + // ... +} +``` + +--- + +## Proposed Solution + +### Option A: Decouple Data Loading from Fetch State (Recommended) + +**Principle**: Always attempt to load data from cache for ALL items, regardless of state. + +The `EntityState` should only indicate whether a fetch is needed/in-progress, not +whether data might exist in the cache. + +#### Implementation Changes + +In `post_metadata_collection.rs`, modify `load_items()`: + +```rust +pub async fn load_items(&self) -> Result, CollectionError> { + let items = self.collection.items(); + + // Load ALL items from cache - data availability is independent of fetch state + let all_ids: Vec = items.iter().map(|item| item.id()).collect(); + + let cached_posts = if all_ids.is_empty() { + Vec::new() + } else { + self.post_service.read_posts_by_ids_from_db(&all_ids)? + }; + + // Build lookup map + let mut cached_map: HashMap> = + cached_posts.into_iter().map(|p| (p.data.id.0, p)).collect(); + + // Combine items with their data (if available in cache) + let result = items + .into_iter() + .map(|item| { + // Data may exist regardless of state - try to get it + let data = cached_map.remove(&item.id()).map(|e| e.into()); + + PostMetadataCollectionItem { + id: item.id(), + state: item.state, + data, + } + }) + .collect(); + + Ok(result) +} +``` + +#### Pros +- Simple, minimal change +- No database schema changes +- Data availability is now independent of transient fetch state +- Works correctly on app restart + +#### Cons +- Queries DB for all IDs every time (but batch query is efficient) +- May return data for items in `Failed` state (acceptable - shows last known data) + +--- + +### Option B: Infer State from Cache + +On collection creation, check which items have data in the cache and set their +initial state to `Cached` or `Stale` accordingly. + +#### Implementation + +In `MetadataCollection::new()` or a new initialization method: + +```rust +pub fn initialize_states_from_cache(&self, post_service: &PostService) { + let items = self.metadata_reader.get(&self.kv_key).unwrap_or_default(); + let ids: Vec = items.iter().map(|m| m.id).collect(); + + // Check which IDs have data in cache + let cached_ids = post_service.get_cached_post_ids(&ids); + + // Compare modified_gmt to determine Cached vs Stale + for metadata in items { + let state = if cached_ids.contains(&metadata.id) { + if let Some(cached) = post_service.get_cached_modified_gmt(metadata.id) { + if cached == metadata.modified_gmt { + EntityState::Cached + } else { + EntityState::Stale + } + } else { + EntityState::Missing + } + } else { + EntityState::Missing + }; + self.state_writer.set(metadata.id, state); + } +} +``` + +#### Pros +- State accurately reflects cache status +- `is_cached()` continues to work as intended + +#### Cons +- More complex implementation +- Requires additional DB queries on initialization +- Need to add `modified_gmt` storage to post cache table + +--- + +### Option C: Persist EntityState to Database + +Store `EntityState` in the database instead of memory. + +#### Pros +- State persists across app restarts +- Most accurate representation + +#### Cons +- Significant schema change +- More complex state management +- Need to handle state cleanup for deleted entities +- Overkill for this use case + +--- + +## Recommendation + +**Implement Option A** - Decouple data loading from fetch state. + +This is the simplest fix that addresses the root cause: the `load_items()` function +should not use `EntityState` to decide whether data might exist in the cache. + +### Key Insight + +The `EntityState` enum represents the **fetch lifecycle**: +``` +Missing → Fetching → Cached + ↓ + Failed +``` + +It does NOT represent cache availability. Data can exist in the cache while state is: +- `Missing` (after app restart) +- `Stale` (modified_gmt mismatch, but old data still valid) +- `Failed` (fetch failed, but previous data may exist) + +### Post-Fix Behavior + +| State | Fetch Needed? | Load from Cache? | UI Shows | +|-------|--------------|------------------|----------| +| Missing | Yes | **Yes** (if exists) | Cached data or placeholder | +| Fetching | In progress | Yes (if exists) | Loading + cached data | +| Cached | No | Yes | Cached data | +| Stale | Yes | Yes | Cached data (may be outdated) | +| Failed | Retry? | Yes (if exists) | Cached data + error | + +--- + +## Documentation Updates + +Update the doc comment in `PostMetadataCollectionItem`: + +```rust +/// Item in a metadata collection with optional loaded data. +/// +/// Combines the collection item (id + state) with the full entity data +/// when available in the cache. +/// +/// Note: `data` being `Some` is independent of `state`. Data may exist in +/// the cache while state is `Missing` (after app restart) or `Failed` +/// (showing last known data). Use `state` to determine fetch requirements, +/// not data availability. +#[derive(uniffi::Record)] +pub struct PostMetadataCollectionItem { + /// The post ID + pub id: i64, + + /// Current fetch state - indicates whether a fetch is needed/in-progress + pub state: EntityState, + + /// Full entity data from cache, if available + /// Note: May be present even when state is Missing, Stale, or Failed + pub data: Option, +} +``` + +--- + +## Testing + +1. Load posts page, verify data loads correctly +2. Navigate away, return to posts page +3. Verify data shows immediately without refresh +4. Kill app, reopen, navigate to posts page +5. Verify data shows immediately (state will be Missing but data loads) +6. Tap refresh to verify sync still works correctly diff --git a/wp_mobile/docs/design/metadata_collection.md b/wp_mobile/docs/design/metadata_collection.md new file mode 100644 index 000000000..7557d89f7 --- /dev/null +++ b/wp_mobile/docs/design/metadata_collection.md @@ -0,0 +1,252 @@ +# MetadataCollection Design & Implementation + +A "metadata-first" sync strategy for efficient list fetching in WordPress mobile apps. + +## Overview + +MetadataCollection uses lightweight metadata (id + modified_gmt) to define list structure, then selectively fetches only missing or stale entities. This optimizes for the common case where most posts are cached. + +**Key features:** +- Database-backed list metadata with pagination persistence +- Split observers for data vs state updates +- State persistence across filter changes and app restarts +- Cross-collection consistency (shared entity state) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PostService │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────────────────────┐ │ +│ │ state_store_with_ │ │ MetadataService │ │ +│ │ edit_context │ │ │ │ +│ │ │ │ DB Tables: │ │ +│ │ Memory-only HashMap │ │ - list_metadata (pagination) │ │ +│ │ i64 → EntityState │ │ - list_metadata_items (entity IDs) │ │ +│ │ │ │ - list_metadata_state (sync state) │ │ +│ │ Per-entity fetch state │ │ │ │ +│ │ (Missing, Fetching, │ │ Persists across app restarts │ │ +│ │ Cached, Stale, │ │ │ │ +│ │ Failed) │ │ │ │ +│ └────────────┬────────────┘ └──────────────────┬──────────────────────┘ │ +│ │ writes │ writes │ +│ │ │ │ +│ ┌────────────┴─────────────────────────────────────┴──────────────────────┐ │ +│ │ fetch_and_store_metadata_persistent(key, filter, page, per_page) │ │ +│ │ 1. begin_refresh() or begin_fetch_next_page() │ │ +│ │ 2. Fetch metadata from API (_fields=id,modified_gmt) │ │ +│ │ 3. Store items via MetadataService │ │ +│ │ 4. Detect staleness (compare modified_gmt with cached) │ │ +│ │ 5. complete_sync() or complete_sync_with_error() │ │ +│ │ │ │ +│ │ fetch_posts_by_ids(ids) → fetches missing/stale, updates state_store │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ │ Arc │ Arc +│ ▼ ▼ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ + └──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MetadataCollection │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ kv_key: String │ +│ metadata_reader: Arc // read-only │ +│ state_reader: Arc // read-only │ +│ fetcher: F // impl MetadataFetcher │ +│ │ +│ items() → Vec // reads from DB │ +│ refresh() → SyncResult // fetch page 1, sync missing/stale │ +│ load_next_page() → SyncResult // fetch next page, sync │ +│ is_relevant_data_update(hook) → bool // entity tables + ListMetadataItems │ +│ is_relevant_state_update(hook) → bool// ListMetadataState table │ +│ sync_state() → ListState // Idle, FetchingFirstPage, etc. │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + │ Kotlin/Swift wrapper + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ObservableMetadataCollection │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ dataObservers: List<() -> Unit> // notified on list content changes │ +│ stateObservers: List<() -> Unit> // notified on sync state changes │ +│ │ +│ addDataObserver(observer) // for list UI │ +│ addStateObserver(observer) // for loading indicators │ +│ notifyIfRelevant(hook) // routes DB updates to observers │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Status + +| Phase | Description | Status | Commit | +|-------|-------------|--------|--------| +| 1.1-1.5 | Database tables, types, repository | ✅ | `3c95dfb4` | +| 1.6 | Concurrency helpers (begin_refresh, etc.) | ✅ | `e484f791` | +| 2.1-2.4 | MetadataService wrapper | ✅ | `3c85514b` | +| 3.1 | Collection closure pattern | ⏸️ Deferred | - | +| 3.2 | PostService integration | ✅ | `5c83b435` | +| 3.3 | Persistent fetcher | ✅ | `7854e9e7` | +| 3.4 | Remove in-memory store | ✅ | `95a2db5f` | +| 4.1 | Split is_relevant_update | ✅ | `ef4d65d0` | +| 4.2 | Kotlin split observers | ✅ | `c29bcd50` | +| 4.3 | sync_state() method | ✅ | `ef4d65d0` | +| 5.1-5.2 | Repository & service tests | ✅ | (inline) | +| 5.3 | Example app UI | ✅ | `c29bcd50` | +| 5.4 | Bug fixes | ✅ | `30c69218` | +| 5.5 | Debug print cleanup | ✅ | `0b120639` | +| 5.6 | State persistence | ✅ | `30c69218` | + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `wp_mobile_cache/migrations/0007-create-list-metadata-tables.sql` | Schema | +| `wp_mobile_cache/src/list_metadata.rs` | `ListState` enum, structs | +| `wp_mobile_cache/src/repository/list_metadata.rs` | Repository (31 tests) | +| `wp_mobile/src/service/metadata.rs` | MetadataService (15 tests) | +| `wp_mobile/src/service/posts.rs` | PostService integration | +| `wp_mobile/src/sync/metadata_collection.rs` | Generic collection | +| `wp_mobile/src/sync/list_metadata_reader.rs` | Read-only trait | +| `native/kotlin/.../ObservableMetadataCollection.kt` | Kotlin wrapper | + +--- + +## Database Schema + +```sql +-- List header/pagination +CREATE TABLE list_metadata ( + rowid INTEGER PRIMARY KEY, + db_site_id INTEGER NOT NULL, + key TEXT NOT NULL, -- "edit:posts:publish" + total_pages INTEGER, + current_page INTEGER DEFAULT 0, + per_page INTEGER DEFAULT 20, + version INTEGER DEFAULT 0, -- for concurrency control + FOREIGN KEY (db_site_id) REFERENCES db_sites(id) +); + +-- List items (rowid = display order) +CREATE TABLE list_metadata_items ( + rowid INTEGER PRIMARY KEY, + db_site_id INTEGER NOT NULL, + key TEXT NOT NULL, + entity_id INTEGER NOT NULL, + modified_gmt TEXT +); + +-- Sync state (separate for efficient observers) +CREATE TABLE list_metadata_state ( + rowid INTEGER PRIMARY KEY, + list_metadata_id INTEGER NOT NULL, + state TEXT DEFAULT 'idle', -- idle, fetching_first_page, fetching_next_page, error + error_message TEXT, + FOREIGN KEY (list_metadata_id) REFERENCES list_metadata(rowid) +); +``` + +--- + +## State Transitions + +``` + ┌─────────────────────────────────────┐ + │ │ + ▼ │ +┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────┴──┐ +│ Missing │──────▶│ Fetching │──────▶│ Cached │──────▶│ Stale │ +└─────────┘ └──────────┘ └────────┘ └─────────┘ + │ │ + │ ┌────────┐ │ + └────────────▶│ Failed │◀───────────┘ + └────────┘ +``` + +| Transition | Trigger | +|------------|---------| +| Missing → Fetching | `fetch_posts_by_ids` called | +| Fetching → Cached | Fetch succeeded | +| Fetching → Failed | Fetch failed or entity not in response | +| Cached → Stale | New metadata has different `modified_gmt` | +| Stale/Failed → Fetching | Retry via refresh or load_next_page | + +--- + +## Key Design Decisions + +### 1. Service owns stores, collection reads only +Collections get `Arc` - no direct write access. Single coordination point prevents race conditions. + +### 2. Split data vs state observers +- **Data observers**: Fire when list content changes (posts table, list_metadata_items) +- **State observers**: Fire when sync state changes (list_metadata_state) + +Allows efficient UI updates - loading spinners don't trigger full list reloads. + +### 3. Async load_items() and sync_state() +SQLite update hooks fire synchronously during transactions. If hook callbacks query the DB, deadlock occurs. Making these async lets UniFFI dispatch to background threads. + +### 4. Simplified relevance checks +`is_relevant_update()` only checks table names, not keys. False positives (extra refreshes) are acceptable; deadlocks are not. + +### 5. Stale state reset on app launch +Transient states (`FetchingFirstPage`, `FetchingNextPage`) are reset to `Idle` in `WpApiCache::perform_migrations()`. Prevents stuck loading indicators after crashes. + +### 6. Collection loads pagination from DB on creation +`MetadataCollection::new()` reads `current_page` and `total_pages` from database. State persists across filter changes and app restarts. + +--- + +## Bug Fixes + +### Race Condition in ViewModel State Updates +**Problem**: UI stuck on "Fetching Next Page" when logs showed IDLE. +**Cause**: Completion handlers did `_state.value.copy(isSyncing = false)` without `syncState`, overwriting observer updates. +**Fix**: Completion handlers now set `syncState = collection.syncState()`. + +### State Not Persisting on Filter Change +**Problem**: `Page: 0` shown for previously-fetched filters. +**Cause**: `MetadataCollection::new()` hardcoded `current_page: 0`. +**Fix**: Added `get_current_page()` and `get_total_pages()` to `ListMetadataReader` trait. + +### Deadlock in Hook Callbacks +**Problem**: App froze when DB updates triggered observers. +**Cause**: Synchronous hook callbacks tried to query DB held by transaction. +**Fix**: Made `load_items()` and `sync_state()` async; simplified relevance checks. + +--- + +## Commit History + +| Commit | Description | +|--------|-------------| +| `3c95dfb4` | Add database foundation for MetadataService (Phase 1) | +| `e484f791` | Add list metadata repository concurrency helpers | +| `3c85514b` | Add MetadataService for database-backed list metadata | +| `5c83b435` | Integrate MetadataService into PostService | +| `7854e9e7` | Update PostMetadataCollection to use database-backed storage | +| `ef4d65d0` | Split collection observers for data vs state updates | +| `c29bcd50` | Complete Phase 4 & 5: Split observers, async methods, UI | +| `95a2db5f` | Remove deprecated in-memory metadata store | +| `0b120639` | Clean up debug prints for better readability | +| `30c69218` | Fix state persistence when switching filters | +| `c80ae6a8` | Update documentation | +| `817e5f76` | Fix tests and detekt issues | + +--- + +## Test Coverage + +- `wp_mobile_cache`: 112 tests (31 for list_metadata repository) +- `wp_mobile`: 60 tests (15 for MetadataService) diff --git a/wp_mobile/docs/design/metadata_collection_flow.txt b/wp_mobile/docs/design/metadata_collection_flow.txt new file mode 100644 index 000000000..35a92a87d --- /dev/null +++ b/wp_mobile/docs/design/metadata_collection_flow.txt @@ -0,0 +1,255 @@ +METADATA COLLECTION FLOW +======================== + +Initial State: Site exists in `db_sites` table, no posts fetched yet + + +═══════════════════════════════════════════════════════════════════════ + FIRST PAGE (REFRESH) +═══════════════════════════════════════════════════════════════════════ + + USER TRIGGERS REFRESH + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. MetadataCollection.refresh() │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 2. PostService.fetch_and_store_metadata_persistent(is_first=true) │ +│ │ +│ a) begin_refresh() │ +│ └─► DB: list_metadata_state = "fetching_first_page" │ +│ │ +│ b) Fetch from WordPress API │ +│ GET /wp/v2/posts?_fields=id,modified_gmt&page=1 │ +│ └─► Response: [{id: 1, modified_gmt: "..."}, {id: 2, ...}] │ +│ │ +│ c) Store metadata (REPLACE mode for page 1) │ +│ └─► DB: list_metadata (header: total_pages=5, current_page=1)│ +│ └─► DB: list_metadata_items (DELETE old, INSERT new) │ +│ │ +│ d) Detect stale posts (compare modified_gmt with cached) │ +│ └─► Memory: state_store marks changed posts as Stale │ +│ │ +│ e) complete_sync() │ +│ └─► DB: list_metadata_state = "idle" │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 3. sync_missing_and_stale() │ +│ │ +│ All posts are Missing (first launch) │ +│ GET /wp/v2/posts?include=1,2,3,... │ +│ │ +│ └─► DB: posts (full post data) │ +│ └─► Memory: state_store[1] = Cached, state_store[2] = Cached... │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ + UI RENDERS PAGE 1 POSTS + + +═══════════════════════════════════════════════════════════════════════ + SECOND PAGE (LOAD MORE) +═══════════════════════════════════════════════════════════════════════ + + USER SCROLLS TO BOTTOM + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. MetadataCollection.load_next_page() │ +│ │ +│ Check: current_page(1) < total_pages(5)? → Yes, proceed │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 2. PostService.fetch_and_store_metadata_persistent(is_first=false) │ +│ │ +│ a) begin_fetch_next_page() │ +│ └─► DB: list_metadata_state = "fetching_next_page" │ +│ │ +│ b) Fetch from WordPress API │ +│ GET /wp/v2/posts?_fields=id,modified_gmt&page=2 │ +│ └─► Response: [{id: 21, ...}, {id: 22, ...}, ...] │ +│ │ +│ c) Store metadata (APPEND mode for page 2+) │ +│ └─► DB: list_metadata (update: current_page=2) │ +│ └─► DB: list_metadata_items (INSERT at end, keep existing) │ +│ │ +│ d) Detect stale posts │ +│ └─► Memory: state_store marks changed posts as Stale │ +│ │ +│ e) complete_sync() │ +│ └─► DB: list_metadata_state = "idle" │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 3. sync_missing_and_stale() │ +│ │ +│ Page 2 posts are Missing, Page 1 posts already Cached │ +│ GET /wp/v2/posts?include=21,22,23,... (only new IDs) │ +│ │ +│ └─► DB: posts (page 2 posts) │ +│ └─► Memory: state_store[21] = Cached, ... │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ + UI RENDERS PAGE 1 + PAGE 2 POSTS + + +═══════════════════════════════════════════════════════════════════════ + API REFERENCE +═══════════════════════════════════════════════════════════════════════ + +┌─────────────────────────────────────────────────────────────────────┐ +│ ObservableMetadataCollection (Kotlin) │ +│ ════════════════════════════════════ │ +│ Wrapper with observer pattern for UI reactivity │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ SYNC OPERATIONS (suspend) │ +│ ───────────────────────── │ +│ refresh() → SyncResult ──► MetadataCollection.refresh() │ +│ loadNextPage() → SyncResult ──► MetadataCollection.load_next_page() +│ │ +│ DATA ACCESS (suspend) │ +│ ───────────────────── │ +│ loadItems() → List ──► MetadataCollection.items() │ +│ syncState() → ListState ──► MetadataCollection.sync_state() +│ │ +│ PAGINATION │ +│ ────────── │ +│ hasMorePages() → Boolean ──► MetadataCollection.has_more_pages() +│ currentPage() → UInt ──► MetadataCollection.current_page() +│ totalPages() → UInt? ──► MetadataCollection.total_pages() +│ │ +│ OBSERVERS │ +│ ───────── │ +│ addDataObserver(callback) Fires when list contents change │ +│ addStateObserver(callback) Fires when sync state changes │ +│ addObserver(callback) Fires on both │ +│ removeObserver(callback) │ +│ close() Unregister from notifier │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + calls via UniFFI│ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ MetadataCollection (Rust) │ +│ ════════════════════════════ │ +│ Core collection logic, reads from DB + memory state store │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ SYNC OPERATIONS (async) │ +│ ─────────────────────── │ +│ refresh() Fetch page 1, replace metadata, sync entities │ +│ load_next_page() Fetch next page, append metadata, sync entities │ +│ │ +│ DATA ACCESS │ +│ ─────────── │ +│ items() → Vec [id, state, modified] │ +│ sync_state() → ListState [Idle, Fetching*, Error]│ +│ │ +│ PAGINATION │ +│ ────────── │ +│ has_more_pages() → bool │ +│ current_page() → u32 │ +│ total_pages() → Option │ +│ │ +│ RELEVANCE CHECKS (for observer routing) │ +│ ─────────────────────────────────────── │ +│ is_relevant_data_update(hook) Entity tables + ListMetadataItems │ +│ is_relevant_state_update(hook) ListMetadataState table only │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + + +═══════════════════════════════════════════════════════════════════════ + DATABASE TABLES +═══════════════════════════════════════════════════════════════════════ + +┌──────────────────────────┐ +│ list_metadata │ One row per filter (e.g. "posts:publish") +├──────────────────────────┤ +│ key TEXT │ "edit:posts:publish" +│ total_pages INTEGER │ 5 +│ current_page INTEGER │ 2 (last loaded page) +│ per_page INTEGER │ 20 +│ version INTEGER │ For concurrency control +└──────────────────────────┘ + │ 1:N + ▼ +┌──────────────────────────┐ +│ list_metadata_items │ Ordered entity IDs (rowid = display order) +├──────────────────────────┤ +│ key TEXT │ "edit:posts:publish" +│ entity_id INTEGER │ 123 +│ modified_gmt TEXT │ "2024-01-15T10:30:00" +└──────────────────────────┘ + +┌──────────────────────────┐ +│ list_metadata_state │ Sync status (separate for efficient observers) +├──────────────────────────┤ +│ state TEXT │ "idle" | "fetching_first_page" | +│ │ "fetching_next_page" | "error" +│ error_message TEXT │ null or error details +└──────────────────────────┘ + +┌──────────────────────────┐ +│ posts │ Full post content +├──────────────────────────┤ +│ id INTEGER │ 123 +│ title TEXT │ "Hello World" +│ content TEXT │ "

...

" +│ modified_gmt TEXT │ "2024-01-15T10:30:00" ← used for stale check +│ ... │ +└──────────────────────────┘ + + +═══════════════════════════════════════════════════════════════════════ + ENTITY STATE (Memory) +═══════════════════════════════════════════════════════════════════════ + + modified_gmt changed + ┌─────────┐ ┌──────────┐ ┌────────┐ ┌───────┐ + │ Missing │───────▶│ Fetching │───────▶│ Cached │───────▶│ Stale │ + └─────────┘ └──────────┘ └────────┘ └───────┘ + │ ▲ │ + │ └──────────────────────────────────┘ + │ retry + ▼ + ┌────────┐ + │ Failed │ + └────────┘ + + • Stored in memory-only HashMap (state_store) + • Resets to Missing on app restart + • Shared across all collections for same entity ID + + +═══════════════════════════════════════════════════════════════════════ + OBSERVER NOTIFICATION FLOW +═══════════════════════════════════════════════════════════════════════ + + SQLite Update Hook fires + │ + ▼ + DatabaseChangeNotifier.notify(hook) + │ + ├──► collection.isRelevantDataUpdate(hook)? + │ │ + │ └── YES ──► dataObservers.forEach { it() } + │ │ + │ └──► UI reloads list + │ + └──► collection.isRelevantStateUpdate(hook)? + │ + └── YES ──► stateObservers.forEach { it() } + │ + └──► UI updates loading indicator diff --git a/wp_mobile/src/cache_key.rs b/wp_mobile/src/cache_key.rs new file mode 100644 index 000000000..cc01ab462 --- /dev/null +++ b/wp_mobile/src/cache_key.rs @@ -0,0 +1,210 @@ +//! Cache key generation for metadata collections. +//! +//! This module provides functions to generate deterministic cache keys from +//! filter parameters. The cache key is used to identify unique list configurations +//! in the metadata store. + +use url::Url; +use wp_api::{ + posts::PostListParamsField, request::endpoint::posts_endpoint::PostEndpointType, + url_query::AsQueryValue, +}; + +use crate::filters::PostListFilter; + +/// Generates a cache key segment from a `PostEndpointType`. +/// +/// Uses a `post_type_` prefix to avoid conflicts with custom post type names +/// that might match other cache key segments. +/// +/// # Returns +/// A string suitable for use in cache keys: +/// - `PostEndpointType::Posts` → `"post_type_posts"` +/// - `PostEndpointType::Pages` → `"post_type_pages"` +/// - `PostEndpointType::Custom(name)` → `"post_type_custom_{name}"` +/// +/// # Example +/// ```ignore +/// let key = endpoint_type_cache_key(&PostEndpointType::Posts); +/// assert_eq!(key, "post_type_posts"); +/// +/// let key = endpoint_type_cache_key(&PostEndpointType::Custom("products".to_string())); +/// assert_eq!(key, "post_type_custom_products"); +/// ``` +pub fn endpoint_type_cache_key(endpoint_type: &PostEndpointType) -> String { + match endpoint_type { + PostEndpointType::Posts => "post_type_posts".to_string(), + PostEndpointType::Pages => "post_type_pages".to_string(), + PostEndpointType::Custom(name) => format!("post_type_custom_{}", name), + } +} + +/// Extension trait to add query pairs using `AsQueryValue`. +/// +/// This replicates the functionality of `wp_api::url_query::QueryPairsExtension` +/// which is `pub(crate)` in wp_api. +trait QueryPairsExt { + fn append_option(&mut self, key: &str, value: Option<&T>); + fn append_vec(&mut self, key: &str, value: &[T]); +} + +impl QueryPairsExt for url::form_urlencoded::Serializer<'_, url::UrlQuery<'_>> { + fn append_option(&mut self, key: &str, value: Option<&T>) { + if let Some(v) = value { + self.append_pair(key, v.as_query_value().as_ref()); + } + } + + fn append_vec(&mut self, key: &str, value: &[T]) { + if !value.is_empty() { + let csv: String = value + .iter() + .map(|v| v.as_query_value().as_ref().to_string()) + .collect::>() + .join(","); + self.append_pair(key, &csv); + } + } +} + +/// Generates a deterministic cache key from `PostListFilter`. +/// +/// All fields in `PostListFilter` are included in the cache key since it only +/// contains filter-relevant fields (pagination, instance-specific, and date +/// range fields are excluded by design in `PostListFilter`). +/// +/// # Arguments +/// * `filter` - The post list filter to generate a cache key from +/// +/// # Returns +/// A URL query string containing the filter parameters in alphabetical order, +/// suitable for use as a cache key suffix. +/// +/// # Example +/// ```ignore +/// let filter = PostListFilter { +/// status: vec![PostStatus::Publish], +/// author: vec![UserId(5)], +/// ..Default::default() +/// }; +/// let key = post_list_filter_cache_key(&filter); +/// // key = "author=5&status=publish" +/// ``` +pub fn post_list_filter_cache_key(filter: &PostListFilter) -> String { + let mut url = Url::parse("https://cache-key-generator.local").expect("valid base URL"); + + { + let mut q = url.query_pairs_mut(); + + // All fields in PostListFilter are included (alphabetically ordered for determinism). + // Fields excluded from PostListFilter (pagination, instance-specific, date ranges) + // are documented in the PostListFilter type definition. + + q.append_vec(PostListParamsField::Author.into(), &filter.author); + q.append_vec( + PostListParamsField::AuthorExclude.into(), + &filter.author_exclude, + ); + q.append_vec(PostListParamsField::Categories.into(), &filter.categories); + q.append_vec( + PostListParamsField::CategoriesExclude.into(), + &filter.categories_exclude, + ); + q.append_option( + PostListParamsField::MenuOrder.into(), + filter.menu_order.as_ref(), + ); + q.append_option(PostListParamsField::Order.into(), filter.order.as_ref()); + q.append_option(PostListParamsField::Orderby.into(), filter.orderby.as_ref()); + q.append_option(PostListParamsField::Parent.into(), filter.parent.as_ref()); + q.append_vec( + PostListParamsField::ParentExclude.into(), + &filter.parent_exclude, + ); + q.append_option(PostListParamsField::Search.into(), filter.search.as_ref()); + q.append_vec( + PostListParamsField::SearchColumns.into(), + &filter.search_columns, + ); + q.append_vec(PostListParamsField::Slug.into(), &filter.slug); + q.append_vec(PostListParamsField::Status.into(), &filter.status); + q.append_option(PostListParamsField::Sticky.into(), filter.sticky.as_ref()); + q.append_vec(PostListParamsField::Tags.into(), &filter.tags); + q.append_vec( + PostListParamsField::TagsExclude.into(), + &filter.tags_exclude, + ); + q.append_option( + PostListParamsField::TaxRelation.into(), + filter.tax_relation.as_ref(), + ); + } + + url.query().unwrap_or("").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_api::posts::PostStatus; + + #[test] + fn test_empty_filter_produces_empty_key() { + let filter = PostListFilter::default(); + let key = post_list_filter_cache_key(&filter); + assert_eq!(key, ""); + } + + #[test] + fn test_status_filter() { + let filter = PostListFilter { + status: vec![PostStatus::Publish], + ..Default::default() + }; + let key = post_list_filter_cache_key(&filter); + assert_eq!(key, "status=publish"); + } + + #[test] + fn test_multiple_statuses() { + let filter = PostListFilter { + status: vec![PostStatus::Publish, PostStatus::Draft], + ..Default::default() + }; + let key = post_list_filter_cache_key(&filter); + assert_eq!(key, "status=publish%2Cdraft"); + } + + #[test] + fn test_multiple_filters_alphabetically_ordered() { + use wp_api::users::UserId; + + let filter = PostListFilter { + status: vec![PostStatus::Publish], + author: vec![UserId(5)], + search: Some("hello".to_string()), + ..Default::default() + }; + let key = post_list_filter_cache_key(&filter); + // Fields should be in alphabetical order: author, search, status + assert_eq!(key, "author=5&search=hello&status=publish"); + } + + #[test] + fn test_endpoint_type_posts() { + let key = endpoint_type_cache_key(&PostEndpointType::Posts); + assert_eq!(key, "post_type_posts"); + } + + #[test] + fn test_endpoint_type_pages() { + let key = endpoint_type_cache_key(&PostEndpointType::Pages); + assert_eq!(key, "post_type_pages"); + } + + #[test] + fn test_endpoint_type_custom() { + let key = endpoint_type_cache_key(&PostEndpointType::Custom("products".to_string())); + assert_eq!(key, "post_type_custom_products"); + } +} diff --git a/wp_mobile/src/collection/mod.rs b/wp_mobile/src/collection/mod.rs index 1775f7d02..532bfd9a3 100644 --- a/wp_mobile/src/collection/mod.rs +++ b/wp_mobile/src/collection/mod.rs @@ -2,13 +2,150 @@ mod collection_error; mod fetch_error; mod fetch_result; pub(crate) mod post_collection; +pub(crate) mod post_metadata_collection; mod stateless_collection; pub use collection_error::CollectionError; pub use fetch_error::FetchError; pub use fetch_result::FetchResult; +pub use post_metadata_collection::{ + PostItemState, PostMetadataCollectionItem, PostMetadataCollectionWithEditContext, +}; pub use stateless_collection::StatelessCollection; +/// Macro to create UniFFI-compatible item state enums for metadata collections. +/// +/// This macro generates a type-safe enum that combines sync status with data availability. +/// Data presence is encoded in the variant itself, eliminating inconsistent states. +/// +/// # Parameters +/// - `$state_name`: Name for the enum (e.g., `PostItemState`) +/// - `$full_entity_type`: The FullEntity wrapper type (e.g., `FullEntityAnyPostWithEditContext`) +/// +/// # Generated Variants +/// - `Missing`: No cached data, needs fetch +/// - `Fetching`: Fetch in progress, no cached data +/// - `FetchingWithData { data }`: Fetch in progress, showing cached data +/// - `Cached { data }`: Fresh cached data +/// - `Stale { data }`: Outdated cached data +/// - `Failed { error }`: Fetch failed, no cached data +/// - `FailedWithData { error, data }`: Fetch failed, showing cached data +/// +/// # Usage +/// ```ignore +/// wp_mobile_item_state!(PostItemState, FullEntityAnyPostWithEditContext); +/// ``` +#[macro_export] +macro_rules! wp_mobile_item_state { + ($state_name:ident, $full_entity_type:ty) => { + /// Combined state and data for an item in a metadata collection. + /// + /// This enum provides type-safe representation of item state with associated data. + /// Data presence is encoded in the variant itself, eliminating the need for + /// separate `state` and `data` fields. + #[derive(uniffi::Enum)] + pub enum $state_name { + /// No cached data available, needs fetch + Missing, + + /// Fetch in progress, no cached data to show + Fetching, + + /// Fetch in progress, showing cached data while loading + FetchingWithData { data: $full_entity_type }, + + /// Fresh cached data, no fetch needed + Cached { data: $full_entity_type }, + + /// Cached data is outdated, could benefit from refresh + Stale { data: $full_entity_type }, + + /// Fetch failed, no cached data available + Failed { error: String }, + + /// Fetch failed, showing last known cached data + FailedWithData { + error: String, + data: $full_entity_type, + }, + } + }; +} + +/// Macro to create UniFFI-compatible metadata collection item types. +/// +/// This macro generates both the state enum and the collection item struct for +/// metadata-driven collections. The generated types are suitable for use across +/// language boundaries via UniFFI. +/// +/// # Parameters +/// - `$item_name`: Name for the collection item struct (e.g., `PostMetadataCollectionItem`) +/// - `$state_name`: Name for the state enum (e.g., `PostItemState`) +/// - `$full_entity_type`: The FullEntity wrapper type (e.g., `FullEntityAnyPostWithEditContext`) +/// +/// # Generated Types +/// +/// ## State Enum (`$state_name`) +/// - `Missing`: No cached data, needs fetch +/// - `Fetching`: Fetch in progress, no cached data +/// - `FetchingWithData { data }`: Fetch in progress, showing cached data +/// - `Cached { data }`: Fresh cached data +/// - `Stale { data }`: Outdated cached data +/// - `Failed { error }`: Fetch failed, no cached data +/// - `FailedWithData { error, data }`: Fetch failed, showing cached data +/// +/// ## Collection Item Struct (`$item_name`) +/// - `id: i64`: The entity ID +/// - `parent: Option`: Parent entity ID (from list metadata, for hierarchical types) +/// - `menu_order: Option`: Menu order (from list metadata, for hierarchical types) +/// - `state: $state_name`: The combined state and data +/// +/// # Usage +/// ```ignore +/// wp_mobile_metadata_item!( +/// PostMetadataCollectionItem, +/// PostItemState, +/// FullEntityAnyPostWithEditContext +/// ); +/// ``` +#[macro_export] +macro_rules! wp_mobile_metadata_item { + ($item_name:ident, $state_name:ident, $full_entity_type:ty) => { + // Generate the state enum using the existing macro + $crate::wp_mobile_item_state!($state_name, $full_entity_type); + + /// Item in a metadata collection with type-safe state representation. + /// + /// The `state` enum encodes both the sync status and data availability, + /// making it impossible to have inconsistent combinations. + /// + /// The `parent` and `menu_order` fields come from the list metadata store, + /// making them available immediately without waiting for full entity data + /// to be fetched. This enables building hierarchical views (like page trees) + /// as soon as the list structure is known. + #[derive(uniffi::Record)] + pub struct $item_name { + /// The entity ID + pub id: i64, + + /// Parent entity ID (from list metadata, for hierarchical post types like pages) + /// + /// This value comes from the list metadata, so it's available immediately + /// without waiting for the full post data to be fetched. + pub parent: Option, + + /// Menu order (from list metadata, for hierarchical post types) + /// + /// This value comes from the list metadata, so it's available immediately + /// without waiting for the full post data to be fetched. + pub menu_order: Option, + + /// Combined state and data - see the state enum for variants + pub state: $state_name, + } + }; +} + /// Macro to create UniFFI-compatible post collection wrappers /// /// This macro generates a wrapper type for `PostCollection` that can be used diff --git a/wp_mobile/src/collection/post_metadata_collection.rs b/wp_mobile/src/collection/post_metadata_collection.rs new file mode 100644 index 000000000..587b87ee0 --- /dev/null +++ b/wp_mobile/src/collection/post_metadata_collection.rs @@ -0,0 +1,272 @@ +//! Post-specific metadata collection for efficient list syncing. + +use std::sync::Arc; + +use wp_api::posts::AnyPostWithEditContext; +use wp_mobile_cache::{UpdateHook, entity::FullEntity}; + +use crate::{ + collection::{CollectionError, FetchError}, + filters::PostListFilter, + service::posts::PostService, + sync::{ + EntityState, ListInfo, MetadataCollection, PersistentPostMetadataFetcherWithEditContext, + SyncResult, + }, +}; + +// Generate PostItemState enum and PostMetadataCollectionItem struct using the macro +crate::wp_mobile_metadata_item!( + PostMetadataCollectionItem, + PostItemState, + crate::FullEntityAnyPostWithEditContext +); + +/// Metadata-first collection for posts with edit context. +/// +/// This collection uses a two-phase sync strategy: +/// 1. Fetch lightweight metadata (id + modified_gmt) to define list structure +/// 2. Selectively fetch full data for missing or stale items +/// +/// Unlike `PostCollectionWithEditContext` which fetches full data for all items, +/// this collection shows cached items immediately and fetches only what's needed. +/// +/// # Usage +/// +/// ```ignore +/// // Create collection +/// let collection = post_service.create_post_metadata_collection_with_edit_context(params); +/// +/// // Initial load - fetches metadata, then syncs missing items +/// collection.refresh().await?; +/// +/// // Get items with states and data +/// let items = collection.load_items()?; +/// for item in items { +/// match item.state { +/// PostItemState::Cached { data } => { /* show data */ } +/// PostItemState::Stale { data } => { /* show data, maybe refresh */ } +/// PostItemState::FetchingWithData { data } => { /* show data + loading */ } +/// PostItemState::FailedWithData { error, data } => { /* show data + error */ } +/// PostItemState::Fetching => { /* show loading placeholder */ } +/// PostItemState::Missing => { /* show placeholder */ } +/// PostItemState::Failed { error } => { /* show error */ } +/// } +/// } +/// +/// // Load more +/// collection.load_next_page().await?; +/// ``` +#[derive(uniffi::Object)] +pub struct PostMetadataCollectionWithEditContext { + /// The underlying metadata collection (database-backed) + collection: MetadataCollection, + + /// Reference to service for loading full entity data + post_service: Arc, + + /// The filter parameters for this collection + filter: PostListFilter, +} + +impl PostMetadataCollectionWithEditContext { + pub fn new( + collection: MetadataCollection, + post_service: Arc, + filter: PostListFilter, + ) -> Self { + Self { + collection, + post_service, + filter, + } + } +} + +#[uniffi::export] +impl PostMetadataCollectionWithEditContext { + /// 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`] variant that encodes both + /// the sync status and data availability. + /// + /// This is the primary method for getting collection contents to display. + /// + /// # Note + /// Data availability is independent of the internal `EntityState`. After an app + /// restart, items may have internal state `Missing` but still have cached data + /// available. This method will return `FetchingWithData`, `Stale`, or `FailedWithData` + /// variants appropriately when cached data exists. + /// + /// This async function is exported to client platforms (Kotlin/Swift) where it + /// will be executed on a background thread. The underlying Rust implementation + /// is synchronous as rusqlite doesn't support async operations. + pub async fn load_items(&self) -> Result, CollectionError> { + let items = self.collection.items(); + + // Load ALL posts from cache - data availability is independent of EntityState. + // After app restart, EntityState resets to Missing but data may still be cached. + let all_ids: Vec = items.iter().map(|item| item.id()).collect(); + + let cached_posts = if all_ids.is_empty() { + Vec::new() + } else { + self.post_service + .read_posts_by_ids_from_db(&all_ids) + .map_err(|e| CollectionError::DatabaseError { + err_message: e.to_string(), + })? + }; + + // Build a map for quick lookup (using remove to take ownership) + let mut cached_map: std::collections::HashMap> = + cached_posts.into_iter().map(|p| (p.data.id.0, p)).collect(); + + // Combine EntityState with cache data into type-safe PostItemState + let result = items + .into_iter() + .map(|item| { + let id = item.id(); + // Extract parent and menu_order from metadata (available immediately) + let parent = item.metadata.parent; + let menu_order = item.metadata.menu_order; + let cached_data = cached_map.remove(&id).map(|e| e.into()); + let state = match (item.state, cached_data) { + // Missing state + (EntityState::Missing, None) => PostItemState::Missing, + (EntityState::Missing, Some(data)) => PostItemState::Stale { data }, + + // Fetching state + (EntityState::Fetching, None) => PostItemState::Fetching, + (EntityState::Fetching, Some(data)) => PostItemState::FetchingWithData { data }, + + // Cached state (should always have data, but handle gracefully) + (EntityState::Cached, Some(data)) => PostItemState::Cached { data }, + (EntityState::Cached, None) => PostItemState::Missing, + + // Stale state (should always have data, but handle gracefully) + (EntityState::Stale, Some(data)) => PostItemState::Stale { data }, + (EntityState::Stale, None) => PostItemState::Missing, + + // Failed state + (EntityState::Failed { error }, None) => PostItemState::Failed { error }, + (EntityState::Failed { error }, Some(data)) => { + PostItemState::FailedWithData { error, data } + } + }; + + PostMetadataCollectionItem { + id, + parent, + menu_order, + state, + } + }) + .collect(); + + Ok(result) + } + + /// 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. + pub async fn refresh(&self) -> Result { + self.collection.refresh().await + } + + /// 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 `SyncResult::no_op()` if already on the last page. + pub async fn load_next_page(&self) -> Result { + self.collection.load_next_page().await + } + + /// Get combined list info (pagination + sync state) in a single query. + /// + /// Returns `None` if the list hasn't been created yet. + /// Use this instead of calling `current_page()`, `total_pages()`, `sync_state()` + /// separately to avoid multiple database queries. + pub fn list_info(&self) -> Option { + self.collection.list_info() + } + + /// Check if there are more pages to load. + pub fn has_more_pages(&self) -> bool { + self.collection.has_more_pages() + } + + /// Get the current page number (0 = not loaded yet). + pub fn current_page(&self) -> u32 { + self.collection.current_page() + } + + /// Get the total number of pages, if known. + pub fn total_pages(&self) -> Option { + self.collection.total_pages() + } + + /// Get the current sync state for this collection. + /// + /// Returns the current `ListState`: + /// - `Idle` - No sync in progress + /// - `FetchingFirstPage` - Refresh in progress + /// - `FetchingNextPage` - Load more in progress + /// - `Error` - Last sync failed + /// + /// Use this to show loading indicators in the UI. Observe state changes + /// via `is_relevant_state_update`. + /// + /// # Note + /// This async function is exported to client platforms (Kotlin/Swift) where it + /// will be executed on a background thread. The underlying Rust implementation + /// is synchronous as rusqlite doesn't support async operations. + pub async fn sync_state(&self) -> wp_mobile_cache::list_metadata::ListState { + self.collection.sync_state() + } + + /// Check if a database update is relevant to this collection (either data or state). + /// + /// Returns `true` if the update affects either data or state. + /// For more granular control, use `is_relevant_data_update` or `is_relevant_state_update`. + pub fn is_relevant_update(&self, hook: &UpdateHook) -> bool { + self.collection.is_relevant_update(hook) + } + + /// Check if a database update affects this collection's data. + /// + /// Returns `true` if the update is to: + /// - An entity table this collection monitors (PostsEditContext, TermRelationships) + /// - The ListMetadataItems table for this collection's key + /// + /// Use this for data observers that should refresh list contents. + pub fn is_relevant_data_update(&self, hook: &UpdateHook) -> bool { + self.collection.is_relevant_data_update(hook) + } + + /// Check if a database update affects this collection's list info (pagination + state). + /// + /// Returns `true` if the update is to: + /// - `ListMetadata` table (pagination info changed) + /// - `ListMetadataState` table (sync state changed) + /// + /// Use this for listInfo observers that should update pagination display and loading indicators. + pub fn is_relevant_list_info_update(&self, hook: &UpdateHook) -> bool { + self.collection.is_relevant_list_info_update(hook) + } + + /// Get the filter parameters for this collection. + pub fn filter(&self) -> PostListFilter { + self.filter.clone() + } +} diff --git a/wp_mobile/src/filters/mod.rs b/wp_mobile/src/filters/mod.rs index 82999ef43..0c43970a7 100644 --- a/wp_mobile/src/filters/mod.rs +++ b/wp_mobile/src/filters/mod.rs @@ -1,3 +1,5 @@ mod post_filter; +mod post_list_filter; pub use post_filter::AnyPostFilter; +pub use post_list_filter::PostListFilter; diff --git a/wp_mobile/src/filters/post_list_filter.rs b/wp_mobile/src/filters/post_list_filter.rs new file mode 100644 index 000000000..794b0bbb6 --- /dev/null +++ b/wp_mobile/src/filters/post_list_filter.rs @@ -0,0 +1,198 @@ +//! Filter type for post metadata collections. +//! +//! This module provides `PostListFilter`, a subset of `PostListParams` containing +//! only fields appropriate for metadata collection filtering. + +use wp_api::{ + WpApiParamOrder, + posts::{ + PostId, PostListParams, PostStatus, WpApiParamPostsOrderBy, WpApiParamPostsSearchColumn, + WpApiParamPostsTaxRelation, + }, + terms::TermId, + users::UserId, +}; + +/// Filter parameters for post metadata collections. +/// +/// This type exposes only the filter-relevant fields from `PostListParams`, +/// excluding fields that are inappropriate for metadata collection use cases. +/// +/// # Excluded Fields +/// +/// The following `PostListParams` fields are intentionally excluded: +/// +/// ## Pagination fields (managed by the collection) +/// - `page` - The collection manages pagination internally via `refresh()` and `load_next_page()` +/// - `per_page` - The collection uses a fixed page size for consistent syncing +/// - `offset` - Incompatible with the collection's page-based pagination model +/// +/// ## Instance-specific fields (not suitable for cached lists) +/// - `include` - For fetching specific posts by ID; use direct entity fetching instead +/// - `exclude` - For excluding specific posts; would require cache invalidation on every change +/// +/// ## Date range fields (incompatible with metadata sync model) +/// - `after` - Date-bounded queries don't fit the "sync all matching posts" model +/// - `modified_after` - Same reason; the collection tracks modifications via `modified_gmt` +/// - `before` - Date-bounded queries create incomplete views that can't be reliably synced +/// - `modified_before` - Same reason; would miss posts modified after the boundary +/// +/// # Usage +/// +/// ```ignore +/// let filter = PostListFilter { +/// status: vec![PostStatus::Publish], +/// orderby: Some(WpApiParamPostsOrderBy::Date), +/// order: Some(WpApiParamOrder::Desc), +/// ..Default::default() +/// }; +/// +/// let collection = post_service.create_post_metadata_collection_with_edit_context( +/// PostEndpointType::Posts, +/// filter, +/// ); +/// ``` +#[derive(Debug, Default, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PostListFilter { + // ============================================================ + // Text Search + // ============================================================ + /// Limit results to those matching a string. + #[uniffi(default = None)] + pub search: Option, + + /// Array of column names to be searched. + #[uniffi(default = [])] + pub search_columns: Vec, + + // ============================================================ + // Author Filtering + // ============================================================ + /// Limit result set to posts assigned to specific authors. + #[uniffi(default = [])] + pub author: Vec, + + /// Ensure result set excludes posts assigned to specific authors. + #[uniffi(default = [])] + pub author_exclude: Vec, + + // ============================================================ + // Ordering + // ============================================================ + /// Order sort attribute ascending or descending. + /// Default: desc + #[uniffi(default = None)] + pub order: Option, + + /// Sort collection by post attribute. + /// Default: date + #[uniffi(default = None)] + pub orderby: Option, + + // ============================================================ + // Slug Filtering + // ============================================================ + /// Limit result set to posts with one or more specific slugs. + #[uniffi(default = [])] + pub slug: Vec, + + // ============================================================ + // Status Filtering + // ============================================================ + /// Limit result set to posts assigned one or more statuses. + /// Default: publish + #[uniffi(default = [])] + pub status: Vec, + + // ============================================================ + // Taxonomy Filtering + // ============================================================ + /// Limit result set based on relationship between multiple taxonomies. + /// One of: AND, OR + #[uniffi(default = None)] + pub tax_relation: Option, + + /// Limit result set to items with specific terms assigned in the categories taxonomy. + #[uniffi(default = [])] + pub categories: Vec, + + /// Limit result set to items except those with specific terms assigned in the categories taxonomy. + #[uniffi(default = [])] + pub categories_exclude: Vec, + + /// Limit result set to items with specific terms assigned in the tags taxonomy. + #[uniffi(default = [])] + pub tags: Vec, + + /// Limit result set to items except those with specific terms assigned in the tags taxonomy. + #[uniffi(default = [])] + pub tags_exclude: Vec, + + // ============================================================ + // Sticky Posts + // ============================================================ + /// Limit result set to items that are sticky. + #[uniffi(default = None)] + pub sticky: Option, + + // ============================================================ + // Hierarchical Post Type Fields (pages, etc.) + // ============================================================ + /// Limit result set to items with a specific parent. + #[uniffi(default = None)] + pub parent: Option, + + /// Limit result set to items except those of a specific parent. + #[uniffi(default = [])] + pub parent_exclude: Vec, + + /// Limit result set by menu order. + #[uniffi(default = None)] + pub menu_order: Option, +} + +impl PostListFilter { + /// Convert filter to `PostListParams` for API requests. + /// + /// This creates a `PostListParams` with pagination fields set by the caller + /// (typically the service layer) and filter fields from this struct. + /// + /// # Arguments + /// * `page` - Page number for the request + /// * `per_page` - Number of items per page + pub fn to_list_params(&self, page: u32, per_page: u32) -> PostListParams { + PostListParams { + // Pagination (provided by caller) + page: Some(page), + per_page: Some(per_page), + + // Filter fields (from self) + search: self.search.clone(), + search_columns: self.search_columns.clone(), + author: self.author.clone(), + author_exclude: self.author_exclude.clone(), + order: self.order, + orderby: self.orderby, + slug: self.slug.clone(), + status: self.status.clone(), + tax_relation: self.tax_relation, + categories: self.categories.clone(), + categories_exclude: self.categories_exclude.clone(), + tags: self.tags.clone(), + tags_exclude: self.tags_exclude.clone(), + sticky: self.sticky, + parent: self.parent, + parent_exclude: self.parent_exclude.clone(), + menu_order: self.menu_order, + + // Excluded fields (set to defaults) + offset: None, + include: Vec::new(), + exclude: Vec::new(), + after: None, + modified_after: None, + before: None, + modified_before: None, + } + } +} diff --git a/wp_mobile/src/lib.rs b/wp_mobile/src/lib.rs index 9b7795a85..4fd63ea9d 100644 --- a/wp_mobile/src/lib.rs +++ b/wp_mobile/src/lib.rs @@ -2,10 +2,12 @@ pub use wp_api; pub use wp_mobile_cache; +mod cache_key; pub mod collection; pub mod entity; pub mod filters; pub mod service; +pub mod sync; #[cfg(test)] mod testing; diff --git a/wp_mobile/src/service/metadata.rs b/wp_mobile/src/service/metadata.rs new file mode 100644 index 000000000..bda0dd99c --- /dev/null +++ b/wp_mobile/src/service/metadata.rs @@ -0,0 +1,756 @@ +use std::sync::Arc; +use wp_api::prelude::WpGmtDateTime; +use wp_mobile_cache::{ + RowId, WpApiCache, + db_types::db_site::DbSite, + list_metadata::{ListKey, ListState}, + repository::list_metadata::{ + FetchNextPageInfo, ListMetadataHeaderUpdate, ListMetadataItemInput, ListMetadataRepository, + RefreshInfo, + }, +}; + +use crate::sync::{EntityMetadata, ListInfo, ListMetadataReader}; + +use super::WpServiceError; + +/// Service layer for list metadata operations. +/// +/// Provides persistence for list structure (ordered entity IDs) and pagination +/// state. This replaces the in-memory `ListMetadataStore` with database-backed +/// storage that survives app restarts. +/// +/// # Usage Pattern +/// +/// MetadataService is typically used alongside PostService (or other entity services): +/// - PostService handles entity data (posts, with their content) +/// - MetadataService handles list metadata (which posts in what order) +/// +/// # Key Responsibilities +/// +/// 1. **List Structure**: Stores ordered lists of entity IDs per filter key +/// 2. **Pagination State**: Tracks current page, total pages, per_page +/// 3. **Sync State**: Tracks whether a list is idle, fetching, or errored +/// 4. **Version Control**: Enables detection of stale load-more operations +pub struct MetadataService { + db_site: Arc, + cache: Arc, +} + +impl MetadataService { + /// Create a new MetadataService for a specific site. + pub fn new(db_site: Arc, cache: Arc) -> Self { + Self { db_site, cache } + } + + // ============================================================ + // Read Operations + // ============================================================ + + /// Get ordered entity IDs for a list. + /// + /// Returns entity IDs in display order (rowid order from database). + /// Returns empty Vec if the list doesn't exist. + pub fn get_entity_ids(&self, key: &ListKey) -> Result, WpServiceError> { + self.cache.execute(|conn| { + let items = ListMetadataRepository::get_items_by_list_key(conn, &self.db_site, key)?; + Ok(items.into_iter().map(|item| item.entity_id).collect()) + }) + } + + /// Get list metadata as EntityMetadata structs (for ListMetadataReader trait). + /// + /// Converts database items to the format expected by MetadataCollection. + pub fn get_metadata( + &self, + key: &ListKey, + ) -> Result>, WpServiceError> { + self.cache.execute(|conn| { + let items = ListMetadataRepository::get_items_by_list_key(conn, &self.db_site, key)?; + + if items.is_empty() { + // Check if header exists - if not, list truly doesn't exist + if ListMetadataRepository::get_header(conn, &self.db_site, key)?.is_none() { + return Ok(None); + } + } + + let metadata = items + .into_iter() + .map(|item| { + let modified_gmt = item + .modified_gmt + .and_then(|s| s.parse::().ok()); + EntityMetadata::new(item.entity_id, modified_gmt, item.parent, item.menu_order) + }) + .collect(); + + Ok(Some(metadata)) + }) + } + + /// Get the current sync state for a list. + pub fn get_state(&self, key: &ListKey) -> Result { + self.cache + .execute(|conn| ListMetadataRepository::get_state_by_list_key(conn, &self.db_site, key)) + .map_err(Into::into) + } + + /// Get pagination info for a list. + /// + /// Returns None if the list doesn't exist. + pub fn get_pagination( + &self, + key: &ListKey, + ) -> Result, WpServiceError> { + self.cache.execute(|conn| { + let header = ListMetadataRepository::get_header(conn, &self.db_site, key)?; + Ok(header.map(|h| ListPaginationInfo { + total_pages: h.total_pages, + total_items: h.total_items, + current_page: h.current_page, + per_page: h.per_page, + })) + }) + } + + /// Check if there are more pages to load. + pub fn has_more_pages(&self, key: &ListKey) -> Result { + self.cache.execute(|conn| { + let header = match ListMetadataRepository::get_header(conn, &self.db_site, key)? { + Some(h) => h, + None => return Ok(false), + }; + + // If we haven't loaded any pages, there are more to load + if header.current_page == 0 { + return Ok(true); + } + + // If we don't know total pages, assume there might be more + match header.total_pages { + Some(total) => Ok(header.current_page < total), + None => Ok(true), + } + }) + } + + /// Get the current version for concurrency checking. + pub fn get_version(&self, key: &ListKey) -> Result { + self.cache + .execute(|conn| ListMetadataRepository::get_version(conn, &self.db_site, key)) + .map_err(Into::into) + } + + /// Check if the current version matches expected (for stale detection). + pub fn check_version( + &self, + key: &ListKey, + expected_version: i64, + ) -> Result { + let current_version = self.get_version(key)?; + Ok(current_version == expected_version) + } + + // ============================================================ + // Write Operations + // ============================================================ + + /// Set items for a list (replaces existing items). + /// + /// Used for refresh (page 1) - clears existing items and stores new ones. + /// Items are stored in the order provided. + pub fn set_items( + &self, + key: &ListKey, + per_page: i64, + metadata: &[EntityMetadata], + ) -> Result<(), WpServiceError> { + let items: Vec = metadata + .iter() + .map(|m| ListMetadataItemInput { + entity_id: m.id, + modified_gmt: m.modified_gmt.as_ref().map(|dt| dt.to_string()), + parent: m.parent, + menu_order: m.menu_order, + }) + .collect(); + + self.cache + .execute(|conn| { + ListMetadataRepository::set_items_by_list_key( + conn, + &self.db_site, + key, + per_page, + &items, + ) + }) + .map_err(Into::into) + } + + /// Append items to a list (for load-more). + /// + /// Used for subsequent pages - adds to existing items without clearing. + pub fn append_items( + &self, + key: &ListKey, + per_page: i64, + metadata: &[EntityMetadata], + ) -> Result<(), WpServiceError> { + let items: Vec = metadata + .iter() + .map(|m| ListMetadataItemInput { + entity_id: m.id, + modified_gmt: m.modified_gmt.as_ref().map(|dt| dt.to_string()), + parent: m.parent, + menu_order: m.menu_order, + }) + .collect(); + + self.cache + .execute(|conn| { + ListMetadataRepository::append_items_by_list_key( + conn, + &self.db_site, + key, + per_page, + &items, + ) + }) + .map_err(Into::into) + } + + /// Update pagination info after a fetch. + pub fn update_pagination( + &self, + key: &ListKey, + total_pages: Option, + total_items: Option, + current_page: i64, + per_page: i64, + ) -> Result<(), WpServiceError> { + let update = ListMetadataHeaderUpdate { + total_pages, + total_items, + current_page, + per_page, + }; + + self.cache + .execute(|conn| { + ListMetadataRepository::update_header_by_list_key(conn, &self.db_site, key, &update) + }) + .map_err(Into::into) + } + + /// Delete all data for a list. + pub fn delete_list(&self, key: &ListKey) -> Result<(), WpServiceError> { + self.cache + .execute(|conn| ListMetadataRepository::delete_list(conn, &self.db_site, key)) + .map_err(Into::into) + } + + // ============================================================ + // State Management + // ============================================================ + + /// Update sync state for a list. + pub fn set_state( + &self, + key: &ListKey, + per_page: i64, + state: ListState, + error_message: Option<&str>, + ) -> Result<(), WpServiceError> { + self.cache + .execute(|conn| { + ListMetadataRepository::update_state_by_list_key( + conn, + &self.db_site, + key, + per_page, + state, + error_message, + ) + }) + .map_err(Into::into) + } + + // ============================================================ + // Concurrency Helpers + // ============================================================ + + /// Begin a refresh operation (fetch first page). + /// + /// This atomically: + /// 1. Creates the list header if needed + /// 2. Increments version (invalidates any in-flight load-more) + /// 3. Sets state to FetchingFirstPage + /// + /// Returns info needed to make the API call and check version afterward. + pub fn begin_refresh( + &self, + key: &ListKey, + per_page: i64, + ) -> Result { + self.cache + .execute(|conn| { + ListMetadataRepository::begin_refresh(conn, &self.db_site, key, per_page) + }) + .map_err(Into::into) + } + + /// Begin a load-next-page operation. + /// + /// This atomically: + /// 1. Checks if there are more pages to load + /// 2. Sets state to FetchingNextPage + /// + /// Returns None if already at last page or no pages loaded yet. + /// Returns info including version to check before storing results. + pub fn begin_fetch_next_page( + &self, + key: &ListKey, + ) -> Result, WpServiceError> { + self.cache + .execute(|conn| ListMetadataRepository::begin_fetch_next_page(conn, &self.db_site, key)) + .map_err(Into::into) + } + + /// Complete a sync operation successfully (by list_metadata_id). + /// + /// Sets state to Idle. Use this when you have the `list_metadata_id` from + /// `begin_refresh` or `begin_fetch_next_page`. + pub fn complete_sync(&self, list_metadata_id: RowId) -> Result<(), WpServiceError> { + self.cache + .execute(|conn| ListMetadataRepository::complete_sync(conn, list_metadata_id))?; + Ok(()) + } + + /// Complete a sync operation successfully (by key). + /// + /// Sets state to Idle. Use this when you don't have the `list_metadata_id`. + /// Does nothing if the list doesn't exist. + pub fn complete_sync_by_key(&self, key: &ListKey) -> Result<(), WpServiceError> { + use wp_mobile_cache::SqliteDbError; + self.cache.execute(|conn| { + if let Some(header) = ListMetadataRepository::get_header(conn, &self.db_site, key)? { + ListMetadataRepository::complete_sync(conn, header.row_id)?; + } + Ok::<(), SqliteDbError>(()) + })?; + Ok(()) + } + + /// Complete a sync operation with error (by list_metadata_id). + /// + /// Sets state to Error with the provided message. Use this when you have the + /// `list_metadata_id` from `begin_refresh` or `begin_fetch_next_page`. + pub fn complete_sync_with_error( + &self, + list_metadata_id: RowId, + error_message: &str, + ) -> Result<(), WpServiceError> { + self.cache.execute(|conn| { + ListMetadataRepository::complete_sync_with_error(conn, list_metadata_id, error_message) + })?; + Ok(()) + } + + /// Complete a sync operation with error (by key). + /// + /// Sets state to Error with the provided message. Use this when you don't have + /// the `list_metadata_id`. Does nothing if the list doesn't exist. + pub fn complete_sync_with_error_by_key( + &self, + key: &ListKey, + error_message: &str, + ) -> Result<(), WpServiceError> { + use wp_mobile_cache::SqliteDbError; + self.cache.execute(|conn| { + if let Some(header) = ListMetadataRepository::get_header(conn, &self.db_site, key)? { + ListMetadataRepository::complete_sync_with_error( + conn, + header.row_id, + error_message, + )?; + } + Ok::<(), SqliteDbError>(()) + })?; + Ok(()) + } +} + +/// Implement ListMetadataReader for database-backed metadata. +/// +/// This allows MetadataCollection to read list structure from the database +/// through the same trait interface it uses for in-memory stores. +impl ListMetadataReader for MetadataService { + fn get_list_info(&self, key: &ListKey) -> Option { + self.cache + .execute(|conn| ListMetadataRepository::get_header_with_state(conn, &self.db_site, key)) + .ok() + .flatten() + .map(|db| ListInfo { + state: db.state, + error_message: db.error_message, + current_page: db.current_page, + total_pages: db.total_pages, + total_items: db.total_items, + per_page: db.per_page, + }) + } + + fn get_items(&self, key: &ListKey) -> Option> { + self.get_metadata(key).ok().flatten() + } +} + +/// Pagination info for a list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListPaginationInfo { + pub total_pages: Option, + pub total_items: Option, + pub current_page: i64, + pub per_page: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + use rusqlite::Connection; + use wp_mobile_cache::{ + MigrationManager, WpApiCache, db_types::self_hosted_site::SelfHostedSite, + repository::sites::SiteRepository, + }; + + struct TestContext { + service: MetadataService, + #[allow(dead_code)] + cache: Arc, + } + + #[fixture] + fn test_ctx() -> TestContext { + let mut conn = Connection::open_in_memory().expect("Failed to create in-memory database"); + let mut migration_manager = + MigrationManager::new(&conn).expect("Failed to create migration manager"); + migration_manager + .perform_migrations() + .expect("Migrations should succeed"); + + let site_repo = SiteRepository; + let self_hosted_site = SelfHostedSite { + url: "https://test.local".to_string(), + api_root: "https://test.local/wp-json".to_string(), + }; + let db_site = site_repo + .upsert_self_hosted_site(&mut conn, &self_hosted_site) + .expect("Site creation should succeed") + .db_site; + + let cache = Arc::new(WpApiCache::from(conn)); + let db_site = Arc::new(db_site); + let service = MetadataService::new(db_site, cache.clone()); + + TestContext { service, cache } + } + + const PER_PAGE: i64 = 20; + + #[rstest] + fn test_get_entity_ids_returns_empty_for_non_existent(test_ctx: TestContext) { + let key = ListKey::from("nonexistent"); + let ids = test_ctx.service.get_entity_ids(&key).unwrap(); + assert!(ids.is_empty()); + } + + #[rstest] + fn test_get_metadata_returns_none_for_non_existent(test_ctx: TestContext) { + let key = ListKey::from("nonexistent"); + let metadata = test_ctx.service.get_metadata(&key).unwrap(); + assert!(metadata.is_none()); + } + + #[rstest] + fn test_set_and_get_items(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + let metadata = vec![ + EntityMetadata::new(100, None, None, None), + EntityMetadata::new(200, None, None, None), + EntityMetadata::new(300, None, None, None), + ]; + + test_ctx + .service + .set_items(&key, PER_PAGE, &metadata) + .unwrap(); + + let ids = test_ctx.service.get_entity_ids(&key).unwrap(); + assert_eq!(ids, vec![100, 200, 300]); + } + + #[rstest] + fn test_set_items_replaces_existing(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:draft"); + + test_ctx + .service + .set_items( + &key, + PER_PAGE, + &[ + EntityMetadata::new(1, None, None, None), + EntityMetadata::new(2, None, None, None), + ], + ) + .unwrap(); + + test_ctx + .service + .set_items( + &key, + PER_PAGE, + &[ + EntityMetadata::new(10, None, None, None), + EntityMetadata::new(20, None, None, None), + ], + ) + .unwrap(); + + let ids = test_ctx.service.get_entity_ids(&key).unwrap(); + assert_eq!(ids, vec![10, 20]); + } + + #[rstest] + fn test_append_items(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:pending"); + + test_ctx + .service + .set_items(&key, PER_PAGE, &[EntityMetadata::new(1, None, None, None)]) + .unwrap(); + + test_ctx + .service + .append_items( + &key, + PER_PAGE, + &[ + EntityMetadata::new(2, None, None, None), + EntityMetadata::new(3, None, None, None), + ], + ) + .unwrap(); + + let ids = test_ctx.service.get_entity_ids(&key).unwrap(); + assert_eq!(ids, vec![1, 2, 3]); + } + + #[rstest] + fn test_get_state_returns_idle_for_non_existent(test_ctx: TestContext) { + let key = ListKey::from("nonexistent"); + let state = test_ctx.service.get_state(&key).unwrap(); + assert_eq!(state, ListState::Idle); + } + + #[rstest] + fn test_set_and_get_state(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + test_ctx + .service + .set_state(&key, PER_PAGE, ListState::FetchingFirstPage, None) + .unwrap(); + + let state = test_ctx.service.get_state(&key).unwrap(); + assert_eq!(state, ListState::FetchingFirstPage); + } + + #[rstest] + fn test_update_and_get_pagination(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + test_ctx + .service + .update_pagination(&key, Some(5), Some(100), 1, 20) + .unwrap(); + + let pagination = test_ctx.service.get_pagination(&key).unwrap().unwrap(); + assert_eq!(pagination.total_pages, Some(5)); + assert_eq!(pagination.total_items, Some(100)); + assert_eq!(pagination.current_page, 1); + assert_eq!(pagination.per_page, 20); + } + + #[rstest] + fn test_has_more_pages(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // No pages loaded yet + test_ctx + .service + .update_pagination(&key, Some(3), None, 0, 20) + .unwrap(); + assert!(test_ctx.service.has_more_pages(&key).unwrap()); + + // Page 1 of 3 loaded + test_ctx + .service + .update_pagination(&key, Some(3), None, 1, 20) + .unwrap(); + assert!(test_ctx.service.has_more_pages(&key).unwrap()); + + // Page 3 of 3 loaded (no more) + test_ctx + .service + .update_pagination(&key, Some(3), None, 3, 20) + .unwrap(); + assert!(!test_ctx.service.has_more_pages(&key).unwrap()); + } + + #[rstest] + fn test_begin_refresh_increments_version(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + let info1 = test_ctx.service.begin_refresh(&key, PER_PAGE).unwrap(); + assert_eq!(info1.version, 1); + + test_ctx + .service + .complete_sync(info1.list_metadata_id) + .unwrap(); + + let info2 = test_ctx.service.begin_refresh(&key, PER_PAGE).unwrap(); + assert_eq!(info2.version, 2); + } + + #[rstest] + fn test_begin_fetch_next_page_returns_none_when_no_pages(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // Create header but don't load any pages + let info = test_ctx.service.begin_refresh(&key, PER_PAGE).unwrap(); + test_ctx + .service + .complete_sync(info.list_metadata_id) + .unwrap(); + + let result = test_ctx.service.begin_fetch_next_page(&key).unwrap(); + assert!(result.is_none()); + } + + #[rstest] + fn test_begin_fetch_next_page_returns_info_when_more_pages(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // Set up: page 1 of 3 loaded + let info = test_ctx.service.begin_refresh(&key, PER_PAGE).unwrap(); + test_ctx + .service + .update_pagination(&key, Some(3), None, 1, 20) + .unwrap(); + test_ctx + .service + .complete_sync(info.list_metadata_id) + .unwrap(); + + let result = test_ctx.service.begin_fetch_next_page(&key).unwrap(); + assert!(result.is_some()); + let info = result.unwrap(); + assert_eq!(info.page, 2); + } + + #[rstest] + fn test_delete_list(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + test_ctx + .service + .set_items(&key, PER_PAGE, &[EntityMetadata::new(1, None, None, None)]) + .unwrap(); + test_ctx + .service + .update_pagination(&key, Some(1), None, 1, 20) + .unwrap(); + + test_ctx.service.delete_list(&key).unwrap(); + + assert!(test_ctx.service.get_metadata(&key).unwrap().is_none()); + assert!(test_ctx.service.get_pagination(&key).unwrap().is_none()); + } + + #[rstest] + fn test_list_metadata_reader_get_items(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + let metadata = vec![ + EntityMetadata::new(100, None, None, None), + EntityMetadata::new(200, None, None, None), + ]; + + test_ctx + .service + .set_items(&key, PER_PAGE, &metadata) + .unwrap(); + + // Access via trait + let reader: &dyn ListMetadataReader = &test_ctx.service; + let result = reader.get_items(&key).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].id, 100); + assert_eq!(result[1].id, 200); + } + + #[rstest] + fn test_list_metadata_reader_get_items_returns_none_for_non_existent(test_ctx: TestContext) { + let key = ListKey::from("nonexistent"); + let reader: &dyn ListMetadataReader = &test_ctx.service; + assert!(reader.get_items(&key).is_none()); + } + + #[rstest] + fn test_list_metadata_reader_get_list_info(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // Initially no info + let reader: &dyn ListMetadataReader = &test_ctx.service; + assert!(reader.get_list_info(&key).is_none()); + + // Create header via update_pagination (this creates the list metadata entry) + test_ctx + .service + .update_pagination(&key, Some(5), Some(100), 1, 20) + .unwrap(); + + let info = reader.get_list_info(&key).unwrap(); + assert_eq!(info.current_page, 1); + assert_eq!(info.per_page, 20); + assert_eq!(info.total_pages, Some(5)); + assert_eq!(info.total_items, Some(100)); + assert_eq!(info.state, wp_mobile_cache::list_metadata::ListState::Idle); + } + + #[rstest] + fn test_list_metadata_reader_get_list_info_with_state(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + let metadata = vec![EntityMetadata::new(100, None, None, None)]; + test_ctx + .service + .set_items(&key, PER_PAGE, &metadata) + .unwrap(); + + // Start a refresh + test_ctx.service.begin_refresh(&key, PER_PAGE).unwrap(); + + let reader: &dyn ListMetadataReader = &test_ctx.service; + let info = reader.get_list_info(&key).unwrap(); + + assert_eq!( + info.state, + wp_mobile_cache::list_metadata::ListState::FetchingFirstPage + ); + } +} diff --git a/wp_mobile/src/service/mod.rs b/wp_mobile/src/service/mod.rs index 9a7e2fc09..f6b5ea092 100644 --- a/wp_mobile/src/service/mod.rs +++ b/wp_mobile/src/service/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use wp_api::prelude::{ApiUrlResolver, WpApiClient, WpApiClientDelegate}; use wp_mobile_cache::WpApiCache; +pub mod metadata; pub mod mock_post_service; pub mod posts; pub mod sites; diff --git a/wp_mobile/src/service/posts.rs b/wp_mobile/src/service/posts.rs index 3c335920e..8982e5b41 100644 --- a/wp_mobile/src/service/posts.rs +++ b/wp_mobile/src/service/posts.rs @@ -1,19 +1,33 @@ use crate::{ AllAnyPostWithEditContextCollection, EntityAnyPostWithEditContext, PostCollectionWithEditContext, - collection::{FetchError, FetchResult, StatelessCollection, post_collection::PostCollection}, - filters::AnyPostFilter, + cache_key::{endpoint_type_cache_key, post_list_filter_cache_key}, + collection::{ + FetchError, FetchResult, PostMetadataCollectionWithEditContext, StatelessCollection, + post_collection::PostCollection, + }, + filters::{AnyPostFilter, PostListFilter}, + service::metadata::MetadataService, + sync::{ + EntityMetadata, EntityState, EntityStateReader, EntityStateStore, MetadataCollection, + MetadataFetchResult, PersistentPostMetadataFetcherWithEditContext, SyncResult, + }, }; use std::sync::Arc; use wp_api::{ - api_client::WpApiClient, posts::AnyPostWithEditContext, + api_client::WpApiClient, + posts::{ + AnyPostWithEditContext, PostId, PostListParams, PostStatus, + SparseAnyPostFieldWithEditContext, + }, request::endpoint::posts_endpoint::PostEndpointType, }; use wp_mobile_cache::{ - DbTable, WpApiCache, + DbTable, RowId, WpApiCache, context::EditContext, db_types::db_site::DbSite, entity::{Entity, EntityId, FullEntity}, + list_metadata::ListKey, repository::posts::PostRepository, }; @@ -21,19 +35,43 @@ use wp_mobile_cache::{ /// /// Provides a bridge between clients and the underlying network/cache layers. /// Handles fetching, creating, updating, and deleting posts. +/// +/// # Metadata Sync Infrastructure +/// +/// The service owns shared stores for metadata-first sync: +/// - `state_store_with_edit_context`: Tracks fetch state per entity for edit context. +/// Each context needs its own state store since the same entity ID can have different +/// fetch states across contexts. +/// - `metadata_service`: Database-backed list metadata (persists across app restarts). +/// +/// Collections get read-only access via reader methods. This ensures cross-collection +/// consistency when multiple collections share the same underlying entities. #[derive(uniffi::Object)] pub struct PostService { db_site: Arc, api_client: Arc, cache: Arc, + + /// Per-entity fetch state for edit context (memory-only, resets on app restart). + /// Each context needs its own state store since the same entity ID can have + /// different fetch states across contexts. + state_store_with_edit_context: Arc, + + /// Database-backed list metadata service. + /// Persists list structure across app restarts. + metadata_service: Arc, } impl PostService { pub fn new(api_client: Arc, db_site: Arc, cache: Arc) -> Self { + let metadata_service = Arc::new(MetadataService::new(db_site.clone(), cache.clone())); + Self { api_client, db_site, cache, + state_store_with_edit_context: Arc::new(EntityStateStore::new()), + metadata_service, } } @@ -103,6 +141,610 @@ impl PostService { current_page: page, }) } + + /// Fetch only metadata (id + modified_gmt) for a page of posts. + /// + /// This is a lightweight fetch that returns just enough information to: + /// 1. Define list structure (order and IDs) + /// 2. Determine which posts need full fetching (missing or stale) + /// + /// Unlike `fetch_posts_page`, this does NOT upsert to the database. + /// The metadata is used transiently to drive selective sync. + /// + /// # Arguments + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `filter` - Filter parameters (pagination is provided separately) + /// * `page` - Page number to fetch (1-indexed) + /// * `per_page` - Number of posts per page + /// + /// # Returns + /// - `Ok(MetadataFetchResult)` with post IDs and modification times + /// - `Err(FetchError)` if network error occurs + pub async fn fetch_posts_metadata( + &self, + endpoint_type: &PostEndpointType, + filter: &PostListFilter, + page: u32, + per_page: u32, + ) -> Result { + // Convert filter to params with pagination + let request_params = filter.to_list_params(page, per_page); + + let response = self + .api_client + .posts() + .filter_list_with_edit_context( + endpoint_type, + &request_params, + &[ + SparseAnyPostFieldWithEditContext::Id, + SparseAnyPostFieldWithEditContext::ModifiedGmt, + SparseAnyPostFieldWithEditContext::Parent, + SparseAnyPostFieldWithEditContext::MenuOrder, + ], + ) + .await?; + + // Map sparse posts to EntityMetadata, filtering out any with missing id + let metadata: Vec = response + .data + .into_iter() + .filter_map(|sparse| { + Some(EntityMetadata::new( + sparse.id?.0, + sparse.modified_gmt, + sparse.parent.map(|p| p.0), + sparse.menu_order.map(|m| m as i64), + )) + }) + .collect(); + + Ok(MetadataFetchResult::new( + metadata, + response.header_map.wp_total().map(|n| n as i64), + response.header_map.wp_total_pages(), + page, + )) + } + + /// Fetch metadata and store it in the persistent database. + /// + /// Stores metadata to `MetadataService` (database-backed) so list structure + /// persists across app restarts. + /// + /// # Arguments + /// * `key` - Key for the metadata store (e.g., "site_1:posts:status=publish") + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `filter` - Filter parameters (pagination is provided separately) + /// * `page` - Page number to fetch (1-indexed) + /// * `per_page` - Number of posts per page + /// * `is_first_page` - If true, replaces metadata; if false, appends + /// + /// # Returns + /// - `Ok(MetadataFetchResult)` with post IDs and modification times + /// - `Err(FetchError)` if network or database error occurs + pub async fn fetch_and_store_metadata_persistent( + &self, + key: &ListKey, + endpoint_type: &PostEndpointType, + filter: &PostListFilter, + page: u32, + per_page: u32, + is_first_page: bool, + ) -> Result { + let mut log = vec![format!( + "key={}, page={}, is_first_page={}", + key, page, is_first_page + )]; + + // Helper to print log on early return + let print_log = |log: &[String], status: &str| { + println!( + "[PostService] fetch_metadata_persistent:\n {} | {}", + log.join(" -> "), + status + ); + }; + + // Update state to fetching (this creates the list if needed) + // Track list_metadata_id for later complete_sync calls + let list_metadata_id: RowId = if is_first_page { + match self.metadata_service.begin_refresh(key, per_page as i64) { + Ok(info) => { + log.push("begin_refresh".to_string()); + info.list_metadata_id + } + Err(e) => { + log.push(format!("begin_refresh failed: {}", e)); + print_log(&log, "FAILED"); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + } + } else { + match self.metadata_service.begin_fetch_next_page(key) { + Ok(Some(info)) => { + log.push("begin_fetch_next_page".to_string()); + info.list_metadata_id + } + Ok(None) => { + log.push("begin_fetch_next_page returned None".to_string()); + print_log(&log, "FAILED"); + return Err(FetchError::Database { + err_message: "Cannot load next page: no pages loaded yet or already at last page. Try refresh first.".to_string(), + }); + } + Err(e) => { + log.push(format!("begin_fetch_next_page failed: {}", e)); + print_log(&log, "FAILED"); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + } + }; + + // Fetch metadata from network + let result = match self + .fetch_posts_metadata(endpoint_type, filter, page, per_page) + .await + { + Ok(result) => { + log.push(format!("fetched {} items", result.metadata.len())); + result + } + Err(e) => { + log.push(format!("network failed: {}", e)); + print_log(&log, "FAILED"); + let _ = self + .metadata_service + .complete_sync_with_error(list_metadata_id, &e.to_string()); + return Err(e); + } + }; + + // Store metadata to database + let store_result = if is_first_page { + self.metadata_service + .set_items(key, per_page as i64, &result.metadata) + } else { + self.metadata_service + .append_items(key, per_page as i64, &result.metadata) + }; + + if let Err(e) = store_result { + log.push(format!("store failed: {}", e)); + print_log(&log, "FAILED"); + let _ = self + .metadata_service + .complete_sync_with_error(list_metadata_id, &e.to_string()); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + log.push("stored".to_string()); + + // Update pagination info + if let Err(e) = self.metadata_service.update_pagination( + key, + result.total_pages.map(|p| p as i64), + result.total_items, + page as i64, + per_page as i64, + ) { + log.push(format!("pagination failed: {}", e)); + print_log(&log, "FAILED"); + let _ = self + .metadata_service + .complete_sync_with_error(list_metadata_id, &e.to_string()); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + log.push("pagination".to_string()); + + // Detect stale posts by comparing modified_gmt + self.detect_and_mark_stale_posts(&result.metadata); + + // Mark sync as complete + if let Err(e) = self.metadata_service.complete_sync(list_metadata_id) { + log.push(format!("complete_sync failed: {}", e)); + print_log(&log, "FAILED"); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + + print_log(&log, "OK"); + Ok(result) + } + + /// Compare fetched metadata against cached posts and mark stale ones. + /// + /// For each post that is currently `Cached`, compares the fetched `modified_gmt` + /// against the database value. If they differ, the post is marked as `Stale`. + fn detect_and_mark_stale_posts(&self, metadata: &[EntityMetadata]) { + // Get IDs of posts that are currently Cached (candidates for staleness check) + let cached_ids: Vec = metadata + .iter() + .filter(|m| { + matches!( + self.state_store_with_edit_context.get(m.id), + EntityState::Cached + ) + }) + .map(|m| m.id) + .collect(); + + if cached_ids.is_empty() { + return; + } + + // Query database for cached modified_gmt values + let cached_timestamps = self + .cache + .execute(|conn| { + let repo = PostRepository::::new(); + repo.select_modified_gmt_by_ids(conn, &self.db_site, &cached_ids) + }) + .unwrap_or_default(); + + // Compare and mark stale + let mut stale_count = 0; + for m in metadata.iter().filter(|m| cached_ids.contains(&m.id)) { + if let Some(fetched_modified) = &m.modified_gmt + && let Some(cached_modified) = cached_timestamps.get(&m.id) + && fetched_modified != cached_modified + { + self.state_store_with_edit_context + .set(m.id, EntityState::Stale); + stale_count += 1; + } + } + + if stale_count > 0 { + println!( + "[PostService] Detected {} stale post(s) via modified_gmt comparison", + stale_count + ); + } + } + + /// Sync a post list using persistent metadata storage. + /// + /// This method orchestrates the full sync flow: + /// 1. Updates state via MetadataService (FetchingFirstPage or FetchingNextPage) + /// 2. Fetches metadata from API + /// 3. Stores metadata in database via MetadataService + /// 4. Detects stale posts by comparing modified_gmt + /// 5. Fetches missing/stale posts + /// 6. Updates pagination info + /// 7. Sets state back to Idle (or Error on failure) + /// + /// # Arguments + /// * `key` - Metadata store key (e.g., "site_1:edit:posts:status=publish") + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `filter` - Filter parameters (pagination is provided separately) + /// * `page` - Page number to fetch (1-indexed) + /// * `per_page` - Number of posts per page + /// * `is_refresh` - If true, replaces metadata; if false, appends + /// + /// # Returns + /// - `Ok(SyncResult)` with sync statistics + /// - `Err(FetchError)` if network or database error occurs + pub async fn sync_post_list( + &self, + key: &ListKey, + endpoint_type: &PostEndpointType, + filter: &PostListFilter, + page: u32, + per_page: u32, + is_refresh: bool, + ) -> Result { + use crate::service::WpServiceError; + use wp_mobile_cache::list_metadata::ListState; + + // 1. Update state to fetching + let state = if is_refresh { + ListState::FetchingFirstPage + } else { + ListState::FetchingNextPage + }; + + self.metadata_service + .set_state(key, per_page as i64, state, None) + .map_err(|e| match e { + WpServiceError::DatabaseError { err_message } => { + FetchError::Database { err_message } + } + WpServiceError::SiteNotFound => FetchError::Database { + err_message: "Site not found".to_string(), + }, + })?; + + // 2. Fetch metadata from API + let metadata_result = match self + .fetch_posts_metadata(endpoint_type, filter, page, per_page) + .await + { + Ok(result) => result, + Err(e) => { + // Update state to error + let _ = self + .metadata_service + .complete_sync_with_error_by_key(key, &e.to_string()); + return Err(e); + } + }; + + // 3. Store metadata in database + let store_result = if is_refresh { + self.metadata_service + .set_items(key, per_page as i64, &metadata_result.metadata) + } else { + self.metadata_service + .append_items(key, per_page as i64, &metadata_result.metadata) + }; + + if let Err(e) = store_result { + let _ = self + .metadata_service + .complete_sync_with_error_by_key(key, &e.to_string()); + return Err(FetchError::Database { + err_message: e.to_string(), + }); + } + + // 4. Detect stale posts + self.detect_and_mark_stale_posts(&metadata_result.metadata); + + // 5. Fetch missing/stale posts + let ids_to_fetch: Vec = metadata_result + .metadata + .iter() + .filter(|m| { + let state = self.state_store_with_edit_context.get(m.id); + matches!(state, EntityState::Missing | EntityState::Stale) + }) + .map(|m| PostId(m.id)) + .collect(); + + let fetched_count = ids_to_fetch.len(); + let mut failed_count = 0; + + if !ids_to_fetch.is_empty() { + // Batch into chunks of 100 + for chunk in ids_to_fetch.chunks(100) { + if let Err(_e) = self.fetch_posts_by_ids(endpoint_type, chunk.to_vec()).await { + // Count failures - items not marked as Cached are considered failed + failed_count += chunk + .iter() + .filter(|id| { + !matches!( + self.state_store_with_edit_context.get(id.0), + EntityState::Cached + ) + }) + .count(); + } + } + } + + // 6. Update pagination info + let _ = self.metadata_service.update_pagination( + key, + metadata_result.total_pages.map(|p| p as i64), + metadata_result.total_items, + page as i64, + per_page as i64, + ); + + // 7. Set state back to idle + let _ = self.metadata_service.complete_sync_by_key(key); + + // Get total items from metadata service + let total_items = self + .metadata_service + .get_entity_ids(key) + .map(|ids| ids.len()) + .unwrap_or(0); + + let has_more_pages = self.metadata_service.has_more_pages(key).unwrap_or(false); + + Ok(SyncResult::new( + total_items, + fetched_count, + failed_count, + has_more_pages, + page, + metadata_result.total_pages, + )) + } + + /// Fetch full post data for specific post IDs and save to cache. + /// + /// This is used for selective sync - fetching only the posts that are + /// missing or stale in the cache. Uses the `include` parameter to batch + /// multiple posts in a single request. + /// + /// # State Tracking + /// + /// This method updates the entity state store: + /// 1. Filters out IDs that are already `Fetching` (prevents duplicate requests) + /// 2. Sets remaining IDs to `Fetching` before the API call + /// 3. On success: Sets fetched posts to `Cached`, missing posts to `Failed` + /// 4. On error: Sets all requested posts to `Failed` + /// + /// # Arguments + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `ids` - Post IDs to fetch + /// + /// # Returns + /// - `Ok(Vec)` with entity IDs of fetched posts + /// - `Err(FetchError)` if network or database error occurs + /// + /// # Note + /// If `ids` is empty or all IDs are already fetching, returns an empty Vec + /// without making a network request. + pub async fn fetch_posts_by_ids( + &self, + endpoint_type: &PostEndpointType, + ids: Vec, + ) -> Result, FetchError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + // Convert to raw IDs and filter out already-fetching + let raw_ids: Vec = ids.iter().map(|id| id.0).collect(); + let fetchable = self + .state_store_with_edit_context + .filter_fetchable(&raw_ids); + + if fetchable.is_empty() { + return Ok(Vec::new()); + } + + // Mark as fetching + self.state_store_with_edit_context + .set_batch(&fetchable, EntityState::Fetching); + + // Convert back to PostId for the API call + let post_ids: Vec = fetchable.iter().map(|&id| PostId(id)).collect(); + + let params = PostListParams { + include: post_ids, + // Ensure we get all requested posts regardless of default per_page + per_page: Some(100), + // Include all statuses - WordPress defaults to 'publish' which would + // filter out drafts, pending, etc. when fetching by ID + status: vec![ + PostStatus::Publish, + PostStatus::Draft, + PostStatus::Pending, + PostStatus::Private, + PostStatus::Future, + ], + ..Default::default() + }; + + match self + .api_client + .posts() + .list_with_edit_context(endpoint_type, ¶ms) + .await + { + Ok(response) => { + // Upsert to database and collect entity IDs + let entity_ids = self.cache.execute(|conn| { + let repo = PostRepository::::new(); + + response + .data + .iter() + .map(|post| { + repo.upsert(conn, &self.db_site, post).map_err(|e| { + FetchError::Database { + err_message: e.to_string(), + } + }) + }) + .collect::, _>>() + })?; + + // Mark successfully fetched posts as Cached + let fetched_ids: Vec = response.data.iter().map(|p| p.id.0).collect(); + self.state_store_with_edit_context + .set_batch(&fetched_ids, EntityState::Cached); + + // Mark posts that were requested but not returned as Failed + let failed_ids: Vec = fetchable + .iter() + .filter(|id| !fetched_ids.contains(id)) + .copied() + .collect(); + if !failed_ids.is_empty() { + self.state_store_with_edit_context + .set_batch(&failed_ids, EntityState::failed("Not found")); + } + + Ok(entity_ids) + } + Err(e) => { + // Mark all as failed + self.state_store_with_edit_context + .set_batch(&fetchable, EntityState::failed(e.to_string())); + Err(e.into()) + } + } + } + + /// Get read-only access to the entity state store for edit context. + /// + /// Used by `MetadataCollection` to read entity states without + /// being able to modify them. + pub fn state_reader_with_edit_context(&self) -> Arc { + self.state_store_with_edit_context.clone() + } + + /// Get read-only access to the persistent metadata service. + /// + /// Returns a reader backed by the database, so list metadata persists + /// across app restarts. Use this for production collections. + pub fn persistent_metadata_reader(&self) -> Arc { + self.metadata_service.clone() + } + + /// Get direct access to the metadata service. + /// + /// Used when you need both read and write access to list metadata. + pub fn metadata_service(&self) -> Arc { + self.metadata_service.clone() + } + + /// Get the current state for a post (edit context). + /// + /// Returns `EntityState::Missing` if no state has been recorded. + pub fn get_entity_state_with_edit_context(&self, post_id: PostId) -> EntityState { + self.state_store_with_edit_context.get(post_id.0) + } + + /// Read posts by IDs from the database cache. + /// + /// Returns full entity data for all requested IDs that exist in the cache. + /// Posts not in the cache are silently omitted from the result. + /// + /// # Arguments + /// * `ids` - Post IDs to load + /// + /// # Returns + /// - `Ok(Vec)` with posts found in cache + /// - `Err` if database error occurs + pub fn read_posts_by_ids_from_db( + &self, + ids: &[i64], + ) -> Result>, wp_mobile_cache::SqliteDbError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + let repo = PostRepository::::new(); + + self.cache.execute(|connection| { + ids.iter() + .map(|&id| PostId(id)) + .map(|post_id| repo.select_by_post_id(connection, &self.db_site, post_id)) + .collect::, _>>() + .map(|options| { + options + .into_iter() + .flatten() + .map(|db_post| FullEntity::new(db_post.entity_id, db_post.data.post)) + .collect() + }) + }) + } } #[uniffi::export] @@ -248,6 +890,69 @@ impl PostService { PostCollection::new(filter, stateless_collection, self.clone()).into() } + /// Create a metadata-first post collection with edit context + /// + /// Returns a collection that uses a two-phase sync strategy: + /// 1. Fetch lightweight metadata (id + modified_gmt) to define list structure + /// 2. Selectively fetch full data for missing or stale items + /// + /// Unlike `create_post_collection_with_edit_context` which fetches full data, + /// this collection shows cached items immediately and fetches only what's needed. + /// + /// # Arguments + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `filter` - Filter parameters (status, author, categories, etc.) + /// + /// # Example (Kotlin) + /// ```kotlin + /// val filter = PostListFilter(status = listOf(PostStatus.DRAFT)) + /// val collection = postService.createPostMetadataCollectionWithEditContext( + /// PostEndpointType.POSTS, + /// filter + /// ) + /// + /// // Initial load - fetches metadata, then syncs missing items + /// collection.refresh() + /// + /// // Get items with states and data + /// val items = collection.loadItems() + /// ``` + pub fn create_post_metadata_collection_with_edit_context( + self: &Arc, + endpoint_type: PostEndpointType, + filter: PostListFilter, + ) -> PostMetadataCollectionWithEditContext { + // Generate cache key from filter + let cache_key = post_list_filter_cache_key(&filter); + let endpoint_key = endpoint_type_cache_key(&endpoint_type); + let key: ListKey = format!( + "site_{:?}:edit:{}:{}", + self.db_site.row_id, endpoint_key, cache_key + ) + .into(); + + let fetcher = PersistentPostMetadataFetcherWithEditContext::new( + self.clone(), + endpoint_type, + filter.clone(), + key.clone(), + ); + + let metadata_collection = MetadataCollection::new( + key, + self.persistent_metadata_reader(), + self.state_reader_with_edit_context(), + fetcher, + vec![ + DbTable::PostsEditContext, + DbTable::TermRelationships, + DbTable::ListMetadataItems, + ], + ); + + PostMetadataCollectionWithEditContext::new(metadata_collection, self.clone(), filter) + } + /// Get a collection of all posts with edit context for this site. /// /// Returns a collection that can be used to observe all posts for this site. diff --git a/wp_mobile/src/sync/collection_item.rs b/wp_mobile/src/sync/collection_item.rs new file mode 100644 index 000000000..f0949bc4a --- /dev/null +++ b/wp_mobile/src/sync/collection_item.rs @@ -0,0 +1,77 @@ +use super::{EntityMetadata, EntityState}; + +/// An item in a metadata-driven collection. +/// +/// Combines the lightweight metadata (id + modified_gmt) with the current +/// fetch state. Platform layers wrap this as observable, with `loadData()` +/// fetching the full entity from cache. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectionItem { + /// Lightweight metadata for this item. + pub metadata: EntityMetadata, + + /// Current fetch state. + pub state: EntityState, +} + +impl CollectionItem { + pub fn new(metadata: EntityMetadata, state: EntityState) -> Self { + Self { metadata, state } + } + + /// The entity ID. + pub fn id(&self) -> i64 { + self.metadata.id + } + + /// Returns `true` if the entity needs to be fetched. + pub fn needs_fetch(&self) -> bool { + self.state.needs_fetch() + } + + /// Returns `true` if a fetch is currently in progress. + pub fn is_fetching(&self) -> bool { + self.state.is_fetching() + } + + /// Returns `true` if the entity is cached (fresh or stale). + pub fn is_cached(&self) -> bool { + self.state.is_cached() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_api::prelude::WpGmtDateTime; + + fn test_metadata() -> EntityMetadata { + EntityMetadata::with_modified(42, WpGmtDateTime::from_timestamp(1000)) + } + + #[test] + fn test_new() { + let item = CollectionItem::new(test_metadata(), EntityState::Cached); + + assert_eq!(item.id(), 42); + assert_eq!(item.state, EntityState::Cached); + } + + #[test] + fn test_delegates_to_state() { + let missing = CollectionItem::new(test_metadata(), EntityState::Missing); + assert!(missing.needs_fetch()); + assert!(!missing.is_fetching()); + assert!(!missing.is_cached()); + + let fetching = CollectionItem::new(test_metadata(), EntityState::Fetching); + assert!(!fetching.needs_fetch()); + assert!(fetching.is_fetching()); + assert!(!fetching.is_cached()); + + let cached = CollectionItem::new(test_metadata(), EntityState::Cached); + assert!(!cached.needs_fetch()); + assert!(!cached.is_fetching()); + assert!(cached.is_cached()); + } +} diff --git a/wp_mobile/src/sync/entity_metadata.rs b/wp_mobile/src/sync/entity_metadata.rs new file mode 100644 index 000000000..0f7c6c7ff --- /dev/null +++ b/wp_mobile/src/sync/entity_metadata.rs @@ -0,0 +1,110 @@ +use wp_api::prelude::WpGmtDateTime; + +/// Lightweight metadata for an entity, used for list structure. +/// +/// Contains the `id` and optionally `modified_gmt`, which are sufficient +/// to determine list order and detect stale cached entries. +/// +/// The `modified_gmt` is optional because some entity types (e.g., Comments) +/// don't have this field. For those entities, staleness is determined via +/// other means (e.g., `last_fetched_at` in the database). +/// +/// For hierarchical post types (like pages), `parent` and `menu_order` are +/// also stored to support proper ordering and hierarchy display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntityMetadata { + pub id: i64, + pub modified_gmt: Option, + /// Parent entity ID (for hierarchical post types like pages) + pub parent: Option, + /// Menu order (for hierarchical post types) + pub menu_order: Option, +} + +impl EntityMetadata { + pub fn new( + id: i64, + modified_gmt: Option, + parent: Option, + menu_order: Option, + ) -> Self { + Self { + id, + modified_gmt, + parent, + menu_order, + } + } + + /// Create metadata with a known modified timestamp. + pub fn with_modified(id: i64, modified_gmt: WpGmtDateTime) -> Self { + Self { + id, + modified_gmt: Some(modified_gmt), + parent: None, + menu_order: None, + } + } + + /// Create metadata without a modified timestamp. + /// + /// Use this for entity types that don't have a `modified_gmt` field. + pub fn without_modified(id: i64) -> Self { + Self { + id, + modified_gmt: None, + parent: None, + menu_order: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_with_modified() { + let modified = WpGmtDateTime::from_timestamp(1000); + let metadata = EntityMetadata::new(42, Some(modified), Some(10), Some(5)); + + assert_eq!(metadata.id, 42); + assert_eq!( + metadata.modified_gmt, + Some(WpGmtDateTime::from_timestamp(1000)) + ); + assert_eq!(metadata.parent, Some(10)); + assert_eq!(metadata.menu_order, Some(5)); + } + + #[test] + fn test_new_without_modified() { + let metadata = EntityMetadata::new(42, None, None, None); + + assert_eq!(metadata.id, 42); + assert_eq!(metadata.modified_gmt, None); + assert_eq!(metadata.parent, None); + assert_eq!(metadata.menu_order, None); + } + + #[test] + fn test_with_modified_helper() { + let modified = WpGmtDateTime::from_timestamp(1000); + let metadata = EntityMetadata::with_modified(42, modified); + + assert_eq!(metadata.id, 42); + assert!(metadata.modified_gmt.is_some()); + assert!(metadata.parent.is_none()); + assert!(metadata.menu_order.is_none()); + } + + #[test] + fn test_without_modified_helper() { + let metadata = EntityMetadata::without_modified(42); + + assert_eq!(metadata.id, 42); + assert!(metadata.modified_gmt.is_none()); + assert!(metadata.parent.is_none()); + assert!(metadata.menu_order.is_none()); + } +} diff --git a/wp_mobile/src/sync/entity_state.rs b/wp_mobile/src/sync/entity_state.rs new file mode 100644 index 000000000..14a207cb9 --- /dev/null +++ b/wp_mobile/src/sync/entity_state.rs @@ -0,0 +1,117 @@ +/// Fetch state for an entity. +/// +/// Tracks the lifecycle of fetching an entity from the network: +/// - `Missing`: Not in cache, needs to be fetched +/// - `Fetching`: Fetch is in progress +/// - `Cached`: Successfully fetched and in cache +/// - `Stale`: In cache but outdated (e.g., `modified_gmt` mismatch) +/// - `Failed`: Fetch was attempted but failed +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum EntityState { + /// Entity is not in cache and not being fetched. + Missing, + + /// Fetch is currently in progress. + Fetching, + + /// Entity is in cache and considered fresh. + Cached, + + /// Entity is in cache but outdated (needs re-fetch). + Stale, + + /// Fetch was attempted but failed. + Failed { error: String }, +} + +impl EntityState { + /// Returns `true` if the entity needs to be fetched. + /// + /// This includes `Missing`, `Stale`, and `Failed` states. + /// Does not include `Fetching` (already in progress) or `Cached` (up to date). + pub fn needs_fetch(&self) -> bool { + matches!(self, Self::Missing | Self::Stale | Self::Failed { .. }) + } + + /// Returns `true` if a fetch is currently in progress. + pub fn is_fetching(&self) -> bool { + matches!(self, Self::Fetching) + } + + /// Returns `true` if the entity is cached (fresh or stale). + pub fn is_cached(&self) -> bool { + matches!(self, Self::Cached | Self::Stale) + } + + /// Returns `true` if the last fetch attempt failed. + pub fn is_failed(&self) -> bool { + matches!(self, Self::Failed { .. }) + } + + /// Create a `Failed` state with the given error message. + pub fn failed(error: impl Into) -> Self { + Self::Failed { + error: error.into(), + } + } +} + +impl Default for EntityState { + fn default() -> Self { + Self::Missing + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_needs_fetch() { + assert!(EntityState::Missing.needs_fetch()); + assert!(EntityState::Stale.needs_fetch()); + assert!( + EntityState::Failed { + error: "err".into() + } + .needs_fetch() + ); + + assert!(!EntityState::Fetching.needs_fetch()); + assert!(!EntityState::Cached.needs_fetch()); + } + + #[test] + fn test_is_fetching() { + assert!(EntityState::Fetching.is_fetching()); + + assert!(!EntityState::Missing.is_fetching()); + assert!(!EntityState::Cached.is_fetching()); + } + + #[test] + fn test_is_cached() { + assert!(EntityState::Cached.is_cached()); + assert!(EntityState::Stale.is_cached()); + + assert!(!EntityState::Missing.is_cached()); + assert!(!EntityState::Fetching.is_cached()); + assert!( + !EntityState::Failed { + error: "err".into() + } + .is_cached() + ); + } + + #[test] + fn test_failed_helper() { + let state = EntityState::failed("Network error"); + assert!(matches!(state, EntityState::Failed { error } if error == "Network error")); + } + + #[test] + fn test_default_is_missing() { + assert_eq!(EntityState::default(), EntityState::Missing); + } +} diff --git a/wp_mobile/src/sync/entity_state_store.rs b/wp_mobile/src/sync/entity_state_store.rs new file mode 100644 index 000000000..4661eb3ff --- /dev/null +++ b/wp_mobile/src/sync/entity_state_store.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; +use std::sync::RwLock; + +use super::EntityState; + +/// Read-only access to entity fetch states. +/// +/// This trait allows components (like `MetadataCollection`) to read entity states +/// without being able to modify them. Only the service layer should write states. +pub trait EntityStateReader: Send + Sync { + /// Get the current state for an entity. + /// + /// Returns `EntityState::Missing` if the entity has no recorded state. + fn get(&self, id: i64) -> EntityState; +} + +/// Store for tracking entity fetch states. +/// +/// Maps entity IDs to their current fetch state (Missing, Fetching, Cached, etc.). +/// This is a memory-only store - state resets on app restart. +/// +/// Thread-safe via `RwLock`. For high-concurrency scenarios, consider +/// switching to `DashMap` for better performance. +pub struct EntityStateStore { + states: RwLock>, +} + +impl EntityStateStore { + pub fn new() -> Self { + Self { + states: RwLock::new(HashMap::new()), + } + } + + /// Set the state for a single entity. + pub fn set(&self, id: i64, state: EntityState) { + self.states + .write() + .expect("RwLock poisoned") + .insert(id, state); + } + + /// Set the state for multiple entities. + pub fn set_batch(&self, ids: &[i64], state: EntityState) { + let mut states = self.states.write().expect("RwLock poisoned"); + ids.iter().for_each(|&id| { + states.insert(id, state.clone()); + }); + } + + /// Filter IDs to only those that can be fetched (not currently `Fetching`). + /// + /// Returns IDs where state is `Missing`, `Stale`, `Failed`, or not recorded. + pub fn filter_fetchable(&self, ids: &[i64]) -> Vec { + let states = self.states.read().expect("RwLock poisoned"); + ids.iter() + .filter(|&&id| states.get(&id).map(|s| !s.is_fetching()).unwrap_or(true)) + .copied() + .collect() + } + + /// Clear all state entries. + pub fn clear(&self) { + self.states.write().expect("RwLock poisoned").clear(); + } + + /// Get the number of tracked entities. + pub fn len(&self) -> usize { + self.states.read().expect("RwLock poisoned").len() + } + + /// Check if the store is empty. + pub fn is_empty(&self) -> bool { + self.states.read().expect("RwLock poisoned").is_empty() + } +} + +impl Default for EntityStateStore { + fn default() -> Self { + Self::new() + } +} + +impl EntityStateReader for EntityStateStore { + fn get(&self, id: i64) -> EntityState { + self.states + .read() + .expect("RwLock poisoned") + .get(&id) + .cloned() + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_returns_missing_for_unknown() { + let store = EntityStateStore::new(); + assert_eq!(store.get(42), EntityState::Missing); + } + + #[test] + fn test_set_and_get() { + let store = EntityStateStore::new(); + + store.set(42, EntityState::Fetching); + assert_eq!(store.get(42), EntityState::Fetching); + + store.set(42, EntityState::Cached); + assert_eq!(store.get(42), EntityState::Cached); + } + + #[test] + fn test_set_batch() { + let store = EntityStateStore::new(); + + store.set_batch(&[1, 2, 3], EntityState::Fetching); + + assert_eq!(store.get(1), EntityState::Fetching); + assert_eq!(store.get(2), EntityState::Fetching); + assert_eq!(store.get(3), EntityState::Fetching); + } + + #[test] + fn test_filter_fetchable() { + let store = EntityStateStore::new(); + + store.set(1, EntityState::Missing); + store.set(2, EntityState::Fetching); + store.set(3, EntityState::Cached); + store.set(4, EntityState::Stale); + store.set(5, EntityState::failed("error")); + // ID 6 has no state (should be fetchable) + + let fetchable = store.filter_fetchable(&[1, 2, 3, 4, 5, 6]); + + // Only Fetching (2) should be excluded - it's already in progress + // All others are "fetchable" (not currently being fetched) + assert!(fetchable.contains(&1)); // Missing + assert!(!fetchable.contains(&2)); // Fetching - excluded (already in progress) + assert!(fetchable.contains(&3)); // Cached - fetchable (could re-fetch if needed) + assert!(fetchable.contains(&4)); // Stale + assert!(fetchable.contains(&5)); // Failed + assert!(fetchable.contains(&6)); // Unknown (no state recorded) + } + + #[test] + fn test_clear() { + let store = EntityStateStore::new(); + + store.set(1, EntityState::Cached); + store.set(2, EntityState::Cached); + assert_eq!(store.len(), 2); + + store.clear(); + assert!(store.is_empty()); + assert_eq!(store.get(1), EntityState::Missing); + } + + #[test] + fn test_reader_trait() { + let store = EntityStateStore::new(); + store.set(42, EntityState::Cached); + + // Access via trait + let reader: &dyn EntityStateReader = &store; + assert_eq!(reader.get(42), EntityState::Cached); + assert_eq!(reader.get(99), EntityState::Missing); + } +} diff --git a/wp_mobile/src/sync/list_metadata_reader.rs b/wp_mobile/src/sync/list_metadata_reader.rs new file mode 100644 index 000000000..b71a8a12a --- /dev/null +++ b/wp_mobile/src/sync/list_metadata_reader.rs @@ -0,0 +1,38 @@ +use super::EntityMetadata; +use wp_mobile_cache::list_metadata::{ListKey, ListState}; + +/// Combined list information: pagination + sync state. +/// +/// Returned by a single JOIN query on `list_metadata` + `list_metadata_state` tables. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ListInfo { + /// Current sync state (Idle, FetchingFirstPage, FetchingNextPage, Error) + pub state: ListState, + /// Error message if state is Error + pub error_message: Option, + /// Current page that has been loaded (0 = no pages loaded) + pub current_page: i64, + /// Total number of pages from API response + pub total_pages: Option, + /// Total number of items from API response + pub total_items: Option, + /// Items per page + pub per_page: i64, +} + +/// Read-only access to list metadata. +/// +/// This trait allows components (like `MetadataCollection`) to read list structure +/// without being able to modify it. Only the service layer should write metadata. +pub trait ListMetadataReader: Send + Sync { + /// Get list info (pagination + state) in a single query. + /// + /// Returns `None` if no metadata has been stored for this key. + fn get_list_info(&self, key: &ListKey) -> Option; + + /// Get the items for a list. + /// + /// Returns `None` if no metadata has been stored for this key. + /// Returns `Some(vec![])` if the list exists but has no items. + fn get_items(&self, key: &ListKey) -> Option>; +} diff --git a/wp_mobile/src/sync/metadata_collection.rs b/wp_mobile/src/sync/metadata_collection.rs new file mode 100644 index 000000000..c5c56c601 --- /dev/null +++ b/wp_mobile/src/sync/metadata_collection.rs @@ -0,0 +1,383 @@ +use std::sync::Arc; + +use wp_mobile_cache::{DbTable, UpdateHook, list_metadata::ListKey}; + +use crate::collection::FetchError; + +use super::{ + CollectionItem, EntityStateReader, ListInfo, ListMetadataReader, MetadataFetcher, SyncResult, +}; + +/// Collection that uses metadata-first fetching strategy. +/// +/// This collection type: +/// 1. Uses lightweight metadata (id + modified_gmt) to define list structure +/// 2. Shows cached entities immediately via `CollectionItem` states +/// 3. Tracks which entities are missing or stale for selective fetching +/// +/// # Type Parameter +/// - `F`: The fetcher implementation (e.g., `PostMetadataFetcher`) +/// +/// # Usage Flow +/// 1. Create collection with filter-specific fetcher +/// 2. Call `refresh()` to fetch metadata and sync missing entities +/// 3. Call `items()` to get current list with states +/// 4. Call `load_next_page()` for pagination +/// 5. Use `is_relevant_update()` to check if DB changes affect this collection +/// +/// # Example +/// ```ignore +/// let fetcher = PostMetadataFetcher::new(&service, filter, kv_key); +/// let mut collection = MetadataCollection::new( +/// kv_key, +/// service.metadata_reader(), +/// service.state_reader(), +/// fetcher, +/// vec![DbTable::PostsEditContext], +/// ); +/// +/// // Initial load +/// collection.refresh().await?; +/// +/// // Get items with states +/// let items = collection.items(); +/// for item in items { +/// match item.state { +/// EntityState::Cached => { /* show full entity */ } +/// EntityState::Fetching => { /* show loading */ } +/// EntityState::Failed { .. } => { /* show error */ } +/// _ => { /* show placeholder */ } +/// } +/// } +/// ``` +pub struct MetadataCollection +where + F: MetadataFetcher, +{ + /// Key for metadata store lookup + key: ListKey, + + /// Read-only access to list metadata + metadata_reader: Arc, + + /// Read-only access to entity states + state_reader: Arc, + + /// Fetcher for metadata and full entities + fetcher: F, + + /// Tables to monitor for data updates (entity tables like PostsEditContext) + relevant_data_tables: Vec, + + /// Items per page configuration (default: 20) + per_page: u32, +} + +impl MetadataCollection +where + F: MetadataFetcher, +{ + /// Create a new metadata collection. + /// + /// # Arguments + /// * `key` - Key for metadata store lookup (e.g., "site_1:posts:publish") + /// * `metadata_reader` - Read-only access to list metadata store + /// * `state_reader` - Read-only access to entity state store + /// * `fetcher` - Implementation for fetching metadata and entities + /// * `relevant_data_tables` - DB tables to monitor for data updates (entity tables) + pub fn new( + key: ListKey, + metadata_reader: Arc, + state_reader: Arc, + fetcher: F, + relevant_data_tables: Vec, + ) -> Self { + Self { + key, + metadata_reader, + state_reader, + fetcher, + relevant_data_tables, + per_page: 20, + } + } + + /// Set the number of items per page. + /// + /// Default is 20. Call this before `refresh()` if you need a different page size. + pub fn with_per_page(mut self, per_page: u32) -> Self { + self.per_page = per_page; + self + } + + /// Get current items with their states. + /// + /// Returns a `CollectionItem` for each entity in the list, combining + /// the metadata with the current fetch state. + pub fn items(&self) -> Vec { + self.metadata_reader + .get_items(&self.key) + .unwrap_or_default() + .into_iter() + .map(|metadata| { + CollectionItem::new(metadata.clone(), self.state_reader.get(metadata.id)) + }) + .collect() + } + + /// Get the combined list info (pagination + sync state) in a single query. + /// + /// Returns `None` if no metadata has been stored for this key. + pub fn list_info(&self) -> Option { + self.metadata_reader.get_list_info(&self.key) + } + + /// Get the current sync state for this collection. + /// + /// Returns the current `ListState`: + /// - `Idle` - No sync in progress + /// - `FetchingFirstPage` - Refresh in progress + /// - `FetchingNextPage` - Load more in progress + /// - `Error` - Last sync failed + /// + /// Use this to show loading indicators in the UI. + pub fn sync_state(&self) -> wp_mobile_cache::list_metadata::ListState { + self.list_info().map(|info| info.state).unwrap_or_default() + } + + /// Check if a database update is relevant to this collection (either data or list info). + /// + /// Returns `true` if the update affects either data or list info. + /// For more granular control, use `is_relevant_data_update` or `is_relevant_list_info_update`. + pub fn is_relevant_update(&self, hook: &UpdateHook) -> bool { + self.is_relevant_data_update(hook) || self.is_relevant_list_info_update(hook) + } + + /// Check if a database update affects this collection's data. + /// + /// Returns `true` if the update is to: + /// - An entity table this collection monitors (e.g., PostsEditContext, TermRelationships) + /// - The ListMetadataItems table (any row - we can't filter by key without deadlocking) + /// + /// Use this for data observers that should refresh list contents. + /// + /// Note: We intentionally don't query the database here to avoid deadlocks when + /// the hook fires during a transaction. This means we may get false positives for + /// ListMetadataItems updates from other collections, but that's safe (just extra refreshes). + pub fn is_relevant_data_update(&self, hook: &UpdateHook) -> bool { + // Check entity tables + if self.relevant_data_tables.contains(&hook.table) { + return true; + } + + // Check ListMetadataItems - return true for any update to avoid deadlock + // (we can't query the DB to check if it's our key during a hook callback) + if hook.table == DbTable::ListMetadataItems { + return true; + } + + false + } + + /// Check if a database update affects this collection's list info (pagination + state). + /// + /// Returns `true` if the update is to: + /// - `ListMetadata` table (pagination info changed) + /// - `ListMetadataState` table (sync state changed) + /// + /// Use this for listInfo observers that should update pagination display and loading indicators. + /// + /// Note: We intentionally don't query the database here to avoid deadlocks when + /// the hook fires during a transaction. This means we may get false positives for + /// updates from other collections, but that's safe (just extra reads). + pub fn is_relevant_list_info_update(&self, hook: &UpdateHook) -> bool { + // Just check the table - don't query DB to avoid deadlock + hook.table == DbTable::ListMetadata || hook.table == DbTable::ListMetadataState + } + + /// 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. + pub async fn refresh(&self) -> Result { + println!("[MetadataCollection] Refreshing collection..."); + + let result = self.fetcher.fetch_metadata(1, self.per_page, true).await?; + + let total_pages_str = result + .total_pages + .map(|p| p.to_string()) + .unwrap_or_else(|| "?".to_string()); + println!( + "[MetadataCollection] Fetched metadata: page 1 of {}, {} items", + total_pages_str, + result.metadata.len() + ); + + self.sync_missing_and_stale().await + } + + /// 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 `SyncResult::no_op()` if already on the last page or no pages loaded yet. + pub async fn load_next_page(&self) -> Result { + let current_page = self.current_page(); + let total_pages = self.total_pages(); + + // Check if no pages have been loaded yet (need refresh first) + if current_page == 0 { + println!("[MetadataCollection] No pages loaded yet, need refresh first"); + return Ok(SyncResult::no_op( + self.items().len(), + true, // has_more_pages = true, but need refresh first + 0, + None, + )); + } + + let next_page = current_page + 1; + + // Check if we're already at the last page + if total_pages.is_some_and(|total| next_page > total) { + println!("[MetadataCollection] Already at last page, nothing to load"); + return Ok(SyncResult::no_op( + self.items().len(), + false, + current_page, + total_pages, + )); + } + + println!("[MetadataCollection] Loading page {}...", next_page); + + let result = self + .fetcher + .fetch_metadata(next_page, self.per_page, false) + .await?; + + let total_pages_str = result + .total_pages + .map(|p| p.to_string()) + .unwrap_or_else(|| "?".to_string()); + println!( + "[MetadataCollection] Fetched metadata: page {} of {}, {} items", + next_page, + total_pages_str, + result.metadata.len() + ); + + self.sync_missing_and_stale().await + } + + /// Check if there are more pages to load. + pub fn has_more_pages(&self) -> bool { + self.list_info() + .and_then(|info| info.total_pages.map(|total| info.current_page < total)) + .unwrap_or(true) // Unknown total or no info = assume more pages + } + + /// Get the current page number (0 = not loaded yet). + pub fn current_page(&self) -> u32 { + self.list_info() + .map(|info| info.current_page as u32) + .unwrap_or(0) + } + + /// Get the total number of pages, if known. + pub fn total_pages(&self) -> Option { + self.list_info() + .and_then(|info| info.total_pages) + .map(|p| p as u32) + } + + /// Get the total number of items, if known. + pub fn total_items(&self) -> Option { + self.list_info().and_then(|info| info.total_items) + } + + /// Fetch missing and stale items. + async fn sync_missing_and_stale(&self) -> Result { + use super::EntityState; + + let items = self.items(); + let total_items = items.len(); + + // Count by state for logging + let missing_count = items + .iter() + .filter(|item| matches!(item.state, EntityState::Missing)) + .count(); + let stale_count = items + .iter() + .filter(|item| matches!(item.state, EntityState::Stale)) + .count(); + let cached_count = items + .iter() + .filter(|item| matches!(item.state, EntityState::Cached)) + .count(); + + // Collect IDs that need fetching + let ids_to_fetch: Vec = items + .iter() + .filter(|item| item.needs_fetch()) + .map(|item| item.id()) + .collect(); + + let fetch_count = ids_to_fetch.len(); + + println!( + "[MetadataCollection] Sync: {} items total ({} cached, {} missing, {} stale)", + total_items, cached_count, missing_count, stale_count + ); + + if !ids_to_fetch.is_empty() { + println!( + "[MetadataCollection] Fetching {} posts by ID...", + fetch_count + ); + + // Batch into chunks of 100 (WordPress API limit) + for chunk in ids_to_fetch.chunks(100) { + self.fetcher.ensure_fetched(chunk.to_vec()).await?; + } + } else { + println!("[MetadataCollection] All items already cached, nothing to fetch"); + } + + // Count failures after fetch attempts + let failed_count = self + .items() + .iter() + .filter(|item| item.state.is_failed()) + .count(); + + if fetch_count > 0 { + let success_count = fetch_count - failed_count; + println!( + "[MetadataCollection] Fetched {} posts ({} succeeded, {} failed)", + fetch_count, success_count, failed_count + ); + } + + Ok(SyncResult::new( + total_items, + fetch_count, + failed_count, + self.has_more_pages(), + self.current_page(), + self.total_pages(), + )) + } +} + +// Tests for MetadataCollection are covered by integration tests in wp_mobile_integration_tests +// and by the PostMetadataCollectionWithEditContext tests which use the real database. diff --git a/wp_mobile/src/sync/metadata_fetch_result.rs b/wp_mobile/src/sync/metadata_fetch_result.rs new file mode 100644 index 000000000..8572c0c8d --- /dev/null +++ b/wp_mobile/src/sync/metadata_fetch_result.rs @@ -0,0 +1,85 @@ +use super::EntityMetadata; + +/// Result of a metadata fetch operation. +/// +/// Contains lightweight metadata (id + modified_gmt) for entities, +/// plus pagination info from the API response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataFetchResult { + /// Metadata for entities in this page. + pub metadata: Vec, + + /// Total number of items matching the query (from API headers). + pub total_items: Option, + + /// Total number of pages available (from API headers). + pub total_pages: Option, + + /// The page number that was fetched. + pub current_page: u32, +} + +impl MetadataFetchResult { + pub fn new( + metadata: Vec, + total_items: Option, + total_pages: Option, + current_page: u32, + ) -> Self { + Self { + metadata, + total_items, + total_pages, + current_page, + } + } + + /// Returns `true` if there are more pages after this one. + pub fn has_more_pages(&self) -> bool { + self.total_pages + .map(|total| self.current_page < total) + .unwrap_or(false) + } + + /// Returns the number of items in this page. + pub fn page_count(&self) -> usize { + self.metadata.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_api::prelude::WpGmtDateTime; + + fn test_metadata(id: i64) -> EntityMetadata { + EntityMetadata::with_modified(id, WpGmtDateTime::from_timestamp(1000 + id)) + } + + #[test] + fn test_new() { + let result = MetadataFetchResult::new( + vec![test_metadata(1), test_metadata(2)], + Some(50), + Some(5), + 1, + ); + + assert_eq!(result.page_count(), 2); + assert_eq!(result.total_items, Some(50)); + assert_eq!(result.total_pages, Some(5)); + assert_eq!(result.current_page, 1); + } + + #[test] + fn test_has_more_pages() { + let page_1_of_3 = MetadataFetchResult::new(vec![], None, Some(3), 1); + assert!(page_1_of_3.has_more_pages()); + + let page_3_of_3 = MetadataFetchResult::new(vec![], None, Some(3), 3); + assert!(!page_3_of_3.has_more_pages()); + + let unknown_total = MetadataFetchResult::new(vec![], None, None, 1); + assert!(!unknown_total.has_more_pages()); + } +} diff --git a/wp_mobile/src/sync/metadata_fetcher.rs b/wp_mobile/src/sync/metadata_fetcher.rs new file mode 100644 index 000000000..615c9ba73 --- /dev/null +++ b/wp_mobile/src/sync/metadata_fetcher.rs @@ -0,0 +1,68 @@ +use crate::collection::FetchError; + +use super::MetadataFetchResult; + +/// Trait for fetching entity metadata and full entities. +/// +/// Implementations of this trait handle: +/// 1. Fetching lightweight metadata (id + modified_gmt) for list structure +/// 2. Fetching full entities by ID and storing them in the cache +/// +/// The service layer provides concrete implementations that know how to +/// fetch specific entity types (posts, media, etc.) and update the +/// appropriate stores. +/// +/// # Example Implementation +/// +/// ```ignore +/// struct PostMetadataFetcher<'a> { +/// service: &'a PostServiceWithEditContext, +/// filter: AnyPostFilter, +/// kv_key: String, +/// } +/// +/// impl MetadataFetcher for PostMetadataFetcher<'_> { +/// async fn fetch_metadata(&self, page: u32, per_page: u32, is_first_page: bool) +/// -> Result +/// { +/// self.service.fetch_and_store_metadata( +/// &self.kv_key, &self.filter, page, per_page, is_first_page +/// ).await +/// } +/// +/// async fn ensure_fetched(&self, ids: Vec) -> Result<(), FetchError> { +/// let post_ids = ids.into_iter().map(PostId).collect(); +/// self.service.fetch_posts_by_ids(post_ids).await +/// } +/// } +/// ``` +pub trait MetadataFetcher: Send + Sync { + /// Fetch metadata for a page and store in the metadata store. + /// + /// # Arguments + /// * `page` - Page number (1-indexed) + /// * `per_page` - Number of items per page + /// * `is_first_page` - If true, replaces existing metadata; if false, appends + /// + /// # Returns + /// Metadata for the fetched page, including pagination info. + fn fetch_metadata( + &self, + page: u32, + per_page: u32, + is_first_page: bool, + ) -> impl std::future::Future> + Send; + + /// Ensure entities are fetched and cached. + /// + /// This fetches full entity data for the given IDs and stores them + /// in the database cache. It also updates the entity state store + /// (Fetching → Cached/Failed). + /// + /// # Arguments + /// * `ids` - Entity IDs to fetch (as raw i64 values) + fn ensure_fetched( + &self, + ids: Vec, + ) -> impl std::future::Future> + Send; +} diff --git a/wp_mobile/src/sync/mod.rs b/wp_mobile/src/sync/mod.rs new file mode 100644 index 000000000..bea6a2645 --- /dev/null +++ b/wp_mobile/src/sync/mod.rs @@ -0,0 +1,53 @@ +//! Metadata-based sync infrastructure for efficient list fetching. +//! +//! This module provides types for a "smart sync" strategy: +//! 1. Fetch lightweight metadata (id + modified_gmt) to define list structure +//! 2. Show cached entities immediately, with loading placeholders for missing items +//! 3. Selectively fetch only entities that are missing or stale +//! +//! ## Key Types +//! +//! - [`EntityMetadata`] - Lightweight metadata (id + optional modified_gmt) +//! - [`EntityState`] - Fetch state (Missing, Fetching, Cached, Stale, Failed) +//! - [`CollectionItem`] - Combines metadata with state +//! - [`MetadataFetchResult`] - Result of metadata-only fetch +//! - [`SyncResult`] - Result of sync operation +//! +//! ## Store Types +//! +//! - [`EntityStateStore`] - Tracks fetch state per entity (read-write) +//! - [`EntityStateReader`] - Read-only access to entity states (trait) +//! - [`ListMetadataReader`] - Read-only access to list metadata (trait) +//! +//! ## Collection Types +//! +//! - [`MetadataFetcher`] - Trait for fetching metadata and entities +//! - [`MetadataCollection`] - Collection using metadata-first strategy +//! +//! ## Fetcher Implementations +//! +//! - [`PersistentPostMetadataFetcherWithEditContext`] - Fetcher for posts with edit context (database-backed) +//! +//! See `wp_mobile/docs/design/metadata_collection_v3.md` for full design details. + +mod collection_item; +mod entity_metadata; +mod entity_state; +mod entity_state_store; +mod list_metadata_reader; +mod metadata_collection; +mod metadata_fetch_result; +mod metadata_fetcher; +mod post_metadata_fetcher; +mod sync_result; + +pub use collection_item::CollectionItem; +pub use entity_metadata::EntityMetadata; +pub use entity_state::EntityState; +pub use entity_state_store::{EntityStateReader, EntityStateStore}; +pub use list_metadata_reader::{ListInfo, ListMetadataReader}; +pub use metadata_collection::MetadataCollection; +pub use metadata_fetch_result::MetadataFetchResult; +pub use metadata_fetcher::MetadataFetcher; +pub use post_metadata_fetcher::PersistentPostMetadataFetcherWithEditContext; +pub use sync_result::SyncResult; diff --git a/wp_mobile/src/sync/post_metadata_fetcher.rs b/wp_mobile/src/sync/post_metadata_fetcher.rs new file mode 100644 index 000000000..51de9ff2b --- /dev/null +++ b/wp_mobile/src/sync/post_metadata_fetcher.rs @@ -0,0 +1,110 @@ +//! Post-specific implementation of `MetadataFetcher`. + +use std::sync::Arc; + +use wp_api::{posts::PostId, request::endpoint::posts_endpoint::PostEndpointType}; +use wp_mobile_cache::list_metadata::ListKey; + +use crate::{ + collection::FetchError, + filters::PostListFilter, + service::posts::PostService, + sync::{MetadataFetchResult, MetadataFetcher}, +}; + +/// Database-backed `MetadataFetcher` implementation for posts with edit context. +/// +/// Stores metadata to the persistent database via `MetadataService`, allowing +/// list metadata to survive app restarts. +/// +/// # Usage +/// +/// ```ignore +/// let fetcher = PersistentPostMetadataFetcherWithEditContext::new( +/// service.clone(), +/// PostEndpointType::Posts, +/// filter, +/// ListKey::from("site_1:edit:posts:status=publish"), +/// ); +/// +/// let mut collection = MetadataCollection::new( +/// ListKey::from("site_1:edit:posts:status=publish"), +/// service.persistent_metadata_reader(), // DB-backed reader +/// service.state_reader_with_edit_context(), +/// fetcher, +/// vec![DbTable::PostsEditContext, DbTable::ListMetadataItems], +/// ); +/// ``` +pub struct PersistentPostMetadataFetcherWithEditContext { + /// Reference to the post service + service: Arc, + + /// The post endpoint type (Posts, Pages, or Custom) + endpoint_type: PostEndpointType, + + /// Filter parameters for the post list (excludes pagination) + filter: PostListFilter, + + /// Key for metadata store lookup + key: ListKey, +} + +impl PersistentPostMetadataFetcherWithEditContext { + /// Create a new persistent post metadata fetcher. + /// + /// # Arguments + /// * `service` - The post service to delegate to + /// * `endpoint_type` - The post endpoint type (Posts, Pages, or Custom) + /// * `filter` - Filter parameters for the post list (pagination is managed internally) + /// * `key` - Key for the metadata store (e.g., "site_1:posts:status=publish") + pub fn new( + service: Arc, + endpoint_type: PostEndpointType, + filter: PostListFilter, + key: ListKey, + ) -> Self { + Self { + service, + endpoint_type, + filter, + key, + } + } +} + +impl MetadataFetcher for PersistentPostMetadataFetcherWithEditContext { + async fn fetch_metadata( + &self, + page: u32, + per_page: u32, + is_first_page: bool, + ) -> Result { + self.service + .fetch_and_store_metadata_persistent( + &self.key, + &self.endpoint_type, + &self.filter, + page, + per_page, + is_first_page, + ) + .await + } + + async fn ensure_fetched(&self, ids: Vec) -> Result<(), FetchError> { + let post_ids: Vec = ids.into_iter().map(PostId).collect(); + self.service + .fetch_posts_by_ids(&self.endpoint_type, post_ids) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + // Integration tests for PostMetadataFetcherWithEditContext would require + // a mock API client and database setup. These are better suited for + // the integration test suite. + // + // Unit tests here would just verify construction, which is trivial. +} diff --git a/wp_mobile/src/sync/sync_result.rs b/wp_mobile/src/sync/sync_result.rs new file mode 100644 index 000000000..d196d6909 --- /dev/null +++ b/wp_mobile/src/sync/sync_result.rs @@ -0,0 +1,109 @@ +/// Result of a sync operation (refresh or load_next_page). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SyncResult { + /// Number of items in the list after sync. + pub total_items: u64, + + /// Number of items that were fetched (missing + stale). + pub fetched_count: u64, + + /// Number of items that failed to fetch. + pub failed_count: u64, + + /// Whether there are more pages available. + pub has_more_pages: bool, + + /// Current page number after sync. + pub current_page: u32, + + /// Total number of pages, if known. + #[uniffi(default = None)] + pub total_pages: Option, +} + +impl SyncResult { + pub fn new( + total_items: usize, + fetched_count: usize, + failed_count: usize, + has_more_pages: bool, + current_page: u32, + total_pages: Option, + ) -> Self { + Self { + total_items: total_items as u64, + fetched_count: fetched_count as u64, + failed_count: failed_count as u64, + has_more_pages, + current_page, + total_pages, + } + } + + /// Create a result indicating no sync was needed. + pub fn no_op( + total_items: usize, + has_more_pages: bool, + current_page: u32, + total_pages: Option, + ) -> Self { + Self { + total_items: total_items as u64, + fetched_count: 0, + failed_count: 0, + has_more_pages, + current_page, + total_pages, + } + } + + /// Returns `true` if all requested fetches succeeded. + pub fn all_succeeded(&self) -> bool { + self.failed_count == 0 + } + + /// Returns `true` if some fetches failed. + pub fn has_failures(&self) -> bool { + self.failed_count > 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let result = SyncResult::new(10, 3, 1, true, 2, Some(5)); + + assert_eq!(result.total_items, 10); + assert_eq!(result.fetched_count, 3); + assert_eq!(result.failed_count, 1); + assert!(result.has_more_pages); + assert_eq!(result.current_page, 2); + assert_eq!(result.total_pages, Some(5)); + } + + #[test] + fn test_no_op() { + let result = SyncResult::no_op(5, false, 3, Some(3)); + + assert_eq!(result.total_items, 5); + assert_eq!(result.fetched_count, 0); + assert_eq!(result.failed_count, 0); + assert!(!result.has_more_pages); + assert_eq!(result.current_page, 3); + assert_eq!(result.total_pages, Some(3)); + } + + #[test] + fn test_success_helpers() { + let success = SyncResult::new(10, 5, 0, true, 1, Some(2)); + assert!(success.all_succeeded()); + assert!(!success.has_failures()); + + let partial = SyncResult::new(10, 5, 2, true, 1, Some(2)); + assert!(!partial.all_succeeded()); + assert!(partial.has_failures()); + } +} diff --git a/wp_mobile_cache/src/repository/list_metadata.rs b/wp_mobile_cache/src/repository/list_metadata.rs index 3dae69304..aa5eb8a7c 100644 --- a/wp_mobile_cache/src/repository/list_metadata.rs +++ b/wp_mobile_cache/src/repository/list_metadata.rs @@ -543,6 +543,130 @@ impl ListMetadataRepository { ], ) } + + // ============================================================ + // Concurrency Helpers + // ============================================================ + + /// Begin a refresh operation (fetch first page). + /// + /// Atomically: + /// 1. Creates header if needed and increments version + /// 2. Updates state to FetchingFirstPage + /// 3. Returns info needed for the fetch + /// + /// Call this before starting an API fetch for page 1. + pub fn begin_refresh( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + ) -> Result { + log::debug!("ListMetadataRepository::begin_refresh: key={}", key); + + // Get or create header and increment version in a single query + let header_info = Self::get_or_create_and_increment_version(executor, site, key, per_page)?; + + // Update state to fetching + Self::update_state_by_list_metadata_id( + executor, + header_info.list_metadata_id, + ListState::FetchingFirstPage, + None, + )?; + + Ok(RefreshInfo { + list_metadata_id: header_info.list_metadata_id, + version: header_info.version, + per_page: header_info.per_page, + }) + } + + /// Begin a load-next-page operation. + /// + /// Atomically: + /// 1. Gets current pagination state + /// 2. Checks if there are more pages to load + /// 3. Updates state to FetchingNextPage + /// 4. Returns info needed for the fetch (including version for later check) + /// + /// Returns None if already at the last page or no pages loaded yet. + /// Call this before starting an API fetch for page N+1. + pub fn begin_fetch_next_page( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result, SqliteDbError> { + log::debug!("ListMetadataRepository::begin_fetch_next_page: key={}", key); + + let header = match Self::get_header(executor, site, key)? { + Some(h) => h, + None => return Ok(None), // List doesn't exist + }; + + // Check if we have pages loaded and more to fetch + if header.current_page == 0 { + return Ok(None); // No pages loaded yet, need refresh first + } + + if let Some(total_pages) = header.total_pages + && header.current_page >= total_pages + { + return Ok(None); // Already at last page + } + + let next_page = header.current_page + 1; + + // Update state to fetching + Self::update_state_by_list_metadata_id( + executor, + header.row_id, + ListState::FetchingNextPage, + None, + )?; + + Ok(Some(FetchNextPageInfo { + list_metadata_id: header.row_id, + page: next_page, + version: header.version, + per_page: header.per_page, + })) + } + + /// Complete a sync operation successfully. + /// + /// Updates state to Idle and clears any error message. + pub fn complete_sync( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + ) -> Result<(), SqliteDbError> { + log::debug!( + "ListMetadataRepository::complete_sync: list_metadata_id={}", + list_metadata_id.0 + ); + Self::update_state_by_list_metadata_id(executor, list_metadata_id, ListState::Idle, None) + } + + /// Complete a sync operation with an error. + /// + /// Updates state to Error with the provided message. + pub fn complete_sync_with_error( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + error_message: &str, + ) -> Result<(), SqliteDbError> { + log::debug!( + "ListMetadataRepository::complete_sync_with_error: list_metadata_id={}, error={}", + list_metadata_id.0, + error_message + ); + Self::update_state_by_list_metadata_id( + executor, + list_metadata_id, + ListState::Error, + Some(error_message), + ) + } } /// Header info returned from `get_or_create_and_increment_version`. @@ -556,6 +680,30 @@ pub struct HeaderVersionInfo { pub per_page: i64, } +/// Information returned when starting a refresh operation. +#[derive(Debug, Clone)] +pub struct RefreshInfo { + /// Row ID of the list_metadata record + pub list_metadata_id: RowId, + /// New version number (for concurrency checking) + pub version: i64, + /// Items per page setting + pub per_page: i64, +} + +/// Information returned when starting a load-next-page operation. +#[derive(Debug, Clone)] +pub struct FetchNextPageInfo { + /// Row ID of the list_metadata record + pub list_metadata_id: RowId, + /// Page number to fetch + pub page: i64, + /// Version at start (check before storing results) + pub version: i64, + /// Items per page setting + pub per_page: i64, +} + /// Input for creating a list metadata item. #[derive(Debug, Clone)] pub struct ListMetadataItemInput { diff --git a/wp_mobile_cache/src/repository/posts.rs b/wp_mobile_cache/src/repository/posts.rs index 910162403..4d6a76a9f 100644 --- a/wp_mobile_cache/src/repository/posts.rs +++ b/wp_mobile_cache/src/repository/posts.rs @@ -20,7 +20,7 @@ use crate::{ term_relationships::DbTermRelationship, }; use rusqlite::{OptionalExtension, Row}; -use std::{marker::PhantomData, sync::Arc}; +use std::{collections::HashMap, marker::PhantomData, sync::Arc}; use wp_api::{ posts::{ AnyPostWithEditContext, AnyPostWithEmbedContext, AnyPostWithViewContext, @@ -28,6 +28,7 @@ use wp_api::{ PostGuidWithViewContext, PostId, PostTitleWithEditContext, PostTitleWithEmbedContext, PostTitleWithViewContext, SparsePostExcerpt, }, + prelude::WpGmtDateTime, taxonomies::TaxonomyType, terms::TermId, }; @@ -307,6 +308,61 @@ impl PostRepository { })) } + /// Select `modified_gmt` timestamps for multiple posts by their WordPress post IDs. + /// + /// This is a lightweight query used for staleness detection - it only fetches + /// the `id` and `modified_gmt` columns without loading the full post data. + /// + /// Returns a HashMap mapping post IDs to their cached `modified_gmt` timestamps. + /// Posts not found in the cache are simply omitted from the result. + /// + /// # Arguments + /// * `executor` - Database connection or transaction + /// * `site` - The site to query posts for + /// * `post_ids` - WordPress post IDs to look up + /// + /// # Returns + /// HashMap where keys are post IDs and values are their `modified_gmt` timestamps. + pub fn select_modified_gmt_by_ids( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + post_ids: &[i64], + ) -> Result, SqliteDbError> { + if post_ids.is_empty() { + return Ok(HashMap::new()); + } + + let ids_str = post_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + + let sql = format!( + "SELECT id, modified_gmt FROM {} WHERE db_site_id = ? AND id IN ({})", + Self::table_name(), + ids_str + ); + + let mut stmt = executor.prepare(&sql)?; + let rows = stmt.query_map([site.row_id], |row| { + let id: i64 = row.get(0)?; + let modified_gmt_str: String = row.get(1)?; + Ok((id, modified_gmt_str)) + })?; + + let mut result = HashMap::new(); + for row_result in rows { + let (id, modified_gmt_str) = row_result.map_err(SqliteDbError::from)?; + if let Ok(modified_gmt) = modified_gmt_str.parse::() { + result.insert(id, modified_gmt); + } + } + + Ok(result) + } + /// Delete a post by its EntityId for a given site. /// /// Returns the number of rows deleted (0 or 1).