Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d919a90
Add list metadata repository to wp_mobile_cache
oguzkocer Dec 18, 2025
dd6d45d
Store ListState as INTEGER instead of TEXT
oguzkocer Dec 18, 2025
f3a0187
Return error for invalid ListState values instead of silent fallback
oguzkocer Dec 18, 2025
49ab6c9
Convert ListMetadataRepository methods to associated functions
oguzkocer Dec 18, 2025
4b1e6cb
Use batch insert for list metadata items
oguzkocer Dec 18, 2025
b271746
Use JOIN query internally in get_state_by_key
oguzkocer Dec 18, 2025
6fc0eb0
Add ListKey newtype for type-safe list key handling
oguzkocer Dec 18, 2025
4e307dc
Simplify reset_stale_fetching_states and return Result
oguzkocer Dec 18, 2025
94e05aa
Add FK from list_metadata_items to list_metadata
oguzkocer Dec 18, 2025
bb3f2dd
Add log crate for structured logging
oguzkocer Dec 18, 2025
ba4ec2a
Add ToSql/FromSql for ListKey and log stale state reset errors
oguzkocer Dec 18, 2025
a100ad5
Remove unused update hook helper functions
oguzkocer Dec 18, 2025
8d39ebf
Remove select_modified_gmt_by_ids from this PR
oguzkocer Dec 18, 2025
52fca29
Split get_items and get_item_count into by_list_metadata_id and by_li…
oguzkocer Dec 18, 2025
565eb4b
Remove redundant check_version function
oguzkocer Dec 18, 2025
745be0b
Add by_list_metadata_id variants for write operations
oguzkocer Dec 18, 2025
fcf3430
Replace unwrap with expect for better panic messages
oguzkocer Dec 18, 2025
893245b
Update migration count in Kotlin and Swift tests
oguzkocer Dec 18, 2025
53405b4
Handle race condition in get_or_create
oguzkocer Dec 18, 2025
cb2f187
Add optimized get_or_create_and_increment_version method
oguzkocer Dec 18, 2025
48e9dd0
Remove standalone increment_version methods
oguzkocer Dec 18, 2025
41f9ba5
Remove concurrency helpers from repository layer
oguzkocer Dec 18, 2025
811eb46
Move reset_stale_fetching_states to ListMetadataRepository
oguzkocer Dec 18, 2025
23add50
Make per_page a required parameter with validation
oguzkocer Dec 18, 2025
e43fe4a
Use PRAGMA-based column enum tests for list_metadata
oguzkocer Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ class WordPressApiCacheTest {

@Test
fun testThatMigrationsWork() = runTest {
assertEquals(6, WordPressApiCache().performMigrations())
assertEquals(7, WordPressApiCache().performMigrations())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions wp_mobile_cache/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
45 changes: 45 additions & 0 deletions wp_mobile_cache/migrations/0007-create-list-metadata-tables.sql
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions wp_mobile_cache/src/db_types.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod db_list_metadata;
pub mod db_site;
pub mod db_term_relationship;
pub mod helpers;
Expand Down
161 changes: 161 additions & 0 deletions wp_mobile_cache/src/db_types/db_list_metadata.rs
Original file line number Diff line number Diff line change
@@ -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<Self, SqliteDbError> {
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<Self, SqliteDbError> {
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<Self, SqliteDbError> {
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<Self, SqliteDbError> {
use ListHeaderWithStateColumn as Col;

// state is nullable due to LEFT JOIN - default to Idle
let state: Option<ListState> = 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)?,
})
}
}
49 changes: 46 additions & 3 deletions wp_mobile_cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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<rusqlite::Error> 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())
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
}
}
}
Expand Down Expand Up @@ -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())),
}
}
Expand Down Expand Up @@ -249,7 +281,17 @@ impl WpApiCache {
pub fn perform_migrations(&self) -> Result<i64, SqliteDbError> {
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)
})
}

Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -350,13 +392,14 @@ impl From<Connection> 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> {
Expand Down
Loading