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

4.1 KiB
Raw Permalink Blame History

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.tsmonotonicCreatedAt(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).

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 — 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.tsrsvpStates: 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 (ReactionServicerecalculateEventReactions 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.tsupsertRSVPState (the rsvpStates.value.set(coord, inner) after mutating inner).

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