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>
64 lines
2.4 KiB
Markdown
64 lines
2.4 KiB
Markdown
# Profiles & batch fetch
|
|
|
|
## One subscription, many pubkeys, request-dedup before subscribe
|
|
|
|
**Canonical:** `src/modules/base/nostr/ProfileService.ts` —
|
|
`fetchProfiles(pubkeys)` + the `requestedProfiles: Set<string>` 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.
|