Search selfstore
v1.8.21

Quick start

selfstore’s front door is one call. selfstore(app) opens a store that OWNS your data: you read and write collections through it, and saving, syncing and multi-device merge happen on their own.

Try it first, install after

Open the playground in StackBlitz: notes that survive a reload, a real file on your disk as their home, and a genuine encrypted ZIP. Nothing to install, and the page it runs on has no backend either.

One caveat worth knowing before you click the file button: the file picker needs the File System Access API, which a cross-origin preview iframe does not get. Open the preview in its own tab first.

1. Install

npm install selfstore

ESM only, TypeScript types included, browser-first. Three small runtime dependencies (fflate, hash-wasm, idb); the compression and crypto ones load lazily, so they stay out of your critical bundle until first used.

2. A working app in five lines

import { selfstore } from 'selfstore';

type Todo = { id: string; text: string };
const store = await selfstore<{ todos: Todo }>('todo-app');

await store.put('todos', { id: crypto.randomUUID(), text: 'ship it' });
store.all('todos');                          // read your data back (typed)
store.onChange(() => render(store.all('todos'))); // your writes AND other devices

await selfstore(app) resolves once the store is ready: data loaded from the IndexedDB working copy, any connected destination restored, first sync done. Auto-save (debounced), save on tab hide, and sync on tab focus and network return are already wired. In tests and SSR, where there is no IndexedDB, the same call lands on an in-memory cache, so this exact code runs under vitest.

The one rule that matters: every record needs a non-empty string id. put() throws a TypeError right away if it is missing or not a string, naming the collection and the fix, instead of letting the record silently never sync. See the string-id rule.

3. Read and write

store.all('todos');            // readonly array of every record
store.get('todos', 't1');      // one record by id, or undefined
await store.put('todos', todo);      // insert or replace one (auto-saves)
await store.putAll('todos', todos);  // many in one save
await store.remove('todos', 't1');   // delete one (propagates to other devices)
await store.clear('todos');          // empty a collection

Your objects stay plain JSON: no proxies, no injected fields, no ORM. all() returns a readonly snapshot; you change data through put/remove, and onChange fires afterwards, whether the change came from you, another tab, a device sync, or a restore.

4. Show the save state

The store is headless: it ships stable keys, you ship the copy and colours.

store.subscribe(() => {
  statusBar.textContent = t(store.status.labelKey); // e.g. 'status.synced'
  if (store.error) toast(t(store.error.labelKey));  // e.g. 'error.authExpired'
});

5. Sync across the user’s devices

One call, the same on every device. It connects a destination the user owns and tells you exactly what happened.

import { gisDriveAuth } from 'selfstore';

const outcome = await store.connectDrive(gisDriveAuth({ clientId }));
// or await store.connectFile();                          // a disk file (Chromium)
// or await store.connectWebdav({ url, username, password }); // Nextcloud & co
// or await store.connectS3({ endpoint, region, bucket, key, accessKeyId, secretAccessKey }); // S3, R2, B2, MinIO

switch (outcome) {
  case 'started':   break; // destination was empty: this device's data now lives there
  case 'merged':    break; // it held a backup: both sides folded, nothing lost
  case 'manual':    break; // no file access here: offer store.downloadBackup()
  case 'cancelled': break; // the user closed the picker / consent
}

If the destination holds an encrypted backup and you passed no password, the call throws PASSWORD_REQUIRED before touching anything: prompt, then retry with connectDrive(auth, { password }).

6. Encrypt end to end, and hand over a real file

await store.protect('a passphrase the user chose'); // end-to-end from here on
await store.unprotect();                             // reversible

await store.downloadBackup();          // a portable .zip (encrypted while protected)
await store.importBackup(pickedFile);  // read one back into the store

The download is a genuine ZIP. Without a password it opens in any archive tool; with one it is AES-256-GCM over an Argon2id-derived key, and the format is publicly specified.

The string-id rule

Every record keys the multi-device merge on a non-empty string id. The simple store enforces it at put():

await store.put('todos', { id: crypto.randomUUID(), text: 'good' });

// Your records key on another field? Map it per collection:
const crm = await selfstore('crm', { sync: { ids: { contacts: 'uuid' } } });

Where next