# API: flows Source: https://selfstore.dev/docs/api-flows Complete reference for selfstore/flows - the headless state machines behind the widgets. connectFlow, shareFlow, joinFlow and replicaFlow with every snapshot field, every action and the engine contracts you implement. The journeys the [widgets](https://selfstore.dev/docs/widgets) render, without any rendering. Every ordering and failure rule lives here and is tested here; a widget is a skin over one of these. Reach for a flow when you want the journey inside your own components. You lose nothing by dropping down - the widgets have no capability the flow does not. ## The shared contract Every flow is a `FlowStore`: ```ts interface FlowStore { readonly snapshot: T; subscribe(listener: (snapshot: T) => void): () => void; } ``` Read `snapshot`, call actions, re-render on `subscribe`. That is the whole pattern; the rest of this page is what each snapshot holds. `withDeadline(work, ms, what)` is exported too: it is the guard the flows use to turn a hung network leg into a named error rather than a spinner that never stops. ## What a flow attaches to ```ts interface FlowHost { engine: LocalStore; kv: KV; backupName: string; } type StoreLike = FlowHost | { flowHost: FlowHost }; ``` A simple store satisfies this through its [`flowHost`](https://selfstore.dev/docs/api-store) member, so you pass the store itself. An app built on the advanced store hands the three members over directly. ## connectFlow ```ts function connectFlow( store: StoreLike, targets: ConnectTargets, options?: ConnectFlowOptions ): ConnectFlow; ``` ### ConnectTargets Which destinations to offer, and how each one authorizes. ```ts type Connector = () => Promise; interface ConnectTargets { drive?: DriveAuth | Connector; file?: true | Connector | { create?: true | Connector; open?: true | Connector }; webdav?: true | WebdavConfig | Connector; s3?: true | S3Config | Connector; } ``` `true` means "offer it with the built-in gesture". A `Connector` replaces the gesture with your own. The object form of `file` declares **two** gestures on one card - create a new backup, or open an existing one. ### ConnectFlowOptions | Option | Type | What it does | | --- | --- | --- | | `resume` | `ResumeOffer` | Offer to reopen a backup this device already knows about, as a card above the destinations | | `hasLocalData` | `() => boolean` | Lets the flow know whether replacing would lose anything | | `defaultResolution` | `ConnectResolution` | Skip the conflict question with a fixed answer | | `deferUnlock` | `boolean` | Do not demand the password during connect | | `password` | `string \| (() => string \| Promise)` | Supply it up front, or lazily | | `deadlineMs` | `number` | Network guard, for slow legs | ```ts interface ResumeOffer { kind: ConnectKind; // picks the icon and the wording, nothing else detail?: string; // what exactly is being reopened: the account, the file name connect: Connector; } ``` `detail` is the field that earns the card. "Resume my backup" only answers the user's question when it can **name** the backup - an account, a file - and a first-run screen that cannot is asking them to guess. [``](https://selfstore.dev/docs/widget-storage) builds this offer from the store by itself, which is most of why it exists: deriving it by hand from a session the library already holds is where apps got it wrong. ### ConnectSnapshot ```ts type ConnectStep = 'choose' | 'form' | 'authorizing' | 'password' | 'conflict' | 'connected' | 'error'; interface ConnectSnapshot { step: ConnectStep; kinds: readonly ConnectKind[]; // 'drive' | 'file' | 'webdav' | 's3' kind: ConnectKind | null; outcome: ConnectFlowOutcome | null; hasBackup: boolean; encrypted: boolean; passwordError: boolean; busy: boolean; error: StoreError | null; } type ConnectResolution = 'merge' | 'resume' | 'replace'; type ConnectFlowOutcome = 'merged' | 'started' | 'resumed' | 'replaced' | 'manual'; ``` `'manual'` is the degraded file mode: no handle, so every save is a download the user performs. ### Actions | Action | Signature | | --- | --- | | `choose` | `(kind: ConnectKind, variant?: 'create' \| 'open') => void` | | `submitWebdav` | `(config: WebdavConfig) => void` | | `submitS3` | `(config: S3Config) => void` | | `submitPassword` | `(password: string) => void` | | `overwrite` | `() => void` | | `resolveConflict` | `(how: ConnectResolution) => void` | | `cancel` | `() => void` | | `retry` | `() => void` | `overwrite()` is the forgotten-password path: abandon the unreadable backup and start a fresh one over it. It is deliberately a separate action from `resolveConflict('replace')`, because the two look alike and destroy different things. ## shareFlow ```ts function shareFlow(engine: ShareEngine, options?: { deadlineMs?: number }): ShareFlow; ``` ### The engine you implement selfstore does not transport links - your app does. The flow drives whatever you give it: ```ts interface ShareEngine { list(): Promise<{ links: ShareLinkInfo[]; members: ShareMemberInfo[] }>; createLink(opts: { level: ShareLevel }): Promise; revokeLink(id: string): Promise; removeMember?(id: string): Promise; revokeAll?(): Promise; } type ShareLevel = 'read' | 'write'; interface ShareLinkInfo { id: string; url: string; level: ShareLevel } interface ShareMemberInfo { id: string; label?: string; self?: boolean; owner?: boolean } ``` The two optional methods are what `canRemoveMembers` and `canRevokeAll` report - the UI hides what your engine cannot do rather than failing on it. ### ShareSnapshot ```ts type ShareBusy = 'refresh' | 'create' | 'revoke' | 'remove' | 'revoke-all' | null; interface ShareSnapshot { links: readonly ShareLinkInfo[]; members: readonly ShareMemberInfo[]; busy: ShareBusy; stale: boolean; error: StoreError | null; canRemoveMembers: boolean; canRevokeAll: boolean; } ``` `busy` **names which operation** is in flight, so a spinner can sit on the row being revoked rather than freezing the panel. `stale` says the list on screen may be behind. It is not an error: it matters before someone concludes a revoke did not work. ### Actions | Action | Signature | Returns | | --- | --- | --- | | `refresh` | `() => Promise` | | | `createLink` | `({ level }) => Promise` | Null on failure | | `revokeLink` | `(id: string) => Promise` | | | `removeMember` | `(id: string) => Promise` | | | `revokeAll` | `() => Promise` | | ## joinFlow ```ts function joinFlow(link: string, engine: JoinEngine, options?: { deadlineMs?: number }): JoinFlow; ``` ```ts interface JoinEngine { preview(link: string): Promise; join(link: string): Promise; switchAccount?(): Promise; } interface JoinPreview { label?: string; from?: string; level?: ShareLevel } type JoinOutcome = 'joined' | 'mismatch' | 'no-invite'; type JoinStep = 'previewing' | 'ready' | 'joining' | 'joined' | 'mismatch' | 'no-invite' | 'error'; interface JoinSnapshot { link: string; step: JoinStep; preview: JoinPreview | null; canSwitchAccount: boolean; busy: boolean; error: StoreError | null; } ``` `mismatch` and `no-invite` are **steps, not errors**. A device already following another share and a spent invitation are expected outcomes with different remedies, and collapsing them into `error` would offer a retry that cannot work. Raise `deadlineMs` when `join()` opens an account chooser: the default guard is sized for a request, not for a human reading a popup. ## replicaFlow The [backup copy](https://selfstore.dev/docs/resilience) journey - the same encrypted file also written to a second destination. ```ts function replicaFlow( store: StoreLike, targets: ConnectTargets, options?: ReplicaFlowOptions ): ReplicaFlow; const REPLICA_ID = 'replica'; ``` ```ts interface ReplicaFlowOptions { fileName?: string; restoreTarget?: (kind: ConnectKind) => Promise; } type ReplicaStep = 'idle' | 'choose' | 'form-webdav' | 'form-s3'; interface ReplicaSnapshot { step: ReplicaStep; kinds: ConnectKind[]; busy: boolean; error: StoreError | null; replica: ReplicaState | null; } ``` | Action | Signature | | --- | --- | | `open` | `() => void` | | `cancel` | `() => void` | | `pick` | `(kind: ConnectKind) => void` | | `submitWebdav` | `(config: WebdavConfig) => void` | | `submitS3` | `(config: S3Config) => void` | | `remove` | `() => Promise` | | `restore` | `() => Promise` | | `dispose` | `() => void` | `restoreTarget` is how a recorded copy comes back after a reload: the flow knows a copy existed and what kind it was, but only your app can re-obtain a handle or re-authorize. `` wires this for you. ## Using one directly ```ts import { connectFlow } from 'selfstore/flows'; const flow = connectFlow(store, { file: true, webdav: true }); const stop = flow.subscribe((s) => render(s)); flow.choose('file'); // ... later stop(); ``` Render `snapshot.step` as your own screen, call the actions from your own buttons. The [widgets](https://selfstore.dev/docs/widgets) do exactly this and nothing more. Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt