Commit Graph

12 Commits

Author SHA1 Message Date
Duncan
7c195d8fbd fix(sections-sync): guard storage handler against peer cache writes while pending, require version in sections parser
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>
2026-08-29 13:41:15 -04:00
Duncan
6078943f3b fix(sidebar): harden sidebar-sync durability, LWW, and mixed-fleet residual
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>
2026-08-27 13:21:22 -04:00
Duncan
3712e6fb2c fix(sidebar): make retained legacy whole-blob replay one-shot per value
The legacy v1 shared outbox key is never deleted (it is mutable and a
concurrently-live old build may still rewrite it), so the whole-blob
resume path re-read it on every boot and republished the stale blob above
the current relay head forever. A found relay head never stopped it: a
fresh manager has no lastPublishedStore and queueing the replay freezes
publishBaseline to the just-fetched head.

Distinguish compatibility replay from permanently pending intent with a
durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox
excludes the legacy record when its exact raw matches the stored marker;
a live old build rewriting the key stores a different raw and is replayed
again. The hook transfers the intent into its own v2 key (synchronous
publish) BEFORE writing the marker, so a crash in that gap replays the
blob once more rather than losing it.

Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate
so a lingering legacy key does not re-drive an identical boot-time publish.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-26 22:11:44 -04:00
Duncan
323bf68107 fix(sidebar): make outbox records write-once so foreign reclaim is race-free
The per-window token/recheck reclamation still performed a non-atomic
compare-then-delete on a mutable foreign key: a live owner could rewrite
its key between the reclaim decision-read and the removeItem, and the
whole-blob `queuedAt <= head.created_at` gate dropped same-second and
legacy `queuedAt=0` records that had not provably lost LWW.

Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`
and is never rewritten. A new edit writes a new key (next zero-padded seq)
then deletes its own older keys (write-before-delete, so a crash leaves at
least one record). Foreign reclamation reads an immutable record, proves it
reclaimable against durable relay evidence, and deletes it with no recheck.
Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs
before reclamation in every hook so a same-second record is consumed into
pending first; the legacy v1 shared key is only ever replayed, never deleted.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-26 20:57:11 -04:00
Duncan
adb3d17c19 fix(sync): key the sidebar durable outbox per window
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>
2026-08-26 19:38:44 -04:00
Duncan
c7933fd9da fix(sync): clamp publish timestamps and give sort prefs the durable lane
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>
2026-08-26 11:35:16 -04:00
Duncan
d5d83e0909 fix(sidebar): correct equal-timestamp tie-break in stars, mutes, sort
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>
2026-08-26 10:54:32 -04:00
Wes
9c074bb89b fix(desktop): bound nine unbounded localStorage stores (#5454)
Part of #5418 (Phase 1, lane A). Companion to #5453 (TTL sweep).

## What

Nine localStorage stores grew without bound (full 58-call-site audit in
the tracking issue). Each now has an explicit leak-guard cap, applied
wherever the store is parsed, merged, or written, preserving each file's
merge/versioning semantics:

- **Community icons:** 32 entries, 96 KiB/value (aligned with the
relay's `MAX_WORKSPACE_ICON_DATA_URL_LEN`); touched relay becomes
newest.
- **Channel mutes/stars:** newest-500 cap each, bounded by recency
(`updatedAt`, channel-ID lexical tie-breaker), with the just-written
channel unconditionally preserved for that write (cap−1 recency slots +
the mutated key). A bounded LWW store cannot guarantee permanent
deletion history; the guarantee here is that **the just-written mutation
survives its own bounding** and, as the newest entry, defeats an older
remote `true` through the pre-publish `mergeStores`. Known residual
(accepted): `updatedAt` is whole-second, so two distinct mutations
inside the same second at exact capacity can still evict the earlier one
before the debounced publish — same root cause as the merge-path
same-second tie, tracked for the follow-up precision fix rather than
more preservation machinery. Enforced at parse, post-merge, local state,
and persistence.
- **Forced unread:** newest 500 insertion-ordered, touched channels
refreshed.
- **Persistent agent audiences:** 200-scope LRU. An unchanged-audience
touch (including re-initializing an existing scope) refreshes LRU order
and persists without advancing the scope's revision or emitting; an
already-most-recent touch is a pure no-op (no clone, no write), so
render-path re-initialization causes zero storage traffic.
- **Self profiles:** newest 8 per relay / 32 globally by `updatedAt`,
just-written key always preserved; trim count-gates before parsing
payloads so under-cap writes skip the scan entirely.
- **Sections:** newest 100 + newest 1,000 assignments, orphans removed;
`assignChannel` delete/reinserts the touched channel so a reassignment
becomes newest in insertion order and cannot be evicted by the next
assignment. **Sort prefs:** 104 groups (100 sections + 4 fixed).
- **Feature overrides:** `getOverrides()` filters to current-manifest
boolean ids on read only — no write-back from the render-path getter.

## Review-driven revisions

- `237f25e4` — three narrow changes from the first adversarial review
(no render-path storage write, icon cap aligned to relay constant,
count-gated profile trim).
- `d864ffb0` — fixes for the two GitHub review findings on `237f25e4`:
(P1) mute/star bounding switched from false-tombstone-first eviction to
pure recency, with regressions proving an at-capacity unmute/unstar
survives bounding and the pre-publish LWW merge; (P2) unchanged
agent-audience touches now refresh LRU order (no revision advance, no
emit), with a subscriber-mounted regression.
- `3ddbb26d` — MRU guard from the second adversarial VERIFY: the P2
touch path skips clone/persist entirely when the scope is already
most-recently-inserted, eliminating repeat synchronous localStorage
writes from render-path effects. Test proves a non-MRU identical touch
writes exactly once (scope persisted last) and an already-MRU touch
writes zero times.
- `e220ccd9` — fixes for the second GitHub review round (Carl, on Wes's
behalf): (1) mute/star bounders preserve the just-mutated key so a
same-second mutation at capacity survives its own bounding; merge/sync
call sites unchanged; (2) `assignChannel` delete/reinserts the touched
key so an at-capacity reassignment isn't evicted by the next new
assignment. Regressions at storage and hook level for both;
negative-control run of the 7 new tests against the old sources: 7 fail.

## Validation

- Full desktop suite 4555/4555 at both `d864ffb0` and `3ddbb26d`, plus
desktop-check/typecheck via the push gate; focused storage/audience
tests 62/62 at `d864ffb0`, 14/14 audience suite at `3ddbb26d`.
- Independent adversarial review: APPROVE at `88a55aee` (including 100
smoke E2E specs covering every seeded store, run manually since push
hooks exclude Playwright), then a second VERIFY pass: **VERIFIED at
`d864ffb0`** — P1/P2 confirmed closed via negative-control runs of the
new suites against the old sources, plus smoke Playwright on the
mute/star/audience specs (17 passed). That VERIFY requested one
pre-merge change (no localStorage writes from the render path), landed
as the narrow MRU guard in `3ddbb26d` within the reviewer's stated
no-re-review boundary. A third VERIFY pass: **VERIFIED at `e220ccd9`** —
both findings from the second GitHub review confirmed closed by
sensitivity testing (new tests fail on old sources), hostile same-call
section-trim case constructed and passed, full suite 4562/4562 re-run
independently.

Authored by Meeseeks (agent), reviewed by Beth (agent), integrated by
Rick (agent). Discussion: Buzz channel time-based-localstorage-eviction,
thread 0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz>
2026-08-10 10:39:07 -07:00
Will Pfleger
b08c8b126c fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
Fixes the bug where running a dev build with stale localStorage would
publish outdated channel sections, sort preferences, starred channels,
and muted channels to the relay, clobbering the DMG installation's live
state.

## Root cause

All four sidebar-preference sync managers (`channelSectionsSync`,
`channelSortSync`, `channelStarsSync`, `channelMutesSync`) collapsed
five distinct fetch outcomes — no event, timeout, error, auth-race empty
result, decrypt/parse failure — into a single `null`. Each hook's boot
effect treated `null` as "no remote exists" and seed-published whatever
was in localStorage, stamped at `max(now, lastRemoteCreatedAt+1)` with
`lastRemoteCreatedAt` reset to 0 on every boot. A dev build with stale
localStorage therefore re-signed old state as newer, and the DMG's live
subscription applied it.

## Two guards

**1. Tri-state fetch result** (`found | absent | failed`) — decrypt
failure on an existing event reports `failed` and records
`event.created_at`, so seed-publish is blocked even when the payload is
unreadable.

**2. Persisted head watermark** (`sidebarSyncWatermark.ts`) — keyed
`{blobType, pubkey, normalizedRelayUrl}`, written to localStorage on
every observed remote event (before decrypt on all paths: initial fetch,
live subscription, `fetchOwnBlobBeforePublish`), hydrated at
construction. Any session that has ever seen a remote blob skips
seed-publish on the next boot even when the fetch returns empty. Relay
URLs are normalised via `shared/lib/normalizeRelayUrl` (also used by
profile storage) so the same relay written two ways never produces two
keys.

**Bootstrap owns the seed.** Each manager exposes
`bootstrap(localStore)` that fetches, records the raw head, and
delegates the decision to the single `runBootstrap` policy: hold on
`failed` or `absent + prior watermark`, seed on genuine first-sync
(`absent + zero watermark + non-empty local`), `apply-remote` when a
blob was found. Hooks only act on `apply-remote`; they cannot publish
during bootstrap. First-time sync is unchanged: successful EOSE with no
event, zero watermark, and non-empty local state still seeds.

## LWW baseline preservation

`fetchOwnBlobBeforePublish` for sections/sort snapshots the watermark
before `recordRemoteHead` advances it, then compares the fetched event
against the snapshot — advancing first would make `remote.createdAt >
lastRemoteCreatedAt` always false and silently kill the whole-blob LWW
merge. Stars/mutes merge per-entry via `mergeStores`, so no snapshot is
needed there.

## Relay lifecycle

All four hooks require a defined `relayUrl` (plumbed from
`communitiesHook.activeCommunity?.relayUrl` in `AppShell.tsx`); while it
is undefined no manager is constructed and no boot/live/reconnect effect
binds. All effects depend on `[pubkey, relayUrl]`, so community switches
tear down and rebind. `destroy()` cancels pending publishes without
flushing — flushing would race community switching and could publish
relay A's state to relay B via the shared `relayClient` singleton.
Pending debounce-window edits are intentionally dropped: stars/mutes
entries survive via per-entry merge on the next publish; a dropped
sections/sort edit is lost because bootstrap whole-blob-replaces from
remote on return.

Known trade-off: a first boot with the relay unreachable holds (never
seeds) until the user's next explicit edit — preferred over risking a
stale seed-publish.

## Files

- `sidebarSyncWatermark.ts` — watermark persistence + `runBootstrap`
policy (tri-state `FetchResult`, `readWatermark`, `advanceWatermark`)
- `shared/lib/normalizeRelayUrl.ts` — relay-URL normalisation shared by
watermark keys and profile storage
- `channelSectionsSync.ts`, `channelSortSync.ts`, `channelStarsSync.ts`,
`channelMutesSync.ts` — tri-state fetch, pre-decrypt `recordRemoteHead`
on all paths, sections/sort watermark snapshot for LWW, `bootstrap()`,
cancel-without-flush `destroy()`
- `useChannelSections.ts`, `useChannelSortPreference.ts`,
`useChannelStars.ts`, `useChannelMutes.ts` — act on `bootstrap()`
results, gate on `relayUrl`, `[pubkey, relayUrl]` deps on all effects
- `AppShell.tsx` — passes `activeCommunity?.relayUrl` to
`useChannelMutes` and `useChannelStars`
- `sidebarSyncTestHelpers.mjs` — shared fake-window/localStorage/Tauri
mocks for the four manager suites
- Test suites — mutation-sensitive coverage: `failed→hold`,
`absent+watermark→hold`, first-sync seeds, undecryptable head recorded
on all paths, relay-A/B watermark isolation, watermark restart
round-trip, sections/sort LWW baseline

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-06 17:21:02 -04:00
Wes
3e76481a14 refactor(clients): standardize product naming on community (#1858)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
2026-07-14 11:11:02 -07:00
Taylor Ho
04a9d10299 sync channel sort preferences across clients (#1556)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
2026-07-06 23:53:23 +00:00
Taylor Ho
36ac5243ff feat(channel list): add persistent channel sort toggle (#1505)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
2026-07-06 16:31:22 -07:00