23 KiB
| name | description |
|---|---|
| flotilla-state | 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).
- Login swaps the app.
login(session)builds aUserfrom the session, cleans up the old app, builds a new one with that user, then setssession. There is no account switching, sologinonly runs while signed out:restoreSessionat boot, theLogIn*/SignUp*flows, andloginWithPomade. - Logout reloads the page.
logoutinsrc/app/session.tsclearskv,ss, the user's IndexedDB andlocalStorage, cleans up the app, then setswindow.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, thunks, … |
usePlugin for 27 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.svelteand what it starts:restoreSession,syncApplicationData, thenotifications.sync*functions,Push.sync, logging ModalContainerand every modal, including theLogIn*/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:
// 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,fromApporderiveUserItem. A module-levelapp.get()builds the first app before the policies register, and a binding made that way keeps reading the discarded app after login. Long-lived listeners re-bind onapp.subscribe, aschatsByIdinsrc/app/chats.ts,syncCheckedRemoteinnotifications.tsand the resync in the root layout do. - Code under the gate can bind at call time.
rooms.get().forUrl(url).$insidederiveUserRooms, 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 Projections |
Each is exposed with usePlugin. Statuses is the minimal shape:
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:
- Define it in the module that owns the concern.
- Push it onto
appPoliciesat the bottom of that module. - 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.
src/app/repository.ts wraps the @welshman/store derivations in fromApp:
deriveEvent,deriveEvents,deriveEventsById,deriveIsDeleted- relay-scoped:
deriveEventsForUrl,deriveEventsByIdForUrl,deriveEventsByIdByUrl,getEventsForUrl deriveLatestEvent
Use these for raw event queries. Welshman's Events plugin has the same surface returning
projections, and flotilla does not use it. 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:
// src/app/actionItems.ts
export const deriveSpaceActionItems = (url: string) =>
derived(
[
deriveEventsForUrl(url, [{kinds: [REPORT]}]),
rooms.get().pendingJoins(url).$,
deriveSpaceSupportedMethods(url),
],
([$reports, $pendingJoins, $methods]) =>
sortEventsDesc([
...($methods.includes("banevent") ? $reports : []),
...($methods.includes("allowpubkey") ? $pendingJoins : []),
]),
)
src/app/rooms.ts is the fullest example. Names follow the return type:
derive*returns a store:deriveUserRooms,deriveUserRoomMembershipStatusget*anddisplay*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:
chatsById(chats.ts) updates incrementally from repositoryupdateevents rather than re-querying.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 behindthrottled(1000, …).deriveLatestEvent(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.
COMMANDdefinitions 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).themeandflTheme(theme.ts),checked(notifications.ts) andshouldUnwrap(sync.ts) use it.sync({key, store, storage})binds a store that already exists. The root layout awaits it fordevice,wallet,notificationSettingsandpushStatebefore first render, so boot code sees the restored values. It also bindsshouldUnwrap, whichsyncedalready 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:
userSettingsValuesis the current user's values merged overdefaultSettings.getSettingis its snapshot, and there are derived helpers such asderiveShouldNotify.publishSettings(partial)callsforceLoadfirst, 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.
syncRelaysloads NIP-11 for the indexer relays, the current route's relay and the user's spaces.syncUserDataloads the user's relay list, then on each relay-list change their other lists, profile andSettings. It also pulls the user's own space and room membership events, and their follows' follow and mute lists.syncSpacescovers 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.syncDMspulls gift wraps from the user's messaging relays, only whenshouldUnwrapis 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:
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), the comment composers (CommentCompose.svelte and EventReply.svelte) 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.
Thunkswrites 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 thedelayremoves the event from the repository and fromhistory. Whensend_delayis set, room chat shows aThunkToastwhose Cancel button aborts, and a comment carries the same Cancel in theThunkPendingrow under it. - Editing. Editing a message deletes it and republishes with the same
created_at(seeRoomChat.svelte). - Status in rows. Rows look up
$thunksByEventId.get(event.id) ?? noThunksand pass$thunks.merge(...)toThunkStatus, or toThunkFailure, which retries per relay.ThunkStatusOrDeletedcombines publish status with deletion.ChatMessage.sveltefilters the wholehistoryper 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.tsderiveslatestActivityByPath, thenallNotifications, thennotificationsand the counts.inbox.tsderives from the same two stores, so the inbox matches the badges. - Singleton session state.
call.tskeeps call state in plain writables (callState,currentCallSession, …). - UI signals.
toastintoast.ts, andrelaysPendingTrustinpolicies.ts. - A controller per flow.
Access(access.ts) andNip46Controller(nip46.ts) are classes a component instantiates (new Access(url)). They hold the writables and actions for a multi-step flow. - Module-owned values.
walletinlightning.tsis awithGetter(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:
- It is an event, or derived from events. It is already in the repository, or should be.
Read it with a plugin or an
@app/repositoryderivation, and put the domain logic in aderive*function in the owning app module (deriveUserRooms). Don't copy it into a writable. - 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 aDerivedPluginhere, exposed withusePlugin(Settings,Statuses,Commands). - 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. - It is a preference. If it follows the user, it is a
SettingsValuesfield. If it is per device, it is asyncedstore inkv. A secret goes inss. - It is app-wide state that does not come from nostr. Make it a writable in the owning app
module (
callState,toast,relaysPendingTrust). - It must outlive a component but not a reload. Use a module map, as
DraftKeyand the dictation registry do. - It is one component's UI. Use
$statein the component.
Related skills
flotilla-architecture: the layer rules, what eachsrc/appmodule is for, boot at a glanceflotilla-views: routes, components, and how components load data and show stateflotilla-model: spaces, rooms, NIP-43/29/86, which relays events go to, domain kindswelshman-app:App, plugins,Command, thunks,Network/Sync,Eventswelshman-store:deriveEventsById,deriveItemsByKey,synced,throttled,withGetterwelshman-domain: the readers and writers behindreader,writerandcommandwelshman-net: the repository, tracker and socket policies under the app