Extract makeTimerBed() helpers in wholeBlobSync.shared.test.mjs and
channelSectionsSync.test.mjs, eliminating 18-22 line inline timer
factory blocks repeated across 9 tests.
Collapse the A-succeeds/A-fails CAS variants in mergeLaneSync.shared
and the 4 bootstrap seed-guard tests in both mergeLaneSync.shared and
wholeBlobSync.shared into parameterized for-loops.
No behavioral coverage removed; only structural duplication eliminated.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
P1.1: Both whole-blob storage event handlers (sections + sort) replaced React
state unconditionally from the shared scoped cache, bypassing the
hasPendingEdit() guard that applyRemote uses for relay arrivals. A peer window
writing B1 while A has a pending edit (A1) caused the updater for A2 to derive
from B1, stranding A1's outbox record (Carl r8).
Fix: check hasPendingEdit() in both storage handlers; defer the peer cache
write until the pending edit's debounced publish-or-adopt cycle completes.
When nothing is pending the peer write applies immediately, preserving
cross-window state mirroring for the idle case.
P1.2: parseChannelSectionPayload did not check version; relay decryptAndParse
called it bare, so a future {version: 2} relay head parsed as v1 state instead
of triggering the retain/retry path. Sort, stars, and mutes all reject non-v1
in their parsers; sections was the odd one out.
Fix: add version !== 1 guard at the top of parseChannelSectionPayload. The
local-storage parse path (parseRaw) already checked version before calling the
parser, so no local-storage read is affected.
Tests: two new hook regression tests (sections + sort) for P1.1 using
StorageEvent dispatch; one new manager regression test and one new storage unit
test for P1.2; existing parser tests updated to include version: 1 in
previously version-less test fixtures.
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Close the pass-3 review blocker and Carl's four P1s on the channel
sections/sort/stars/mutes relay sync, all in the sidebar lib:
- Gate the legacy-consumed marker on a proven v2 transfer. writeOwnOutbox
now returns whether the fresh key's setItem succeeded; the whole-blob
hooks write the marker only when durable, so a quota failure leaves the
legacy record replayable instead of silently suppressing the only copy.
- Reconnect wakes the existing pending edit (retryPendingPublish) rather
than re-queueing via publish*(), which reset the frozen baseline and
published a stale edit over an advanced remote.
- Merge lanes clear an own outbox only after an authoritative retained-head
fetch proves the head subsumes the attempted store; a bare EVENT OK on a
superseded NIP-33 write no longer drops the loser's durable click.
- An unreadable/unsupported pre-publish head retains the pending edit and
retries rather than overwriting state the client could not inspect.
- Document the accepted mixed-fleet residual (a same-second old-build click
reads rev 0 and loses to an earlier new-build rev, healing on the next
later-second click) and pin it with a test; no protocol change.
Regressions added for each item, mutation-verified where the fix lives in
the hook.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
localStorage has no atomic compare-and-delete or transactional
read-modify-write, so a single outbox key shared across every window
could not be mutated safely: one window's read-then-write or
read-then-remove races a peer's write in the gap and drops its
still-unpublished edit. A per-write ownership token narrowed that window
but could not close it — the token proves what was read, not what is
still stored at the destructive op.
Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>,
where the nonce is minted once and parked in sessionStorage. Each window
is the sole writer of its own key, so a hot-path write is one
unconditional setItem — the write race is designed out, not guarded.
Resume enumerates every window's key: merge lanes (stars/mutes) fold all
records order-independently; whole-blob lanes (sort/sections) replay the
max-queuedAt record with a nonce tiebreak. Redundant foreign keys are
reclaimed at boot, gated on durable relay evidence (merge: head subsumes;
whole-blob: head created_at supersedes) and re-read immediately before
removal so a live peer's fresh write in the recheck gap survives.
Reclamation runs only on a successful head fetch, never on a failed one.
The token contract is deleted entirely — ownership is the key.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Carl's re-review found three edit-loss paths remaining after the
stars/mutes rev-merge landed.
[P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1
uncapped, so a self-authored head accepted near the relay's +900s drift
boundary wedged every later publish until wall time caught up. Extract
the sections clamp into a shared clampPublishCreatedAt in
sidebarSyncWatermark.ts (all four surfaces already import that module)
and wire sections/stars/mutes/sort to it.
[P1] Sort preferences never got the durable lane: doPublish only logged
on failure, destroy() discarded the pending edit, and a live remote
cancelled the debounce with the edit stranded. Port the sections lane —
durable outbox + bootstrap resume, generation/CAS ownership, single-
flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and
pending-aware remote application. Sort stays whole-blob LWW; a lost head
is adopted at pre-publish rather than republished.
Tests: a clamp test per surface, and sort lifecycle coverage — outbox
resume after destroy-inside-debounce, retry without a later edit,
overlapping-generation safety, live-remote-during-debounce adopt, and a
hook-level pending-defer test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.
Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>