Search selfstore
v1.8.21

Advanced (the pull-model store)

The simple store owns your data and covers most apps. Reach for selfstore/advanced in two cases: your state already lives in its own reactive model (Svelte runes, Redux, signals) and you want selfstore to follow it rather than own it, or you are writing your own destination.

Everything here is the same install, one import away, and store.advanced on a simple store IS this store, the same instance, so the two styles compose instead of competing.

The pull-model store

You keep ownership of the data; selfstore pulls a snapshot to save and pushes one back to load.

import { createLocalStore, indexedDbCache } from 'selfstore/advanced';

const store = createLocalStore({
  app: 'my-app',
  schemaVersion: 1,
  gather: () => myApp.toSnapshot(),   // your state -> Snapshot
  apply: (snap) => myApp.load(snap),  // Snapshot -> your state
  cache: indexedDbCache('my-app'),    // or memoryCache() in tests / SSR
});

await store.init();                          // hydrate from cache, restore target, converge
myApp.onEveryChange(() => store.schedule()); // debounced auto-save
window.addEventListener('pagehide', () => store.flush());

The store logs a one-time warning for a mis-keyed record here rather than throwing, because, unlike the simple store, it cannot know which of your collections you consider mergeable.

Destinations, and silent reboot

import { driveTarget, fileTarget, webdavTarget, s3Target, gisDriveAuth } from 'selfstore/advanced';

const cache = indexedDbCache('my-app');
const auth = gisDriveAuth({ clientId: GOOGLE_CLIENT_ID });
const drive = () => driveTarget.connect({ auth, kv: cache.kv, fileName: 'my-app.zip' });

const store = createLocalStore({
  app: 'my-app', schemaVersion: 1, gather, apply, cache,
  restoreTarget: async (kind) => (kind === 'drive' ? drive() : null), // silent reboot
});
await store.init();

// On a user click (opens Google consent the first time):
await store.attachTarget(await drive(), { strategy: 'merge', password });

// Disk file (Chromium) and WebDAV work alike:
const f = await fileTarget.connect({ kv: cache.kv, fileName: 'my-app.zip' });
const w = await webdavTarget.connect({ kv: cache.kv, config: { url, username, password } });

strategy is 'merge' (default, multi-device), 'replace-local' (load the target over local) or 'replace-remote' (push local over the target).

Write your own destination

A BackupTarget is about six methods. The error protocol is the whole contract: throw AuthExpiredError for a genuine loss of access (the store raises the reconnect gate); anything else you throw reads as transient and is retried silently.

import { AuthExpiredError, type BackupTarget } from 'selfstore/advanced';

const bucket: BackupTarget = {
  kind: 's3',                        // any string except 'device' / 'file-manual'
  label: 'my bucket',
  async save(blob) {
    const res = await put(KEY, blob);
    if (res.status === 401 || res.status === 403) throw new AuthExpiredError();
    if (!res.ok) throw new Error(`upload failed: ${res.status}`); // transient
    return res.headers.get('etag'); // or null
  },
  async load() { return (await getObject(KEY)) ?? null; },
  async isReady() { return true; }, // false = transient blip; throw AuthExpiredError = genuine
  async reconnect() { return true; }, // user gesture; true if access re-established
  async disconnect() {},              // forget locally; never delete remote data
  abortInFlight() { controller.abort(); }, // optional (1.1.0): cut in-flight work now
};

await store.connectTarget(bucket);                 // on a simple store
// or advancedStore.attachTarget(bucket, { strategy: 'merge' });

Get the isReady() split right - return false for “not right now”, throw AuthExpiredError only for “the user must act” - and your users never see a false-alarm reconnect dialog. A complete, typechecked version ships as examples/custom-target.ts in the repository.

The optional abortInFlight() (added in 1.1.0) lets the store cut your target’s in-flight request the instant the user detaches it, instead of the detach waiting behind a request stuck on a woken-from-sleep radio. Wire it to an AbortController your save/load fetches share; skip it and detaching simply runs those calls to their own deadlines first, as before.

Peers, groups and the merge engine

Three deeper capabilities have their own pages, all built on this store:

  • Peers and groups: share a store between people over read-only links (store.attachPeer), including passwordless groups from selfstore/groups (per-member keys, signed manifests).
  • Multi-device sync: the strategies, the conflict journal and the honest limits; the bare merge engine is selfstore/sync.
  • Testing: memoryCache() plus a fifteen-line in-memory target drive the whole loop in plain vitest, no browser.

The subpaths

Import What it is
selfstore the simple store + the fluent backup-file API. Start here.
selfstore/advanced the pull-model store, caches, targets, status derivation, functional codec
selfstore/groups passwordless group encryption: identities, sealed envelopes, signed manifests
selfstore/sync the bare merge engine (HLC + strategies), for embedding in your own persistence