webapp/docs/nostr-patterns/replaceable-events.md
Padreug 2febf0926d docs(nostr-patterns): point monotonic created_at at the shared helper
The "strictly-monotonic created_at per coord" section named useRSVP.ts as
canonical, but that file no longer exists. monotonicCreatedAt() in
src/lib/nostr/timestamp.ts is now the single implementation — make the
doc reference it and show both the per-coord-Map and single-field
tracking shapes. Keeps doc and code aligned per the docs discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:03:58 +00:00

93 lines
4.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 helper:** `src/lib/nostr/timestamp.ts`
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`.
Use it for **every** replaceable-event publish; track the last
`created_at` per coord (a `Map<coord, number>` when one composable
publishes many coords like `useRSVP.ts`, or a single field when there's
one coord per user like `useBookmarks.ts`' kind-10003 list).
```ts
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
const lastPublishAt = new Map<string, number>()
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord))
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/events/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/events/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/events/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.