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