# Error codes Source: https://selfstore.dev/docs/errors Every selfstore failure carries a stable code and an i18n label key - the full table, the transient-versus-genuine philosophy, and the custom-target contract. 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`. ```ts 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](https://selfstore.dev/docs/advanced)): ```ts 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. Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt