mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## 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>
140 lines
6.4 KiB
Markdown
140 lines
6.4 KiB
Markdown
# Bridge `/query` Extension: Channel Window
|
|
|
|
> **Normative spec:** [NIP-CW](nips/NIP-CW.md) 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:
|
|
|
|
```json
|
|
{
|
|
"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.
|