Search selfstore
v1.8.21

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.