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>
4 KiB
Services, DI & reactivity
BaseService lifecycle: initialize / onInitialize / onDestroy
Canonical: src/core/base/BaseService.ts (the abstract class) +
src/modules/base/nostr/ReactionService.ts (a representative concrete
service).
- Constructor: do nothing async, register no subscriptions. Just stash config.
onInitialize()(override): grab dependencies viainjectService(...), open relay subscriptions, set up timers.onDestroy(): unsubscribe everything, clear caches. Anything you started inonInitializeyou tear down here.
Why not in the constructor: services are constructed before all
dependencies are registered; injectService(SOME_TOKEN) from the
constructor will throw or return undefined non-deterministically depending
on registration order. onInitialize runs after the DI container is
ready.
Always call super.initialize() if you override initialize() instead of
onInitialize(). Most concrete services override only onInitialize and
leave initialize() alone.
injectService vs tryInjectService
Canonical: src/core/di-container.ts — SERVICE_TOKENS registry +
both inject helpers.
injectService<T>(TOKEN): throws if not registered. Use in services that require the dependency to function.tryInjectService<T>(TOKEN): returnsundefinedif not registered. Use in composables and components that should degrade gracefully (e.g., rendering an offline empty state when RelayHub isn't up yet).
The composable in useRSVP.ts uses tryInjectService(RELAY_HUB) and
returns null from setRSVP if the hub is missing. That's the right call
for user-facing buttons: don't crash, just no-op. A service that needs
the hub to do its job (ReactionService.initialize) uses the throwing
variant so the failure is loud at startup.
Expose service state via getters / computed, never as mutable refs
Canonical: src/modules/base/nostr/ReactionService.ts — eventReactions
getter, isLoading getter; src/modules/base/nostr/ProfileService.ts —
profiles getter.
class ReactionService extends BaseService {
private _state = reactive(new Map<…>()) // private, mutable
get eventReactions() { return this._state } // public, read-only by convention
// Mutation through guarded methods only
async likeEvent(...) { … }
async unlikeEvent(...) { … }
}
The Vue reactivity stays correct because we hand out the same reactive
object — components reading service.eventReactions get a reactive
reference. They're trusted not to mutate it; the type system can enforce
that with readonly modifiers if you want to be strict.
Why not a public field: anything mutating the cache from outside the
service can produce events that don't get published, or skip
recalculateEventReactions. Keep mutation paths inside the service so the
invariants are enforced in one place.
Vue 3 nested ref<Map> reactivity gotcha
Covered fully in
replaceable-events.md → Vue 3 reactivity for nested ref-Map.
Cross-linked here because it bites every service that exposes a
Map<key1, Map<key2, value>> shape (not common, but real — RSVPs are the
main one).
The reactive(new Map()) route avoids the gotcha because reactive
recursively wraps. ref(new Map()) doesn't. Either is fine for top-level;
prefer reactive if you have nesting.
Service singletons, cleanup is real
Services are registered once per app lifetime. There's no "destroy on component unmount" — components come and go, the service persists.
This means: any subscription a service opens must be cleaned up in
onDestroy(), which only runs at app teardown. Don't open per-component
subscriptions from a service — that's the composable's job (with
onUnmounted cleanup). Service-level subscriptions are session-long.
If you find yourself wanting a "subscribe to X just for this view" pattern inside a service, you've crossed the layer boundary. Move it to a composable that consumes the service.