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
67
docs/nostr-patterns/reactions-and-deletions.md
Normal file
67
docs/nostr-patterns/reactions-and-deletions.md
Normal 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue