From 8303b0981b23469ff7088d83201bd0f79d10c4cd Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 20:24:26 +0200 Subject: [PATCH] docs(nostr): add reusable Nostr patterns reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- docs/nostr-patterns/README.md | 66 +++++++++++++ docs/nostr-patterns/profiles.md | 64 +++++++++++++ docs/nostr-patterns/publishing.md | 94 +++++++++++++++++++ .../nostr-patterns/reactions-and-deletions.md | 67 +++++++++++++ docs/nostr-patterns/replaceable-events.md | 89 ++++++++++++++++++ docs/nostr-patterns/services-and-di.md | 94 +++++++++++++++++++ docs/nostr-patterns/subscriptions.md | 83 ++++++++++++++++ 7 files changed, 557 insertions(+) create mode 100644 docs/nostr-patterns/README.md create mode 100644 docs/nostr-patterns/profiles.md create mode 100644 docs/nostr-patterns/publishing.md create mode 100644 docs/nostr-patterns/reactions-and-deletions.md create mode 100644 docs/nostr-patterns/replaceable-events.md create mode 100644 docs/nostr-patterns/services-and-di.md create mode 100644 docs/nostr-patterns/subscriptions.md diff --git a/docs/nostr-patterns/README.md b/docs/nostr-patterns/README.md new file mode 100644 index 0000000..b69da5a --- /dev/null +++ b/docs/nostr-patterns/README.md @@ -0,0 +1,66 @@ +# Nostr patterns + +Living reference for reusable Nostr patterns that show up across modules +(activities, forum, market, chat, tasks, base, nostr-feed). + +**Read before writing any new Nostr code in this repo.** **Update whenever you +introduce, refine, or correct a pattern.** Each section has a "Canonical +implementation" line — that's the file the pattern was harvested from. If a +caller deviates, document why or align with the canonical version. + +The single biggest reason this directory exists: Nostr's edge cases (relay +dedup, replaceable events, reactivity-of-nested-Map, EOSE timing) are subtle +enough that re-deriving them per module produces different bugs each time. +Consolidating the *resolution* prevents that. + +## Index + +- [**Subscriptions & lifecycle**](./subscriptions.md) — RelayHub usage, EOSE, + unsubscribe on unmount, visibility-aware reconnect. +- [**Replaceable events**](./replaceable-events.md) — NIP-01 §13 semantics, + monotonic `created_at`, per-pubkey latest-wins state, replaceable bookmark + lists. +- [**Publishing & confirmation**](./publishing.md) — `RelayHub.publishEvent` + result checks, optimistic updates, signing with `nostr-tools`, pending-coord + debounce. +- [**Reactions, deletions & dedup**](./reactions-and-deletions.md) — NIP-25 + toggle, NIP-09 deletion handling, per-event-id dedup. +- [**Profiles & batch fetch**](./profiles.md) — kind-0 caching, request dedup. +- [**Services, DI & reactivity**](./services-and-di.md) — `BaseService`, + `tryInjectService`, exposing service state via computed, Vue 3 nested + ref-Map reactivity gotchas. + +Patterns specific to a single NIP that doesn't repeat across modules +(currently: NIP-59 gift-wrapped market orders) are not listed here yet — only +the cross-cutting patterns. When a second consumer adopts an NIP-specific +pattern, promote it. + +## Conventions + +- **Canonical implementation** is a single file path with line numbers. If a + pattern lives in multiple modules, list one canonical and the rest as + "alternates" with a short note on what differs. +- **Why** sections explain the failure mode the pattern prevents — not just + what it does. If the why is obvious from the code, omit it. +- **Cross-link** by file when patterns compose (e.g. replaceable events almost + always pair with the pending-coord debounce). +- Don't duplicate code into the docs. Reference file:line and quote at most + the 5-line core. Drift between code and docs is the failure mode. + +## Updating + +When you implement a new pattern (or fix a subtle bug in an existing one): + +1. Add or amend the relevant topic file — leave a brief "added 2026-MM-DD, + from " note if it helps trace provenance. +2. If it's a brand-new topic, add a section to this README's index and create + a new topic file. +3. If the pattern obsoletes an alternate implementation listed here, either + align that implementation or note explicitly why it diverges. + +## Improving + +We periodically deep-dive into well-known open-source Nostr apps (Coracle, +Snort, Damus, NoStrudel, Habla, Highlighter, Flotilla, Zap.cooking) to mine +patterns we haven't reinvented yet. Tracked as a recurring issue on Forgejo +(`aiolabs/webapp`). Findings land here. diff --git a/docs/nostr-patterns/profiles.md b/docs/nostr-patterns/profiles.md new file mode 100644 index 0000000..4f5bede --- /dev/null +++ b/docs/nostr-patterns/profiles.md @@ -0,0 +1,64 @@ +# Profiles & batch fetch + +## One subscription, many pubkeys, request-dedup before subscribe + +**Canonical:** `src/modules/base/nostr/ProfileService.ts` — +`fetchProfiles(pubkeys)` + the `requestedProfiles: Set` it consults +before adding to the in-flight filter. + +```ts +const toFetch = pubkeys.filter(pk => !requestedProfiles.has(pk) && !profiles.has(pk)) +if (toFetch.length === 0) return +toFetch.forEach(pk => requestedProfiles.add(pk)) + +const sub = relayHub.subscribe({ + filters: [{ kinds: [0], authors: toFetch }], + onEvent: …, + onEose: () => sub.unsubscribe(), +}) +``` + +**Why both checks:** +- `profiles.has(pk)` skips pubkeys already cached. +- `requestedProfiles.has(pk)` skips pubkeys with a fetch *in flight* — two + components rendering at the same time both call `fetchProfiles([pk])` + without coordinating, and you'd otherwise open two redundant subscriptions + for the same author. + +Reset `requestedProfiles` if the subscription errors / the pubkey didn't +arrive within EOSE — otherwise a transient failure leaves the pubkey +permanently un-fetchable until a session restart. + +## Kind-0 is replaceable per `(kind=0, pubkey)`, keep latest by `created_at` + +The cache update path looks the same as everywhere else replaceable events +appear: compare incoming `created_at` to cached and skip if older. + +**Caller pattern:** `src/modules/base/composables/useProfiles.ts` exposes a +read-only `getProfile(pubkey)` returning the cached metadata (or null while +loading). Trigger fetches in `onMounted` for any pubkey the component +needs, including in lists — the request-dedup makes it cheap. + +Don't re-fetch on every render; cache invalidation here is "until the user +publishes a new kind-0", which the live subscription handles automatically. + +## One-shot fetch: unsubscribe inside `onEose` + +When you're filling a finite cache (profile metadata, kind-3 contact list, +kind-10000-series replaceable lists), the live tail of the subscription has +no value — you only care about the backfill. Unsubscribe in `onEose` to +free the slot on the relay and the closure on the client. + +```ts +let sub: { unsubscribe: () => void } +sub = relayHub.subscribe({ + filters: …, + onEvent: handleProfile, + onEose: () => sub.unsubscribe(), +}) +``` + +Compare with RSVPs / reactions, where you DO want the live tail (other +users' reactions arriving after EOSE). Pick based on the question being +asked: "what is X right now?" → unsubscribe on EOSE; "what does X look like +over time?" → keep open. diff --git a/docs/nostr-patterns/publishing.md b/docs/nostr-patterns/publishing.md new file mode 100644 index 0000000..3fa9f4a --- /dev/null +++ b/docs/nostr-patterns/publishing.md @@ -0,0 +1,94 @@ +# Publishing & confirmation + +## Treat `result.success === 0` as failure, not success + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`if (!result || result.success <= 0) return null`. + +```ts +const result = await relayHub.publishEvent(signedEvent) +if (!result || result.success <= 0) return null // no relay accepted +// only now mutate local cache +``` + +**Why:** `RelayHub.publishEvent` returns `{ success: N, total: M }`. +`success > 0` means at least one relay sent OK. `success === 0` means every +connected relay rejected (rate limit, signature failure, or the relay was +just down). Updating local state on a 0-success publish leaves the UI ahead +of every relay — on next refresh the user sees their action has vanished. + +Some implementations (`relay-hub.ts` ~530–551) throw on `success === 0` +instead. Either contract works; pick one and be consistent within a +composable. Don't write code that silently treats both as success. + +## Optimistic-on-success, not optimistic-on-click + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — local +cache update after the `await` resolves with `success > 0`, before the +relay echoes the event back through the subscription. + +The window we're closing is *between* "publish succeeded" and "subscription +delivers the event back to us". That window can be 50ms or 5s depending on +relays. Updating the cache there gives instant UI feedback without lying: +if the publish actually fails, the cache stays untouched and the UI never +showed the false state. + +Avoid pre-publish optimistic updates ("flip the button on click, roll back +on failure"). Rollback is jarring, and on slow connections users see the +button flip twice. + +## Pending-coord debounce: disable the button during in-flight publish + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`pendingCoords: ref>` + `isPending(...)` predicate + +`try { … } finally { pendingCoords.value.delete(coord) }`. + +```ts +if (pendingCoords.value.has(coord)) return null // throttle +pendingCoords.value.add(coord) +try { + await publish… +} finally { + pendingCoords.value.delete(coord) +} +``` + +Bind `:disabled="pending"` on the button. This pairs with the +[monotonic `created_at`](./replaceable-events.md#strictly-monotonic-created_at-per-coord) — together they make a series of rapid clicks well-defined: one in flight at a +time, each strictly newer than the last. + +**Why finally:** if the publish throws, you must still release the lock or +the button is stuck disabled forever. + +**Why per-coord, not global:** the user might click RSVP on activity A +while a previous publish on activity B is still flying. A global +`isPending` would block that. + +## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`. + +`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables +duplicate the hex→bytes helper inline; centralize when you have a third +caller (the duplication is fine for two). + +**Validate before signing:** check `currentUser.value?.prvkey` is set, and +guard against logging the privkey or its derived bytes anywhere +(intentionally or via `console.log(JSON.stringify(...))`). Keys never leave +the auth service except into `finalizeEvent` arguments. + +If the project ever gains NIP-07/NIP-46 support, the signing call site is +where you'd branch on auth method. Until then, prvkey-in-memory is the only +path. + +## Publish-only-once vs publish-to-many-relays + +`RelayHub.publishEvent` fans out to all connected healthy relays. Don't +loop in user code; the hub handles the fan-out, dedup, and timeout. The +return aggregates outcomes (`{ success, total }`). + +If you need to target a specific relay (rare — usually for migration or +a custom personal relay), use the relay's lower-level API directly and +skip the hub. Document why in a comment, because it breaks the visibility/ +restoration story above. diff --git a/docs/nostr-patterns/reactions-and-deletions.md b/docs/nostr-patterns/reactions-and-deletions.md new file mode 100644 index 0000000..495f5b7 --- /dev/null +++ b/docs/nostr-patterns/reactions-and-deletions.md @@ -0,0 +1,67 @@ +# Reactions, deletions & dedup + +## NIP-25 reactions: per-user latest, count from set size + +**Canonical:** `src/modules/base/nostr/ReactionService.ts` — +`handleReactionEvent` (incoming) + `recalculateEventReactions` (count derive). + +The same per-pubkey latest-wins pattern as +[replaceable RSVPs](./replaceable-events.md#per-pubkey-latest-wins-state-for-derived-counts): +each `target_event_id → Map`. Counts are derived +from `latestReactionsByUser.size`, not by summing every kind-7 event seen. + +**Toggle-as-delete (not toggle-as-opposite):** unliking publishes a NIP-09 +deletion (kind 5) referencing the original kind-7 event, not a kind-7 with +content `'-'`. Some clients use the latter pattern; align with the +canonical implementation here so the deletion handler downstream actually +removes the reaction from the count. + +**Pair with** [deletion handling](#nip-09-deletion-handler) below. + +## NIP-09 deletion handler + +**Canonical:** `src/modules/base/nostr/ReactionService.ts` — +`handleDeletionEvent`. Validates `deletion.pubkey === eventToDelete.pubkey` +before removing. + +```ts +if (deletion.pubkey !== eventToDelete.pubkey) return // not authorized +// remove from caches, recalculate counts +``` + +**Why the pubkey check:** without it, anyone can publish a kind-5 event +referencing anyone else's event id and trick the UI into hiding it locally. +Relays should enforce this server-side; clients must not assume they did. + +Deletions are NOT replaceable — the same deletion event id may arrive +multiple times across reconnects. Track processed deletion event ids in a +bounded Set (same as +[the dedup pattern](./subscriptions.md#per-incoming-event-dedup)) so the +"recalculate counts" path runs only once per deletion. + +Forum submissions / comments use the same handler shape in +`src/modules/forum/services/SubmissionService.ts`. + +## Skip-by-event-id dedup before any state mutation + +```ts +function handleIncomingEvent(event) { + if (seenEventIds.has(event.id)) return + seenEventIds.add(event.id) + // … process … +} +``` + +This is the first line of every `onEvent` callback. It's cheap (Set lookup), +covers reconnect replays, and makes downstream logic idempotent. The +[per-incoming-event dedup](./subscriptions.md#per-incoming-event-dedup) +section in subscriptions has the bounded-Set caveat. + +**Don't conflate** event-id dedup (this section) with replaceable-event +dedup (`(kind, pubkey, d-tag)` key). They handle different failure modes: +- Event-id dedup: same event delivered twice via different paths → process + once. +- Replaceable dedup: different events superseding each other → keep latest. + +A reaction handler does both: skip if event id seen, else upsert by +`(target_event_id, pubkey)` keeping latest by `created_at`. diff --git a/docs/nostr-patterns/replaceable-events.md b/docs/nostr-patterns/replaceable-events.md new file mode 100644 index 0000000..602a623 --- /dev/null +++ b/docs/nostr-patterns/replaceable-events.md @@ -0,0 +1,89 @@ +# Replaceable events (NIP-01 §13) + +Replaceable kinds (10000–19999) are keyed by `(kind, pubkey)`; addressable +replaceable kinds (30000–39999) by `(kind, pubkey, d-tag)`. The relay keeps +only the event with the highest `created_at` for that key. Almost everything +in this file follows from that single fact. + +## Strictly-monotonic `created_at` per coord + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`lastPublishAt` map + the `Math.max(now, previous + 1)` line. + +```ts +const lastPublishAt = new Map() + +const now = Math.floor(Date.now() / 1000) +const previous = lastPublishAt.get(coord) ?? 0 +const createdAt = Math.max(now, previous + 1) +… +lastPublishAt.set(coord, signedEvent.created_at) // only after publish success +``` + +**Why:** NIP-01 `created_at` is integer seconds. Two button clicks in the +same wall-clock second produce the same timestamp; relays treat the second +event as a non-newer replacement and silently drop it. The publish appears +to succeed (websocket ACK), but the relay never updates the canonical +state. Bumping past `previous + 1` guarantees each click is strictly newer +than the last click on the same coord. + +**Pair with** the [pending-coord debounce](./publishing.md#pending-coord-debounce-disable-the-button-during-in-flight-publish) — together they make rapid-click sequences both well-ordered and rate-limited. + +## Per-pubkey latest-wins state for derived counts + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`rsvpStates: ref>>` + `upsertRSVPState` + +`getRSVPCount` (count entries where status === 'accepted'). + +**Why a flat counter is wrong:** every replaceable event your subscription +sees may be a *replacement* of a previous one from the same pubkey. A +naive `count++` per accepted event: + +- double-counts when the relay echoes the same RSVP again (reconnect, dup + delivery) +- never decrements when a pubkey flips from accepted → tentative +- never decrements when a pubkey deletes their RSVP via NIP-09 + +Per-pubkey latest-wins gives a correct count derived from current state. +Same shape works for: RSVPs, reaction tallies (`ReactionService` — +`recalculateEventReactions` from `latestReactionsByUser`), poll responses, +any "who's currently in state X" question. + +## Replaceable list, full-rewrite on toggle + +**Canonical:** `src/modules/activities/composables/useBookmarks.ts` — +NIP-51 kind 10003 bookmark list. + +For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities, +etc.), don't try to compute a delta and merge — fetch latest with `limit: 1`, +mutate the in-memory array, then publish the *whole list* as one event. +Simpler than diff-merge, and there's nothing to diff against because the +event is a complete replacement. + +Confirm `result.success > 0` before updating the local set — a failed +publish leaves the in-memory list ahead of the relay's view, which silently +diverges on next refresh. + +## Vue 3 reactivity for nested `ref` + +**Canonical:** `src/modules/activities/composables/useRSVP.ts` — +`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating +`inner`). + +```ts +function upsertRSVPState(coord, pubkey, entry) { + let inner = rsvpStates.value.get(coord) ?? new Map() + inner.set(pubkey, entry) + rsvpStates.value.set(coord, inner) // ← required to notify dependents +} +``` + +**Why:** Vue 3's reactive proxy on `ref` notifies on mutations of the +*outer* Map (`set`/`delete`). It does NOT proxy through to nested values. +`computed(() => outer.get(k).size)` will read once, cache, and never re-run +when the inner Map mutates. Re-`set`ting on the outer is the cheap fix. + +Same caveat applies to nested `ref`, `ref>`, etc. If you +have one level of nesting, this trick suffices; if you have two levels, +you'll need to re-set up the chain or use `reactive()` instead of `ref()` +for the whole structure. diff --git a/docs/nostr-patterns/services-and-di.md b/docs/nostr-patterns/services-and-di.md new file mode 100644 index 0000000..f9abc79 --- /dev/null +++ b/docs/nostr-patterns/services-and-di.md @@ -0,0 +1,94 @@ +# Services, DI & reactivity + +## `BaseService` lifecycle: `initialize` / `onInitialize` / `onDestroy` + +**Canonical:** `src/core/base/BaseService.ts` (the abstract class) + +`src/modules/base/nostr/ReactionService.ts` (a representative concrete +service). + +- Constructor: do nothing async, register no subscriptions. Just stash + config. +- `onInitialize()` (override): grab dependencies via `injectService(...)`, + open relay subscriptions, set up timers. +- `onDestroy()`: unsubscribe everything, clear caches. Anything you started + in `onInitialize` you tear down here. + +**Why not in the constructor:** services are constructed before all +dependencies are registered; `injectService(SOME_TOKEN)` from the +constructor will throw or return undefined non-deterministically depending +on registration order. `onInitialize` runs after the DI container is +ready. + +Always call `super.initialize()` if you override `initialize()` instead of +`onInitialize()`. Most concrete services override only `onInitialize` and +leave `initialize()` alone. + +## `injectService` vs `tryInjectService` + +**Canonical:** `src/core/di-container.ts` — `SERVICE_TOKENS` registry + +both inject helpers. + +- `injectService(TOKEN)`: throws if not registered. Use in services + that **require** the dependency to function. +- `tryInjectService(TOKEN)`: returns `undefined` if not registered. Use + in composables and components that should degrade gracefully (e.g., + rendering an offline empty state when RelayHub isn't up yet). + +The composable in `useRSVP.ts` uses `tryInjectService(RELAY_HUB)` and +returns null from `setRSVP` if the hub is missing. That's the right call +for user-facing buttons: don't crash, just no-op. A service that *needs* +the hub to do its job (`ReactionService.initialize`) uses the throwing +variant so the failure is loud at startup. + +## Expose service state via getters / computed, never as mutable refs + +**Canonical:** `src/modules/base/nostr/ReactionService.ts` — `eventReactions` +getter, `isLoading` getter; `src/modules/base/nostr/ProfileService.ts` — +`profiles` getter. + +```ts +class ReactionService extends BaseService { + private _state = reactive(new Map<…>()) // private, mutable + get eventReactions() { return this._state } // public, read-only by convention + + // Mutation through guarded methods only + async likeEvent(...) { … } + async unlikeEvent(...) { … } +} +``` + +The Vue reactivity stays correct because we hand out the same reactive +object — components reading `service.eventReactions` get a reactive +reference. They're trusted not to mutate it; the type system can enforce +that with `readonly` modifiers if you want to be strict. + +**Why not a public field:** anything mutating the cache from outside the +service can produce events that don't get published, or skip +`recalculateEventReactions`. Keep mutation paths inside the service so the +invariants are enforced in one place. + +## Vue 3 nested `ref` reactivity gotcha + +Covered fully in +[replaceable-events.md → Vue 3 reactivity for nested ref-Map](./replaceable-events.md#vue-3-reactivity-for-nested-refmap). +Cross-linked here because it bites every service that exposes a +`Map>` shape (not common, but real — RSVPs are the +main one). + +The `reactive(new Map())` route avoids the gotcha because `reactive` +recursively wraps. `ref(new Map())` doesn't. Either is fine for top-level; +prefer `reactive` if you have nesting. + +## Service singletons, cleanup is real + +Services are registered once per app lifetime. There's no "destroy on +component unmount" — components come and go, the service persists. + +This means: any subscription a service opens must be cleaned up in +`onDestroy()`, which only runs at app teardown. Don't open *per-component* +subscriptions from a service — that's the composable's job (with +`onUnmounted` cleanup). Service-level subscriptions are session-long. + +If you find yourself wanting a "subscribe to X just for this view" +pattern inside a service, you've crossed the layer boundary. Move it to a +composable that consumes the service. diff --git a/docs/nostr-patterns/subscriptions.md b/docs/nostr-patterns/subscriptions.md new file mode 100644 index 0000000..5921351 --- /dev/null +++ b/docs/nostr-patterns/subscriptions.md @@ -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` 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.