Skip to content

fix(hardcover): hardcover sync duplicates reads and doesn't update progress#247

Merged
balazs-szucs merged 12 commits into
grimmory-tools:developfrom
pedronave:fix/hardcover-sync-duplicate-progress
Apr 7, 2026
Merged

fix(hardcover): hardcover sync duplicates reads and doesn't update progress#247
balazs-szucs merged 12 commits into
grimmory-tools:developfrom
pedronave:fix/hardcover-sync-duplicate-progress

Conversation

@pedronave

@pedronave pedronave commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Description

This PR fixes the issue where hardcover progress sync creates two dates read, and the progress doesn't update.

There are some quirks with the Hardcover API that make this update flow a bit harder, and may change other read dates that are not finished yet, but overall should work well for simpler cases, and should ensure that the progress update is always visible.

Linked Issue: Fixes #143

Changes

The sync process now considers the existing read dates and current status of the book.
We update the existing user_book (book in the user's library) to match the reading status and edition.
Then we update the latest user_book_read that has the edition ID we're trying to sync. If it doesn't exist, we insert a new one. We need to update the last one because this is what Hardcover uses to display the progress in the main page.

Summary by CodeRabbit

  • Bug Fixes

    • Improved hardcover progress sync with explicit finished detection, smarter skip logic to avoid redundant updates, and safer update-vs-insert handling for reading records. Enhanced API response/error checks and early-exit guards to reduce incorrect or duplicate writes.
  • Tests

    • Added unit tests covering finished-books, zero-page/missing data, update vs insert read paths, and abort/failure scenarios.

@coderabbitai

coderabbitai Bot commented Mar 28, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Refactors HardcoverSyncService to compute finished from progress (>=99%), fetch user_book with reads, create or update user_book as needed, then update-or-insert a single compatible user_book_read; removes prior two-step upsert flow and tightens GraphQL response/error handling.

Changes

Cohort / File(s) Summary
HardcoverSyncService Core Logic
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
Rewrote sync flow: parse hardcoverBook.bookId, compute isFinished from progress, call new GraphQL helpers (getUserBookAndReads, insertUserBook, updateUserBook, updateUserBookRead, insertUserBookRead), skip when already READ+finished, and choose update vs insert for reads. Removed older upsert helpers and adjusted error checks.
Models / Utilities
booklore-api/src/main/java/org/booklore/service/hardcover/...
Added UserBookWithReads, UserBookReadInfo; made extractInteger static and added extractFloat for progress parsing.
GraphQL call shapes & error handling
booklore-api/src/main/java/org/booklore/service/hardcover/...
Replaced multi-step read upsert GraphQL interactions with single richer queries/mutations that return nested reads or booleans; updated checks to explicitly validate errors and mutation error fields and return early on failures.
Tests
booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
Added unit tests covering skip-on-READ+finished, proceed-on-READ-not-finished, abort-on-zero-pages, update-vs-insert read paths (edition/status cases), and abort when insertUserBook returns null. Added test helpers for mocked GraphQL payloads.

Sequence Diagram(s)

sequenceDiagram
  participant Service as HardcoverSyncService
  participant GraphQL as GraphQL API
  participant DB as DB/Backend

  Service->>GraphQL: resolve hardcoverBook.bookId -> getUserBookAndReads(hardcoverId)
  GraphQL-->>Service: UserBookWithReads / null
  alt user book missing
    Service->>GraphQL: insertUserBook(...)
    GraphQL-->>Service: insertedUserBook or null
  else user book exists
    Service->>Service: compute isFinished from progress
    alt userBook.status != CURRENTLY_READING or edition mismatch
      Service->>GraphQL: updateUserBook(...)
      GraphQL-->>Service: updated UserBookWithReads
    end
  end
  alt try update compatible read
    Service->>GraphQL: updateUserBookRead(userBookId, readInfo)
    GraphQL-->>Service: success/failure
  else insert read
    Service->>GraphQL: insertUserBookRead(...)
    GraphQL-->>Service: success/failure
  end
  Service->>DB: log result / update local state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • balazs-szucs

Poem

🐇 I hopped through lines with gentle cheer,
Merged reads to one and chased the fear,
One open date, no duplicate track,
Pages forward — no turning back,
A tiny hop, a tidy sync — hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title follows conventional commit format with 'fix' type and descriptive scope/subject about the hardcover sync issue.
Description check ✅ Passed The description includes all required sections with clear explanation of the fix, linked issue #143, and detailed changes documentation.
Linked Issues check ✅ Passed The code changes directly address issue #143 by fixing duplicate read entries and implementing continuous progress updates through refined sync logic.
Out of Scope Changes check ✅ Passed All changes are scoped to hardcover sync flow refactoring and testing; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@balazs-szucs

Copy link
Copy Markdown
Contributor

So far, everything looks good on my end.

I don't see any obvious functional issues. You clearly have deeper expertise here than I do, so if you're comfortable with this code, I am too. As mentioned, nothing stood out during local testing either

Thanks for the PR!

@pedronave

Copy link
Copy Markdown
Contributor Author

Hey @balazs-szucs I added some tests. I really don't like the way they're being done, but what I'd like to improve in them would require a few more changes, and I'm not sure it's worth it to hold this up for that.

If it's okay, I propose we merge this PR, and then I could work on improving this to use a proper Hardcover service that we can mock to make the code easier to test and more reliable. What do you think?

@pedronave pedronave marked this pull request as ready for review April 1, 2026 17:32
@coderabbitai coderabbitai Bot requested a review from balazs-szucs April 1, 2026 17:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 167-187: The success log is emitted unconditionally because
requiresNewReadEntry is cleared even when updateUserBookRead(...) or
insertUserBookRead(...) fail; change both helper methods to return a boolean/or
result object indicating success, then only set requiresNewReadEntry = false
when the call returns success, and only emit the "Synced progress to Hardcover"
log after a successful return; apply the same pattern to the other Hardcover
sync blocks that call updateUserBookRead/insertUserBookRead (the repeated sync
sections referenced in the comment).
- Around line 156-159: The code assumes userBook.reads.getLast() is the most
recent read but the reads are fetched without an explicit order; update the
query that populates userBook.reads (the code that builds the user_book_reads
list used by UserBookReadInfo) to include an explicit ORDER BY (e.g., order by
read_timestamp or created_at DESC) and then pick the appropriate element
consistently (if ordering DESC use getFirst(), or if ordering ASC use getLast())
so userBook.reads.getLast()/UserBookReadInfo resolves to the actual latest read;
apply the same ordering change to the other fetch path that populates reads (the
block around the other user_book_reads fetch) to keep behavior consistent.
- Around line 160-171: HardcoverSyncService currently treats a missing
readInfo.editionId as a match but never backfills it, so later writes leave
user_book/user_book_read detached from the resolved edition; before calling
updateUserBookRead (and the corresponding user book update in the other branch
where editionId is null), set readInfo.editionId = hardcoverBook.editionId (and
also update the userBook's editionId field that will be persisted) so the
resolved hardcoverBook.editionId is written into both user_book_read and
user_book when you call updateUserBookRead(...) and the user-book update method.
- Around line 594-623: The updateUserBook method and the insert new read flow
build a bookInput map that sets the user_book's edition_id and status, but the
mutation variables always set status_id to STATUS_CURRENTLY_READING and ignore
isFinished. To fix, modify the mutation variable construction to use the correct
status_id based on isFinished (e.g., finished or currently reading) and include
the bookInput in the mutation variables so the parent user_book updates
alongside any new user_book_read inserts, ensuring the parent stays in sync with
the read rows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: de48dc9d-227c-4421-8e06-e0ea5f75b719

📥 Commits

Reviewing files that changed from the base of the PR and between 8ec2d1e and c44f4d9.

📒 Files selected for processing (2)
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Packaging Smoke Test
🧰 Additional context used
📓 Path-based instructions (2)
booklore-api/src/**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

booklore-api/src/**/*.java: Use 4-space indentation in Java files and match surrounding Java style
Prefer constructor injection via Lombok patterns already used in the codebase. Do not introduce @Autowired field injection
Use MapStruct for entity/DTO mapping

Files:

  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
booklore-api/src/test/**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

Prefer focused unit tests; use @SpringBootTest only when the Spring context is required

Files:

  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
🧠 Learnings (5)
📓 Common learnings
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.
📚 Learning: 2026-03-24T18:58:08.199Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).

Applied to files:

  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:47.249Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.

Applied to files:

  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:10.869Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:220-225
Timestamp: 2026-03-24T18:46:10.869Z
Learning: In the Hardcover GraphQL API, empty strings (`""`) are not valid ISBN values and querying with `_eq: ""` returns no results. Therefore, binding a missing ISBN to `""` in `_or` edition filters in HardcoverSyncService.java is safe and will not accidentally match unrelated editions or books.

Applied to files:

  • booklore-api/src/test/java/org/booklore/service/hardcover/HardcoverSyncServiceTest.java
📚 Learning: 2026-03-26T01:46:48.863Z
Learnt from: CR
Repo: grimmory-tools/grimmory PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-26T01:46:48.863Z
Learning: Applies to booklore-api/src/**/*.java : Use MapStruct for entity/DTO mapping

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🔇 Additional comments (1)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (1)

156-159: No action needed — List.getLast() is fully supported.

The project targets Java 25 (configured in booklore-api/build.gradle.kts), which includes full support for List.getLast() (available since Java 21). The code will compile and run correctly as written.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (3)

624-626: ⚠️ Potential issue | 🟠 Major

Keep user_book in sync when inserting a new read.

updateUserBook(...) hardcodes STATUS_CURRENTLY_READING, and insertUserBookRead(...) computes bookInput but never sends it. If this flow inserts a new read for a finished sync or an edition switch, the child user_book_read changes but the parent user_book can keep the old status_id / edition_id, which leaves Hardcover’s library state inconsistent with the row you just inserted.

🩹 Possible shape
-        bookInput.put("status_id", STATUS_CURRENTLY_READING);
+        bookInput.put("status_id", isFinished ? STATUS_READ : STATUS_CURRENTLY_READING);
-        String mutation = """
-            mutation InsertUserBookRead($userBookId: Int!, $userBookReadObject: DatesReadInput!) {
-                insert_user_book_read(user_book_id: $userBookId, user_book_read: $userBookReadObject) {
+        String mutation = """
+            mutation InsertUserBookRead($userBookId: Int!, $userBookObject: UserBookUpdateInput!, $userBookReadObject: DatesReadInput!) {
+                update_user_book(id: $userBookId, object: $userBookObject) {
+                    id
+                    error
+                }
+                insert_user_book_read(user_book_id: $userBookId, user_book_read: $userBookReadObject) {
                     user_book_read {
                         id
                     }
@@
         request.setVariables(Map.of(
             "userBookId", userBookId,
+            "userBookObject", bookInput,
             "userBookReadObject", readInput
         ));

Also applies to: 678-709

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 624 - 626, The parent user_book row isn't being updated when a new
user_book_read is inserted because insertUserBookRead builds bookInput
(edition_id/status_id) but never forwards it, and updateUserBook currently
hardcodes STATUS_CURRENTLY_READING; modify insertUserBookRead to pass the
computed bookInput into the call that updates/creates the parent (call/update to
updateUserBook or the create flow) and change updateUserBook to accept and apply
the incoming status_id and edition_id instead of always setting
STATUS_CURRENTLY_READING; apply the same fix in the other similar block
referenced (the block covering the 678-709 flow) so parent user_book
edition_id/status_id are kept in sync with the newly inserted user_book_read.

159-170: ⚠️ Potential issue | 🟠 Major

Populate missing fields before reusing the blank read row.

When the latest read has editionId == null, this path still sends edition_id: null back through updateUserBookRead(...), so the reused row stays detached from the resolved edition. The same problem exists for startedAt: if Hardcover created a blank row, a finished sync still sends started_at: null, which can recreate the “finish date only” entry from issue #143.

🩹 Possible fix
                     if (readInfo.editionId == null || (readInfo.editionId != null && readInfo.editionId.equals(hardcoverBook.editionId))) {
+                        if (readInfo.editionId == null) {
+                            readInfo.editionId = hardcoverBook.editionId;
+                        }
+                        if (readInfo.startedAt == null && (progressPages > 0 || isFinished)) {
+                            readInfo.startedAt = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
+                        }
                         readInfo.progressPages = progressPages;
                         if (isFinished) {
                             readInfo.finishedAt = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
                         }

Also applies to: 760-769


791-804: ⚠️ Potential issue | 🟠 Major

Return failure when either nested mutation reports an error.

This helper only treats top-level GraphQL errors as fatal. If update_user_book.error or update_user_book_read.error is populated, it logs a warning and still returns true, so the caller can log a successful sync even though Hardcover rejected part of the update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 791 - 804, The current logic in HardcoverSyncService inspects
nested GraphQL results (update_user_book and update_user_book_read) but only
logs warnings and still returns true; change it so that if either
updateUserBookResult.get("error") or updateUserBookReadResult.get("error") is
non-null the method logs the error (or warn) and returns false instead of
proceeding to the final "return true". Locate the block that reads response ->
data -> update_user_book / update_user_book_read and add a boolean failure flag
(or early return) that flips to false when either nested "error" is present,
ensuring the method returns false to signal the overall sync failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 124-185: Race condition: concurrent calls to
syncProgressToHardcover can read/modify/insert overlapping reads and create
duplicate user_book_read entries. Fix by serializing per user+book inside
syncProgressToHardcover: acquire a short-lived mutex keyed by userId+bookId
before calling getUserBookAndReads and hold it until after
insertUserBookRead/updateUserBookRead completes (and release it after), so only
one thread can perform the read-modify-write; implement the mutex using an
in-memory ConcurrentHashMap<String, Lock>/ReentrantLock or a dedicated
single-flight mechanism and ensure locks are always released (try/finally).
Apply this around the code paths that call getUserBookAndReads, insertUserBook,
updateUserBook, updateUserBookRead, and insertUserBookRead.

---

Duplicate comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 624-626: The parent user_book row isn't being updated when a new
user_book_read is inserted because insertUserBookRead builds bookInput
(edition_id/status_id) but never forwards it, and updateUserBook currently
hardcodes STATUS_CURRENTLY_READING; modify insertUserBookRead to pass the
computed bookInput into the call that updates/creates the parent (call/update to
updateUserBook or the create flow) and change updateUserBook to accept and apply
the incoming status_id and edition_id instead of always setting
STATUS_CURRENTLY_READING; apply the same fix in the other similar block
referenced (the block covering the 678-709 flow) so parent user_book
edition_id/status_id are kept in sync with the newly inserted user_book_read.
- Around line 791-804: The current logic in HardcoverSyncService inspects nested
GraphQL results (update_user_book and update_user_book_read) but only logs
warnings and still returns true; change it so that if either
updateUserBookResult.get("error") or updateUserBookReadResult.get("error") is
non-null the method logs the error (or warn) and returns false instead of
proceeding to the final "return true". Locate the block that reads response ->
data -> update_user_book / update_user_book_read and add a boolean failure flag
(or early return) that flips to false when either nested "error" is present,
ensuring the method returns false to signal the overall sync failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc8408b6-4549-417a-a733-0edfe8c2e8b0

📥 Commits

Reviewing files that changed from the base of the PR and between c44f4d9 and 07b5f83.

📒 Files selected for processing (1)
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Test Suite / Frontend Tests
  • GitHub Check: Test Suite / Backend Tests
  • GitHub Check: Analyze (java-kotlin)
🧰 Additional context used
📓 Path-based instructions (1)
booklore-api/src/**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

booklore-api/src/**/*.java: Use 4-space indentation in Java files and match surrounding Java style
Prefer constructor injection via Lombok patterns already used in the codebase. Do not introduce @Autowired field injection
Use MapStruct for entity/DTO mapping

Files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🧠 Learnings (7)
📓 Common learnings
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:03.758Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.
📚 Learning: 2026-03-24T18:46:47.249Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:58:08.199Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-04-01T17:50:03.758Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:03.758Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-27T15:06:25.913Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory PR: 217
File: booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java:151-153
Timestamp: 2026-03-27T15:06:25.913Z
Learning: In `booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java`, the try/catch around `koboServerProxy.proxyCurrentRequest()` in `syncLibrary()` is intentionally scoped only to handle a missing/failed proxy response (null). Once a response is successfully obtained, its body and headers (including the upstream sync token decoded via `tokenGenerator.fromBase64()`) are processed without additional defensive wrapping. This is a deliberate design decision: other parts of the response are equally unguarded, and the goal is to handle proxy unavailability gracefully — not to catch every possible downstream processing error.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:10.869Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:220-225
Timestamp: 2026-03-24T18:46:10.869Z
Learning: In the Hardcover GraphQL API, empty strings (`""`) are not valid ISBN values and querying with `_eq: ""` returns no results. Therefore, binding a missing ISBN to `""` in `_or` edition filters in HardcoverSyncService.java is safe and will not accidentally match unrelated editions or books.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-26T01:46:48.863Z
Learnt from: CR
Repo: grimmory-tools/grimmory PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-26T01:46:48.863Z
Learning: Applies to booklore-api/src/**/*.java : Use MapStruct for entity/DTO mapping

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java

@pedronave

Copy link
Copy Markdown
Contributor Author

@balazs-szucs I found another quirk in this logic, need a bit longer to fix it. Hope to get it done today

@balazs-szucs

Copy link
Copy Markdown
Contributor

@balazs-szucs I found another quirk in this logic, need a bit longer to fix it. Hope to get it done today

Sounds good, no pressure :)

@zachyale

zachyale commented Apr 5, 2026

Copy link
Copy Markdown
Member

@balazs-szucs I found another quirk in this logic, need a bit longer to fix it. Hope to get it done today

Can you resolve the open convos w/ a quick note for posterity why or why not they don't need to be addressed?

Let me know if this is ready for review again and we can work to get this in ASAP :)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (1)

690-703: ⚠️ Potential issue | 🟡 Minor

Dead code: bookInput is constructed but never used.

Lines 690-692 build a bookInput map with edition_id and status_id, but this map is never included in the mutation variables (line 706-709). The mutation only sends userBookId and readInput.

If the intent was to also update the parent user_book status/edition when inserting a new read, this is currently not happening. Otherwise, remove the dead code.

🩹 Proposed fix (if dead code)
-        Map<String, Object> bookInput = new java.util.HashMap<>();
-        bookInput.put("edition_id", editionId);
-        bookInput.put("status_id", isFinished ? STATUS_READ : STATUS_CURRENTLY_READING);
-
         Map<String, Object> readInput = new java.util.HashMap<>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 690 - 703, The Map bookInput is built but never used; either remove
its construction or wire it into the GraphQL mutation variables so the parent
user_book is updated. Locate the code that prepares the mutation variables
around where readInput and userBookId are sent (see symbols bookInput,
readInput, STATUS_READ, STATUS_CURRENTLY_READING and the enclosing class
HardcoverSyncService) and either (A) delete the bookInput creation block if you
do not intend to update the user_book, or (B) add bookInput to the mutation
payload/variables alongside readInput and userBookId so edition_id and status_id
are actually sent to the server. Ensure tests/usage paths that expect the parent
status/edition behave accordingly after the change.
🧹 Nitpick comments (2)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (2)

43-50: Consider using @RequiredArgsConstructor for constructor injection.

The fields are already final, so this class could use Lombok's @RequiredArgsConstructor instead of explicit @Autowired constructor to align with Lombok patterns used elsewhere in the codebase.

♻️ Suggested refactor
+import lombok.RequiredArgsConstructor;
+
 `@Slf4j`
 `@Service`
+@RequiredArgsConstructor
 public class HardcoverSyncService {
 
     private static final String HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql";
     private static final int STATUS_CURRENTLY_READING = 2;
     private static final int STATUS_READ = 3;
 
-    private final RestClient restClient;
+    private final RestClient restClient = RestClient.builder()
+            .baseUrl(HARDCOVER_API_URL)
+            .build();
     private final HardcoverSyncSettingsService hardcoverSyncSettingsService;
     private final BookRepository bookRepository;
 
     // Thread-local to hold the current API token for GraphQL requests
     private final ThreadLocal<String> currentApiToken = new ThreadLocal<>();
-
-    `@Autowired`
-    public HardcoverSyncService(HardcoverSyncSettingsService hardcoverSyncSettingsService, BookRepository bookRepository) {
-        this.hardcoverSyncSettingsService = hardcoverSyncSettingsService;
-        this.bookRepository = bookRepository;
-        this.restClient = RestClient.builder()
-                .baseUrl(HARDCOVER_API_URL)
-                .build();
-    }

As per coding guidelines: "Prefer constructor injection via Lombok patterns already used in the codebase."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 43 - 50, Replace the explicit `@Autowired` constructor in
HardcoverSyncService with Lombok's `@RequiredArgsConstructor`: make sure the
fields hardcoverSyncSettingsService, bookRepository and restClient are declared
final (initialize restClient lazily or via a `@PostConstruct` if it requires
HARDCOVER_API_URL), remove the manual constructor, and annotate the class with
`@RequiredArgsConstructor` so Spring will use the generated constructor for
HardcoverSyncSettingsService and BookRepository injection while handling
restClient initialization appropriately (e.g., move RestClient.builder() into a
field initializer or `@PostConstruct` method).

486-490: Consider using a more specific exception type.

Throwing generic Exception makes it harder for callers to distinguish between error types. A custom exception (e.g., HardcoverApiException) or a standard type like IOException would be more descriptive.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 486 - 490, Replace the two generic "throw new Exception(...)"
usages in HardcoverSyncService (the method that processes the Hardcover
response) with a more specific exception type: either create and throw a custom
HardcoverApiException (e.g., extends IOException or RuntimeException) or throw
an existing specific type such as IOException; update the method signature or
callers if necessary to declare/handle the new exception, and ensure the new
exception includes the same descriptive message (e.g., "No response from
Hardcover for book ID ..." and "No data returned from Hardcover for book ID
...") so callers can distinguish Hardcover API errors from other failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 780-789: The code in HardcoverSyncService currently logs a warning
when updateUserBookReadResult.get("error") is non-null but still returns true;
change the logic in the block that inspects response -> data ->
update_user_book_read so that if updateUserBookReadResult != null and
updateUserBookReadResult.get("error") != null the method returns false (instead
of falling through to return true), preserving the existing log.warn; use the
existing local variable updateUserBookReadResult to implement the early return
and ensure all null-safety checks remain in place.

---

Outside diff comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 690-703: The Map bookInput is built but never used; either remove
its construction or wire it into the GraphQL mutation variables so the parent
user_book is updated. Locate the code that prepares the mutation variables
around where readInput and userBookId are sent (see symbols bookInput,
readInput, STATUS_READ, STATUS_CURRENTLY_READING and the enclosing class
HardcoverSyncService) and either (A) delete the bookInput creation block if you
do not intend to update the user_book, or (B) add bookInput to the mutation
payload/variables alongside readInput and userBookId so edition_id and status_id
are actually sent to the server. Ensure tests/usage paths that expect the parent
status/edition behave accordingly after the change.

---

Nitpick comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 43-50: Replace the explicit `@Autowired` constructor in
HardcoverSyncService with Lombok's `@RequiredArgsConstructor`: make sure the
fields hardcoverSyncSettingsService, bookRepository and restClient are declared
final (initialize restClient lazily or via a `@PostConstruct` if it requires
HARDCOVER_API_URL), remove the manual constructor, and annotate the class with
`@RequiredArgsConstructor` so Spring will use the generated constructor for
HardcoverSyncSettingsService and BookRepository injection while handling
restClient initialization appropriately (e.g., move RestClient.builder() into a
field initializer or `@PostConstruct` method).
- Around line 486-490: Replace the two generic "throw new Exception(...)" usages
in HardcoverSyncService (the method that processes the Hardcover response) with
a more specific exception type: either create and throw a custom
HardcoverApiException (e.g., extends IOException or RuntimeException) or throw
an existing specific type such as IOException; update the method signature or
callers if necessary to declare/handle the new exception, and ensure the new
exception includes the same descriptive message (e.g., "No response from
Hardcover for book ID ..." and "No data returned from Hardcover for book ID
...") so callers can distinguish Hardcover API errors from other failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7efcae4f-8ad5-4711-bee3-7ea1d521d1d4

📥 Commits

Reviewing files that changed from the base of the PR and between 07b5f83 and 983a4f8.

📒 Files selected for processing (1)
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Test Suite / Backend Tests
  • GitHub Check: Test Suite / Frontend Tests
  • GitHub Check: Analyze (java-kotlin)
🧰 Additional context used
📓 Path-based instructions (1)
booklore-api/src/**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

booklore-api/src/**/*.java: Use 4-space indentation in Java files and match surrounding Java style
Prefer constructor injection via Lombok patterns already used in the codebase. Do not introduce @Autowired field injection
Use MapStruct for entity/DTO mapping

Files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🧠 Learnings (8)
📓 Common learnings
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:06.817Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.
📚 Learning: 2026-03-24T18:58:08.199Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:47.249Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-04-01T17:50:06.817Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:06.817Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-27T15:06:25.913Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory PR: 217
File: booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java:151-153
Timestamp: 2026-03-27T15:06:25.913Z
Learning: In `booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java`, the try/catch around `koboServerProxy.proxyCurrentRequest()` in `syncLibrary()` is intentionally scoped only to handle a missing/failed proxy response (null). Once a response is successfully obtained, its body and headers (including the upstream sync token decoded via `tokenGenerator.fromBase64()`) are processed without additional defensive wrapping. This is a deliberate design decision: other parts of the response are equally unguarded, and the goal is to handle proxy unavailability gracefully — not to catch every possible downstream processing error.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:10.869Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:220-225
Timestamp: 2026-03-24T18:46:10.869Z
Learning: In the Hardcover GraphQL API, empty strings (`""`) are not valid ISBN values and querying with `_eq: ""` returns no results. Therefore, binding a missing ISBN to `""` in `_or` edition filters in HardcoverSyncService.java is safe and will not accidentally match unrelated editions or books.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-04-04T15:36:55.394Z
Learnt from: balazs-szucs
Repo: grimmory-tools/grimmory PR: 372
File: booklore-api/src/main/java/org/booklore/app/service/AppBookService.java:357-359
Timestamp: 2026-04-04T15:36:55.394Z
Learning: In `booklore-api/src/main/java/org/booklore/app/service/AppBookService.java`, `shelfId` and `magicShelfId` are mutually exclusive navigation contexts in `getFilterOptions`. A request will never supply both at the same time, so the `else if (shelfId != null)` branch after the `magicBookIds != null` check is intentional and correct — there is no missing shelf-∩-magicShelf intersection case. Do not flag this pattern as a bug.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-26T01:46:48.863Z
Learnt from: CR
Repo: grimmory-tools/grimmory PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-26T01:46:48.863Z
Learning: Applies to booklore-api/src/**/*.java : Use MapStruct for entity/DTO mapping

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🔇 Additional comments (6)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (6)

159-177: Backfill readInfo.editionId when null to ensure the read is associated with the resolved edition.

When readInfo.editionId == null, the code proceeds to update but never assigns hardcoverBook.editionId to readInfo.editionId. Subsequently, line 756 writes the still-null editionId to the API, leaving the read detached from the resolved edition.

Also, the condition on line 160 has a redundant null check: (readInfo.editionId != null && ...) is always true at that point due to short-circuit evaluation.

🩹 Proposed fix
                     // Only update if the edition matches (to avoid updating progress on a different edition if the user restarted the book with a different edition). If edition is missing, we assume it's the same edition and update it.
-                    if (readInfo.editionId == null || (readInfo.editionId != null && readInfo.editionId.equals(hardcoverBook.editionId))) {
+                    if (readInfo.editionId == null || readInfo.editionId.equals(hardcoverBook.editionId)) {
+                        // Backfill edition_id if missing so the read is properly associated
+                        if (readInfo.editionId == null) {
+                            readInfo.editionId = hardcoverBook.editionId;
+                        }
                         readInfo.progressPages = progressPages;

124-149: Serialization of sync per user/book remains unaddressed.

This multi-step read/modify/write flow is still vulnerable to race conditions when concurrent async calls for the same user+book overlap. Both could observe the same remote state and insert duplicate reads, which is the exact issue this PR aims to fix.


524-588: LGTM!

The insertUserBook method has proper error handling for both GraphQL-level errors and response-level errors, and correctly returns the created UserBookWithReads or null on failure.


597-666: LGTM!

The updateUserBook method appropriately handles the Hardcover API quirks as documented in the comments, and consistently returns null on any error condition.


857-895: LGTM!

The helper classes UserBookWithReads and UserBookReadInfo are appropriate internal DTOs for mapping GraphQL responses. The fromMap factory method cleanly handles the conversion.


825-838: LGTM!

The extractFloat helper follows the same pattern as extractInteger and properly handles the various types that can come from GraphQL responses.

@pedronave

Copy link
Copy Markdown
Contributor Author

Sorry, was away for the extended easter weekend.
The quirk I was noticing was that adding a new read entry, would place it in the middle of the list, instead of at the bottom, which would prevent the progress from being shown on Hardcover. I chatted with the Hardcover team, and it is supposed to always add to the bottom. This was also happening on the Hardcover UI, so it probably had nothing to do with this, but it may have been a bug, since I can't reproduce it anymore.

So far, the only other quirk I can find is when the user_book is not marked as read, and we insert a finished user_book_read (this happens if the first progress sync is for 100% progress), the book status stays Currently Reading instead of Read.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (2)

160-177: ⚠️ Potential issue | 🟠 Major

Backfill missing read edition_id before update_user_book_read.

The null-edition match branch updates progress but keeps readInfo.editionId null, and then Line 756 sends that null back in mutation payload. This leaves the read detached from the resolved edition.

🩹 Suggested fix
-                    if (readInfo.editionId == null || (readInfo.editionId != null && readInfo.editionId.equals(hardcoverBook.editionId))) {
+                    if (readInfo.editionId == null || readInfo.editionId.equals(hardcoverBook.editionId)) {
+                        if (readInfo.editionId == null) {
+                            readInfo.editionId = hardcoverBook.editionId;
+                        }
                         readInfo.progressPages = progressPages;

Also applies to: 756-757

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 160 - 177, The branch in HardcoverSyncService where
readInfo.editionId is null but matches the hardcoverBook should set
readInfo.editionId = hardcoverBook.editionId before calling updateUserBookRead
so the mutation payload contains the resolved edition; specifically, in the
block that logs "Updating existing reading progress..." and calls
updateUserBookRead(userBook.id, readInfo), assign readInfo.editionId =
hardcoverBook.editionId (and then proceed to set progressPages/finishedAt and
call updateUserBookRead) to avoid persisting a null edition_id.

61-192: ⚠️ Potential issue | 🟠 Major

Serialize sync per userId+bookId to prevent duplicate user_book_read inserts.

syncProgressToHardcover(...) is async and invoked from multiple services; concurrent calls for the same user/book can still run the same read-modify-write sequence and both choose the “insert new read” path.

🛠️ Suggested fix
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
...
 public class HardcoverSyncService {
+    private final ConcurrentHashMap<String, ReentrantLock> syncLocks = new ConcurrentHashMap<>();
...
     public void syncProgressToHardcover(Long bookId, Float progressPercent, Long userId) {
         try {
             ...
-            Integer hardcoverBookIdInt = extractInteger(hardcoverBook.bookId);
+            Integer hardcoverBookIdInt = extractInteger(hardcoverBook.bookId);
+            String lockKey = userId + ":" + hardcoverBookIdInt;
+            ReentrantLock lock = syncLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
+            lock.lock();
+            try {
                 // existing getUserBookAndReads / update / insert flow
+            } finally {
+                lock.unlock();
+                if (!lock.hasQueuedThreads()) {
+                    syncLocks.remove(lockKey, lock);
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 61 - 192, Concurrent executions of syncProgressToHardcover(userId,
bookId, ...) can race and create duplicate user_book_read entries; serialize
operations per userId+bookId. Fix by adding a per-key lock (e.g., a
ConcurrentHashMap<String, ReentrantLock> or Guava Striped lock) in
HardcoverSyncService and acquire the lock at the start of
syncProgressToHardcover using a key like userId + ":" + bookId, then hold the
lock around the read-modify-write sequence that calls getUserBookAndReads,
insertUserBook, updateUserBook, insertUserBookRead and updateUserBookRead, and
finally release the lock in a try/finally (cleaning up the lock map entry when
no longer needed); alternatively, implement the serialization using a DB-level
pessimistic lock when loading the user_book row in getUserBookAndReads to ensure
only one updater proceeds.
🧹 Nitpick comments (1)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (1)

516-523: Align Javadocs with actual method signatures and behavior.

insertUserBook(...) Javadoc documents progressPages and isFinished params that are not in the signature, and insertUserBookRead(...) Javadoc says it updates user_book state even though this method only sends userBookReadObject.

Also applies to: 668-677

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`
around lines 516 - 523, The Javadocs are out of sync: update the JavaDoc for
insertUserBook(...) to only document the actual method parameters and behavior
(remove references to progressPages and isFinished if they are not in the
signature, and describe that it inserts a user_book and creates a user_book_read
with the editionId provided), and update the JavaDoc for insertUserBookRead(...)
to reflect that it only sends/persists the provided userBookReadObject (do not
claim it updates user_book state if it does not). Make the descriptions and
`@param/`@return tags match the actual method signatures and returned types (e.g.,
UserBookWithReads, userBookReadObject, editionId) and ensure consistency with
the related JavaDoc block at the other location (lines ~668-677).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 160-177: The branch in HardcoverSyncService where
readInfo.editionId is null but matches the hardcoverBook should set
readInfo.editionId = hardcoverBook.editionId before calling updateUserBookRead
so the mutation payload contains the resolved edition; specifically, in the
block that logs "Updating existing reading progress..." and calls
updateUserBookRead(userBook.id, readInfo), assign readInfo.editionId =
hardcoverBook.editionId (and then proceed to set progressPages/finishedAt and
call updateUserBookRead) to avoid persisting a null edition_id.
- Around line 61-192: Concurrent executions of syncProgressToHardcover(userId,
bookId, ...) can race and create duplicate user_book_read entries; serialize
operations per userId+bookId. Fix by adding a per-key lock (e.g., a
ConcurrentHashMap<String, ReentrantLock> or Guava Striped lock) in
HardcoverSyncService and acquire the lock at the start of
syncProgressToHardcover using a key like userId + ":" + bookId, then hold the
lock around the read-modify-write sequence that calls getUserBookAndReads,
insertUserBook, updateUserBook, insertUserBookRead and updateUserBookRead, and
finally release the lock in a try/finally (cleaning up the lock map entry when
no longer needed); alternatively, implement the serialization using a DB-level
pessimistic lock when loading the user_book row in getUserBookAndReads to ensure
only one updater proceeds.

---

Nitpick comments:
In
`@booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java`:
- Around line 516-523: The Javadocs are out of sync: update the JavaDoc for
insertUserBook(...) to only document the actual method parameters and behavior
(remove references to progressPages and isFinished if they are not in the
signature, and describe that it inserts a user_book and creates a user_book_read
with the editionId provided), and update the JavaDoc for insertUserBookRead(...)
to reflect that it only sends/persists the provided userBookReadObject (do not
claim it updates user_book state if it does not). Make the descriptions and
`@param/`@return tags match the actual method signatures and returned types (e.g.,
UserBookWithReads, userBookReadObject, editionId) and ensure consistency with
the related JavaDoc block at the other location (lines ~668-677).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6bbb588c-e3d3-48e7-9b8a-13cd0b4b8716

📥 Commits

Reviewing files that changed from the base of the PR and between 983a4f8 and 9a11a2e.

📒 Files selected for processing (1)
  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Test Suite / Backend Tests
  • GitHub Check: Test Suite / Frontend Tests
  • GitHub Check: Analyze (java-kotlin)
🧰 Additional context used
📓 Path-based instructions (1)
booklore-api/src/**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

booklore-api/src/**/*.java: Use 4-space indentation in Java files and match surrounding Java style
Prefer constructor injection via Lombok patterns already used in the codebase. Do not introduce @Autowired field injection
Use MapStruct for entity/DTO mapping

Files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🧠 Learnings (9)
📓 Common learnings
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:06.817Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.
📚 Learning: 2026-03-24T18:58:08.199Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:291-294
Timestamp: 2026-03-24T18:58:08.199Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a Hardcover edition has no page count, falling back to the book-level page count (book.pages) is the preferred behavior. Skipping the sync entirely in this case is considered worse than using a slightly approximate total, because the page count is only a proxy for converting progress % to page numbers for the API. The Hardcover API allows setting page-based progress even when the edition lacks a page count, and progress displays in at least some UI views (e.g., the book detail dropdown).

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-04-01T17:50:06.817Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 247
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:156-159
Timestamp: 2026-04-01T17:50:06.817Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), the Hardcover API already returns `user_book_reads` in a consistent, ordered fashion (ascending by recency), so `getLast()` reliably retrieves the most recent read entry. Adding an explicit `order_by` to the `user_book_reads` query is unnecessary.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:47.249Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:188-199
Timestamp: 2026-03-24T18:46:47.249Z
Learning: In grimmory-tools/grimmory (HardcoverSyncService.java), when a `hardcoverBookId` is stored in book metadata but is malformed (non-numeric), the preferred behavior is to return null and skip the sync rather than falling back to ISBN-based lookup. The reasoning is that an explicitly set bookId represents deliberate user intent, and silently resolving via ISBN could sync progress to a different book than intended.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-24T18:46:10.869Z
Learnt from: pedronave
Repo: grimmory-tools/grimmory PR: 173
File: booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java:220-225
Timestamp: 2026-03-24T18:46:10.869Z
Learning: In the Hardcover GraphQL API, empty strings (`""`) are not valid ISBN values and querying with `_eq: ""` returns no results. Therefore, binding a missing ISBN to `""` in `_or` edition filters in HardcoverSyncService.java is safe and will not accidentally match unrelated editions or books.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-04-04T15:36:55.394Z
Learnt from: balazs-szucs
Repo: grimmory-tools/grimmory PR: 372
File: booklore-api/src/main/java/org/booklore/app/service/AppBookService.java:357-359
Timestamp: 2026-04-04T15:36:55.394Z
Learning: In `booklore-api/src/main/java/org/booklore/app/service/AppBookService.java`, `shelfId` and `magicShelfId` are mutually exclusive navigation contexts in `getFilterOptions`. A request will never supply both at the same time, so the `else if (shelfId != null)` branch after the `magicBookIds != null` check is intentional and correct — there is no missing shelf-∩-magicShelf intersection case. Do not flag this pattern as a bug.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-27T15:06:25.913Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory PR: 217
File: booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java:151-153
Timestamp: 2026-03-27T15:06:25.913Z
Learning: In `booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java`, the try/catch around `koboServerProxy.proxyCurrentRequest()` in `syncLibrary()` is intentionally scoped only to handle a missing/failed proxy response (null). Once a response is successfully obtained, its body and headers (including the upstream sync token decoded via `tokenGenerator.fromBase64()`) are processed without additional defensive wrapping. This is a deliberate design decision: other parts of the response are equally unguarded, and the goal is to handle proxy unavailability gracefully — not to catch every possible downstream processing error.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-25T19:09:09.638Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory PR: 189
File: booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java:113-116
Timestamp: 2026-03-25T19:09:09.638Z
Learning: In `booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java`, when deciding whether to forward the `Authorization` header to the upstream Kobo server proxy on the `/v1/library/sync` endpoint, a simple null check (`request.getHeader(HttpHeaders.AUTHORIZATION) != null`) is the correct guard. The header is either absent or present — if present it may or may not be a valid token, but validity is determined by the upstream Kobo server, not by Grimmory. Blank/whitespace intermediate states do not occur in practice, and pre-validating the token value before forwarding is not the responsibility of this service.

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
📚 Learning: 2026-03-26T01:46:48.863Z
Learnt from: CR
Repo: grimmory-tools/grimmory PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-26T01:46:48.863Z
Learning: Applies to booklore-api/src/**/*.java : Use MapStruct for entity/DTO mapping

Applied to files:

  • booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java
🔇 Additional comments (1)
booklore-api/src/main/java/org/booklore/service/hardcover/HardcoverSyncService.java (1)

780-790: Good fix on mutation error handling.

Returning false when update_user_book_read.error is present correctly prevents false success logging in the caller path.

@zachyale

zachyale commented Apr 7, 2026

Copy link
Copy Markdown
Member

[...] So far, the only other quirk I can find is when the user_book is not marked as read, and we insert a finished user_book_read (this happens if the first progress sync is for 100% progress), the book status stays Currently Reading instead of Read.

@pedronave Given the improvements in behaviour within this PR and the fact your mentioned issue is not a regression from current behaviour, I wouldn't necessarily consider that issue a blocker for this first approach to fixing Hardcover.

Would you like to take a stab at the Currently Reading issue in this PR, or should we make a follow up issue for it?

@pedronave

Copy link
Copy Markdown
Contributor Author

[...] So far, the only other quirk I can find is when the user_book is not marked as read, and we insert a finished user_book_read (this happens if the first progress sync is for 100% progress), the book status stays Currently Reading instead of Read.

@pedronave Given the improvements in behaviour within this PR and the fact your mentioned issue is not a regression from current behaviour, I wouldn't necessarily consider that issue a blocker for this first approach to fixing Hardcover.

Would you like to take a stab at the Currently Reading issue in this PR, or should we make a follow up issue for it?

I'd say we can go ahead with this PR and I'll try to get to it later on. Maybe the hardcover will make some improvements in the meantime :D

@balazs-szucs balazs-szucs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Sounds good to me.

@balazs-szucs balazs-szucs merged commit 6f78a48 into grimmory-tools:develop Apr 7, 2026
14 checks passed
zachyale pushed a commit to zachyale/grimmory that referenced this pull request Apr 17, 2026
…ogress (grimmory-tools#247)

* fix: use ISBN to match the edition to sync

* fix: optimize fallback to default editions when ISBN is not present

* chore: added tests for hardcover sync

* fix: handle cases where hardcover does not have page count

* fix: initial fix to hardcover sync

* added some additional logic to cover the hardcover API

* feat: added tests for hardcover progress sync

* chore: code cleanup

* fix: check if the progress insert and update are actually successful before logging

* fix: ensure book edition id is being updated

* fix: updateUserBookRead returned true even if there was an error
zachyale pushed a commit to zachyale/grimmory that referenced this pull request Apr 17, 2026
…ogress (grimmory-tools#247)

* fix: use ISBN to match the edition to sync

* fix: optimize fallback to default editions when ISBN is not present

* chore: added tests for hardcover sync

* fix: handle cases where hardcover does not have page count

* fix: initial fix to hardcover sync

* added some additional logic to cover the hardcover API

* feat: added tests for hardcover progress sync

* chore: code cleanup

* fix: check if the progress insert and update are actually successful before logging

* fix: ensure book edition id is being updated

* fix: updateUserBookRead returned true even if there was an error
zachyale pushed a commit that referenced this pull request Apr 17, 2026
…ogress (#247)

* fix: use ISBN to match the edition to sync

* fix: optimize fallback to default editions when ISBN is not present

* chore: added tests for hardcover sync

* fix: handle cases where hardcover does not have page count

* fix: initial fix to hardcover sync

* added some additional logic to cover the hardcover API

* feat: added tests for hardcover progress sync

* chore: code cleanup

* fix: check if the progress insert and update are actually successful before logging

* fix: ensure book edition id is being updated

* fix: updateUserBookRead returned true even if there was an error
zachyale pushed a commit that referenced this pull request Apr 22, 2026
…ogress (#247)

* fix: use ISBN to match the edition to sync

* fix: optimize fallback to default editions when ISBN is not present

* chore: added tests for hardcover sync

* fix: handle cases where hardcover does not have page count

* fix: initial fix to hardcover sync

* added some additional logic to cover the hardcover API

* feat: added tests for hardcover progress sync

* chore: code cleanup

* fix: check if the progress insert and update are actually successful before logging

* fix: ensure book edition id is being updated

* fix: updateUserBookRead returned true even if there was an error
dsmouse pushed a commit to dsmouse/grimmory that referenced this pull request May 11, 2026
…ogress (grimmory-tools#247)

* fix: use ISBN to match the edition to sync

* fix: optimize fallback to default editions when ISBN is not present

* chore: added tests for hardcover sync

* fix: handle cases where hardcover does not have page count

* fix: initial fix to hardcover sync

* added some additional logic to cover the hardcover API

* feat: added tests for hardcover progress sync

* chore: code cleanup

* fix: check if the progress insert and update are actually successful before logging

* fix: ensure book edition id is being updated

* fix: updateUserBookRead returned true even if there was an error
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hardcover Sync: Creates Multiple Dates Read; Stops Syncing.

3 participants