Search selfstore
v1.8.21

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