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>
94 lines
4 KiB
Markdown
94 lines
4 KiB
Markdown
# 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 via `injectService(...)`,
|
|
open relay subscriptions, set up timers.
|
|
- `onDestroy()`: unsubscribe everything, clear caches. Anything you started
|
|
in `onInitialize` you 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)`: returns `undefined` if 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.
|
|
|
|
```ts
|
|
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](./replaceable-events.md#vue-3-reactivity-for-nested-refmap).
|
|
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.
|