This commit is contained in:
2026-09-11 08:52:04 +07:00
parent b7a020767e
commit b7221ef814
8 changed files with 895 additions and 190 deletions
+242 -95
View File
@@ -6,6 +6,12 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
> an account is active, and that home screen is the inbox.
> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`:
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds.
> Phases 2-5 are not started. This document reflects the implementation as it stands, including
> the Phase 1 refactors (§4.3).
## 1. What the GitWorkshop home screen is
`Index.tsx`:
@@ -217,54 +223,66 @@ when the signed-in key changes.
```rust
/// d tag identifying the inbox read/archive state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{me}")
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored read state for `me`, with the id of the event it came from.
async fn load_state(
client: &Client,
me: PublicKey,
) -> Result<Option<(InboxReadState, EventId)>> {
// No author filter: the signing key is random per session.
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
// No author filter: the signing key is random per save.
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|e| e.created_at) else {
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
Ok(serde_json::from_str(&event.content)
.ok()
.map(|state| (state, event.id)))
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with the random `keys` and store locally.
async fn save_state(
client: &Client,
keys: &Keys,
me: PublicKey,
state: &InboxReadState,
) -> Result<EventId> {
/// Sign with a fresh random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(keys)?; // synchronous: random keys, no user signer
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(event.id)
Ok(())
}
```
Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because
`keys` is random per session, the first save of a session creates a new coordinate; the store then
deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains.
A fresh random key is generated on every save, so each save writes a new event rather than
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
id across saves; that was removed as more derived state than it was worth.
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
### 4.3 `signed_state`: one `InboxStore`
### 4.3 `signed_state`: `Inbox`, a child entity of `Backend`
One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`):
The inbox is not an app-wide global. It is a child `Entity<Inbox>` owned by `Backend`
(`inbox: Entity<Inbox>`), following the project's child-entity pattern
(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a
strict subset of the backend's, so it is observed independently.
```rust
pub struct InboxStore {
// backend.rs
pub struct Backend {
...
inbox: Entity<Inbox>,
}
// inbox.rs
pub struct Inbox {
/// Activity directed at the user, grouped by thread root, newest first.
pub notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
@@ -272,69 +290,132 @@ pub struct InboxStore {
/// Unread notification count (non-archived).
pub unread_count: usize,
state: InboxReadState,
user: Option<PublicKey>,
/// Random keypair signing the local NIP-78 storage event.
keys: Keys,
/// Event id of the loaded state event, pruned on the next save.
loaded_state: Option<EventId>,
/// Set once the stored state has been read for the current user.
state_loaded: bool,
refresh: RefreshGate,
_subscription: Subscription,
}
```
**Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the
other stores, but it does nothing until the user has a signer. It is never wired from the `desktop`
crate, and `signed_state::init` gains no parameters.
`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through
`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`,
`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`.
- `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks
`Backend::current_user()`:
- signer already present (session restored before the store was created): activate now;
- no signer: do nothing, wait for the event.
- `BackendEvent::SignerChanged`: activate.
- `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`,
`activity`, `unread_count`.
- `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`,
is `Kind::Comment`, or is a deletion.
- `BackendEvent::Synced`: refresh.
- `BackendEvent::Published`: refresh.
**The store holds no derived state.** The current user is read from `Backend::current_user()` at
each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the
signing key is random per save rather than cached. This follows the project rule against caching
derived state.
**Activation** (only on signer):
**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing
until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init`
gains no parameters (the `InboxStore::set_global` idea was dropped).
The dependency is strictly one-way: **`Backend``Inbox`**. `Inbox` holds no `Backend` handle, so
there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them.
**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a
deferred effect and then emits to the other subscribers:
```rust
fn activate(&mut self, me: PublicKey, cx: &mut Context<Self>) {
self.user = Some(me);
// Load the NIP-78 state from LMDB, then subscribe and refresh.
// All run on background tasks; only plain data crosses back.
/// Update the inbox, then emit `event` to the other stores.
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
let inbox = self.inbox.downgrade();
let inbox_event = event.clone();
cx.defer(move |cx| {
if let Err(error) = inbox.update(cx, |inbox, cx| {
inbox.handle_backend_event(&inbox_event, cx);
}) {
log::warn!("inbox dropped before handling backend event: {error}");
}
});
cx.emit(event);
}
```
The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox
handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All
`cx.emit(...)` sites route through `self.emit(...)`.
`Inbox::handle_backend_event` reacts to only three shapes:
- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is
`Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`).
- `Synced` / `Published`: refresh.
- everything else: ignored.
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
and `SidebarPanel` consume them. They no longer drive the inbox.
**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` /
`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer
transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and
`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and
repo relays need `Backend`'s state:
```rust
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
if let Some(me) = self.current_user {
self.subscribe_bootstrap(filters::notifications(me), cx);
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
.read(cx)
.announcements_of(&me)
.into_iter()
.flat_map(|announcement| announcement.relays)
.collect();
if !relays.is_empty() {
let relays: Vec<RelayUrl> = relays.into_iter().collect();
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
}
}
let inbox = self.inbox.downgrade();
cx.defer(move |cx| {
let updated = inbox.update(cx, |inbox, cx| {
if Backend::global(cx).read(cx).current_user().is_some() {
inbox.activate(cx);
} else {
inbox.reset(cx);
}
});
if let Err(error) = updated {
log::warn!("inbox dropped before syncing with the signer: {error}");
}
});
}
```
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every
caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer
has `subscribe_remote` / `connect_own_repo_relays`.
**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to
the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB
and chains the first refresh once it is loaded:
```rust
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else { return };
// clear notifications, activity, unread_count; state = default; state_loaded = false;
// self.refresh = RefreshGate::default();
self.load_state(me, cx);
self.subscribe_remote(cx);
self.refresh(cx);
}
```
Loading uses the random `keys` only for signing on save; reading the state event needs no signer at
all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey.
`reset` performs the same clearing without a state load, and is used on logout.
**Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`):
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
still gated on the signer because the fetch filters need the user's pubkey.
```rust
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let Some(me) = self.user else { return };
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(filters::notifications(me), cx);
backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
});
// Relays of the user's own repos, so their activity there is found too.
let relays = own_repo_relays(me, cx);
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
});
}
```
`own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their
`relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.)
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in
`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a
wait on the network; see the note below.
**Refresh** (mirrors `RepoListStore::run_refresh`):
@@ -345,12 +426,23 @@ fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
`inbox::group`.
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their `K` tag is a git kind; sort newest first; take the top N.
- Cross back to the main thread: set `notifications`, `activity`, `unread_count`, `cx.notify()`,
`refresh.finish()`.
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
previous user's results never land. Then set `notifications`, `activity`, `unread_count`,
`cx.notify()`, `refresh.finish()`.
**Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the
`read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB,
then delete the previously loaded copy (see 4.2).
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
so the query that follows them reads the local cache rather than waiting for the relays. That is
deliberate offline-first behavior: cached content appears at once on a warm start and with no
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was
reviewed and left as-is.
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are
marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After
each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB,
signed with a fresh random key (see 4.2).
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
`announcements_of(user)`.
@@ -371,7 +463,7 @@ One scrollable two-column flex row.
**Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle
expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a
relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`.
- **Continue where you left off**: `InboxStore::activity`, top 15, each row a kind icon, subject,
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject,
repo name, and relative time.
- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
@@ -398,7 +490,7 @@ pub fn add_bottom_panel(
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
`InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list.
`Entity<Inbox>`. It renders the matching subset of `Inbox::notifications` as a list.
- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the
requested mode. `InboxView` keeps `filter_view: Option<WeakEntity<InboxFilterView>>`; when it
already exists, update its mode and focus instead of adding a duplicate.
@@ -417,7 +509,7 @@ In `views/sidebar/mod.rs`:
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
```
- `cx.observe` the `InboxStore` so the badge updates.
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates.
### 5.4 Click-through (P1)
@@ -455,20 +547,22 @@ patch-root click opens the repo panel. Note as a known limitation.
| File | Change |
|---|---|
| `crates/signed_core/Cargo.toml` | add `serde` |
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity` |
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
| `crates/signed_state/Cargo.toml` | add `serde_json` |
| `crates/signed_state/src/inbox.rs` | **new**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions |
| `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` |
| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` |
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import |
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) |
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` |
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge |
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; the store
bootstraps itself via `cx.defer` once a signer is present.
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
activates the `Inbox` child entity at each signer transition.
## 7. Phasing
@@ -477,9 +571,11 @@ bootstraps itself via `cx.defer` once a signer is present.
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
and `authored_activity` results must pass through `is_git_activity` before display (comments on
non-git roots are matched by the filter). `cargo test -p signed_core` passes (62 tests).
2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and
NIP-78 load/save to LMDB, global install.
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
end of Phase 0; 68 after the two Phase 1 additions).
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below.
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
@@ -488,12 +584,59 @@ bootstraps itself via `cx.defer` once a signer is present.
Each phase compiles and is usable on its own.
### Phase 1 implementation notes
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
and two additions to `crates/signed_core/src/inbox.rs`.
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
a re-entrant entity access.
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
`SidebarPanel`, but no longer drive the inbox.
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
before the stored state is read would briefly mark everything unread. The first refresh is chained
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
user the query was started for.
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
Both are covered by tests.
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
rather than a single hop, because a patch series chains through parent patches. Only the notification
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
not mistaken for a notification.
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
re-derives the groups locally so the UI updates immediately, then persists in the background.
- The store keeps no derived state. The signing key is generated per save, the current user is read
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
either: the newest state event is selected by `created_at`.
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
## 8. Validation
- `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip.
- `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is
factored out.
- `cargo check --workspace` after each phase.
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.
- `cargo test -p signed_state` (24 tests): the `Inbox` store paths that do not need GPUI (state
round-trip, grouping helpers).
- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase.
- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another
identity's issue/comment, the activity list shows your own items, the repositories panel matches
the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events).
@@ -513,3 +656,7 @@ Each phase compiles and is usable on its own.
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
`client.sync(...)` both persist received events to LMDB and surface them as
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
This is why the query right after a fetch is a cache read, not a race.