Search selfstore
v1.8.21

API: the advanced store

The pull-model store the simple one is built on, plus the destination primitives. The narrative version, and when to reach for it at all, is advanced.

Two rules before anything else. selfstore() gives you store.advanced, so you almost never construct a LocalStore yourself. And the advanced store does not auto-save: you call schedule() and flush(), which is precisely the control you came for.

LocalStore

State and lifecycle

Member Signature
state LocalStoreState (readonly)
subscribe (fn: () => void) => () => void
init () => Promise<void>
schedule () => void
flush () => Promise<void>
dispose () => void

Syncing

Member Signature Notes
syncIfStale (source: SyncSource) => Promise<void> 'boot' | 'focus' | 'online' | 'interval' | 'manual' | 'push' | 'connect'
syncNow () => Promise<SyncJournalEntry | null> Null when nothing changed

syncNow returning the journal entry is what lets an app report what a sync moved - see CollectionChanges.

Destinations

Member Signature
inspectTarget (target) => Promise<{ hasBackup, date, encrypted }>
attachTarget (target, opts?) => Promise<void>
detachTarget (opts?: { keepSession?: boolean }) => Promise<void>
setManualFile () => Promise<void>
exportBlob () => Promise<Blob>
markDownloaded () => void
forget () => Promise<void>
attachTarget(target, {
  password?: string | null,
  group?: StoreGroupConfig,
  strategy?: 'merge' | 'replace-local' | 'replace-remote',
  keepSession?: boolean,
  wipe?: boolean
})

inspectTarget before attachTarget is the honest order: it tells you whether a backup is already there and whether it is encrypted, without touching local data, so you can ask the user before anything is merged or replaced.

Copies, peers and mirrors

Member Signature What it is
attachReplica / detachReplica (target, opts?) => string / (id) => void The same backup, written twice (resilience)
attachPeer / detachPeer (source: PeerSource, opts?) => string / (id) => void Someone else’s backup, read-only (peers)
attachMirror / detachMirror (target, opts: { password }) => string / (id) => void A copy under a different key

Three different words for three different things, and the distinction is worth holding: a replica is your data written again, a peer is data you read from someone else, a mirror is your data re-encrypted for another audience.

Encryption

Member Signature
setEncryption (password: string | null) => Promise<void>
addEncryptionKey (password: string, id?: string) => Promise<string>
removeEncryptionKey (id: string) => Promise<void>
setExternalEncryption (secret: Uint8Array, keyRef: string) => Promise<void>
addExternalKey (secret: Uint8Array, keyRef: string, id?: string) => Promise<string>
unlockWithExternal (secret: Uint8Array) => Promise<boolean>
listEncryptionKeys () => { id: string; kind: 'password' | 'external' }[]
unlock / lock (password) => Promise<boolean> / () => void
reconnect () => Promise<boolean>
setGroup (manifest: SignedManifest) => Promise<void>

The envelope holds several key slots. That is what makes a recovery code possible (addEncryptionKey) and what makes a passwordless group possible (setGroup plus one slot per member) - the same mechanism, two products.

addExternalKey takes raw bytes rather than a password: a passkey PRF result or a hardware-held secret never has to become a string.

Modes

Member Signature
setEphemeral () => void
leaveEphemeral () => Promise<void>

BackupTarget: writing a destination

Any object satisfying this contract is a destination. The store’s merge semantics, encryption and status all work unchanged.

interface BackupTarget {
  readonly kind: string;
  readonly label: string;
  save(blob: Blob): Promise<string | null>;
  load(): Promise<Blob | null>;
  stat?(): Promise<string | null>;
  isReady(): Promise<boolean>;
  reconnect(): Promise<boolean>;
  disconnect(): Promise<void>;
  abortInFlight?(): void;
}
Member Contract
kind Flows into TargetKind and the status. Must not be 'device' or 'file-manual' - those name the absence of a target and attachTarget refuses them.
label What the user sees.
save Returns a version token, or null.
load Null means no backup there yet - not an error.
stat Optional cheap freshness check.
isReady False when a gesture is needed; drives needs-attention.
reconnect Re-run the auth gesture.
abortInFlight Optional; cancel a hung upload.

Getting the error semantics right matters more than the happy path. A transient failure must not look like a gone destination, or the store will propose the wrong remedy. The rules, with the exact codes to throw, are on error codes.

The built-in targets

Export Destination
fileTarget A disk file, File System Access (Chromium)
driveTarget Google Drive
webdavTarget A WebDAV server
s3Target An S3-compatible bucket

Each exposes the same shape of helpers: connect(...) for the first-time gesture, fromSession(...) to rebuild from what was persisted, plus isSupported() and, for files, isOpenSupported() and openExisting().

Every one of them takes a KV, because that is where the session - a file handle, a token, a config - is persisted. Keeping it injected is what stops the targets from knowing anything about your storage.

interface FileConnectOptions   { kv: KV; fileName: string }
interface DriveOptions         { auth: DriveAuth; kv: KV; fileName: string }
interface WebdavConnectOptions { kv: KV; config: WebdavConfig }
interface S3ConnectOptions     { kv: KV; config: S3Config }

interface WebdavPeerOptions    { url: string; username?: string; password?: string }

WebdavPeerOptions is the read-only variant: a peer’s backup you fetch but never write. BuiltinTargetKind is 'file' | 'drive' | 'webdav', the kinds the library ships.

The file handle is persisted through the injected KV, which is what keeps the target storage-agnostic. The browser re-confirms write permission once per session, which is the one-click reconnect the status asks for.

Sharing over Drive: companion files

driveTarget carries a second family of calls, and they operate on a different kind of file. Everything above works on the user’s own backup. Sharing needs a companion: a file the app creates next to that backup, publishes on a link, and tears down on its own schedule - a member’s published copy, an invitation others read before they join.

function createCompanion(opts: { auth: DriveAuth; fileName: string }): Promise<{ fileId: string }>;
function share(opts: { auth: DriveAuth; fileId: string }): Promise<void>;
function unshare(opts: { auth: DriveAuth; fileId: string }): Promise<void>;
function owner(opts: { auth: DriveAuth; fileId: string }): Promise<DriveAccount>;
function secondary(opts: { auth: DriveAuth; kv: KV }, fileId: string): BackupTarget;
Call What it does
createCompanion Creates an empty named file and answers its id. Unlike createBackup it does not refuse a duplicate name: several members of several groups may legitimately hold identically named copies, and the id is the handle everywhere.
share Makes the file readable by anyone holding its link. The capability-link model: the file carries ciphertext only, the key travels in the link’s fragment, so the link is the capability and the recipient needs no Google account.
unshare Drops every link grant, leaving named ones alone. Call it before any plaintext rewrite of a file that was shared - a decrypted copy must never stay link-readable for even one save.
owner Which account owns the file, not which one this session belongs to (that is account()). It is how a copy living on somebody else’s Drive gets a label: “Google Drive” is the same for everyone, only the owner’s address says whose.
secondary A read-write target over one companion file, for attachMirror or a replica. No fileName, unlike the rest of DriveOptions: a target bound to an id never searches or creates by name.

Publish encrypted files, always. share hands the bytes to whoever has the URL, including whoever it gets forwarded to.

const { fileId } = await driveTarget.createCompanion({ auth, fileName: 'my copy.zip' });
await driveTarget.share({ auth, fileId });
await store.attachMirror(driveTarget.secondary({ auth, kv }, fileId), {
  password: linkKey
});

Teardown, in this order: unshare, then deleteBackup({ auth, fileId }).

secondary rather than preview

preview(opts, fileId) also binds a target to a given file, and its save() works - which makes reaching for it tempting. Do not. Its name says read-only because its disconnect() belongs to the primary connection: it calls auth.forget() and can drop the remembered backup id. That is right when the user is leaving Drive, and catastrophic when they are only dropping a shared copy.

secondary detaches nothing but itself, and carries kind: 'drive-companion' so a store that persists the kind never mistakes a shared copy for the destination.

What is not here, and why

Reading another account’s link-shared file. The drive.file scope only sees files this app created or the user picked, so fetching a copy from somebody else’s Drive takes either the Google Picker (a user gesture per file, plus a third-party script) or a relay you host. One of those is a server, and neither is a choice this library makes for you - attachPeer accepts whatever load() you can provide.

Every call here follows the error protocol the rest of the target does: AuthExpiredError for a genuine loss of access and only after the stale-token retry, TARGET_WRITE_FAILED for a refusal, TARGET_UNAVAILABLE for a metadata read that did not land. owner is the one exception, and deliberately: a file that will not say answers nulls rather than throwing, because a missing label must never break the sync round that asked for it.

storageAdvice

function storageAdvice(): StorageAdvice;

type StorageRisk = 'none' | 'ephemeral' | 'evicted-when-idle';

interface StorageAdvice {
  risk: StorageRisk;
  remedy?: 'install-to-dock' | 'install-to-home-screen';
  installed: boolean;
}

Whether the browser is likely to throw the working copy away, and what the user could do about it. 'ephemeral' is a private window; 'evicted-when-idle' is a browser that reclaims storage from sites the user does not return to.

The advice disappears on its own once the remedy is applied, so it is safe to render unconditionally rather than tracking dismissal.

This is not a substitute for a durable home - it is the argument for one. A working copy the browser can evict is exactly the state <selfstore-gate> exists to get the user out of.

Snapshots and reserved names

Export Signature Notes
exportSnapshot (store) => ... The raw snapshot, no file format
importSnapshot (store, snapshot) => ...
datedName (name: string) => string The same name stamped to the minute
inspect / isEncrypted (input) => ... Read a backup’s header without decrypting
deriveStatus (input: StatusInput) => StatusDescriptor The ranking function the store uses
RESERVED_COLLECTION_PREFIX '__' Library bookkeeping collections
RESERVED_STORE_MODES constant 'device', 'file-manual'
isReservedStoreMode (kind: string) => boolean

datedName is used only on the download path, and the reason looks backwards until you see it: through a handle the app rewrites the same file, so its name must stay stable; a download never replaces anything, so a stamped name keeps successive downloads distinguishable instead of backup (3).zip.

deriveStatus is exported so you can test your own status rendering against the same ranking the store applies, rather than reimplementing the precedence and drifting from it.

Types you will meet but rarely write

These are exported because they appear in the shapes above, not because most apps construct them. Listed so a .d.ts never sends you hunting.

Type What it is
LocalStoreOptions What createLocalStore takes
LocalStoreState Everything store.state holds: mode, target, journal, peers, replicas
SnapshotFile One file entry inside a snapshot
EncodeOptions How a backup is written: app name, version, encryption, verification
KdfParams The Argon2id parameters recorded in the header, so a backup can always be re-derived
RecipientStanza One key slot in the envelope - a password, an external key or a group member
ReplicaState A copy’s health: last write, last error
MirrorState A mirror’s health
PeerState A followed peer’s health and last read
CachedFile A file as the local cache holds it
LockableCache A cache that can be sealed; isLockableCache(cache) narrows to it
GroupMember One member entry in a signed manifest
DriveBackupInfo One row of Drive’s listBackups(): id, name, modifiedTime, size

ReplicaState, MirrorState and PeerState are worth reading when you render health: each carries its own last error, and a broken one never gates the store - that is the point of the design, and it means you have to surface them yourself or nobody will see them.

DriveBackupInfo deliberately omits whether a backup is encrypted. That lives inside the file, and a listing has to stay one cheap metadata call rather than N downloads - which is also why BackupRow.encrypted is three-state and learned afterwards.