Skip to content

fix: make favorite writes idempotent and ordered - #183

Merged
moul merged 1 commit into
mainfrom
fix/favorites-write-race
Aug 2, 2026
Merged

fix: make favorite writes idempotent and ordered#183
moul merged 1 commit into
mainfrom
fix/favorites-write-race

Conversation

@moul

@moul moul commented Aug 2, 2026

Copy link
Copy Markdown
Owner

A second bug in #171, found by probing rather than re-reading.

Reproduction

Double-click the star:

after a double-click → UI pressed=false, rows in IndexedDB=1
*** INCONSISTENT ***
after reload, UI pressed = true

The UI and the database disagreed permanently. The star read unstarred, the row was there, and a reload brought it back.

Why

sdk.favorites.toggle(refUri, label) re-derived from the database a decision the caller had already made — a read-modify-write with no ordering guarantee. Two calls in one tick both read "not favorited", so both wrote, while the in-memory store had toggled twice and settled on "not favorited".

The fix

set(refUri, label, favorite). Taking the desired state as an argument removes the read entirely, and a write queue — the same pattern the Trail API already uses for exactly this reason — keeps two writes for one refUri in the order they were requested, so the last one wins.

Re-starring something already starred keeps its original createdAt, so a no-op write can't quietly move it to the top of the newest-first list on the Browser home.

Verification

2 rapid clicks → UI=false, db rows=0  consistent
3 rapid clicks → UI=true,  db rows=1  consistent
5 rapid clicks → UI=false, db rows=0  consistent
after reload UI = false | db rows = 0

Four new SDK tests: set/clear, idempotence (including that createdAt doesn't move), clearing something absent, and overlapping writes. The overlap test was checked to fail without the queue — expected [ { …(3) } ] to have a length of +0 but got 1.

Note on the API change

toggle is gone rather than kept alongside set. It only ever had one caller, and leaving a read-modify-write primitive available invites the same bug back in.

Double-click the star and the UI and the database disagreed permanently:
the star read unstarred, the row was in IndexedDB, and a reload brought it
back.

sdk.favorites.toggle re-derived from the database a decision the caller
had already made — a read-modify-write with no ordering guarantee. Two
calls in the same tick both read "not favorited", so both wrote, while the
in-memory store had toggled twice and settled on "not favorited".

Replaced with set(refUri, label, favorite). Taking the desired state as an
argument removes the read; a write queue — the same pattern the Trail API
already uses for the same reason — keeps two writes for one refUri in the
order they were requested, so the last one wins.

Re-starring something already starred keeps its original createdAt, so a
no-op write cannot move it to the top of the newest-first list on the
Browser home.

Found by probing, not by reading: firing two clicks in one tick and then
comparing the rendered state against IndexedDB directly. The overlap test
was checked to fail without the queue.
@moul
moul merged commit 732731f into main Aug 2, 2026
9 checks passed
@moul
moul deleted the fix/favorites-write-race branch August 2, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant