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>
93 lines
4.1 KiB
Markdown
93 lines
4.1 KiB
Markdown
# 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).
|
||
|
||
```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.
|