Files
buzz/desktop/src/shared/api/presenceRelaySubscription.ts
Wes df9e773a13 Scope desktop presence subscriptions to active demand (#5830)
## Summary

- replace the desktop's global kind-20001 presence subscription with one
author-filtered subscription derived from active TanStack presence
queries
- reconcile changing demand without a delivery gap: promote only after
relay EOSE, keep the last confirmed subscription on failure, discard
stale opens, and close entirely when demand is empty
- preserve REST presence as the initial seed and TTL/crash-recovery
backstop
- add transport-seam and lifecycle tests for readiness, normalization,
churn, retries, close failures, reconnect ownership assumptions, and
disposal

## Why

The desktop currently receives presence heartbeats from every identity
on the relay. A live tap measured roughly 2,700 events/minute (45/sec),
about 1 MB/minute and 71.5% of readable traffic, from approximately
1,300 distinct fleet identities. Most are discarded only after
WebSocket, Tauri IPC, and JS parsing.

This change applies normal Nostr author filtering at relay fan-out,
before those costs. It deliberately does not introduce a relay digest
protocol or client-side event batching; relevant-author traffic should
be small after scoping, and the existing signed-delta/REST-TTL model
remains intact.

## Correctness model

- active query observers are the demand source; inactive cached queries
retain no authors
- replacement opens before old closes and is promoted only after EOSE
- timeout/CLOSED rejects and closes the candidate while preserving the
last good subscription
- rapid A→B→C and A→B→A churn cannot unseat current A with stale B
- empty demand never sends an unfiltered subscription
- RelayClient continues to own reconnect replay; the reconciler does not
duplicate subscriptions on reconnect

## Validation

Exact pushed head: `8845093aec0330be16efe52d3459ff67f1000ff4`

Pre-push hooks passed:
- desktop check and file-size ratchet
- desktop TypeScript
- desktop unit suite: 4,791/4,791
- branch-skew check

Focused lifecycle/transport suite: 34/34 passed before commit.
Independent Royal Court review found and blocked two prototype flaws
(timeout-as-success and starvation-prone trailing debounce); both were
fixed and the final worktree was cleared with no remaining correctness
or lifecycle blockers.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-13 20:32:34 -07:00

46 lines
1.4 KiB
TypeScript

import type {
LiveSubscriptionReadiness,
RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_PRESENCE_UPDATE } from "@/shared/constants/kinds";
export type OpenLiveSubscription = (
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
onReady: (readiness: LiveSubscriptionReadiness) => void,
readinessTimeoutMs: number,
) => Promise<() => Promise<void>>;
/** Open an author-scoped presence subscription and require relay EOSE. */
export async function openPresenceSubscription(
pubkeys: string[],
onEvent: (event: RelayEvent) => void,
openLive: OpenLiveSubscription,
) {
const authors = [...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase()))]
.filter(Boolean)
.sort();
if (authors.length === 0) {
throw new Error("Presence subscriptions require at least one author.");
}
const readiness: { value: LiveSubscriptionReadiness } = { value: "timeout" };
const unsubscribe = await openLive(
{ kinds: [KIND_PRESENCE_UPDATE], authors, limit: 0 },
onEvent,
(nextReadiness) => {
readiness.value = nextReadiness;
},
5_000,
);
if (readiness.value === "eose") return unsubscribe;
await unsubscribe();
throw new Error(
readiness.value === "closed"
? "Relay rejected the presence subscription."
: "Timed out confirming the presence subscription.",
);
}