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:
Padreug 2026-05-05 20:24:26 +02:00
commit 8303b0981b
7 changed files with 557 additions and 0 deletions

View file

@ -0,0 +1,94 @@
# Publishing & confirmation
## Treat `result.success === 0` as failure, not success
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`if (!result || result.success <= 0) return null`.
```ts
const result = await relayHub.publishEvent(signedEvent)
if (!result || result.success <= 0) return null // no relay accepted
// only now mutate local cache
```
**Why:** `RelayHub.publishEvent` returns `{ success: N, total: M }`.
`success > 0` means at least one relay sent OK. `success === 0` means every
connected relay rejected (rate limit, signature failure, or the relay was
just down). Updating local state on a 0-success publish leaves the UI ahead
of every relay — on next refresh the user sees their action has vanished.
Some implementations (`relay-hub.ts` ~530551) throw on `success === 0`
instead. Either contract works; pick one and be consistent within a
composable. Don't write code that silently treats both as success.
## Optimistic-on-success, not optimistic-on-click
**Canonical:** `src/modules/activities/composables/useRSVP.ts` — local
cache update after the `await` resolves with `success > 0`, before the
relay echoes the event back through the subscription.
The window we're closing is *between* "publish succeeded" and "subscription
delivers the event back to us". That window can be 50ms or 5s depending on
relays. Updating the cache there gives instant UI feedback without lying:
if the publish actually fails, the cache stays untouched and the UI never
showed the false state.
Avoid pre-publish optimistic updates ("flip the button on click, roll back
on failure"). Rollback is jarring, and on slow connections users see the
button flip twice.
## Pending-coord debounce: disable the button during in-flight publish
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`pendingCoords: ref<Set<string>>` + `isPending(...)` predicate +
`try { … } finally { pendingCoords.value.delete(coord) }`.
```ts
if (pendingCoords.value.has(coord)) return null // throttle
pendingCoords.value.add(coord)
try {
await publish…
} finally {
pendingCoords.value.delete(coord)
}
```
Bind `:disabled="pending"` on the button. This pairs with the
[monotonic `created_at`](./replaceable-events.md#strictly-monotonic-created_at-per-coord) — together they make a series of rapid clicks well-defined: one in flight at a
time, each strictly newer than the last.
**Why finally:** if the publish throws, you must still release the lock or
the button is stuck disabled forever.
**Why per-coord, not global:** the user might click RSVP on activity A
while a previous publish on activity B is still flying. A global
`isPending` would block that.
## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`.
`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables
duplicate the hex→bytes helper inline; centralize when you have a third
caller (the duplication is fine for two).
**Validate before signing:** check `currentUser.value?.prvkey` is set, and
guard against logging the privkey or its derived bytes anywhere
(intentionally or via `console.log(JSON.stringify(...))`). Keys never leave
the auth service except into `finalizeEvent` arguments.
If the project ever gains NIP-07/NIP-46 support, the signing call site is
where you'd branch on auth method. Until then, prvkey-in-memory is the only
path.
## Publish-only-once vs publish-to-many-relays
`RelayHub.publishEvent` fans out to all connected healthy relays. Don't
loop in user code; the hub handles the fan-out, dedup, and timeout. The
return aggregates outcomes (`{ success, total }`).
If you need to target a specific relay (rare — usually for migration or
a custom personal relay), use the relay's lower-level API directly and
skip the hub. Document why in a comment, because it breaks the visibility/
restoration story above.