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.