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:
Padreug 2026-05-05 20:24:26 +02:00
commit 8303b0981b
7 changed files with 557 additions and 0 deletions

View file

@ -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 <module>" 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.

View file

@ -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<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.

View file

@ -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` ~530551) 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<Set<string>>` + `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.

View file

@ -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<pubkey, latestReaction>`. 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`.

View file

@ -0,0 +1,89 @@
# Replaceable events (NIP-01 §13)
Replaceable kinds (1000019999) are keyed by `(kind, pubkey)`; addressable
replaceable kinds (3000039999) 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<string, number>()
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<Map<coord, Map<pubkey, RSVPEntry>>>` + `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<Map>`
**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<Map>` 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<Set>`, `ref<Map<_, Array>>`, 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.

View file

@ -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<T>(TOKEN)`: throws if not registered. Use in services
that **require** the dependency to function.
- `tryInjectService<T>(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<Map>` 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<key1, Map<key2, value>>` 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.

View 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.