Back up and restore IndexedDB in the browser, encrypted, in TypeScript.
Local-first storage for browser apps, batteries included. Your data lives on the user's device, syncs across their devices without a server, and leaves as a portable encrypted backup they actually own.
import { selfstore } from 'selfstore';
const store = await selfstore('todo-app');
await store.put('todos', { id: 't1', text: 'ship it' });
store.all('todos'); // [{ id: 't1', text: 'ship it' }]
store.onChange(render); // local writes AND merges from other devicesThat is a working app: saving to IndexedDB, debounced auto-save on every mutation, and the browser sync moments (tab focus, network return, tab hide, a slow interval) are already wired. No schema ceremony, no server, nothing to host.
Docs, guides, a live demo and honest comparisons with the alternatives: selfstore.dev.
Or run the whole pitch in a browser tab, nothing installed: open the playground in StackBlitz
- notes that survive a reload, a real file on the disk as their home, and a genuine encrypted ZIP (source).
// Multi-device sync: connect a destination the USER owns.
await store.connectDrive(gisDriveAuth({ clientId })); // Google Drive
await store.connectFile(); // a file on disk (Chromium, or any desktop shell)
await store.connectWebdav({ url, username, password: appPassword }); // Nextcloud & friends
// End-to-end encryption of everything that leaves the device.
await store.protect('a passphrase the user chose');
// A portable encrypted .zip the user can walk away with. False when they closed
// the save dialog - nothing was written, so do not tell them they are covered.
const saved = await store.downloadBackup();connectDrive / connectFile / connectWebdav all resolve to what actually
happened: 'merged' (the destination already held data; both sides were
folded together, nothing lost), 'started' (it was empty; this device's data
now lives there), 'cancelled' (the user closed the picker), or 'manual'
(no File System Access; offer downloadBackup() instead). If the destination
holds an encrypted backup and no password was given, the call throws
PASSWORD_REQUIRED BEFORE touching anything, so you can prompt and retry.
The File System Access API is Chromium-only, and inside a native webview it is usually absent altogether: macOS and Linux shells embed WebKit, which never shipped it. So wrapping a web app as a desktop app would lose the file mode, in the environment where writing a real file is easiest. Hand the shell's own calls over once and it comes back, on a real path:
import { readFile, writeFile, stat, exists } from '@tauri-apps/plugin-fs';
import { save, open } from '@tauri-apps/plugin-dialog';
import { useDesktopFiles } from 'selfstore';
useDesktopFiles({ readFile, writeFile, stat, exists, save, open });That is the whole integration. connectFile(), file: true in the connect
flow and the widgets all keep meaning what they meant; they just write a path.
The path outlives the session, so the app reopens on its file with nothing
asked of the user, where the browser needs a click to re-grant. The calls are
injected rather than imported, so a web build carries no desktop dependency.
Records need a non-empty string id: it is what the multi-device merge
keys on. put() enforces it immediately with a clear error instead of letting
the record silently never sync. A different field name is one option away:
const store = await selfstore('crm', { sync: { ids: { contacts: 'uuid' } } });Everything else about your records is your business: plain JSON in, plain JSON out, no proxies, no injected fields, no ORM to fight.
Bytes ride in the same store, the same backup and the same merge:
const id = await store.putFile({ bytes, name: 'scan.pdf', mime: 'application/pdf' });
store.getFile(id); // { id, name, mime, bytes }
store.allFiles(); // every file
await store.removeFile(id);The id defaults to the SHA-256 of the bytes, and that default is load bearing. Files are merged 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 impossible, since different bytes are a different file and the union keeps both.
So putFile refuses different bytes under an id you named (identical bytes are
a no-op), 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 union is also what makes a CRDT 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.
examples/yjs-document.ts is a complete, typechecked integration - real
concurrent-edit merging, still no server.
The store never ships UI copy. It exposes a status descriptor
(store.status: severity, optional action, a stable labelKey like
status.synced) and typed errors (store.error: { code, labelKey }, keys
like error.authExpired). You map the keys to your own wording and language;
the raw English message is for logs only.
store.subscribe(() => statusBar.textContent = t(store.status.labelKey));Transient trouble (offline, a cold-started backend, a 5xx) never raises a
scary reconnect dialog: the edit stays safe in the local cache and the next
save or sync retries. Only a GENUINE loss of access (token revoked, permission
withdrawn) surfaces as AUTH_EXPIRED with a reconnect action.
| Code | Meaning |
|---|---|
BAD_FORMAT |
Not a backup file, or corrupt framing. |
UNSUPPORTED_VERSION |
Written by a newer format generation, cipher or KDF. |
PASSWORD_REQUIRED |
Encrypted backup opened without a password. |
DECRYPT_FAILED |
Wrong password, or tampered/corrupt ciphertext. |
TOO_LARGE |
An archive entry exceeds the zip-bomb guard (512 MiB). |
AUTH_EXPIRED |
Access to the destination genuinely lost; a user gesture reconnects. |
TARGET_UNAVAILABLE |
Transient: offline, cold start, 5xx. Retried automatically. |
TARGET_WRITE_FAILED |
The destination refused or failed the write (non-auth). |
NOT_CONNECTED |
The target has no connected destination. |
ENCRYPTION_REQUIRED |
requireEncryption is set: a plaintext attach, backup or export is refused. |
WEAK_PASSWORD |
The password fails the store's passwordPolicy. |
UNEXPECTEDLY_UNENCRYPTED |
Downgrade guard: expected encrypted, found plaintext. |
SCHEMA_TOO_NEW |
Data written by a newer app schema; update the app to sync. |
Branch on err.code, never parse messages. New codes may be ADDED in a minor
release, so keep a default branch in exhaustive switches.
The backup is a real ZIP, specified independently of this library (SPEC.md, with a small Python reference reader and canonical test vectors): unencrypted it opens in any archive tool; encrypted it is still a valid ZIP holding the AES-256-GCM ciphertext, the cleartext parameters and a readme - never a mystery blob. Working with backup FILES needs no store:
import { backup, restore, changePassword } from 'selfstore';
// Write: fluent and STAGED - an illegal order does not compile.
const blob = await backup({ collections: { notes }, files: [] })
.as('my-app', '1.2.0')
.encryptedWith(password) // omit for a plain, browsable ZIP
.withReadme('Import this file into MyApp with your password.')
.toBlob(); // or .toBytes(), or .toDisk('my-backup.zip')
// Read
const meta = await restore(file).meta(); // cleartext, no password needed
if (await restore(file).isEncrypted()) { /* ask the user */ }
const snap = await restore(file).withPassword(password).read();
// Rotate, add or remove the password
const rekeyed = await changePassword(blob, { from: 'old', to: 'new' });The package root stays small on purpose. The machinery underneath is public too, as subpaths of the same install:
| Import | What it is | Reach for it when |
|---|---|---|
selfstore |
the simple store + backup files | almost always: start here |
selfstore/flows |
the user journeys (connect a destination, share panel, join an invitation) as headless, tested state machines | you are building the connect/share/join UI and want the ordering and failure rules - one popup per gesture, password proven before attach, merge by default - already right |
selfstore/widgets |
the same journeys as drop-in web components (<selfstore-storage> for the whole journey in one tag - it opens on a full-screen first-run gate, so reach for <selfstore-destination> or <selfstore-status> instead on a page that must work before the user answers; also <selfstore-connect>, <selfstore-gate>, <selfstore-account>, <selfstore-share>, <selfstore-join> on their own) - framework-free, themable via CSS custom properties and ::part(), reworded/localized via a labels map |
you want the screens ready-made and your app's look on top |
selfstore/advanced |
the pull-model store (createLocalStore), custom BackupTargets, caches, headless status derivation, functional codec |
your state lives in its own reactive model (Svelte runes, Redux), or you are writing a destination (S3, your KV...) |
selfstore/groups (experimental) |
passwordless group encryption: per-member identities, sealed envelopes, signed membership manifests | several PEOPLE share one encrypted store without a shared password (PEERS.md) - see Stability for what experimental costs you |
selfstore/sync |
the bare merge engine (HLC + per-collection strategies, no CRDT runtime) | you only want the algorithm inside your own persistence (SYNC docs in SPEC.md) |
store.advanced on the simple store IS the selfstore/advanced store - same
instance - so the two styles compose instead of competing: start simple, reach
down for one advanced call (attachPeer, setGroup, a custom target), keep
the rest.
import { createLocalStore, indexedDbCache } from 'selfstore/advanced';
const store = createLocalStore({
app: 'my-app',
schemaVersion: 1,
gather: () => state.toSnapshot(), // YOUR app owns the data
apply: (snap) => state.load(snap),
cache: indexedDbCache('my-app')
});
await store.init();
onEveryChange(store.schedule);selfstore exists so an app can prove its local-first claims instead of asserting them:
- No server anywhere in the loop. Save, backup, restore and merge all run in the browser. There is nothing to host and nothing that sees the data.
- The user owns a real file. See "Backup files" above: a genuine, documented ZIP, readable by anything that follows the spec.
- Encrypted the moment it leaves the device. Cleartext at rest locally (so you keep queries and DevTools), end-to-end encrypted as soon as it goes to a file or the cloud: AES-256-GCM over an Argon2id-derived key, parameters stored per file so old backups keep decrypting when defaults improve.
- Offline is the normal case, not a fallback. The working copy is IndexedDB; durable homes and merges catch up whenever the device is back online.
- Leaving is easy. The format is stable across generations; a file written years ago by another app still reads.
A Hybrid Logical Clock plus pluggable per-collection strategies converge two
replicas deterministically, while your objects stay plain JSON. The default -
records keyed by id, later edit per record wins, deletes propagate as
tombstones - fits most apps; sync: { strategies } tunes it per collection:
| Strategy | Semantics |
|---|---|
lww-set |
records keyed by id; later write per id wins; deletes tombstoned |
lww-map |
field-by-field merge: concurrent edits to different fields both survive; same field goes to the later clock; deletes stay record-level |
grow-set |
append-only, entries immutable per id; union, never a conflict |
lww-register |
a single value; later write wins |
manual |
surface concurrent same-id edits (via MergeResult.conflicts) instead of resolving |
Guarantees (seeded-fuzz tested): every two-way merge is symmetric and
idempotent; lww-set and grow-set (used within their contracts) are also
order-independent across replicas. Honest limits: LWW resolves a
concurrent same-record (or same-field) edit by KEEPING THE LATER CLOCK - the
losing value is surfaced in the sync journal's conflicts, not merged. In
lww-map, a record deleted on one replica while another edits one of its
fields resurrects with the surviving side's fields only. Change detection
hashes content with 32-bit FNV-1a: a collision (~2^-32 per edit) would miss an
edit. If concurrent-edit losslessness is your requirement, keep a real CRDT
document (Yjs/Automerge) and let the store carry it - see "Files" below;
examples/yjs-document.ts is the whole integration.
Share a store between several people WITHOUT anyone granting write access:
each member publishes their own copy on their own storage, shares it
read-only, and attaches the other members' links as peers
(store.advanced.attachPeer(source)). Every converge folds each peer's copy
in as one more replica and publishes the merged state back: one writer per
file (no write races), copies converge to the union (every member is a full
backup of the group), gossip is transitive (a star around one member is
enough). Peer problems are recorded per peer on state.peers and never gate
your own saves.
On Google Drive, the file work that topology needs is in driveTarget
(selfstore/advanced): createCompanion mints a copy file next to the backup,
share / unshare publish it on a link and take it back, owner says whose
Drive a copy lives on, and secondary({ auth, kv }, fileId) is a read-write target over
it whose lifecycle is its own - dropping a shared copy leaves the user's real
backup and their Drive session alone, where preview's disconnect would end
both. Every failure carries a code, so a refused grant and a lost session read
differently.
const { fileId } = await driveTarget.createCompanion({ auth, fileName: 'my copy.zip' });
await driveTarget.share({ auth, fileId }); // link-readable, ciphertext only
await store.advanced.attachMirror(driveTarget.secondary({ auth, kv }, fileId),
{ password: linkKey }); // republished on every saveWhat stays yours: READING another member's copy. The drive.file scope only
sees files this app created or the user picked, so fetching someone else's
link-shared file needs either the Google Picker (a gesture per file, plus a
third-party script) or a relay you host - both app decisions, one of them a
server. attachPeer takes whatever load() you can provide.
Group crypto, two options: ONE shared passphrase (exchanged once out of band),
or passwordless groups (selfstore/groups, experimental - see
Stability): a keypair per member, every copy Ed25519-signed by its
author and sealed per recipient (X25519 envelopes), membership governed by an
admin-signed manifest with rollback protection. The full model, the failure
table and the operational notes live in PEERS.md; the trust
analysis in THREAT-MODEL.md.
Built in: disk file (File System Access, truly zero backend, Chromium),
Google Drive (bring a DriveAuth; gisDriveAuth is the client-only
reference - occasional re-consent; a tiny refresh-token broker buys "connected
for good"; the cloud only ever sees opaque encrypted bytes), WebDAV
(Nextcloud and friends; credentials sealed at rest under a non-extractable
device key), and S3-compatible buckets (Amazon S3, Cloudflare R2, Backblaze
B2, MinIO): the browser signs each request with SigV4 itself, no vendor SDK, the
secret sealed at rest like the WebDAV credentials.
Yours: implement BackupTarget { kind / save / load / isReady / reconnect / disconnect } from selfstore/advanced with any kind string (say 'ipfs')
and hand it to store.connectTarget(...). Signal a genuine loss of access by
throwing AuthExpiredError; anything else you throw is treated as transient
and retried. examples/custom-target.ts is a complete one in ~30 lines.
Honest scope: a disk file needs no backend; Drive client-only needs no backend but re-consents roughly hourly (third-party cookies); a permanent Drive connection needs a small refresh-token broker. WebDAV and S3 need only a CORS rule on the server/bucket for your app origin.
Opt-in is about the bytes too: WebDAV and S3 arrive on the gesture that opens
them, so an app offering only Drive and a disk file never ships the SigV4
signer or the WebDAV client (4.4 KB gzipped it would otherwise carry). Drive and
the disk file stay eager on purpose - both open a picker inside the transient
activation of the user's click, which an await import() can spend.
For resilience, store.addReplica(target) keeps a SECOND destination in sync
with the same backup - Drive as the primary, an S3 bucket as the synced copy -
so losing one home never loses the data. For locked-down deployments,
createLocalStore({ requireEncryption, passwordPolicy }) refuses a plaintext
backup and a weak password at the engine (not just the UI); offering only
webdav / s3 in the connect widget, and not mounting the share/join widgets,
keeps an app on the user's own storage with no sharing surface at all.
store.subscribe(fn) plus reading state is the whole contract, and the state
snapshot is referentially stable between changes - so every framework binds in
a few lines and there is deliberately NO adapter package to install:
// React. The THIRD argument matters: without it useSyncExternalStore throws
// during SSR (Next, Remix...).
import { useSyncExternalStore } from 'react';
const s = useSyncExternalStore(store.subscribe, () => store.state, () => store.state);
// Svelte (3, 4 and 5). The store contract is structural - this object IS a
// readable, no svelte import needed; use it as $persistence in components.
const persistence = {
subscribe(run: (s: typeof store.state) => void) {
run(store.state);
return store.subscribe(() => run(store.state));
}
};
// Vue, Solid, anything else: subscribe on mount, read store.state, call the
// returned unsubscriber on unmount.- Confidentiality and integrity come from AES-256-GCM: a wrong password, a flipped ciphertext byte or altered KDF/IV parameters all fail decryption. There is no partially-valid read.
- The KDF is Argon2id (memory-hard), 46 MiB / 3 passes by default, parameters
stored per file. It runs in a Web Worker (shipped as
selfstore/kdf-worker), falling back to the main thread with byte-identical results wherever workers cannot load. - The cosmetic header fields (
app,appVersion,createdAt) are cleartext and NOT authenticated; never base a security decision on them. - Passwords are JavaScript strings: the platform gives no way to zeroize them. Derived keys are non-extractable WebCrypto keys.
- Decompression is guarded: any archive entry declaring more than 512 MiB is
refused before inflation (
TOO_LARGE), and a backup declaring absurd Argon2id parameters is rejected before the KDF runs. - WebDAV Basic-auth credentials are refused over plain http (loopback aside); S3 requests are refused over plain http as well (loopback aside).
- The local IndexedDB cache is sealed at rest: the collections snapshot and file blobs are AES-256-GCM-encrypted under a non-extractable per-device key (only the small sync bookkeeping stays in the clear). Defense-in-depth against disk forensics and partial exfiltration, not against a full-profile copy or code in the origin - see T13 in the threat model.
- Ultra-sensitive
cacheLock: seal that cache under a key held in memory only (a password, or an app key such as a passkey PRF), never on disk, so even a full-profile copy cannot read it. One unlock per session -cacheLockon the simple facade, orindexedDbCache(name, { lock: true })at the store layer. Still bounded by the origin (unlocked, code in the page decrypts). A forgotten secret is unrecoverable by design, so give your users a way out rather than a dead-end prompt:indexedDbCache(name, { lock: true }) .clear()needs no key and abandons the sealed cache, after which they can start again from a backup file or from scratch. Throwing from thecacheLockcallback aborts the boot and hands control back to you, which is where that offer belongs. Read the backup BEFORE you clear anything - recipe 4b spells out why that order is the difference between a recovery and a second loss. - Opt-in hardening for sensitive deployments:
requireEncryptionrefuses to write or export a plaintext backup, andpasswordPolicyrefuses a password weaker than a length / character-class rule - both enforced at the store, so the app cannot forget to check. - The full analysis - assets, trust boundaries, threats and explicit non-goals (XSS, rollback replay, metadata) - is in THREAT-MODEL.md.
- WHY each of those choices, with the alternative it rejected and where its author would attack it first, is in CRYPTO-RATIONALE.md. Written to be argued with: review is wanted, and a case that a decision is wrong is worth more here than a bug report.
Honest limits: everything fits in memory (backups measured in MB, not GB;
there is no streaming path and no delta sync - every converge ships the whole
state); the merge targets one person's devices and small groups, not real-time
collaboration (use a CRDT library for that); there is no undo or history - an
LWW overwrite is final locally, so keep dated backup copies if you want time
travel (backup(...).toDisk() makes that a one-liner); and it is a browser
library (Node >= 20 works for tests, but the client is the point).
Other current limits, documented with open eyes: multi-tab coordination is
data-only (connections and mode switches apply to other tabs on reload;
multiTab: false opts out); deletion tombstones grow unless you opt in to
compaction (tombstoneHorizonMs on the advanced store - safe only if the
horizon exceeds the longest a device stays offline); binary files merge by id
union and their DELETIONS do not propagate (no tombstones), 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. In Vite DEV, if
the KDF worker 404s under dependency pre-bundling,
add optimizeDeps: { exclude: ['selfstore'] }.
The simple store already runs in plain Node/vitest with no browser and no
mocks: without IndexedDB it lands on an in-memory cache, and
await selfstore('app', { cache: memoryCache() }) (cache from
selfstore/advanced) makes the sharing explicit - two stores over one
memoryCache() simulate a reopen; a ~15-line in-memory BackupTarget (a Blob
variable) exercises the full save/sync/restore loop. That is exactly how
selfstore tests itself.
A skill ships in the package, so it is on disk at the version installed and fires when an assistant writes selfstore code rather than waiting to be pointed at a URL:
cp -r node_modules/selfstore/skills/selfstore ~/.claude/skills/
# or, to follow the repository
claude plugin marketplace add selfstoredev/selfstore
claude plugin install selfstore@selfstoreskills/selfstore/SKILL.md is plain markdown
with no assistant-specific instruction in the body, so it doubles as an
AGENTS.md for anything else that reads one. It carries the decision (is this
the right tool at all) and the five things a first integration gets wrong, and
hands over to the reference below for the rest. npm run skill fails when it
names something this package no longer has.
llms.txt at the package root summarizes the API with canonical snippets, and
RECIPES.md has copy-paste tasks (the examples/ folder holds
the same mini-apps, typechecked). Rules of thumb: every record needs a
string id (the simple store enforces it; the advanced one only logs);
always branch on err.code, never parse error messages; treat
DECRYPT_FAILED as "wrong password or corrupted file" (indistinguishable by
design); an empty password means "not encrypted"; collection names starting
with __ are reserved.
The number on the package is not what you are trusting - the file is.
A backup written by any version of selfstore stays readable by every later version. The test suite enforces it, and it holds across majors, not just minors. The format is specified and readable without this library at all, including from Python, so leaving is documented rather than promised.
For the package itself: semver, with a major reserved for a change that stops an app compiling or alters what it already does - a new default counts. Releases are deliberate and batched, not one per merge. The rules, and what the version history reads like, are in RELEASING.md.
One exception, named rather than hidden: selfstore/groups is
experimental. Its exports may change shape, or be withdrawn, in a MINOR
release; every other entry waits for a major. The reason is evidence: no
application has shipped that API, so it has never been tested by a second pair
of hands, and it is the most security-sensitive surface here - a contract
nobody has exercised is a promise, not a fact. What it does NOT put at risk is
anyone's data: group mode is format generation 2, specified in
SPEC.md section 12 with a canonical test vector, and it keeps the
guarantee above. An app that adopts it today may have to recompile; its users'
files stay readable forever either way.
Published versions are never unpublished. A superseded one stays installable;
the tool for a release that should not be used is npm deprecate, and the tool
for a broken one is the next release.
npm install selfstoreESM only, TypeScript types included, browser-first. fflate and hash-wasm
are loaded lazily via dynamic import, so they stay out of your critical bundle
until the first compress/encrypt.
MIT (c) Florian Mousseau