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
89
docs/nostr-patterns/replaceable-events.md
Normal file
89
docs/nostr-patterns/replaceable-events.md
Normal file
|
|
@ -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<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue