Search selfstore
v1.8.21

Error codes

Every failure selfstore raises is a SelfstoreError carrying a stable code. The rule has no exceptions: branch on the code, never parse the message. Messages are developer-facing detail (often stating the fix) and may change; codes are API. A labelKey (e.g. error.authExpired) rides alongside for i18n: map it to your own copy rather than showing the raw English message.

import { isSelfstoreError } from 'selfstore';

try {
  await store.importBackup(file, { password: pw });
} catch (err) {
  if (isSelfstoreError(err)) {
    switch (err.code) {
      case 'PASSWORD_REQUIRED': /* ask for one */ break;
      case 'DECRYPT_FAILED':    /* wrong password OR corrupted file */ break;
      case 'BAD_FORMAT':        /* not a backup */ break;
      default:                  /* future codes: keep this branch */ break;
    }
  }
}

On a running store the last problem is store.error ({ code, labelKey, message } | null). New codes may be added in a minor release: keep a default branch in exhaustive switches.

The table

Code Meaning
BAD_FORMAT Not a backup file, or corrupt framing.
UNSUPPORTED_VERSION Written by a newer format generation, cipher, or KDF parameters out of range.
PASSWORD_REQUIRED Encrypted backup opened without a password.
DECRYPT_FAILED Wrong password, or tampered/corrupt ciphertext. Indistinguishable by design; say both.
TOO_LARGE Zip-bomb guard: an entry over 512 MiB, or the archive total over 1 GiB.
AUTH_EXPIRED Access to the destination genuinely lost; a user gesture reconnects.
TARGET_UNAVAILABLE Transient: offline, a cold-starting host, a 5xx. Retried automatically.
TARGET_WRITE_FAILED The destination refused or failed the write (non-auth).
NOT_CONNECTED The target has no connected destination.
UNEXPECTEDLY_UNENCRYPTED Downgrade guard: expected encrypted, found plaintext.
SCHEMA_TOO_NEW Data written by a newer app schema; update the app, then sync.
IDENTITY_REQUIRED Group-keyed store opened without a member identity.
SIGNATURE_INVALID A group manifest or copy failed signature verification.
NOT_A_RECIPIENT This member is not sealed into the group copy it fetched.
MANIFEST_ROLLBACK An older membership manifest was replayed; refused.

errorLabelKey(code) computes the i18n key for any code, mirroring status.labelKey, so a consumer maps keys instead of English strings.

Transient versus genuine: the philosophy

The costliest UX bug in sync apps is the scary “reconnect” dialog that appears while everything is fine, because a host cold-started or a train went through a tunnel. selfstore’s contract makes that bug hard to write:

  • A target signals a genuine loss of access one way only: by throwing AuthExpiredError (the isAuthExpired(err) helper recognizes it). Only then does store.status.action become 'reconnect'.
  • Anything else a target throws is transient. The edit stays safe in the working copy, and the next save or sync retries with no gate and no dialog.

Writing a correct custom target

The contract, stated as rules (the full guide is advanced):

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

const target: BackupTarget = {
  kind: 's3',
  label: 'My bucket',
  async save(blob) {
    const res = await put(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 null; },     // a Blob, or null when no backup exists yet
  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 the remote data
};

Return false from isReady() for “not right now”; reserve the throw for “the user must act”. Get that split right and your users never see a false alarm.