flotilla/.agents/skills/flotilla-state/SKILL.md

460 lines
24 KiB
Markdown

---
name: flotilla-state
description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)."
---
# Flotilla state
State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in
the current app's repository. Plugin indexes and derived stores read the repository, and
components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the
command becomes a thunk, and the thunk writes its event into the repository before any relay has
seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
## The app instance
`src/app/core.ts` builds the app lazily. `app` is a hand-written `ReadableWithGetter<App>`
whose first `get()` or `subscribe` builds an anonymous app. `App` runs its policies in its
constructor. Flotilla's own policies live in modules that import `core.ts` and push themselves
onto `appPolicies` when imported, so the first app has to be built after those imports run (see
[App policies](#app-policies)).
- **Login swaps the app.** `login(session)` builds a `User` from the session, cleans up the old
app, builds a new one with that user, then sets `session`. There is no account switching, so
`login` only runs while signed out: `restoreSession` at boot, the `LogIn*`/`SignUp*` flows,
and `loginWithPomade`.
- **Logout reloads the page.** `logout` in `src/app/session.ts` clears `kv`, `ss`, the user's
IndexedDB and `localStorage`, cleans up the app, then sets `window.location.href = "/"`.
The app is therefore stable for as long as anything under the login gate is mounted.
| Export | What it is | Signed out |
|---|---|---|
| `app` | the current `App` | an anonymous app |
| `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe |
| `profiles`, `rooms`, `relays`, `events`, `thunks`, … | `usePlugin` for 28 welshman plugins | safe |
| `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe |
| `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` |
| `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults |
| `reader`, `writer`, `command` | `Domain` entry points on the current app | — |
| `login`, `appPolicies` | see above and below | — |
AGENTS.md lists `pubkey` and `signer` stores, but neither exists. Read `$app.user?.pubkey` where
absence is legitimate, and `$user.pubkey` or `user.get().signer` behind the login gate.
## Signed in vs signed out
`src/app/components/AppContainer.svelte` renders the route (`children`) only when
`$app.user?.pubkey` is set, and shows the `Landing` dialog otherwise. Route pages, and
everything under `PrimaryNav`, can assume a user.
The following run outside that gate:
- the root `src/routes/+layout.svelte` and what it starts: `restoreSession`,
`syncApplicationData`, the `notifications.sync*` functions, `Push.sync`, logging
- `ModalContainer` and every modal, including the `LogIn*`/`SignUp*` flows. Modals stay mounted
across a login.
- `Toast`, `CallBanner`, `SpeechBanner`, `NewNotificationSound`
- every app policy
This code reads `app.get().user?.pubkey` and bails when it is missing, as
`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts`
do.
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
(`management.ts`), so only use them from gated components.
## Reaching plugins
The code reaches plugins three ways:
```typescript
// Components: a store exported from core.ts, when there is one
const display = $profiles.display(pubkey, [url]).$
// Components: $app.use() for plugins core.ts doesn't export (Zappers, Feeds, Pinboards)
const zapper = $app.use(Zappers).forPubkey(pubkey, removeUndefined([url])).$
// Module scope: always through a store that rebinds when the app changes
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
```
The rule is about when a binding is made:
- **Module scope, and anything outside the gate**, goes through `app`, `usePlugin`, `fromApp`
or `deriveUserItem`. A module-level `app.get()` builds the first app before the policies
register, and a binding made that way keeps reading the discarded app after login. A
long-lived listener over the repository belongs on a plugin, as in `Chats` (`chats.ts`), so
it is dropped with the app. Other listeners re-bind on `app.subscribe`, as
`syncCheckedRemote` in `notifications.ts` and the resync in the root layout do.
- **Code under the gate** can bind at call time. `rooms.get().forUrl(url).$` inside
`deriveUserRooms`, or `$app.use(X)` in a component's script, is fine there, because the app
cannot change while that code is mounted.
For a new module-level store over a welshman plugin, use the `usePlugin` export in `core.ts` if
there is one. Add an export when several modules need the plugin, and otherwise write
`fromApp($app => ...)` where the store is defined.
### Per-identity caches
Bookkeeping for one identity lives on a plugin instance, so it is discarded with the app.
`Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason.
Module-level caches are fine when their contents do not depend on who is signed in, or when what
they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
`@welshman/lib` to share one store per url across every component that asks.
## Flotilla's own plugins
| Plugin | Base | What it is |
|---|---|---|
| `Settings` (`settings.ts`) | `DerivedPlugin` | encrypted app-data settings, plus a `values` projection |
| `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey |
| `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay |
| `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s |
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
```typescript
export class Statuses extends DerivedPlugin<TrustedEvent> {
constructor(app: IApp) {
super(app, {filters: [filter], eventToItem: event => event, getKey: event => event.pubkey})
}
fetch(pubkey: string, hints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, filter, hints)
}
}
export const statuses = usePlugin(Statuses)
```
A plugin fits a keyed collection of an event kind that needs `index`, `one` and `load`, or
per-identity logic with its own caches. Expose derived views as projections with
`projectFrom(this.index, ...)`, as `Settings.values` and `Commands.forUrl` do.
A generic nostr kind belongs upstream in `@welshman/app`, with its reader in `@welshman/domain`
(see flotilla-model), because the maintainer prefers fixing welshman to working around it here.
`Statuses` could move. `Settings`, keyed on the `flotilla/settings` d-tag, stays.
## App policies
An `AppPolicy` is `(app) => Unsubscriber`, and `app.cleanup()` tears policies down in reverse.
`core.ts` seeds `appPolicies` with welshman's `appPolicyWraps`, `appPolicyRelayStats`,
`appPolicyCacheDecrypt` and `appPolicyLogSignerMethods`. It leaves out welshman's
`appPolicyIngest` and `appPolicyAuthUnlessBlocked`, because flotilla replaces them:
| Policy | Module | What it adds |
|---|---|---|
| `ingestPolicy` | `policies.ts` | drops DVM and ephemeral kinds; skips signature checks for trusted relays |
| `authPolicy` | `policies.ts` | NIP-42 by the `relay_auth` setting, conservative or aggressive |
| `socketPolicy` | `policies.ts` | blocked relays, `relaysPendingTrust`, `relaysMostlyRestricted` |
| `storagePolicy` | `storage.ts` | the per-user IndexedDB cache, only when the app has a user |
The root layout imports `@app/policies` and `@app/storage` before anything touches the app. To
add a policy:
1. Define it in the module that owns the concern.
2. Push it onto `appPolicies` at the bottom of that module.
3. Make sure the root layout imports that module ahead of the first `app.get()`.
Inside a policy, use the `$app` argument. During construction the new app is not in the store
yet, so `app.get()` returns the previous, cleaned-up app. On first boot there is no previous
app, and `app.get()` recurses into building another one.
## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing
optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay.
Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`.
Every method returns a projection over the current app's repository:
- `one(idOrAddress, relays)` (loads when nothing local matches, keeps deleted events), `byId`,
`all`, `asc`, `desc`, `isDeleted`
- relay-scoped through the tracker: `forUrl`, `byIdForUrl`, `byIdByUrl`, and
`relaySignedForUrl`, which keeps only events signed by the relay's NIP-11 `self`
Components bind `$events.forUrl(url, filters).$`. A `derive*` function called from gated code
binds `events.get().forUrl(...).$` at call time, and a module-scope store wraps it in
`fromApp($app => $app.use(Events).byIdByUrl(filters).$)`, as `latestActivityByPath` does.
`src/app/repository.ts` holds only the `LatestEvents` plugin. Plugin reads (`get`, `one`, `load`,
`index`) are documented in welshman-app.
### Free functions in an app module
Most derived state is a plain function in the app module that owns its domain, composing plugin
projections and repository derivations:
```typescript
// src/app/actionItems.ts
export const deriveSpaceActionItems = (url: string) =>
derived(
[
events.get().forUrl(url, [{kinds: [REPORT]}]).$,
rooms.get().pendingJoins(url).$,
deriveSpacePermissions(url),
],
([$reports, $pendingJoins, $permissions]) =>
sortEventsDesc([
...($permissions.deleteContent ? $reports : []),
...($permissions.addMembers ? $pendingJoins : []),
]),
)
```
`src/app/rooms.ts` is the fullest example. Names follow the return type:
- `derive*` returns a store: `deriveUserRooms`, `deriveUserRoomMembershipStatus`
- `get*` and `display*` return a snapshot: `displayRoom`
- plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls`
Rules that involve more than one plugin belong in these functions rather than in components.
"A space's staff are room admins" lives in `deriveUserIsRoomAdmin`.
### Hand-built indexes for hot paths
A derivation that every row subscribes to, or that joins large sets, is built by hand:
- `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather
than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
profiles index.
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array
wherever an event's thunks are unchanged so rows don't churn.
- `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`.
- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
watched author.
## Local and persisted state
| State | Where | Per user | On logout |
|---|---|---|---|
| repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `session`, `wallet` | `ss` | no | cleared |
| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state | `kv` | no | cleared |
| drafts, dictations | a module `Map`, lost on reload | no | page reloads |
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
`storagePolicy` builds a `Storage` only for an app with a user, so a signed-out app caches
nothing. Each identity gets its own database, `flotilla-9gl-<pubkey>`. `IDB` reconciles object
stores by bumping the database version, so adding or removing a table needs no migration.
`shouldPersistEvent` keeps:
- profiles and metadata lists (follows, mutes, relay lists, app data, room lists) from any author
- alert kinds
- relay- and room-scoped kinds
- DMs
- room membership changes, only when they tag the user
Room messages, threads and other content are not kept, and background sync pulls them again.
- Rows keep each event's relays inline. A relay-scoped event without them can never be keyed to
a space again, so it is dropped on load.
- `COMMAND` definitions expire after a week.
- Boot waits only for events and relays (`storage.get()?.ready`). The other tables load on the
next tick.
To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin,
add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe
to `onItem`, and batch the writes.
### kv, ss and the two ways to bind them
`kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from
`storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so
anything in them outlives a login and is cleared only by logout. Secrets go in `ss`.
- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme`
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it.
- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it
for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them
there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme.
### Settings (`src/app/settings.ts`)
Settings are an encrypted app-data event with d-tag `flotilla/settings`, read through the
`Settings` plugin:
- `userSettingsValues` is the current user's values merged over `defaultSettings`. `getSetting`
is its snapshot, and there are derived helpers such as `deriveShouldNotify`.
- `publishSettings(partial)` calls `forceLoad` first, so a write merges onto the latest event
rather than a stale cache.
- Settings pages bind `createSettingsForm()`. The form adopts the real values when the event
finishes decrypting, but only while untouched, so defaults never overwrite real settings.
A preference that should follow the user across devices goes in `SettingsValues` and
`defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do
in `notificationSettings`. Per-space alert preferences are published (`alerts`); the device's
push permission is not.
`checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to
dufflepud's `kv/checked` with NIP-98 auth. That makes it cross-device without publishing an
event on every read.
### Drafts (`src/app/drafts.ts`)
`DraftKey<T>` is a typed handle over an in-memory `Map`. A draft survives the composer
unmounting and a navigation, but not a reload. Key it by context: `RoomCompose` uses
`room:${url ?? ""}:${h ?? ""}` and `EventReply` uses `reply:${event.id}:${parent?.id || ""}`.
The dictation registry in `dictation.ts` works the same way, so a transcription can finish after
its composer has gone.
## Background sync (`src/app/sync.ts`)
The root layout calls `syncApplicationData()` once the session is restored and storage is ready,
and again after every app swap. `Access.completeJoin` calls it after a space is joined. Each
call tears down the previous run.
- `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's
spaces.
- `syncUserData` loads the user's relay list, then on each relay-list change their other lists,
profile and `Settings`. It also pulls the user's own space and room membership events, and
their follows' follow and mute lists.
- `syncSpaces` covers each joined space plus the current route's space. It pulls membership,
roles, room metadata, pins and livekit state in full, and recent content: a month of it, or a
week for reactions and comments.
- `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on.
`pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through
an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against
the room list, so a new filter goes into the right `pullAndListen` call.
Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data
that must be current app-wide belongs here. Data only one page shows is loaded by that page's
components; see flotilla-views.
## Mutations
The prevailing path runs from a domain writer to a command to a thunk, adapted from
`ThreadCreate.svelte`:
```typescript
const eventWriter = writer(Thread)
.setContent(content)
.setTitle(title)
.setProtected(protect)
.forceRoutes(relay(url))
if (room) {
eventWriter.setRoom(url, room)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
```
`publish` sends to the writer's own routes, which is why the excerpt forces them with
`forceRoutes`. `publishToRelays(urls)` overrides those routes instead.
flotilla-model's "Which relays an event goes to" says which one each kind needs.
Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(publish)`,
`rooms.get().addMember(url, room, pubkey)`, `reactions.get().react(event, content, ...)`,
`deletes.get().deleteEvent(event, w => w.setProtected(protect))`. Their `update`-style methods
`forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`.
Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
`{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status
- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its
relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
unsigned event for the signed one.
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts,
and a comment carries the same Cancel in the `ThunkPending` row under it.
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`).
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
whole `history` per row instead.
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
above.
## Other app-level stores
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
`allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two
stores, so the inbox matches the badges.
- **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
`currentCallSession`, …).
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are
classes a component instantiates (`new Access(url)`). They hold the writables and actions for
a multi-step flow.
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the
root layout persists.
## Runes and stores
Modules in `src/app` use svelte stores. The one `.svelte.ts` module is `src/app/modal.svelte.ts`:
its modal registry is `$state`, and the open stack is `$derived` from `page.state` in
`$app/state`, SvelteKit's rune-based replacement for the deprecated `$app/stores`. A rune-only
source is what justifies the exception. `sync.ts` and `notifications.ts` read `page` from
`$app/stores` because they subscribe to it outside a component.
Component-local `$state` covers UI state that dies with the component. Everything else is a
store, consumed in components with `$store`.
## Where does this state belong?
Take the first answer that fits:
1. **It is an event, or derived from events.** It is already in the repository, or should be.
Read it with a plugin or the `events` projections, and put the domain logic in a
`derive*` function in the owning app module (`deriveUserRooms`). Don't copy it into a
writable.
2. **It is a keyed collection of one kind, loaded by key.** Write a plugin. A generic kind goes
upstream in `@welshman/app`. A flotilla-specific one is a `DerivedPlugin` here, exposed with
`usePlugin` (`Settings`, `Statuses`, `Commands`).
3. **It is bookkeeping for one identity.** Put it on a plugin instance (`Commands.pulled`) or in
a policy, never in a module-level map that outlives login. Retry or resume logic around
welshman behaviour is a fix for welshman instead.
4. **It is a preference.** If it follows the user, it is a `SettingsValues` field. If it is per
device, it is a `synced` store in `kv`. A secret goes in `ss`.
5. **It is app-wide state that does not come from nostr.** Make it a writable in the owning app
module (`callState`, `toast`, `relaysPendingTrust`).
6. **It must outlive a component but not a reload.** Use a module map, as `DraftKey` and the
dictation registry do.
7. **It is one component's UI.** Use `$state` in the component.
## Related skills
- `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance
- `flotilla-views`: routes, components, and how components load data and show state
- `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds
- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events`
- `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app