docs(nostr): add reusable Nostr patterns reference
Living reference at docs/nostr-patterns/ that future Claude Code sessions
(per memory directive) and human contributors must consult before writing
Nostr code, and update when implementing or refining patterns.
Six topic files covering 18 patterns harvested from existing modules
(activities, base, forum, market, chat, tasks, nostr-feed):
- subscriptions.md — RelayHub lifecycle, EOSE, visibility-aware
reconnect, per-event-id dedup
- replaceable-events.md — monotonic created_at, per-pubkey latest-wins,
replaceable-list rewrite, Vue 3 nested ref<Map>
reactivity gotcha
- publishing.md — result.success > 0 checks, optimistic-on-success,
pending-coord debounce, finalizeEvent with bytes
- reactions-and-deletions.md — NIP-25 toggle-as-delete, NIP-09 pubkey
check, dedup-before-mutate
- profiles.md — kind-0 batch fetch with request dedup,
unsubscribe-on-EOSE for snapshot fetches
- services-and-di.md — BaseService lifecycle, injectService vs
tryInjectService, expose state via getters
Each pattern points at a canonical implementation (file:line) and notes
the *why* behind each pattern so a new caller doesn't trip on the same
edge case the canonical implementation already learned about.
Recurring deep-dive issue (#42) tracks mining patterns from Coracle,
Snort, NoStrudel, Damus, Habla, Highlighter, Flotilla, Zap.cooking, NDK
that we haven't reinvented yet — findings land in this directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c734f04e96
commit
8303b0981b
7 changed files with 557 additions and 0 deletions
83
docs/nostr-patterns/subscriptions.md
Normal file
83
docs/nostr-patterns/subscriptions.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Subscriptions & lifecycle
|
||||
|
||||
## Subscribe, store the unsubscribe handle, clean up on unmount
|
||||
|
||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
||||
`loadRSVPs()` (subscribe block) + the matching `onUnmounted(() => unsubscribe?.())`.
|
||||
|
||||
```ts
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
unsubscribe = relayHub.subscribe({
|
||||
id: `rsvps-${Date.now()}`,
|
||||
filters: [{ kinds: [NIP52_KINDS.RSVP], limit: 500 }],
|
||||
onEvent: …,
|
||||
onEose: () => { isLoaded.value = true },
|
||||
})
|
||||
|
||||
onUnmounted(() => unsubscribe?.())
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- The subscription ID must be unique per mount. Reusing a static id (e.g.
|
||||
`'rsvps'`) causes RelayHub to return the *previous* subscription on rapid
|
||||
re-mount, which leaks the old `onEvent` closure or skips the new one
|
||||
entirely. `Date.now()` (or a uuid) suffices.
|
||||
- Forgetting `onUnmounted` cleanup leaks subscriptions across route changes;
|
||||
the relay keeps streaming events into a closure that updates a stale ref.
|
||||
|
||||
**Alternate implementation:** `src/modules/base/composables/useProfiles.ts`
|
||||
uses the same shape; differs only in subscription id construction. If you
|
||||
diverge, do it because the lifetime of the subscription differs (e.g.
|
||||
session-long vs view-long), not by accident.
|
||||
|
||||
## EOSE means "backfill done", not "all events delivered"
|
||||
|
||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
||||
`onEose: () => { isLoaded.value = true }`.
|
||||
|
||||
`onEose` fires once, after the relay flushes everything stored that matches
|
||||
the filter. Live events keep arriving on `onEvent` afterwards. Use the EOSE
|
||||
flag to drop a "loading…" placeholder and let *partial* results render
|
||||
during backfill — don't block the UI on EOSE.
|
||||
|
||||
If you want a one-shot snapshot (fetch profiles, build a one-time list), it
|
||||
*is* fine to call `unsubscribe()` from inside `onEose` — see
|
||||
`src/modules/base/nostr/ProfileService.ts` (the post-EOSE cleanup path).
|
||||
|
||||
## Visibility-aware reconnect
|
||||
|
||||
**Canonical:** `src/modules/base/nostr/relay-hub.ts` —
|
||||
`registerWithVisibilityService` + `handleResume` / `handlePause` +
|
||||
`restoreSubscriptions`.
|
||||
|
||||
When the tab is hidden / device sleeps, the websocket drops. RelayHub closes
|
||||
stale subscriptions on pause and replays their `Filter` configs against
|
||||
healthy relays on resume. Consumers don't need to do anything — the
|
||||
subscription you registered survives the reconnect.
|
||||
|
||||
**Why callers should know this:** subscriptions don't transparently
|
||||
guarantee replay. If you stash event IDs in a local `Set` to dedupe and the
|
||||
tab is hidden for an hour, on resume the relay may re-stream events from
|
||||
before the dedupe set existed (or after the set was reset). Treat the
|
||||
dedupe state as best-effort, not authoritative — pair with the
|
||||
`is_duplicate_event` style check on every incoming event regardless.
|
||||
|
||||
## Per-incoming-event dedup
|
||||
|
||||
When subscribing to an actively-published kind (reactions, RSVPs, market
|
||||
orders), the relay can deliver the same event ID more than once across
|
||||
reconnects, multiple connected relays, or filter overlaps. The cure is a
|
||||
bounded `Set<eventId>` checked on every `onEvent`.
|
||||
|
||||
**Canonical:** `src/modules/base/nostr/ReactionService.ts` — `seenEventIds`
|
||||
check at the top of `handleReactionEvent`.
|
||||
|
||||
**Why bounded:** the set otherwise grows unboundedly across a long session.
|
||||
Use an `OrderedDict`-style eviction (insert order = oldest first) and cap at
|
||||
~10k entries — events older than that are not going to come back through
|
||||
the same subscription's lifetime in any realistic flow.
|
||||
|
||||
The events extension's Python `nostr/nostr_client.py` has the same pattern
|
||||
(`is_duplicate_event` over an `OrderedDict`) for the server-side
|
||||
subscription. If you build a new subscription consumer, copy the cap.
|
||||
Loading…
Add table
Add a link
Reference in a new issue