API: the store
Everything import { ... } from 'selfstore' gives you. The narrative version is
the quick start; this page is the surface, exhaustively.
import { selfstore } from 'selfstore';
const store = await selfstore<{ todos: Todo }>('todo-app');
function selfstore<S extends Record<string, SimpleRecord>>(
app: string,
options?: SimpleOptions
): Promise<SimpleStore<S>>;
The type parameter maps collection names to record shapes, so store.all('todos')
returns readonly Todo[] rather than a bag of unknowns. Omit it and every
record is Record<string, unknown>.
SimpleOptions
Every field is optional; the defaults carry a real app.
| Option | Type | Default | What it does |
|---|---|---|---|
schema |
number |
1 |
Your data schema version. Bump it together with migrate. |
migrate |
(from: number, snap: Snapshot) => Snapshot |
- | Upgrade a snapshot written by an older schema version. |
sync |
SyncConfig |
- | Per-collection merge tuning: id field mapping, strategies. See sync. |
drive |
DriveAuth |
- | Providing Drive auth here lets a connected Drive backup restore itself on the next start. |
cache |
LocalCache |
IndexedDB | Where the working copy lives. In-memory when IndexedDB does not exist (tests, SSR). |
debounceMs |
number |
- | Auto-save debounce. |
multiTab |
boolean |
true in browsers |
Cross-tab coordination. |
requireEncryption |
boolean |
false |
Refuse to ever write or export a plaintext backup. Connecting then demands a password or a group. |
passwordPolicy |
PasswordPolicy |
- | Reject a backup password weaker than this at protect() time. |
cacheLock |
CacheUnlock |
- | Seal the local cache under a key held only in memory. See sensitive apps. |
autoSync |
boolean |
true in browsers |
Wire tab focus, network return, interval and tab hide. false to drive syncing yourself. |
SimpleStore
Data
| Method | Signature | Notes |
|---|---|---|
all |
(collection) => readonly Record[] |
Treat as read-only; write through put / remove. |
get |
(collection, id) => Record | undefined |
|
put |
(collection, record) => Promise<void> |
Insert or replace. Auto-saves, debounced. Throws TypeError when the record has no non-empty string id. |
putAll |
(collection, records) => Promise<void> |
Many records in one save. |
remove |
(collection, id) => Promise<void> |
Propagates to other devices. Unknown id is a no-op. |
clear |
(collection) => Promise<void> |
Empty a collection; every removal propagates. |
onChange |
(fn) => () => void |
After any data change: your writes, another tab, another device, a restore. Returns an unsubscribe. |
Files
Bytes ride in the same store, the same backup and the same merge as records.
| Method | Signature | Notes |
|---|---|---|
putFile |
(file: PutFileInput, opts?: { replace?: boolean }) => Promise<string> |
Answers the file’s id. |
getFile |
(id) => SnapshotFile | undefined |
|
allFiles |
() => readonly SnapshotFile[] |
|
removeFile |
(id) => Promise<void> |
Locally. Deletions do not propagate - see below. |
type FileBytes = Uint8Array | ArrayBuffer | Blob;
interface PutFileInput {
bytes: FileBytes;
name?: string;
mime?: string;
/** Omit it: the default is the SHA-256 of the bytes, and that default is the feature. */
id?: string;
}
The id defaults to the SHA-256 of the bytes, and that is load bearing rather than a convenience. Files merge by a union on their id, with no clock to order two bodies: when two devices hold different bytes under the same id, one is kept and the other is dropped - silently, because at that level there is nothing to compare and nothing to report. A content id makes that unreachable, since different bytes are a different file and the union keeps both.
So putFile refuses different bytes under an id you named yourself - identical
bytes are a no-op, different bytes throw a TypeError naming the way out - and
{ replace: true } says you meant it. Correct for a body only ever written on
one device; a silent loser as soon as two devices write it.
That same union is what makes a CRDT document safe to carry here. Yjs and Automerge updates are commutative and idempotent, so storing each update under its content id turns the union into the CRDT merge: no device’s update is lost when the copies meet, and folding them is the entire read path.
Two limits, stated rather than discovered: file deletions do not propagate (there are no tombstones for files), so a device that was offline re-contributes files another device removed - tie a file’s lifetime to a record and let the record’s deletion drive the cleanup. And everything is in memory, so this is for documents and images, not archives.
Destinations
| Method | Signature |
|---|---|
connectDrive |
(auth: DriveAuth, opts?: { password?: string }) => Promise<ConnectOutcome> |
connectFile |
(opts?: { password?: string }) => Promise<ConnectOutcome> |
connectWebdav |
(config: WebdavConfig, opts?: { password?: string }) => Promise<ConnectOutcome> |
connectS3 |
(config: S3Config, opts?: { password?: string }) => Promise<ConnectOutcome> |
connectTarget |
(target: BackupTarget, opts?: { password?: string }) => Promise<ConnectOutcome> |
disconnect |
() => Promise<void> |
addReplica |
(target: BackupTarget, opts?: { id?: string }) => string |
removeReplica |
(id: string) => void |
An existing backup at the destination is merged with this device. An
encrypted one needs its password up front: PASSWORD_REQUIRED is thrown before
anything changes.
disconnect() goes back to device-only; the destination keeps its last backup.
addReplica writes the same encrypted backup to a second destination on every
save, and a broken copy never gates the store - see
resilience.
Encryption
| Method | Signature | Notes |
|---|---|---|
protect |
(password: string) => Promise<void> |
Encrypt the durable backup end to end. Reversible. |
unprotect |
() => Promise<void> |
Remove the backup password. |
unlock |
(password: string) => Promise<boolean> |
For status.action === 'unlock'. |
reconnect |
() => Promise<boolean> |
For status.action === 'reconnect': re-run the destination’s auth gesture. |
Backup files
| Method | Signature | Notes |
|---|---|---|
exportBackup |
() => Promise<Blob> |
A real ZIP; encrypted when protect() is on. |
downloadBackup |
(filename?: string) => Promise<boolean> |
False means nothing was written - the user closed the save dialog. Do not record a backup. |
importBackup |
(file: Blob | Uint8Array, opts?: { password?: string }) => Promise<void> |
Replaces local data; removals propagate like edits. Throws PASSWORD_REQUIRED / DECRYPT_FAILED. |
The boolean from downloadBackup is the one people miss. A save dialog the user
dismissed returns false, the pending flag stands, and telling them they have a
backup would be a lie.
Status and lifecycle
| Member | Type | Notes |
|---|---|---|
status |
StatusDescriptor (readonly) |
Headless: map labelKey to your own copy. |
error |
StoreError | null (readonly) |
The last problem. Show labelKey, log message. |
state |
LocalStoreState (readonly) |
The full underlying state: journal, peers, mode. |
subscribe |
(fn) => () => void |
Any state change, status flips included. For framework bindings. |
flush |
() => Promise<void> |
Save now. Called for you on tab hide when autoSync is on. |
sync |
() => Promise<void> |
Converge with the destination now - a pull-to-refresh gesture. |
dispose |
() => void |
Drop timers and listeners: tests, SPA teardown. |
subscribe fires on every state change; onChange fires only on data changes.
Bind a save badge to the first and a list to the second.
The escape hatches
| Member | Type | What it is for |
|---|---|---|
advanced |
LocalStore (readonly) |
The full pull-model store this one is built on. See advanced. |
flowHost |
{ engine, kv, backupName } (readonly) |
The attachment point for selfstore/flows and every widget. |
flowHost is what a widget’s store property actually consumes. An app built
on the advanced store hands a flow the same three members itself.
Status types
type StorageState =
| 'ephemeral' | 'cache-only' | 'saving'
| 'saved' | 'pending-download' | 'needs-attention';
type Severity = 'ok' | 'info' | 'warn' | 'danger';
type StatusAction = 'choose-destination' | 'download' | 'reconnect' | 'unlock';
interface StatusDescriptor {
state: StorageState;
severity: Severity;
actionable: boolean;
action?: StatusAction;
labelKey: string; // stable i18n key; the app owns the copy
}
The descriptor is ranked: when several things are true at once, the most
important wins. That is why you read status.action rather than deriving a
remedy from the flags yourself - and why <selfstore-gate> can decide on its
own whether to be on screen.
Mode is 'persistent' | 'ephemeral'. TargetKind is 'device' | 'file-manual' | (string & {}) - deliberately open, so a custom target’s kind
flows through.
Errors
interface StoreError {
code: SelfstoreErrorCode;
labelKey: string; // show this, mapped to your copy
message: string; // developer detail for logs. Never display it.
}
SelfstoreErrorCode has 19 members; the full table with what each one means
and whether it is transient lives on error codes.
| Helper | Signature |
|---|---|
isSelfstoreError |
(e: unknown) => e is SelfstoreError |
errorLabelKey |
(code: SelfstoreErrorCode) => string |
SelfstoreError |
the thrown class |
Backup files, without a store
backup() and restore() build and read the file format directly - useful for
a one-off export, a migration script, or a Node-side tool.
const blob = await backup(snapshot)
.as('my-app', '2.1.0')
.encryptedWith(password)
.alsoOpenedWith(recoveryCode)
.verified()
.toBlob();
| Step | Signature | Notes |
|---|---|---|
backup |
(snapshot: Snapshot) => BackupDraft |
|
.as |
(app: string, appVersion?: string) => BackupBuilder |
Required. Stored cleartext in the metadata. |
.encryptedWith |
(password: string) => EncryptedBackupBuilder |
AES-256-GCM over an Argon2id-derived key. |
.alsoOpenedWith |
(secret: string) => EncryptedBackupBuilder |
A second secret that also opens this backup - a printed recovery code. Call it more than once for more. |
.withReadme |
(text: string) => EncryptedBackupBuilder |
Brand the README shipped inside the ZIP. |
.verified |
() => BackupBuilder |
Read the backup back before handing it over; throws VERIFY_FAILED. |
.toBytes |
() => Promise<Uint8Array> |
|
.toBlob |
() => Promise<Blob> |
|
.toDisk |
(filename?: string) => Promise<boolean> |
Browser only. Defaults to <app>-<date>.zip. False when the dialog was dismissed. |
Why .verified() exists. A backup encrypted with a key nobody can
reproduce, truncated, or built from an empty snapshot looks exactly like a good
one: right name, right date, plausible size. The difference shows up on the day
of the disaster. Reading it back costs one decrypt of data the app already
holds.
Why alsoOpenedWith exists. A password that lives in one person’s memory is
the likeliest way a local-first backup dies - no server can reset it. Each
secret wraps the same data key, so either opens the file and neither can read
the other. Reading needs no change: withPassword(code) already tries every
slot.
Reading
const snapshot = await restore(file).withPassword(pw).read();
| Step | Signature | Notes |
|---|---|---|
restore |
(input: Blob | Uint8Array) => RestoreBuilder |
|
.withPassword |
(password?: string) => this |
Accepts undefined, so an optional field passes straight through. |
.meta |
() => Promise<Header> |
Cleartext metadata - app, date, encryption - without decrypting. |
.isEncrypted |
() => Promise<boolean> |
|
.read |
() => Promise<Snapshot> |
Reserved __* collections are stripped. Throws PASSWORD_REQUIRED / DECRYPT_FAILED. |
Standalone helpers
| Export | Signature | Notes |
|---|---|---|
saveToDisk |
(blob: Blob, filename: string) => Promise<boolean> |
File System Access, else a download. False when dismissed. |
pickFromDisk |
() => Promise<File | null> |
Null when the user cancelled. |
changePassword |
(input, { from?, to?, readme? }) => Promise<Blob> |
Re-key a backup file without a store. Omit to to decrypt. |
gisDriveAuth |
(opts) => DriveAuth |
Google Identity Services auth. See Google Drive. |
checkPasswordPolicy |
(password, policy) => PasswordCheck |
Pure and synchronous: the same call drives a live UI hint and the store’s enforcement. |
BACKUP_EXTENSION |
'.zip' |
|
BACKUP_MIME |
'application/zip' |
Desktop shell
Inside a native webview the File System Access API is usually absent, so the disk file home would degrade to download-on-demand. Register the shell’s own filesystem and dialog calls and it writes a real path instead. The narrative version, with the Tauri one-liner, is desktop shell.
| Export | Signature | Notes |
|---|---|---|
useDesktopFiles |
(b: DesktopFileBridge | null) => void |
Call once at start-up, before opening the store. null unregisters. |
hasDesktopFiles |
() => boolean |
True once a bridge is registered. The file destination asks this before probing the browser. |
interface DesktopFileBridge {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<{ mtime?: Date | number | null } | null>;
exists(path: string): Promise<boolean>;
save(options: { defaultPath?: string; filters?: DesktopDialogFilter[] }): Promise<string | null>;
open(options: {
multiple?: boolean;
filters?: DesktopDialogFilter[];
}): Promise<string | string[] | null>;
}
interface DesktopDialogFilter {
name: string;
extensions: string[];
}
Only mtime is read out of stat, as the version marker, and a shell that
cannot report one may leave it absent. open may answer with an array, so a
host can pass its shell’s function through unchanged.
PasswordPolicy
interface PasswordPolicy {
minLength?: number; // in code points, so an emoji counts as one
requireLowercase?: boolean; // any script with case
requireUppercase?: boolean;
requireDigit?: boolean;
requireSymbol?: boolean; // anything that is not a letter or a number
}
interface PasswordCheck {
ok: boolean;
unmet: PasswordRequirement[]; // stable order; empty when ok
}
type PasswordRequirement = 'minLength' | 'lowercase' | 'uppercase' | 'digit' | 'symbol';
An empty or absent policy passes everything. Because checkPasswordPolicy is
the same function the store enforces with, a live strength hint can never
disagree with the rejection the user is about to hit.
The other entry points
| Import | Reference |
|---|---|
selfstore/flows |
Flows |
selfstore/backups |
Backups manager |
selfstore/sync |
Sync and merge |
selfstore/groups, selfstore/households |
Peers |
selfstore/advanced |
Advanced |
selfstore/widgets |
Widgets |