Files
buzz/docs/bridge-channel-window.md
Tyler 2d280376ad perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
## Summary

Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.

**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).

Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.

**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.

**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.

**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).

### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.

### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):

- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.

### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).

Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):

- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean

At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green

CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.

Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.

---------

Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
2026-08-24 10:11:07 -04:00

6.4 KiB

Bridge /query Extension: Channel Window

Normative spec: NIP-CW is the canonical, standalone specification of the channel window (kinds 39005/39006, filter extension, cursor and trust semantics). This document remains as the ratified engineering contract and internal design record; where wording differs, NIP-CW governs.

Status: frozen contract v2 (2026-07-03) — GUI read-model overhaul. Reviewed by: Mari (relay ground truth), Wren (client core), Quinn (spec guardian), Perci (NIP landscape). Ratified in #buzz-gui-formal-relay-interaction-spec, thread a7c68013.

The channel window is how Buzz clients page a channel timeline by top-level rows instead of raw events. It is a raw-filter extension on the existing HTTP bridge POST /query — the same extension family as before_id and thread_cursor. There is no new endpoint, and the wire carries only signed nostr events.

Vanilla NIP-01 cannot express "messages with no reply e-tag" (filters have no negation), which is why generic nostr clients page raw events and reassemble threads client-side. This relay computes thread_metadata (depth, root, reply counts) at ingest, so it can serve the top-level view directly. The WS REQ path ignores all fields below via nostr::Filter's unknown-field behavior — generic clients degrade gracefully to a normal full-event query; they never see a wrong-but-plausible timeline.

Request

A standard bridge filter plus extension fields:

{
  "kinds": [9],
  "#h": ["<channel-uuid>"],
  "limit": 50,
  "top_level": true,
  "include_summaries": true,
  "include_aux": true,
  "until": 1751500000,
  "before_id": "<64-hex event id>"
}
  • top_level: true — routes this filter to the top-level SQL view. Requires exactly one #h channel the caller can access.
  • limit — row budget. Counts row events only; summaries, aux, and bounds overlays never consume it.
  • until + before_id — the composite request cursor (created_at, id) of the last retained row from the previous page. Both or neither. top_level with until but no before_id is rejected (400): the window path has no timestamp-only fallback, ever. Neither = head request.
  • include_summaries / include_aux — opt-in overlay/closure appends.

page/OFFSET is not honored on the window path.

Top-level predicate

A row is top-level iff depth IS NULL OR depth = 0 OR (depth = 1 AND broadcast = true) in thread_metadata (v1 ruling: NULL — an event ingested before thread metadata existed — counts as top-level; the harness legacyReply scenario decides whether a backfill migration is needed). Deleted rows (deleted_at IS NOT NULL) are excluded before the limit.

Ordering and cursor

Rows are ordered (created_at DESC, id ASC) — the same composite every other read path uses. The next-page cursor is the (created_at, id) of the last retained row; the server echoes it in the 39006 bounds overlay as next_cursor. Keyset comparison is created_at < $ts OR (created_at = $ts AND id > $id); dense seconds paginate without loss or duplication by construction.

has_more is a server fact: the relay probes limit + 1 rows after all predicates (access, deletion, top-level, kinds), returns at most limit, and reports the probe result in 39006. The sentinel row never reaches the wire, and no closure is computed for it. Clients must not infer exhaustion from row count (rows < limit does not imply anything on an exact-multiple final page) — 39006.has_more is the only authority.

Response

The existing flat bridge shape: a JSON array of signed nostr events. Clients partition by kind before any cursor math:

  1. Rows — the top-level events, in keyset order.
  2. Aux closure (include_aux) — reactions (7), deletions (5, 9005), and edits (40003) targeting the retained rows by #e, plus deletions targeting those aux events (the transitive second hop, e.g. a delete-of-a-reaction). One round trip; no client #e fan-out. Each hop is drained server-side across the DB page clamp, so the closure is complete rather than newest-1000.
  3. Thread summaries (include_summaries) — one relay-signed kind:39005 per row that has replies.
  4. Window bounds — exactly one relay-signed kind:39006 per window response.

kind:39005 — thread summary overlay

  • tags: ["e", <root-id>], ["d", <root-id>], ["h", <channel-id>]
  • content: {"reply_count":n,"descendant_count":n,"last_reply_at":ts|null,"participants":["<hex-pubkey>",...]} (participants: up to 10, most recent first)
  • Signed by the relay keypair. Synthesized at query time, never stored. Clients treat it as replace-by-target metadata keyed by the e/d tag (the d tag gives parameterized-replaceable semantics natively); it is never a row, never a cursor input, never durable timeline history.

kind:39006 — window bounds overlay

  • tags: ["d", "<channel_id>:<request-cursor-or-head>"], ["h", "<channel_id>"]
  • content: {"has_more": bool, "next_cursor": {"created_at": ts, "id": "<hex>"} | null}
  • next_cursor = null ⇔ has_more = false.
  • d-tag suffix serialization (canonical): head for a head request, else <created_at>:<event_id> — decimal unix seconds, then the full 64-char lowercase hex id, colon-delimited. Clients must verify the suffix equals the cursor they sent and reject the overlay on mismatch.
  • Reserved field: oldest_retained (retention gap), added without a wire break if needed.
  • Same overlay rules as 39005: relay-signed, query-time, never stored, never a row or cursor input.

Both kinds are relay-only: client submission is rejected at ingest.

Client obligations (frozen)

  • Pages are immutable authoritative history chained cursor→cursor; live events land in a separate overlay, never spliced into pages.
  • Reconnect refetches page 0 and re-arms the live subscription (since: now); deeper pages need no repair path.
  • Replies never enter the channel timeline; the thread panel uses the existing thread_cursor surface (#1418). Thread filters may opt into include_aux to append the same authorized two-hop reactions, edits, and deletions closure as a channel-window response.

Siblings

before_id (requires until), thread_cursor/thread_cursor_id, depth_limit, feed_types — see bridge.rs. All are bridge-only raw filter extensions invisible to vanilla relays and clients.