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(theisAuthExpired(err)helper recognizes it). Only then doesstore.status.actionbecome'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.