Concepts
selfstore has one mental model, and once it clicks the whole API reads naturally.
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: falseto 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.