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:
parent
c734f04e96
commit
8303b0981b
7 changed files with 557 additions and 0 deletions
94
docs/nostr-patterns/publishing.md
Normal file
94
docs/nostr-patterns/publishing.md
Normal 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` ~530–551) 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue