API: flows
The journeys the 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:
interface FlowStore<T> {
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
interface FlowHost {
engine: LocalStore;
kv: KV;
backupName: string;
}
type StoreLike = FlowHost | { flowHost: FlowHost };
A simple store satisfies this through its flowHost
member, so you pass the store itself. An app built on the advanced store hands
the three members over directly.
connectFlow
function connectFlow(
store: StoreLike,
targets: ConnectTargets,
options?: ConnectFlowOptions
): ConnectFlow;
ConnectTargets
Which destinations to offer, and how each one authorizes.
type Connector = () => Promise<BackupTarget | null>;
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<string>) |
Supply it up front, or lazily |
deadlineMs |
number |
Network guard, for slow legs |
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.
<selfstore-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
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
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:
interface ShareEngine {
list(): Promise<{ links: ShareLinkInfo[]; members: ShareMemberInfo[] }>;
createLink(opts: { level: ShareLevel }): Promise<ShareLinkInfo>;
revokeLink(id: string): Promise<void>;
removeMember?(id: string): Promise<void>;
revokeAll?(): Promise<void>;
}
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
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<void> |
|
createLink |
({ level }) => Promise<ShareLinkInfo | null> |
Null on failure |
revokeLink |
(id: string) => Promise<boolean> |
|
removeMember |
(id: string) => Promise<boolean> |
|
revokeAll |
() => Promise<boolean> |
joinFlow
function joinFlow(link: string, engine: JoinEngine, options?: { deadlineMs?: number }): JoinFlow;
interface JoinEngine {
preview(link: string): Promise<JoinPreview>;
join(link: string): Promise<JoinOutcome | null>;
switchAccount?(): Promise<void>;
}
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 journey - the same encrypted file also written to a second destination.
function replicaFlow(
store: StoreLike,
targets: ConnectTargets,
options?: ReplicaFlowOptions
): ReplicaFlow;
const REPLICA_ID = 'replica';
interface ReplicaFlowOptions {
fileName?: string;
restoreTarget?: (kind: ConnectKind) => Promise<BackupTarget | null>;
}
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<void> |
restore |
() => Promise<void> |
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. <selfstore-backups> wires this for you.
Using one directly
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 do exactly this and nothing more.