From d530c859b09187e5be68b89848bc7d5fb6e04749 Mon Sep 17 00:00:00 2001 From: Michael Joel Hall Date: Sat, 11 Jul 2026 15:35:55 -0400 Subject: [PATCH 1/2] Use INSERT OR IGNORE for excluded_statuses markers upsertUnread/upsertStarred insert into excluded_statuses(article_id, type) with no conflict clause. markAllUnread runs the marker inserts and the updateStaleUnreads cleanup in one transaction, so a duplicate or stranded marker makes the insert throw, rolls back the transaction, skips the cleanup, and strands the markers -- failing identically on every subsequent sync, so unread refresh never completes. Use INSERT OR IGNORE so duplicate/leftover markers are skipped and the cleanup self-heals. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015yVKgdxd3ZSrFeY5eWpFuA --- capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq index 15e3fc9a1..5e402a77c 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq @@ -147,7 +147,7 @@ upsertUnread { last_read_at = excluded.last_read_at, read = excluded.read; - INSERT INTO excluded_statuses( + INSERT OR IGNORE INTO excluded_statuses( article_id, type ) @@ -186,7 +186,7 @@ upsertStarred { SET starred = excluded.starred; - INSERT INTO excluded_statuses( + INSERT OR IGNORE INTO excluded_statuses( article_id, type ) From e3427c92ae1ba2b6f6e801c82e72dfe23305f44e Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:46:48 -0500 Subject: [PATCH 2/2] Dedupe status IDs and cover repeated markers --- .../jocmp/capy/persistence/ArticleRecords.kt | 4 +- .../capy/persistence/ArticleRecordsTest.kt | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt index 6056b201b..ee93110ae 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt @@ -159,7 +159,7 @@ class ArticleRecords( val updated = updatedAt.toEpochSecond() database.transactionWithErrorHandling { - articleIDs.forEach { articleID -> + articleIDs.distinct().forEach { articleID -> database.articlesQueries.upsertUnread( articleID = articleID, updatedAt = updated @@ -174,7 +174,7 @@ class ArticleRecords( val updated = updatedAt.toEpochSecond() database.transactionWithErrorHandling { - articleIDs.forEach { articleID -> + articleIDs.distinct().forEach { articleID -> database.articlesQueries.upsertStarred( articleID = articleID, updatedAt = updated diff --git a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt index 97e78680a..ab20097f5 100644 --- a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt +++ b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt @@ -373,6 +373,52 @@ class ArticleRecordsTest { assertTrue(articleRecords.find(readArticle.id)!!.read) } + @Test + fun markAllUnread_withDuplicateIDs() = runTest { + val articleIDs = 3.repeated { RandomUUID.generate() } + val readArticle = articleFixture.create(read = false) + + articleIDs.forEach { id -> + articleFixture.create(id = id, read = true) + } + + articleRecords.markAllUnread(articleIDs + articleIDs.first()) + + val articles = articleIDs.map { articleRecords.find(it)!! } + + assertTrue(articles.none { it.read }) + assertTrue(articleRecords.find(readArticle.id)!!.read) + } + + @Test + fun markAllStarred_withDuplicateIDs() = runTest { + val articleIDs = 3.repeated { RandomUUID.generate() } + + articleIDs.forEach { id -> + articleFixture.create(id = id) + } + + articleRecords.markAllStarred(articleIDs + articleIDs.first()) + + val articles = articleIDs.map { articleRecords.find(it)!! } + + assertTrue(articles.all { it.starred }) + } + + @Test + fun upsertUnread_ignoresRepeatedMarkers() = runTest { + val article = articleFixture.create(read = true) + val updatedAt = nowUTC().toEpochSecond() + + database.transaction { + database.articlesQueries.upsertUnread(articleID = article.id, updatedAt = updatedAt) + database.articlesQueries.upsertUnread(articleID = article.id, updatedAt = updatedAt) + database.articlesQueries.updateStaleUnreads() + } + + assertFalse(articleRecords.find(article.id)!!.read) + } + @Test fun deleteOldArticles() = runTest { val oldPublishedAt = nowUTC().minusMonths(4).toEpochSecond()