Skip to content

fix(app): serialise quote writes against an incoming sync - #321

Open
kYem wants to merge 31 commits into
mainfrom
claude/quote-write-lock
Open

fix(app): serialise quote writes against an incoming sync#321
kYem wants to merge 31 commits into
mainfrom
claude/quote-write-lock

Conversation

@kYem

@kYem kYem commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Every quote writer read the array, mutated it, and wrote it back — so a pull landing in the service worker between the read and the write was erased by whatever the page wrote. Quotes were the last collection without the lock goals, reminders and collections already had.

All 19 writers now read inside the 'quotes' web lock the sync engine's arrayBinding already takes, and collect their sync notify ids from that locked read — so a quote a pull removed is not marked dirty and tombstoned for every other device.

Most of this PR is not the lock

Checking the write result made a lot of previously-silent failure reachable, and that exposed the reporting layer behind it. Fifteen review rounds turned up ~75 defects; these are the ones worth a reviewer's attention:

  • A favourite could destroy the quote it was applied to. isCustomQuote counts a favourited seed quote as custom, so the write moves it between storage keys — seed first, which drops it, then custom, which on a full disk never takes it. The quote ended up in neither key while the user was told only that the update failed. Pre-existing on main; the seed key is now restored when the second write fails.
  • The card outlived the quote. initialize trusted the stored card's contents and checked only that its id still existed, so a quote another device hid or edited kept rendering here — no failure involved.
  • A failed write took out the page. Both renderers treat error as "the load failed", so clicking the heart while over quota replaced the card, the shortcut and the quotes page with a "Failed to load quotes" panel — including the page you would use to free space.
  • Forms and dialogs reported success on failure. AddQuoteForm and EditQuoteModal cleared and closed on a failed save; the collection and reset dialogs vanished on writes that had not landed.
  • A reset and an unlink did not survive the next pull, because neither announced what it changed.
  • An id-less stored row could wedge sync permanently — the enroll backfill claimed it and the server rejected that device's whole push batch every cycle.

Notes for review

resetAllQuotes still replaces the list wholesale; it reads only to learn which ids it destroys. persistOneQuote takes its own lock and answers a discriminated union, so a write aimed at an id the locked read no longer holds is skipped rather than rewritten, and no caller can adopt an unpersisted list.

Every guard is mutation-tested: the lock, the locked read, each write-failure guard, each gone branch, each card repair and each outcome the UI acts on has a named test that fails when the guard is removed.

Worth knowing before you read it: the card-repair path in incrementViewCount took five attempts across three rounds — an unbounded recursion that exhausted the heap, a re-entry guard that reopened the phantom it existed to close, a blanked card, and an unfiltered pick — before the current version. It is the piece I would read most closely.

kYem added 30 commits August 12, 2026 10:16
Every quote writer hoisted its read above the write, so a pull landing in
the service worker between the two was erased by whatever the page wrote
back. Route them all through updateQuotes, which reads inside the same
'quotes' web lock arrayBinding already takes, and collect the sync notify
ids from the locked read so a quote the pull removed is not marked dirty
and tombstoned for every other device.
deleteQuote decided its tombstone from the pre-lock snapshot, the rule
bulkDelete states one function away, so a quote a pull had already removed
was announced as this device's delete and outranked every peer still
holding it. The single-quote writers also toasted success for a write that
matched nothing, losing the edit silently.

Also drop the "seed quotes are excluded" claim: the enroll backfill claims
every stored id, seed ones included. Only the announcement is custom-only,
which syncsLocalEdits now says once instead of ten times.
Mutation testing put real read-inside-the-lock coverage at 2 of 17 writers:
the rest passed whether they read storage or their own snapshot, because
the default getQuotes mock mirrors the store. Nineteen tests now fail when
the read is hoisted, and eight when the write-failure guard goes.

Also stop the sync sink block borrowing setQuotes from the describe above
it. Under isolation twelve of its tests failed, and its five negative
assertions passed only because the writer threw before reaching a notify.
Removing the stale-snapshot write took away what had been keeping the
displayed quote and the stored list in agreement: nothing converges
currentQuote, so a write that found the quote gone left the card
rendering it across reloads. bulkToggleFavorite was still stamping that
snapshot into storage outright.

Bulk toasts now count what the locked read matched rather than what was
selected, editQuote reports 'gone'/'failed' so the modal can keep a
retryable edit on screen, and a pull that beats the seed skips the write
instead of failing the load with it.
Nine of eighteen writers still passed with their lock removed entirely,
and eight of the sixteen write-failure guards were untested. The worst
were the ones with no coverage at all: incrementViewCount runs on every
new tab and rewrites the whole list, and addCustomQuote appends, so a
stale read there drops every quote a concurrent pull added.

Two tables were also passing loosely. The failed-write rows matched a
message substring, so editQuote's assertion was satisfied by
bulkToggleHidden's copy, and they compared ids where every mutation but a
delete leaves the id alone. The announce rows inspected one sink channel,
so an id leaking through another was invisible.
A pull that removed everything the user had selected left them reading
"Deleted 0 quotes" as a success. The single-quote writers already warn in
that case, and restoreMissingQuotes already downgrades its own zero to an
info toast.
The card outlived the quote on every bulk path: reading inside the lock
turned the currentQuote rewrite into a lookup that can miss, and nothing
took the else branch. Bulk unhide never touched currentQuote at all.

addQuotesToCollection counted only what it added, so a pull that deleted
the whole selection was reported as membership the quotes already had.

Clearing the card also stopped at memory: nothing clears the stored key
and initialize only rejected a hidden quote, so the next tab restored one
the list no longer holds.
The shared table matched one `if (!result.success) { throw }` form, so the
seed write, restoreMissingQuotes, resetAllQuotes and bulkAddQuotes were
never pinned — each could lose its guard with the suite green.

Also correct the fresh-array note in the converging block: the no-op guard
compares by value; it is the test below that asserts reference identity,
and one shared array satisfies that either way.
clearCurrentQuoteIfGone never checked whether the quote was gone — it
matched the id and refreshed. That was fine where the caller already knew,
but bulk unhide called it for a quote that had survived, so unhiding a
batch containing the displayed quote swapped the card, bumped a view count
and pushed history for a write that touched only the hidden flag.

It now checks against the written list, so the name is true wherever it is
called, and addQuotesToCollection gets the guard the other bulk writers
already had.
QuoteDisplay and QuoteManagementPage both read `error` as "initialize
failed" and render a panel titled "Failed to load quotes" in place of
everything. On main a failed write never reached those catch blocks
because the result was discarded, so checking it made the collapse
reachable: clicking the heart while over quota took out the card, the
Space shortcut and the whole quotes page, and the user's route to freeing
space was behind the same panel.

The writers keep their log and toast. `error` now means only what the two
renderers already assume it means.
The freshness tables asserted toContain('late'), so a writer that picked
up the locked read and dropped the quote it was editing satisfied them —
real data loss for every row but the delete. They compare the whole list
now, which fifteen tests catch.

The seed test read setQuotes.mock.calls[0], which is the view-count bump,
not a seed write; bulkToggleFavorite's announce check inspected one sink
channel where the table it belongs in checks all three. Also drop the
QuoteForm reference, a component that does not exist, and move the sink
cleanup into afterEach so a throwing act cannot leak it forward.
Dropping `error` state from the writers removed the panel that had been
covering for three callers that never read an outcome. AddQuoteForm was
the worst of them: addCustomQuote resolved the same either way, so a
failed save wiped every field and closed the form, which is the bug
EditQuoteModal was fixed for one file over.

addCustomQuote and the three bulk writers now report whether the write
landed; the form keeps what was typed, the collection modal stays open on
a partial apply, and a failed bulk keeps the selection so the retry does
not start from scratch.

refreshQuote also stops latching `error` — the new-tab interval calls it
unprompted, so a failed write there replaced the card with a load-failure
panel the user never triggered.
Neither announced what it changed, so a peer still holding those quotes
pushed them straight back: the reset wiped custom quotes locally only, and
deleting a collection left its members re-linked on every other device.
The reset still replaces the list wholesale — it reads only to learn which
ids it destroys.

Quota failures also stop advising a retry that cannot work. The writers
throw through assertPersisted now, so the StorageError survives as `cause`
and the catch can say the disk is full instead. A write aimed at an id the
locked read no longer holds is skipped rather than rewritten identically,
which stops a pointless failure masking the real answer.
Collecting tombstone ids gave resetAllQuotes a read it never had, and an
unreadable quote key throws — so the one lever for recovering a wedged
store refused to run, permanently, on exactly the storage it is for. The
read is guarded now and raw, which also matches the raw write: the
validating read hides the rows that write destroys, so their ids were
never getting tombstones.

The collection modal could also never close once a pull deleted a selected
quote: 'gone' and 'failed' both came back as false, and reapplying could
only fail the same way. Those two writers report the outcome the rest
already do.

Seeding was the one write still dropping its StorageError, and it is the
largest one in the store, so a full disk on first run advised a refresh
that could not work. assertPersisted also stops reading a missing result
as a successful write.
Switching the tombstone read to raw dropped the schema guarantee with it,
so a row with no usable id could reach notifyDeleted. The push then fails
validation for the whole batch and never clears — every later push from
that device fails on it too, until a re-enroll.

An unreadable read also left the reset claiming success while authoring no
tombstones at all, so the quotes came back from every peer with nothing on
screen connecting the two. It warns now, like deleteCollection does.

setCurrentQuote was the last storage write in this store whose result was
dropped — ten call sites, no log, so the displayed quote could revert on
the next tab with no record anywhere.
persistCurrentQuote logged and moved on, so editQuote toasted "Quote
updated successfully" while the write that keeps the card in step had
failed. initialize reads that key back verbatim, so the card then showed
the pre-edit quote on every later tab while the list showed the new one.
The six writers whose list write has already landed now say the card is
behind instead of claiming success.

The helper also dereferenced a result it had just admitted might be
nullish — the same shape assertPersisted was hardened against in the
commit that introduced it — and let getStorageArea's rejection escape,
which reported the whole action as failed for a write that persisted. It
answers now, so both exits land in the same place.

The reset warning no longer tells a sync-off user their quotes may return
from devices they do not have, the modal drops the changes that landed
rather than reporting the whole batch as pending, and addQuotesToCollection
clears the selection its two siblings already clear.
…s it

The message had both nouns backwards. Every emit site sets currentQuote
straight after the failed write, so this tab is the one surface showing
the new value — it is the stored card other tabs read that is stale. And
initialize reads that key back verbatim, so refreshing is what replaces
the edit with the old copy, on every reload. The advice reproduced the
bug it warned about.

refreshQuote no longer warns at all: hideQuote, deleteQuote and bulkDelete
all delegate their card write to it, and there the stale key self-heals
because initialize discards a card whose quote is gone — so the warning
was a false alarm arriving next to a contradicting success toast, and
"Saved," was untrue for the unattended interval.

The bulk writers also stopped returning early, which had swallowed the
matched count an earlier round added to surface a partly-deleted
selection. resetAllQuotes is the seventh writer of this shape and now
reports it too, and rows skipped for having no id mark the reset as
incomplete rather than logging below the shipped log level.
initialize took the stored card's contents on trust and checked only that
its id still existed. So a quote another device hid or edited kept
rendering on this one — no failure involved, just a snapshot nothing
converges. It now resolves that id against the list it just read.

That removes the reason the card warnings existed. For every writer that
keeps the quote's id, a lost card write is now invisible: the next mount
reads the live copy. The message they emitted is gone, and with it the
flag six writers threaded through to reach it.

The same unvalidated-id hazard the reset guards against was still open in
the sync binding: readAll keyed an id-less row as "undefined", which the
enroll backfill claims and the server rejects, failing that device's whole
push batch on every cycle until a re-enroll.
deleteCollection has reported its outcome since the collections work and
nothing read it, so a failed delete closed the dialog and left the
collection in the list with an error toast as the only trace.

The bulk handlers had the same gap in their tests rather than their code:
nothing reached handleBulkUnhide or either favourite handler, so flipping
one argument would have inverted the action across the user's whole
selection with the suite still green. AddQuoteForm had no test at all,
though its early return is the reason addCustomQuote reports anything.

The bulk toolbar gets a role and a name so those tests can tell its
buttons from the identically titled ones on each quote card.
persistOneQuote's gone path returned `{success: true}` for a write that
never ran, so assertPersisted — the one check keeping an unpersisted list
off the screen — was handed a value nothing vouched for. Only the
correlated `target` could tell the two apart, and the type did not
correlate them: seven callers hand-ordered assert, set, null-check, and
skipping the last was silent.

It answers a discriminated union now. Reading `target` without
discriminating is a compile error, so the ordering is the compiler's to
enforce rather than each caller's to remember, and the fabricated result
has nowhere to live.

The id is also kept from the map instead of re-found afterwards: a change
that rewrote it read as gone after the write had already landed.
Deleting the card warnings was right for the six writers that keep the
quote's id — the next mount re-resolves it from the list. It was wrong for
the two that change the id: refreshQuote and navigateHistory replace a
stored id that is still live, so nothing re-resolves it and the tab the
user just navigated reverts everywhere else, silently. refreshQuote now
reports only a deliberate reroll, so the interval stays quiet.

incrementViewCount was the one gone path that neither reported nor moved
the card, which is the phantom an earlier commit removed reaching back
through the writer that fix skipped.

persistOneQuote also folds the assert in and drops `result`, so both
members of the union carry a list storage has vouched for; and `target` is
boxed, because a bare `let` narrows to `never` past the guard and the
compiler stops checking the guarantee the union exists for.
Giving the view-count gone path a card refresh closed a phantom but tied
two writers into a loop: refreshQuote bumps the count, a gone count
rerolls the card, and the reroll bumps again. Each hop usually resyncs the
list and settles, so it looked safe — but a read that keeps answering a
list without the quote just picked never settles. The test that proves it
takes the worker down without the guard.

A first attempt at that test passed against a deliberately broken version,
because an empty list terminates on its own; it needed a read that answers
a different quote every time to reach the real path.
Gating the warning on user intent missed a caller: ConceptRotation's skip
is a keypress, paired with onManualRefresh for the same reason the space
shortcut is, and it passed no options — so the one path the gate exists
for stayed silent. The comment justifying the gate enumerated two kinds of
caller and this was a third.

The refuted sync claim also survived in the test file: the source copy was
corrected last round and its duplicate was not, which is the cost of
saying the same thing twice made concrete. Inserting the report-once set
between arrayBinding's docblock and arrayBinding orphaned it, the same way
an earlier round already found once.
Bounding the cycle with a re-entry flag stopped the hang and reopened the
phantom it existed to close: the nested call is the card repair, so
refusing it left the card on a quote storage had just proven gone. Two
reviewers reproduced that with a failing assertion, and one measured the
unguarded version dying of heap exhaustion after 31,342 levels.

The gone path now picks a replacement from the list already in hand. No
call back into refreshQuote, so nothing can recurse, and the card keeps a
real quote rather than blanking into "No quotes available".

The reset also stopped shadowing its own warning — a card message must not
swallow the one saying the wiped quotes may return from other devices —
and the gone reports move to error, which is the level shipped builds keep.
The gone path picked with getRandomQuote(list) and no arguments, so the
card could land on a category the user turned off, a non-favourite under
favourites-only, or a quote outside the collection they were looking at —
while refreshQuote passes all four. Same list, same moment, different
rules depending on which path repaired the card.

The test that would have caught it could not: EMPTY_STORE_STATE never
reset the filter fields, so a test that narrowed them left every later
test picking from a filtered list. It resets them now, which is what made
the spin guard fail the moment a filter was introduced above it.
isCustomQuote counts a favourited or hidden seed quote as custom, so the
write moves it between keys. The seed key is written first and drops it,
then a failed custom write never takes it — leaving the quote in neither,
while the user is told only that the update failed. The observer then
makes it disappear from the list with no further word.

The seed key is now captured before the write and restored when the
second one fails, on the throwing path as well as the resolving one.

Also here: initialize and resetAllQuotes were the two remaining unfiltered
card picks, the reset dialog closed on a failed reset, and toggling a
pending change back left it counted and rewrote the list for nothing.
readAll went straight to storage while writeOne took the lock and said why.
Quotes live in two keys, so a favourite moving between them is briefly in
neither; a push cycle reading in that window sees no entity and seals a
tombstone every other device applies.

Unlocked, the new test observes [] where the quote should be — the absence
that becomes a delete.
setQuotesRaw never got the try/catch setQuotes has, so getStorageArea
rejecting after the seed write skipped the rollback entirely — an earlier
commit message claimed parity the code did not have. Both now share one
helper rather than two copies that drift.

Two reporting bugs in that path: a failed custom write logged nothing at all
when the seed snapshot was unreadable, which is the case that loses the
quote outright; and `stranded` filtered the pre-write rows by isCustomQuote,
so it was empty for exactly the quote at risk.

The rollback table covers both writers against a result failure and a throw,
and asserts the restored quote is the pre-write copy — an id-only assertion
cannot see a rollback that lands the incoming favourite.
resetAllQuotes only moved the card when the pick returned a quote. That
else-branch was dead while the pick was unfiltered; filtering it made it
live, so a user whose filters exclude every seed quote keeps looking at a
custom quote the reset just deleted.

deleteQuote asserted the write before checking whether the id was still
there, so a delete a pull had already applied surfaced as "storage is full"
— telling the user to free space for something already done.

incrementViewCount's gone path no longer warns that the card was not
remembered: the stored id is the deleted one, so the next mount rerolls and
there is nothing for the user to lose.

pickQuote names the four filter fields at all four call sites; getRandomQuote
takes two adjacent booleans that transpose without a type error.
The setQuotes docblock still said a failed second write leaves the first
standing, which the rollback commit made false. Two test comments named
scenarios the code cannot produce: navigateHistory skips deleted quotes
rather than landing on them, and the claim that every existing case seeded a
gone id is refuted by the live-id case at :285.

handleBulkDelete's catch closed the dialog, the inverse of the rule stated
three lines above it. expectNavigationMethodCalled asserted nothing when the
mock store lacked the key, silently disarming every test using it.

Strengthens three assertions that could not fail for the reason they name:
the card-repair table had one survivor to choose from, the spin guard's
bound was unreachable because re-entry dies of OOM first, and QuoteDisplay's
two userInitiated call sites were unasserted.
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