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>
4.1 KiB
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 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).
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.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).
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.