Search selfstore
v1.8.21

Concepts

selfstore has one mental model, and once it clicks the whole API reads naturally.

Your data flows through the store to an IndexedDB working copy, leaves the device only as an encrypted ZIP to a home you own, and other devices converge at that same home. Your data JSON records + binary files put / all / remove The store selfstore('app') : owns + auto-saves working copy Working copy IndexedDB, sealed at rest, offline-first encrypted ZIP (AES-256-GCM) A home you own disk / Drive / WebDAV / S3 same home = meeting point Other devices deterministic on-device merge
There is no server in this picture. The cloud (if you use one) holds only an opaque encrypted ZIP; every merge runs on your devices.

The simple store

const store = await selfstore('app') is the front door. It OWNS the data: you put and all through it, and it handles saving, syncing and merging. Convention over configuration, every default overridable:

  • an IndexedDB cache named after the app (in-memory where IndexedDB does not exist, so tests and SSR just work),
  • schema version 1,
  • debounced auto-save on every mutation,
  • the browser sync moments (tab focus, network return, an interval, tab hide) wired automatically (autoSync: false to take over).

Everything deeper lives on store.advanced - the full createLocalStore store, the same instance. Start simple; reach down without rewiring. That deeper surface is the advanced guide.

Snapshot

The unit the backup and merge layers exchange:

type Snapshot = {
  collections: Record<string, unknown[]>; // named arrays of plain JSON records
  files: { id: string; name: string; mime: string; bytes: Uint8Array }[];
};

You rarely build one by hand with the simple store, but it is what a backup file contains and what gather()/apply() move on the advanced store. Two constraints: every record carries a string id (or you map one via the sync config), and collection names starting with __ are reserved for the library’s own bookkeeping.

Working copy

The IndexedDB cache that makes offline the normal case. Writes land there (debounced); selfstore(app) restores from it on open. It is encrypted at rest: your records and file blobs are AES-256-GCM envelopes under a non-extractable per-device key, unsealed only in memory as your app reads them. That defeats casual inspection, partial exfiltration and disk forensics; it does not stop code running in your own origin. For data where even a full copy of the browser profile must stay sealed, cacheLock keeps the key in memory only.

Durable home

Where the data survives a cleared browser: a disk file, the user’s Google Drive, any WebDAV server, an S3-compatible bucket. Attached with one call and one user gesture (store.connectFile() / connectDrive(auth) / connectWebdav(config) / connectS3(config)), rebuilt silently on the next open. The home receives an encrypted ZIP; the cloud only ever holds opaque bytes.

Connecting resolves to an honest ConnectOutcome: merged, started, manual or cancelled. A home is any object implementing BackupTarget; the built-ins are conveniences, not privileges (write your own).

Backup

A portable, self-describing ZIP of one snapshot. store.downloadBackup() and store.exportBackup() produce one; store.importBackup(file) reads one in. Unencrypted it opens in any archive tool; encrypted (while protect() is on) it is AES-256-GCM over an Argon2id-derived key with the parameters stored per file. The format is specified independently of the library.

Sync

The same home, connected from a second device, becomes a meeting point. A Hybrid Logical Clock plus per-collection strategies converge replicas deterministically, on-device; the storage stays dumb. Conflicts are journaled with both values instead of silently dropped. Details: sync and peers for sharing between people.

Headless status

One descriptor drives your persistence UI: store.status ({ state, severity, actionable, action?, labelKey }) and store.error ({ code, labelKey, message } | null). No colours, no copy: you map labelKey through your i18n and severity through your design tokens.

The action field is load-bearing. A transient failure (an offline blip, a cold-starting server) is not attention-worthy: the store retries silently. Only a genuine loss of access sets action, and only to one of two gestures: unlock (store.unlock(password) for a locked encrypted home) or reconnect (store.reconnect() after real auth loss).

Schema versions

schema versions your data shape, not your app release. Bump it when the shape changes and pass migrate(fromVersion, snap); older backups then upgrade on read. Data written by a newer schema than the running app refuses loudly with SCHEMA_TOO_NEW instead of corrupting silently.