diff --git a/Cargo.lock b/Cargo.lock index 6750f3ee4..e0794b38e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5306,6 +5306,7 @@ version = "0.1.0" dependencies = [ "chrono", "integration_test_credentials", + "log", "rstest", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4fc9bf349..73756858c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ hyper-util = "0.1.19" indoc = "2.0" itertools = "0.14.0" linkify = "0.10.0" +log = "0.4" parse_link_header = "0.4" paste = "1.0" proc-macro-crate = "3.4.0" diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt index 8bd3a07ea..7e5f98b4a 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -10,6 +10,6 @@ class WordPressApiCacheTest { @Test fun testThatMigrationsWork() = runTest { - assertEquals(6, WordPressApiCache().performMigrations()) + assertEquals(7, WordPressApiCache().performMigrations()) } } diff --git a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift index 8ccf97a39..853c45ade 100644 --- a/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift +++ b/native/swift/Tests/wordpress-api-cache/WordPressApiCacheTests.swift @@ -16,7 +16,7 @@ actor Test { @Test func testMigrationsWork() async throws { let migrationsPerformed = try await self.cache.performMigrations() - #expect(migrationsPerformed == 6) + #expect(migrationsPerformed == 7) } #if !os(Linux) diff --git a/wp_mobile_cache/Cargo.toml b/wp_mobile_cache/Cargo.toml index 13736a7cb..45b9009ee 100644 --- a/wp_mobile_cache/Cargo.toml +++ b/wp_mobile_cache/Cargo.toml @@ -8,6 +8,7 @@ default = ["rusqlite/bundled"] test-helpers = ["dep:chrono", "dep:integration_test_credentials", "dep:rstest"] [dependencies] +log = { workspace = true } rusqlite = { workspace = true, features = ["hooks"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/wp_mobile_cache/migrations/0007-create-list-metadata-tables.sql b/wp_mobile_cache/migrations/0007-create-list-metadata-tables.sql new file mode 100644 index 000000000..71b70fa14 --- /dev/null +++ b/wp_mobile_cache/migrations/0007-create-list-metadata-tables.sql @@ -0,0 +1,45 @@ +-- Table 1: List header/pagination info +CREATE TABLE `list_metadata` ( + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + `db_site_id` INTEGER NOT NULL, + `key` TEXT NOT NULL, -- e.g., "edit:posts:publish" + `total_pages` INTEGER, + `total_items` INTEGER, + `current_page` INTEGER NOT NULL DEFAULT 0, + `per_page` INTEGER NOT NULL DEFAULT 20, + `last_first_page_fetched_at` TEXT, + `last_fetched_at` TEXT, + `version` INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (db_site_id) REFERENCES db_sites(id) ON DELETE CASCADE +) STRICT; + +CREATE UNIQUE INDEX idx_list_metadata_unique_key ON list_metadata(db_site_id, key); + +-- Table 2: List items (rowid = insertion order = display order) +CREATE TABLE `list_metadata_items` ( + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + `list_metadata_id` INTEGER NOT NULL, + `entity_id` INTEGER NOT NULL, -- post/comment/etc ID + `modified_gmt` TEXT, -- nullable for entities without it + `parent` INTEGER, -- parent post ID (for hierarchical post types like pages) + `menu_order` INTEGER, -- menu order (for hierarchical post types) + + FOREIGN KEY (list_metadata_id) REFERENCES list_metadata(rowid) ON DELETE CASCADE +) STRICT; + +CREATE INDEX idx_list_metadata_items_list ON list_metadata_items(list_metadata_id); +CREATE INDEX idx_list_metadata_items_entity ON list_metadata_items(list_metadata_id, entity_id); + +-- Table 3: Sync state (FK to list_metadata, not duplicating key) +CREATE TABLE `list_metadata_state` ( + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + `list_metadata_id` INTEGER NOT NULL, + `state` INTEGER NOT NULL DEFAULT 0, -- 0=idle, 1=fetching_first_page, 2=fetching_next_page, 3=error + `error_message` TEXT, + `updated_at` TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + + FOREIGN KEY (list_metadata_id) REFERENCES list_metadata(rowid) ON DELETE CASCADE +) STRICT; + +CREATE UNIQUE INDEX idx_list_metadata_state_unique ON list_metadata_state(list_metadata_id); diff --git a/wp_mobile_cache/src/db_types.rs b/wp_mobile_cache/src/db_types.rs index 0a3874799..eabc434ff 100644 --- a/wp_mobile_cache/src/db_types.rs +++ b/wp_mobile_cache/src/db_types.rs @@ -1,3 +1,4 @@ +pub mod db_list_metadata; pub mod db_site; pub mod db_term_relationship; pub mod helpers; diff --git a/wp_mobile_cache/src/db_types/db_list_metadata.rs b/wp_mobile_cache/src/db_types/db_list_metadata.rs new file mode 100644 index 000000000..71915d9fb --- /dev/null +++ b/wp_mobile_cache/src/db_types/db_list_metadata.rs @@ -0,0 +1,161 @@ +use crate::{ + SqliteDbError, + db_types::row_ext::{ColumnIndex, RowExt}, + list_metadata::{ + DbListHeaderWithState, DbListMetadata, DbListMetadataItem, DbListMetadataState, ListState, + }, +}; +use rusqlite::Row; + +/// Column indexes for list_metadata table. +/// These must match the order of columns in the CREATE TABLE statement. +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +pub enum ListMetadataColumn { + Rowid = 0, + DbSiteId = 1, + Key = 2, + TotalPages = 3, + TotalItems = 4, + CurrentPage = 5, + PerPage = 6, + LastFirstPageFetchedAt = 7, + LastFetchedAt = 8, + Version = 9, +} + +impl ColumnIndex for ListMetadataColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +impl DbListMetadata { + /// Construct a list metadata entity from a database row. + pub fn from_row(row: &Row) -> Result { + use ListMetadataColumn as Col; + + Ok(Self { + row_id: row.get_column(Col::Rowid)?, + db_site_id: row.get_column(Col::DbSiteId)?, + key: row.get_column(Col::Key)?, + total_pages: row.get_column(Col::TotalPages)?, + total_items: row.get_column(Col::TotalItems)?, + current_page: row.get_column(Col::CurrentPage)?, + per_page: row.get_column(Col::PerPage)?, + last_first_page_fetched_at: row.get_column(Col::LastFirstPageFetchedAt)?, + last_fetched_at: row.get_column(Col::LastFetchedAt)?, + version: row.get_column(Col::Version)?, + }) + } +} + +/// Column indexes for list_metadata_items table. +/// These must match the order of columns in the CREATE TABLE statement. +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +pub enum ListMetadataItemColumn { + Rowid = 0, + ListMetadataId = 1, + EntityId = 2, + ModifiedGmt = 3, + Parent = 4, + MenuOrder = 5, +} + +impl ColumnIndex for ListMetadataItemColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +impl DbListMetadataItem { + /// Construct a list metadata item from a database row. + pub fn from_row(row: &Row) -> Result { + use ListMetadataItemColumn as Col; + + Ok(Self { + row_id: row.get_column(Col::Rowid)?, + list_metadata_id: row.get_column(Col::ListMetadataId)?, + entity_id: row.get_column(Col::EntityId)?, + modified_gmt: row.get_column(Col::ModifiedGmt)?, + parent: row.get_column(Col::Parent)?, + menu_order: row.get_column(Col::MenuOrder)?, + }) + } +} + +/// Column indexes for list_metadata_state table. +/// These must match the order of columns in the CREATE TABLE statement. +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +pub enum ListMetadataStateColumn { + Rowid = 0, + ListMetadataId = 1, + State = 2, + ErrorMessage = 3, + UpdatedAt = 4, +} + +impl ColumnIndex for ListMetadataStateColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +impl DbListMetadataState { + /// Construct a list metadata state from a database row. + pub fn from_row(row: &Row) -> Result { + use ListMetadataStateColumn as Col; + + Ok(Self { + row_id: row.get_column(Col::Rowid)?, + list_metadata_id: row.get_column(Col::ListMetadataId)?, + state: row.get_column(Col::State)?, + error_message: row.get_column(Col::ErrorMessage)?, + updated_at: row.get_column(Col::UpdatedAt)?, + }) + } +} + +/// Column indexes for the header + state JOIN query. +/// +/// Query: SELECT m.total_pages, m.total_items, m.current_page, m.per_page, s.state, s.error_message +/// FROM list_metadata m LEFT JOIN list_metadata_state s ON ... +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +pub enum ListHeaderWithStateColumn { + TotalPages = 0, + TotalItems = 1, + CurrentPage = 2, + PerPage = 3, + State = 4, + ErrorMessage = 5, +} + +impl ColumnIndex for ListHeaderWithStateColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +impl DbListHeaderWithState { + /// Construct from a JOIN query row. + /// + /// Expects columns in order: total_pages, total_items, current_page, per_page, state, error_message + pub fn from_row(row: &Row) -> Result { + use ListHeaderWithStateColumn as Col; + + // state is nullable due to LEFT JOIN - default to Idle + let state: Option = row.get_column(Col::State)?; + + Ok(Self { + state: state.unwrap_or(ListState::Idle), + error_message: row.get_column(Col::ErrorMessage)?, + current_page: row.get_column(Col::CurrentPage)?, + total_pages: row.get_column(Col::TotalPages)?, + total_items: row.get_column(Col::TotalItems)?, + per_page: row.get_column(Col::PerPage)?, + }) + } +} diff --git a/wp_mobile_cache/src/lib.rs b/wp_mobile_cache/src/lib.rs index cff397785..0e2ff5643 100644 --- a/wp_mobile_cache/src/lib.rs +++ b/wp_mobile_cache/src/lib.rs @@ -3,9 +3,12 @@ use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput}; use rusqlite::{Connection, Result as SqliteResult, params}; use std::sync::Mutex; +use crate::repository::list_metadata::ListMetadataRepository; + pub mod context; pub mod db_types; pub mod entity; +pub mod list_metadata; pub mod repository; pub mod term_relationships; @@ -15,13 +18,18 @@ pub mod test_fixtures; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] pub enum SqliteDbError { SqliteError(String), + ConstraintViolation(String), TableNameMismatch { expected: DbTable, actual: DbTable }, + PerPageMismatch { expected: i64, actual: i64 }, } impl std::fmt::Display for SqliteDbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SqliteDbError::SqliteError(message) => write!(f, "SqliteDbError: message={}", message), + SqliteDbError::ConstraintViolation(message) => { + write!(f, "Constraint violation: {}", message) + } SqliteDbError::TableNameMismatch { expected, actual } => { write!( f, @@ -30,12 +38,24 @@ impl std::fmt::Display for SqliteDbError { actual.table_name() ) } + SqliteDbError::PerPageMismatch { expected, actual } => { + write!( + f, + "per_page mismatch: expected {}, but list has {}", + expected, actual + ) + } } } } impl From for SqliteDbError { fn from(err: rusqlite::Error) -> Self { + if let rusqlite::Error::SqliteFailure(sqlite_err, _) = &err + && sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation + { + return SqliteDbError::ConstraintViolation(err.to_string()); + } SqliteDbError::SqliteError(err.to_string()) } } @@ -64,6 +84,12 @@ pub enum DbTable { DbSites, /// Term relationships (post-category, post-tag associations) TermRelationships, + /// List metadata headers (pagination, version) + ListMetadata, + /// List metadata items (entity IDs with ordering) + ListMetadataItems, + /// List metadata sync state (idle, fetching, error) + ListMetadataState, } impl DbTable { @@ -79,6 +105,9 @@ impl DbTable { DbTable::SelfHostedSites => "self_hosted_sites", DbTable::DbSites => "db_sites", DbTable::TermRelationships => "term_relationships", + DbTable::ListMetadata => "list_metadata", + DbTable::ListMetadataItems => "list_metadata_items", + DbTable::ListMetadataState => "list_metadata_state", } } } @@ -107,6 +136,9 @@ impl TryFrom<&str> for DbTable { "self_hosted_sites" => Ok(DbTable::SelfHostedSites), "db_sites" => Ok(DbTable::DbSites), "term_relationships" => Ok(DbTable::TermRelationships), + "list_metadata" => Ok(DbTable::ListMetadata), + "list_metadata_items" => Ok(DbTable::ListMetadataItems), + "list_metadata_state" => Ok(DbTable::ListMetadataState), _ => Err(DbTableError::UnknownTable(table_name.to_string())), } } @@ -249,7 +281,17 @@ impl WpApiCache { pub fn perform_migrations(&self) -> Result { self.execute(|connection| { let mut mgr = MigrationManager::new(connection)?; - mgr.perform_migrations().map_err(SqliteDbError::from) + let version = mgr.perform_migrations().map_err(SqliteDbError::from)?; + + // Reset stale fetching states after migrations complete. + // Errors are logged but not propagated: this is a best-effort cleanup, + // and failure doesn't affect core functionality (worst case: UI shows + // stale loading state). + if let Err(e) = ListMetadataRepository::reset_stale_fetching_states(connection) { + log::warn!("Failed to reset stale fetching states: {}", e); + } + + Ok(version) }) } @@ -271,7 +313,7 @@ impl WpApiCache { // Ignore SQLite system tables (sqlite_sequence, sqlite_master, etc.) // and migration tracking table (_migrations) if !table_name.starts_with("sqlite_") && table_name != "_migrations" { - eprintln!("Warning: Unknown table in update hook: {}", table_name); + log::warn!("Unknown table in update hook: {}", table_name); } } } @@ -350,13 +392,14 @@ impl From for WpApiCache { } } -static MIGRATION_QUERIES: [&str; 6] = [ +static MIGRATION_QUERIES: [&str; 7] = [ include_str!("../migrations/0001-create-sites-table.sql"), include_str!("../migrations/0002-create-posts-table.sql"), include_str!("../migrations/0003-create-term-relationships.sql"), include_str!("../migrations/0004-create-posts-view-context-table.sql"), include_str!("../migrations/0005-create-posts-embed-context-table.sql"), include_str!("../migrations/0006-create-self-hosted-sites-table.sql"), + include_str!("../migrations/0007-create-list-metadata-tables.sql"), ]; pub struct MigrationManager<'a> { diff --git a/wp_mobile_cache/src/list_metadata.rs b/wp_mobile_cache/src/list_metadata.rs new file mode 100644 index 000000000..9c05ed0a1 --- /dev/null +++ b/wp_mobile_cache/src/list_metadata.rs @@ -0,0 +1,176 @@ +use crate::RowId; +use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput}; +use std::fmt; + +/// Type-safe wrapper for list keys. +/// +/// List keys identify specific lists, e.g., `"edit:posts:publish"` or `"view:comments"`. +/// Using a newtype prevents accidental misuse of arbitrary strings as keys. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ListKey(String); + +impl ListKey { + /// Create a new ListKey from a string. + pub fn new(key: impl Into) -> Self { + Self(key.into()) + } + + /// Get the key as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for ListKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ListKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&str> for ListKey { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for ListKey { + fn from(s: String) -> Self { + Self(s) + } +} + +impl ToSql for ListKey { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(self.0.as_str())) + } +} + +impl FromSql for ListKey { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> FromSqlResult { + String::column_result(value).map(ListKey) + } +} + +/// Represents list metadata header in the database. +/// +/// Stores pagination info and version for a specific list (e.g., "edit:posts:publish"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbListMetadata { + /// SQLite rowid of this list metadata + pub row_id: RowId, + /// Database site ID (rowid from sites table) + pub db_site_id: RowId, + /// List key (e.g., "edit:posts:publish") + pub key: String, + /// Total number of pages from API response + pub total_pages: Option, + /// Total number of items from API response + pub total_items: Option, + /// Current page that has been loaded (0 = no pages loaded) + pub current_page: i64, + /// Items per page + pub per_page: i64, + /// ISO 8601 timestamp of when page 1 was last fetched + pub last_first_page_fetched_at: Option, + /// ISO 8601 timestamp of when any page was last fetched + pub last_fetched_at: Option, + /// Version number, incremented on page 1 refresh for concurrency control + pub version: i64, +} + +/// Represents a single item in a list metadata collection. +/// +/// Items are ordered by rowid (insertion order = display order). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbListMetadataItem { + /// SQLite rowid (determines display order) + pub row_id: RowId, + /// Foreign key to list_metadata table + pub list_metadata_id: RowId, + /// Entity ID (post ID, comment ID, etc.) + pub entity_id: i64, + /// Last modified timestamp (for staleness detection) + 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, +} + +/// Represents sync state for a list metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbListMetadataState { + /// SQLite rowid + pub row_id: RowId, + /// Foreign key to list_metadata.rowid + pub list_metadata_id: RowId, + /// Current sync state + pub state: ListState, + /// Error message if state is error + pub error_message: Option, + /// ISO 8601 timestamp of last state change + pub updated_at: String, +} + +/// Sync state for a list. +/// +/// Stored as INTEGER in the database. The repr(i32) ensures stable values +/// even if the enum definition order changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, uniffi::Enum)] +#[repr(i32)] +pub enum ListState { + /// No sync in progress + #[default] + Idle = 0, + /// Fetching first page (pull-to-refresh) + FetchingFirstPage = 1, + /// Fetching subsequent page (load more) + FetchingNextPage = 2, + /// Last sync failed + Error = 3, +} + +impl ToSql for ListState { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(*self as i32)) + } +} + +impl FromSql for ListState { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> FromSqlResult { + i32::column_result(value).and_then(|i| match i { + 0 => Ok(ListState::Idle), + 1 => Ok(ListState::FetchingFirstPage), + 2 => Ok(ListState::FetchingNextPage), + 3 => Ok(ListState::Error), + _ => Err(rusqlite::types::FromSqlError::Other( + format!("Invalid ListState value: {}", i).into(), + )), + }) + } +} + +/// Combined header + state from a JOIN query. +/// +/// Contains pagination info from `list_metadata` and sync state from `list_metadata_state`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbListHeaderWithState { + /// Current sync state (defaults to Idle if no state record exists) + 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, +} diff --git a/wp_mobile_cache/src/repository/list_metadata.rs b/wp_mobile_cache/src/repository/list_metadata.rs new file mode 100644 index 000000000..3dae69304 --- /dev/null +++ b/wp_mobile_cache/src/repository/list_metadata.rs @@ -0,0 +1,1249 @@ +use crate::{ + DbTable, RowId, SqliteDbError, + db_types::db_site::DbSite, + list_metadata::{ + DbListHeaderWithState, DbListMetadata, DbListMetadataItem, DbListMetadataState, ListKey, + ListState, + }, + repository::QueryExecutor, +}; + +/// Repository for managing list metadata in the database. +/// +/// Provides associated functions for querying and managing list pagination, +/// items, and sync state. All functions are stateless. +pub struct ListMetadataRepository; + +impl ListMetadataRepository { + /// Get the database table for list metadata headers + pub const fn header_table() -> DbTable { + DbTable::ListMetadata + } + + /// Get the database table for list metadata items + pub const fn items_table() -> DbTable { + DbTable::ListMetadataItems + } + + /// Get the database table for list metadata state + pub const fn state_table() -> DbTable { + DbTable::ListMetadataState + } + + // ============================================================ + // Read Operations + // ============================================================ + + /// Get list metadata header by site and key. + /// + /// Returns None if the list doesn't exist. + pub fn get_header( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result, SqliteDbError> { + let sql = format!( + "SELECT * FROM {} WHERE db_site_id = ? AND key = ?", + Self::header_table().table_name() + ); + let mut stmt = executor.prepare(&sql)?; + let mut rows = stmt.query_map(rusqlite::params![site.row_id, key], |row| { + DbListMetadata::from_row(row) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })?; + + match rows.next() { + Some(result) => Ok(Some(result.map_err(SqliteDbError::from)?)), + None => Ok(None), + } + } + + /// Get or create list metadata header. + /// + /// If the header doesn't exist, creates it with the given `per_page` and returns its rowid. + /// If it exists with matching `per_page`, returns the existing rowid. + /// If it exists with different `per_page`, returns `PerPageMismatch` error. + /// + /// This function is safe against race conditions: if another thread creates the header + /// between our SELECT and INSERT, we catch the constraint violation and re-fetch. + pub fn get_or_create( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + ) -> Result { + // Try to get existing + if let Some(header) = Self::get_header(executor, site, key)? { + if header.per_page != per_page { + return Err(SqliteDbError::PerPageMismatch { + expected: per_page, + actual: header.per_page, + }); + } + return Ok(header.row_id); + } + + // Create new header + let sql = format!( + "INSERT INTO {} (db_site_id, key, current_page, per_page, version) VALUES (?, ?, 0, ?, 0)", + Self::header_table().table_name() + ); + + match executor.execute(&sql, rusqlite::params![site.row_id, key, per_page]) { + Ok(_) => Ok(executor.last_insert_rowid()), + Err(SqliteDbError::ConstraintViolation(_)) => { + // Race condition: another thread created it between our SELECT and INSERT. + // Re-fetch and validate per_page matches. + let header = Self::get_header(executor, site, key)?.ok_or_else(|| { + SqliteDbError::SqliteError( + "Header disappeared after constraint violation".to_string(), + ) + })?; + if header.per_page != per_page { + return Err(SqliteDbError::PerPageMismatch { + expected: per_page, + actual: header.per_page, + }); + } + Ok(header.row_id) + } + Err(e) => Err(e), + } + } + + /// Get all items for a list by ID, ordered by rowid (insertion order = display order). + /// + /// Use this when you already have the `list_metadata_id` from a previous call + /// (e.g., from `get_or_create` or `begin_refresh`) to avoid an extra lookup. + pub fn get_items_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + ) -> Result, SqliteDbError> { + let sql = format!( + "SELECT * FROM {} WHERE list_metadata_id = ? ORDER BY rowid", + Self::items_table().table_name() + ); + let mut stmt = executor.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params![list_metadata_id], |row| { + DbListMetadataItem::from_row(row) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })?; + + rows.collect::, _>>() + .map_err(SqliteDbError::from) + } + + /// Get all items for a list by site and key, ordered by rowid (insertion order = display order). + /// + /// If you already have the `list_metadata_id`, use `get_items_by_list_metadata_id` instead. + pub fn get_items_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result, SqliteDbError> { + match Self::get_header(executor, site, key)? { + Some(header) => Self::get_items_by_list_metadata_id(executor, header.row_id), + None => Ok(Vec::new()), + } + } + + /// Get the current sync state for a list by ID. + /// + /// Returns None if no state record exists (list not yet synced). + pub fn get_state_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + ) -> Result, SqliteDbError> { + let sql = format!( + "SELECT * FROM {} WHERE list_metadata_id = ?", + Self::state_table().table_name() + ); + let mut stmt = executor.prepare(&sql)?; + let mut rows = stmt.query_map(rusqlite::params![list_metadata_id], |row| { + DbListMetadataState::from_row(row) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })?; + + match rows.next() { + Some(result) => Ok(Some(result.map_err(SqliteDbError::from)?)), + None => Ok(None), + } + } + + /// Get the current sync state for a list by site and key. + /// + /// Uses a JOIN query internally for efficiency. + /// Returns ListState::Idle if the list or state doesn't exist. + pub fn get_state_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result { + Self::get_header_with_state(executor, site, key) + .map(|opt| opt.map(|h| h.state).unwrap_or(ListState::Idle)) + } + + /// Get header with state in a single JOIN query. + /// + /// Returns pagination info + sync state combined. More efficient than + /// calling `get_header()` and `get_state()` separately when both are needed. + /// + /// Returns `None` if the list doesn't exist. + pub fn get_header_with_state( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result, SqliteDbError> { + let sql = format!( + "SELECT m.total_pages, m.total_items, m.current_page, m.per_page, s.state, s.error_message \ + FROM {} m \ + LEFT JOIN {} s ON s.list_metadata_id = m.rowid \ + WHERE m.db_site_id = ? AND m.key = ?", + Self::header_table().table_name(), + Self::state_table().table_name() + ); + let mut stmt = executor.prepare(&sql)?; + let mut rows = stmt.query_map(rusqlite::params![site.row_id, key], |row| { + DbListHeaderWithState::from_row(row) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })?; + + match rows.next() { + Some(result) => Ok(Some(result.map_err(SqliteDbError::from)?)), + None => Ok(None), + } + } + + /// Get the current version for a list. + /// + /// Returns 0 if the list doesn't exist. + pub fn get_version( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result { + let header = Self::get_header(executor, site, key)?; + Ok(header.map(|h| h.version).unwrap_or(0)) + } + + /// Get the item count for a list by ID. + /// + /// Use this when you already have the `list_metadata_id` from a previous call. + pub fn get_item_count_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + ) -> Result { + let sql = format!( + "SELECT COUNT(*) FROM {} WHERE list_metadata_id = ?", + Self::items_table().table_name() + ); + let mut stmt = executor.prepare(&sql)?; + stmt.query_row(rusqlite::params![list_metadata_id], |row| row.get(0)) + .map_err(SqliteDbError::from) + } + + /// Get the item count for a list by site and key. + /// + /// If you already have the `list_metadata_id`, use `get_item_count_by_list_metadata_id` instead. + pub fn get_item_count_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result { + match Self::get_header(executor, site, key)? { + Some(header) => Self::get_item_count_by_list_metadata_id(executor, header.row_id), + None => Ok(0), + } + } + + // ============================================================ + // Write Operations + // ============================================================ + + /// Set items for a list by ID, replacing any existing items. + /// + /// Used for refresh (page 1) - deletes all existing items and inserts new ones. + /// Items are inserted in order, so rowid determines display order. + pub fn set_items_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + items: &[ListMetadataItemInput], + ) -> Result<(), SqliteDbError> { + log::debug!( + "ListMetadataRepository::set_items_by_list_metadata_id: list_metadata_id={}, count={}", + list_metadata_id.0, + items.len() + ); + + // Delete existing items + let delete_sql = format!( + "DELETE FROM {} WHERE list_metadata_id = ?", + Self::items_table().table_name() + ); + executor.execute(&delete_sql, rusqlite::params![list_metadata_id])?; + + // Insert new items + Self::insert_items(executor, list_metadata_id, items) + } + + /// Set items for a list by site and key, replacing any existing items. + /// + /// If you already have the `list_metadata_id`, use `set_items_by_list_metadata_id` instead. + pub fn set_items_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + items: &[ListMetadataItemInput], + ) -> Result<(), SqliteDbError> { + let list_metadata_id = Self::get_or_create(executor, site, key, per_page)?; + Self::set_items_by_list_metadata_id(executor, list_metadata_id, items) + } + + /// Append items to an existing list by ID. + /// + /// Used for load-more (page 2+) - appends items without deleting existing ones. + /// Items are inserted in order, so they appear after existing items. + pub fn append_items_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + items: &[ListMetadataItemInput], + ) -> Result<(), SqliteDbError> { + log::debug!( + "ListMetadataRepository::append_items_by_list_metadata_id: list_metadata_id={}, count={}", + list_metadata_id.0, + items.len() + ); + + Self::insert_items(executor, list_metadata_id, items) + } + + /// Append items to an existing list by site and key. + /// + /// If you already have the `list_metadata_id`, use `append_items_by_list_metadata_id` instead. + pub fn append_items_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + items: &[ListMetadataItemInput], + ) -> Result<(), SqliteDbError> { + let list_metadata_id = Self::get_or_create(executor, site, key, per_page)?; + Self::append_items_by_list_metadata_id(executor, list_metadata_id, items) + } + + /// Internal helper to insert items using batch insert for better performance. + fn insert_items( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + items: &[ListMetadataItemInput], + ) -> Result<(), SqliteDbError> { + if items.is_empty() { + return Ok(()); + } + + // SQLite has a variable limit (default 999). Each item uses 5 variables, + // so batch in chunks of ~180 items to stay well under the limit. + const BATCH_SIZE: usize = 180; + + items.chunks(BATCH_SIZE).try_for_each(|chunk| { + let placeholders = vec!["(?, ?, ?, ?, ?)"; chunk.len()].join(", "); + let sql = format!( + "INSERT INTO {} (list_metadata_id, entity_id, modified_gmt, parent, menu_order) VALUES {}", + Self::items_table().table_name(), + placeholders + ); + + let params: Vec> = chunk + .iter() + .flat_map(|item| -> [Box; 5] { + [ + Box::new(list_metadata_id), + Box::new(item.entity_id), + Box::new(item.modified_gmt.clone()), + Box::new(item.parent), + Box::new(item.menu_order), + ] + }) + .collect(); + + let param_refs: Vec<&dyn rusqlite::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + executor.execute(&sql, param_refs.as_slice())?; + Ok(()) + }) + } + + /// Update header pagination info by ID. + pub fn update_header_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + update: &ListMetadataHeaderUpdate, + ) -> Result<(), SqliteDbError> { + let sql = format!( + "UPDATE {} SET total_pages = ?, total_items = ?, current_page = ?, per_page = ?, last_fetched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE rowid = ?", + Self::header_table().table_name() + ); + + executor.execute( + &sql, + rusqlite::params![ + update.total_pages, + update.total_items, + update.current_page, + update.per_page, + list_metadata_id + ], + )?; + + Ok(()) + } + + /// Update header pagination info by site and key. + /// + /// If you already have the `list_metadata_id`, use `update_header_by_list_metadata_id` instead. + pub fn update_header_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + update: &ListMetadataHeaderUpdate, + ) -> Result<(), SqliteDbError> { + let list_metadata_id = Self::get_or_create(executor, site, key, update.per_page)?; + Self::update_header_by_list_metadata_id(executor, list_metadata_id, update) + } + + /// Update sync state for a list by ID. + /// + /// Creates the state record if it doesn't exist (upsert). + pub fn update_state_by_list_metadata_id( + executor: &impl QueryExecutor, + list_metadata_id: RowId, + state: ListState, + error_message: Option<&str>, + ) -> Result<(), SqliteDbError> { + // Use INSERT OR REPLACE for upsert behavior + let sql = format!( + "INSERT INTO {} (list_metadata_id, state, error_message, updated_at) VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ON CONFLICT(list_metadata_id) DO UPDATE SET state = excluded.state, error_message = excluded.error_message, updated_at = excluded.updated_at", + Self::state_table().table_name() + ); + + executor.execute( + &sql, + rusqlite::params![list_metadata_id, state, error_message], + )?; + + Ok(()) + } + + /// Update sync state for a list by site and key. + /// + /// If you already have the `list_metadata_id`, use `update_state_by_list_metadata_id` instead. + pub fn update_state_by_list_key( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + state: ListState, + error_message: Option<&str>, + ) -> Result<(), SqliteDbError> { + let list_metadata_id = Self::get_or_create(executor, site, key, per_page)?; + Self::update_state_by_list_metadata_id(executor, list_metadata_id, state, error_message) + } + + /// Delete all data for a list (header, items, and state). + pub fn delete_list( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + ) -> Result<(), SqliteDbError> { + log::debug!("ListMetadataRepository::delete_list: key={}", key); + + // Delete header - items and state are cascade deleted via FK + let sql = format!( + "DELETE FROM {} WHERE db_site_id = ? AND key = ?", + Self::header_table().table_name() + ); + executor.execute(&sql, rusqlite::params![site.row_id, key])?; + + Ok(()) + } + + /// Get or create a list header and increment its version in a single query. + /// + /// Uses `INSERT ... ON CONFLICT DO UPDATE ... RETURNING` to atomically: + /// - Create the header with the given `per_page` and version=1 if it doesn't exist + /// - Increment the version and update `last_first_page_fetched_at` if it exists + /// - Return the rowid, new version, and per_page + /// + /// Returns `PerPageMismatch` error if the existing header has a different `per_page`. + /// + /// This is more efficient than calling `get_or_create` + `increment_version` separately. + pub fn get_or_create_and_increment_version( + executor: &impl QueryExecutor, + site: &DbSite, + key: &ListKey, + per_page: i64, + ) -> Result { + let sql = format!( + "INSERT INTO {} (db_site_id, key, current_page, per_page, version, last_first_page_fetched_at) \ + VALUES (?1, ?2, 0, ?3, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \ + ON CONFLICT(db_site_id, key) DO UPDATE SET \ + version = version + 1, \ + last_first_page_fetched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \ + RETURNING rowid, version, per_page", + Self::header_table().table_name() + ); + + let mut stmt = executor.prepare(&sql)?; + let info = stmt + .query_row(rusqlite::params![site.row_id, key, per_page], |row| { + Ok(HeaderVersionInfo { + list_metadata_id: row.get(0)?, + version: row.get(1)?, + per_page: row.get(2)?, + }) + }) + .map_err(SqliteDbError::from)?; + + // Validate per_page matches (could differ if row already existed) + if info.per_page != per_page { + return Err(SqliteDbError::PerPageMismatch { + expected: per_page, + actual: info.per_page, + }); + } + + Ok(info) + } + + /// Reset stale fetching states to Idle. + /// + /// If the app terminates while a fetch is in progress, `FetchingFirstPage` and + /// `FetchingNextPage` states persist in the database. On next launch, this could + /// cause perpetual loading indicators or blocked fetches. + /// + /// This resets those transient states to `Idle`. Error states are intentionally + /// preserved for UI feedback and debugging. + /// + /// Returns the number of rows updated. + pub fn reset_stale_fetching_states( + executor: &impl QueryExecutor, + ) -> Result { + let sql = format!( + "UPDATE {} SET state = ?1 WHERE state IN (?2, ?3)", + Self::state_table().table_name() + ); + executor.execute( + &sql, + rusqlite::params![ + ListState::Idle as i32, + ListState::FetchingFirstPage as i32, + ListState::FetchingNextPage as i32, + ], + ) + } +} + +/// Header info returned from `get_or_create_and_increment_version`. +#[derive(Debug, Clone)] +pub struct HeaderVersionInfo { + /// Row ID of the list_metadata record + pub list_metadata_id: RowId, + /// Current version number + pub version: i64, + /// Items per page setting + pub per_page: i64, +} + +/// Input for creating a list metadata item. +#[derive(Debug, Clone)] +pub struct ListMetadataItemInput { + /// Entity ID (post ID, comment ID, etc.) + pub entity_id: i64, + /// Last modified timestamp (for staleness detection) + 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, +} + +/// Update parameters for list metadata header. +#[derive(Debug, Clone, Default)] +pub struct ListMetadataHeaderUpdate { + /// Total number of pages from API response + pub total_pages: Option, + /// Total number of items from API response + pub total_items: Option, + /// Current page that has been loaded + pub current_page: i64, + /// Items per page + pub per_page: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db_types::db_list_metadata::{ + ListMetadataColumn, ListMetadataItemColumn, ListMetadataStateColumn, + }; + use crate::db_types::row_ext::ColumnIndex; + use crate::test_fixtures::{TestContext, get_table_column_names, test_ctx}; + use rstest::*; + + const TEST_PER_PAGE: i64 = 25; + + #[rstest] + fn test_get_header_returns_none_for_non_existent(test_ctx: TestContext) { + let result = ListMetadataRepository::get_header( + &test_ctx.conn, + &test_ctx.site, + &ListKey::from("nonexistent:key"), + ) + .expect("should succeed"); + assert!(result.is_none()); + } + + #[rstest] + fn test_get_or_create_creates_new_header(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // Create new header + let row_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + + // Verify it was created with provided per_page + let header = ListMetadataRepository::get_header(&test_ctx.conn, &test_ctx.site, &key) + .expect("query should succeed") + .expect("should succeed"); + assert_eq!(header.row_id, row_id); + assert_eq!(header.key, key.as_str()); + assert_eq!(header.current_page, 0); + assert_eq!(header.per_page, TEST_PER_PAGE); + assert_eq!(header.version, 0); + assert!(header.total_pages.is_none()); + assert!(header.total_items.is_none()); + } + + #[rstest] + fn test_get_or_create_returns_existing_header(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:draft"); + + // Create initial header + let first_row_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + + // Get or create again should return same rowid + let second_row_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + + assert_eq!(first_row_id, second_row_id); + } + + #[rstest] + fn test_get_or_create_fails_on_per_page_mismatch(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:mismatch"); + + // Create header with per_page = 25 + ListMetadataRepository::get_or_create(&test_ctx.conn, &test_ctx.site, &key, 25) + .expect("should succeed"); + + // Try to get_or_create with different per_page + let result = + ListMetadataRepository::get_or_create(&test_ctx.conn, &test_ctx.site, &key, 10); + assert!(matches!( + result, + Err(SqliteDbError::PerPageMismatch { + expected: 10, + actual: 25 + }) + )); + } + + #[rstest] + fn test_get_items_returns_empty_for_non_existent_list(test_ctx: TestContext) { + let items = ListMetadataRepository::get_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &ListKey::from("nonexistent:key"), + ) + .expect("should succeed"); + assert!(items.is_empty()); + } + + #[rstest] + fn test_get_state_returns_none_for_non_existent(test_ctx: TestContext) { + let result = + ListMetadataRepository::get_state_by_list_metadata_id(&test_ctx.conn, RowId(999999)) + .expect("should succeed"); + assert!(result.is_none()); + } + + #[rstest] + fn test_get_state_by_key_returns_idle_for_non_existent_list(test_ctx: TestContext) { + let state = ListMetadataRepository::get_state_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &ListKey::from("nonexistent:key"), + ) + .expect("should succeed"); + assert_eq!(state, ListState::Idle); + } + + #[rstest] + fn test_get_version_returns_zero_for_non_existent_list(test_ctx: TestContext) { + let version = ListMetadataRepository::get_version( + &test_ctx.conn, + &test_ctx.site, + &ListKey::from("nonexistent:key"), + ) + .expect("should succeed"); + assert_eq!(version, 0); + } + + #[rstest] + fn test_get_item_count_returns_zero_for_empty_list(test_ctx: TestContext) { + let count = ListMetadataRepository::get_item_count_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &ListKey::from("empty:list"), + ) + .expect("should succeed"); + assert_eq!(count, 0); + } + + /// Verify that ListMetadataColumn enum values match the actual database schema. + /// This test protects against column reordering in migrations breaking the positional index mapping. + #[rstest] + fn test_list_metadata_column_enum_matches_schema(test_ctx: TestContext) { + use ListMetadataColumn::*; + + let columns = get_table_column_names( + &test_ctx.conn, + ListMetadataRepository::header_table().table_name(), + ); + + // Verify each enum value maps to the correct column name + assert_eq!(columns[Rowid.as_index()], "rowid"); + assert_eq!(columns[DbSiteId.as_index()], "db_site_id"); + assert_eq!(columns[Key.as_index()], "key"); + assert_eq!(columns[TotalPages.as_index()], "total_pages"); + assert_eq!(columns[TotalItems.as_index()], "total_items"); + assert_eq!(columns[CurrentPage.as_index()], "current_page"); + assert_eq!(columns[PerPage.as_index()], "per_page"); + assert_eq!( + columns[LastFirstPageFetchedAt.as_index()], + "last_first_page_fetched_at" + ); + assert_eq!(columns[LastFetchedAt.as_index()], "last_fetched_at"); + assert_eq!(columns[Version.as_index()], "version"); + + assert_eq!(columns.len(), Version.as_index() + 1); + } + + /// Verify that ListMetadataItemColumn enum values match the actual database schema. + #[rstest] + fn test_list_metadata_items_column_enum_matches_schema(test_ctx: TestContext) { + use ListMetadataItemColumn::*; + + let columns = get_table_column_names( + &test_ctx.conn, + ListMetadataRepository::items_table().table_name(), + ); + + assert_eq!(columns[Rowid.as_index()], "rowid"); + assert_eq!(columns[ListMetadataId.as_index()], "list_metadata_id"); + assert_eq!(columns[EntityId.as_index()], "entity_id"); + assert_eq!(columns[ModifiedGmt.as_index()], "modified_gmt"); + assert_eq!(columns[Parent.as_index()], "parent"); + assert_eq!(columns[MenuOrder.as_index()], "menu_order"); + + assert_eq!(columns.len(), MenuOrder.as_index() + 1); + } + + /// Verify that ListMetadataStateColumn enum values match the actual database schema. + #[rstest] + fn test_list_metadata_state_column_enum_matches_schema(test_ctx: TestContext) { + use ListMetadataStateColumn::*; + + let columns = get_table_column_names( + &test_ctx.conn, + ListMetadataRepository::state_table().table_name(), + ); + + assert_eq!(columns[Rowid.as_index()], "rowid"); + assert_eq!(columns[ListMetadataId.as_index()], "list_metadata_id"); + assert_eq!(columns[State.as_index()], "state"); + assert_eq!(columns[ErrorMessage.as_index()], "error_message"); + assert_eq!(columns[UpdatedAt.as_index()], "updated_at"); + + assert_eq!(columns.len(), UpdatedAt.as_index() + 1); + } + + // ============================================================ + // Write Operation Tests + // ============================================================ + + #[rstest] + fn test_set_items_inserts_new_items(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + let items = vec![ + ListMetadataItemInput { + entity_id: 100, + modified_gmt: Some("2024-01-01T00:00:00Z".to_string()), + parent: Some(50), + menu_order: Some(1), + }, + ListMetadataItemInput { + entity_id: 200, + modified_gmt: Some("2024-01-02T00:00:00Z".to_string()), + parent: Some(50), + menu_order: Some(2), + }, + ListMetadataItemInput { + entity_id: 300, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ]; + + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &items, + ) + .expect("should succeed"); + + let retrieved = + ListMetadataRepository::get_items_by_list_key(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + assert_eq!(retrieved.len(), 3); + assert_eq!(retrieved[0].entity_id, 100); + assert_eq!(retrieved[0].parent, Some(50)); + assert_eq!(retrieved[0].menu_order, Some(1)); + assert_eq!(retrieved[1].entity_id, 200); + assert_eq!(retrieved[2].entity_id, 300); + assert!(retrieved[2].modified_gmt.is_none()); + assert!(retrieved[2].parent.is_none()); + assert!(retrieved[2].menu_order.is_none()); + } + + #[rstest] + fn test_set_items_replaces_existing_items(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:draft"); + + // Insert initial items + let initial_items = vec![ + ListMetadataItemInput { + entity_id: 1, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ListMetadataItemInput { + entity_id: 2, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ]; + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &initial_items, + ) + .expect("should succeed"); + + // Replace with new items + let new_items = vec![ + ListMetadataItemInput { + entity_id: 10, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ListMetadataItemInput { + entity_id: 20, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ListMetadataItemInput { + entity_id: 30, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ]; + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &new_items, + ) + .expect("should succeed"); + + let retrieved = + ListMetadataRepository::get_items_by_list_key(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + assert_eq!(retrieved.len(), 3); + assert_eq!(retrieved[0].entity_id, 10); + assert_eq!(retrieved[1].entity_id, 20); + assert_eq!(retrieved[2].entity_id, 30); + } + + #[rstest] + fn test_append_items_adds_to_existing(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:pending"); + + // Insert initial items + let initial_items = vec![ + ListMetadataItemInput { + entity_id: 1, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ListMetadataItemInput { + entity_id: 2, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ]; + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &initial_items, + ) + .expect("should succeed"); + + // Append more items + let more_items = vec![ + ListMetadataItemInput { + entity_id: 3, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ListMetadataItemInput { + entity_id: 4, + modified_gmt: None, + parent: None, + menu_order: None, + }, + ]; + ListMetadataRepository::append_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &more_items, + ) + .expect("should succeed"); + + let retrieved = + ListMetadataRepository::get_items_by_list_key(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + assert_eq!(retrieved.len(), 4); + assert_eq!(retrieved[0].entity_id, 1); + assert_eq!(retrieved[1].entity_id, 2); + assert_eq!(retrieved[2].entity_id, 3); + assert_eq!(retrieved[3].entity_id, 4); + } + + #[rstest] + fn test_update_header_updates_pagination(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + let update = ListMetadataHeaderUpdate { + total_pages: Some(5), + total_items: Some(100), + current_page: 1, + per_page: TEST_PER_PAGE, + }; + + ListMetadataRepository::update_header_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + &update, + ) + .expect("should succeed"); + + let header = ListMetadataRepository::get_header(&test_ctx.conn, &test_ctx.site, &key) + .expect("query should succeed") + .expect("should succeed"); + assert_eq!(header.total_pages, Some(5)); + assert_eq!(header.total_items, Some(100)); + assert_eq!(header.current_page, 1); + assert_eq!(header.per_page, TEST_PER_PAGE); + assert!(header.last_fetched_at.is_some()); + } + + #[rstest] + fn test_update_state_creates_new_state(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + let list_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + ListMetadataRepository::update_state_by_list_metadata_id( + &test_ctx.conn, + list_id, + ListState::FetchingFirstPage, + None, + ) + .expect("should succeed"); + + let state = ListMetadataRepository::get_state_by_list_metadata_id(&test_ctx.conn, list_id) + .expect("query should succeed") + .expect("should succeed"); + assert_eq!(state.state, ListState::FetchingFirstPage); + assert!(state.error_message.is_none()); + } + + #[rstest] + fn test_update_state_updates_existing_state(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:draft"); + + let list_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + + // Set initial state + ListMetadataRepository::update_state_by_list_metadata_id( + &test_ctx.conn, + list_id, + ListState::FetchingFirstPage, + None, + ) + .expect("should succeed"); + + // Update to error state + ListMetadataRepository::update_state_by_list_metadata_id( + &test_ctx.conn, + list_id, + ListState::Error, + Some("Network error"), + ) + .expect("should succeed"); + + let state = ListMetadataRepository::get_state_by_list_metadata_id(&test_ctx.conn, list_id) + .expect("query should succeed") + .expect("should succeed"); + assert_eq!(state.state, ListState::Error); + assert_eq!(state.error_message.as_deref(), Some("Network error")); + } + + #[rstest] + fn test_update_state_by_key(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:pending"); + + ListMetadataRepository::update_state_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ListState::FetchingNextPage, + None, + ) + .expect("should succeed"); + + let state = + ListMetadataRepository::get_state_by_list_key(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + assert_eq!(state, ListState::FetchingNextPage); + } + + #[rstest] + fn test_get_or_create_and_increment_version_creates_new(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:new"); + + // First call creates header with version 1 + let info = ListMetadataRepository::get_or_create_and_increment_version( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + assert_eq!(info.version, 1); + assert_eq!(info.per_page, TEST_PER_PAGE); + + // Verify header was created + let header = ListMetadataRepository::get_header(&test_ctx.conn, &test_ctx.site, &key) + .expect("query should succeed") + .expect("should exist"); + assert_eq!(header.row_id, info.list_metadata_id); + assert_eq!(header.version, 1); + } + + #[rstest] + fn test_get_or_create_and_increment_version_increments_existing(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:existing"); + + // Create header first + ListMetadataRepository::get_or_create(&test_ctx.conn, &test_ctx.site, &key, TEST_PER_PAGE) + .expect("should succeed"); + + // Now call get_or_create_and_increment_version - should increment from 0 to 1 + let info1 = ListMetadataRepository::get_or_create_and_increment_version( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + assert_eq!(info1.version, 1); + + // Call again - should increment to 2 + let info2 = ListMetadataRepository::get_or_create_and_increment_version( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + assert_eq!(info2.version, 2); + assert_eq!(info2.list_metadata_id, info1.list_metadata_id); + } + + #[rstest] + fn test_delete_list_removes_all_data(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:publish"); + + // Create header and add items and state + let list_id = ListMetadataRepository::get_or_create( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + ) + .expect("should succeed"); + let items = vec![ListMetadataItemInput { + entity_id: 1, + modified_gmt: None, + parent: None, + menu_order: None, + }]; + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &items, + ) + .expect("should succeed"); + ListMetadataRepository::update_state_by_list_metadata_id( + &test_ctx.conn, + list_id, + ListState::Idle, + None, + ) + .expect("should succeed"); + + // Verify data exists + assert!( + ListMetadataRepository::get_header(&test_ctx.conn, &test_ctx.site, &key) + .expect("query should succeed") + .is_some() + ); + assert_eq!( + ListMetadataRepository::get_item_count_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key + ) + .expect("query should succeed"), + 1 + ); + + // Delete the list + ListMetadataRepository::delete_list(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + + // Verify everything is deleted + assert!( + ListMetadataRepository::get_header(&test_ctx.conn, &test_ctx.site, &key) + .expect("query should succeed") + .is_none() + ); + assert_eq!( + ListMetadataRepository::get_item_count_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key + ) + .expect("query should succeed"), + 0 + ); + } + + #[rstest] + fn test_items_preserve_order(test_ctx: TestContext) { + let key = ListKey::from("edit:posts:ordered"); + + // Insert items in specific order + let items: Vec = (1..=10) + .map(|i| ListMetadataItemInput { + entity_id: i * 100, + modified_gmt: None, + parent: None, + menu_order: None, + }) + .collect(); + + ListMetadataRepository::set_items_by_list_key( + &test_ctx.conn, + &test_ctx.site, + &key, + TEST_PER_PAGE, + &items, + ) + .expect("should succeed"); + + let retrieved = + ListMetadataRepository::get_items_by_list_key(&test_ctx.conn, &test_ctx.site, &key) + .expect("should succeed"); + assert_eq!(retrieved.len(), 10); + + // Verify order is preserved (rowid ordering) + for (i, item) in retrieved.iter().enumerate() { + assert_eq!(item.entity_id, ((i + 1) * 100) as i64); + } + } +} diff --git a/wp_mobile_cache/src/repository/mod.rs b/wp_mobile_cache/src/repository/mod.rs index b2d4f9122..84e16a1f8 100644 --- a/wp_mobile_cache/src/repository/mod.rs +++ b/wp_mobile_cache/src/repository/mod.rs @@ -1,6 +1,7 @@ use crate::{RowId, SqliteDbError}; use rusqlite::Connection; +pub mod list_metadata; pub mod posts; pub mod sites; pub mod term_relationships;