# API: the store Source: https://selfstore.dev/docs/api-store Complete reference for the default entry point - selfstore(), every SimpleStore method with its signature, every SimpleOptions field, the status and error types, the backup file builders and the disk helpers. Everything `import { ... } from 'selfstore'` gives you. The narrative version is the [quick start](https://selfstore.dev/docs/quick-start); this page is the surface, exhaustively. ```ts import { selfstore } from 'selfstore'; const store = await selfstore<{ todos: Todo }>('todo-app'); ``` ```ts function selfstore>( app: string, options?: SimpleOptions ): Promise>; ``` The type parameter maps collection names to record shapes, so `store.all('todos')` returns `readonly Todo[]` rather than a bag of unknowns. Omit it and every record is `Record`. ## SimpleOptions Every field is optional; the defaults carry a real app. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `schema` | `number` | `1` | Your **data** schema version. Bump it together with `migrate`. | | `migrate` | `(from: number, snap: Snapshot) => Snapshot` | - | Upgrade a snapshot written by an older schema version. | | `sync` | `SyncConfig` | - | Per-collection merge tuning: id field mapping, strategies. See [sync](https://selfstore.dev/docs/api-sync). | | `drive` | `DriveAuth` | - | Providing Drive auth here lets a connected Drive backup restore itself on the next start. | | `cache` | `LocalCache` | IndexedDB | Where the working copy lives. In-memory when IndexedDB does not exist (tests, SSR). | | `debounceMs` | `number` | - | Auto-save debounce. | | `multiTab` | `boolean` | `true` in browsers | Cross-tab coordination. | | `requireEncryption` | `boolean` | `false` | Refuse to ever write or export a plaintext backup. Connecting then demands a password or a group. | | `passwordPolicy` | `PasswordPolicy` | - | Reject a backup password weaker than this at `protect()` time. | | `cacheLock` | `CacheUnlock` | - | Seal the local cache under a key held only in memory. See [sensitive apps](https://selfstore.dev/docs/sensitive). | | `autoSync` | `boolean` | `true` in browsers | Wire tab focus, network return, interval and tab hide. `false` to drive syncing yourself. | ## SimpleStore ### Data | Method | Signature | Notes | | --- | --- | --- | | `all` | `(collection) => readonly Record[]` | Treat as read-only; write through `put` / `remove`. | | `get` | `(collection, id) => Record \| undefined` | | | `put` | `(collection, record) => Promise` | Insert or replace. Auto-saves, debounced. **Throws `TypeError`** when the record has no non-empty string id. | | `putAll` | `(collection, records) => Promise` | Many records in one save. | | `remove` | `(collection, id) => Promise` | Propagates to other devices. Unknown id is a no-op. | | `clear` | `(collection) => Promise` | Empty a collection; every removal propagates. | | `onChange` | `(fn) => () => void` | After **any** data change: your writes, another tab, another device, a restore. Returns an unsubscribe. | ### Files Bytes ride in the same store, the same backup and the same merge as records. | Method | Signature | Notes | | --- | --- | --- | | `putFile` | `(file: PutFileInput, opts?: { replace?: boolean }) => Promise` | Answers the file's id. | | `getFile` | `(id) => SnapshotFile \| undefined` | | | `allFiles` | `() => readonly SnapshotFile[]` | | | `removeFile` | `(id) => Promise` | Locally. Deletions do **not** propagate - see below. | ```ts type FileBytes = Uint8Array | ArrayBuffer | Blob; interface PutFileInput { bytes: FileBytes; name?: string; mime?: string; /** Omit it: the default is the SHA-256 of the bytes, and that default is the feature. */ id?: string; } ``` **The id defaults to the SHA-256 of the bytes**, and that is load bearing rather than a convenience. Files merge by a **union on their id**, with no clock to order two bodies: when two devices hold different bytes under the same id, one is kept and the other is dropped - silently, because at that level there is nothing to compare and nothing to report. A content id makes that unreachable, since different bytes are a different file and the union keeps both. So `putFile` refuses different bytes under an id you named yourself - identical bytes are a no-op, different bytes throw a `TypeError` naming the way out - and `{ replace: true }` says you meant it. Correct for a body only ever written on one device; a silent loser as soon as two devices write it. That same union is what makes a CRDT document safe to carry here. Yjs and Automerge updates are commutative and idempotent, so storing each update under its content id turns the union **into** the CRDT merge: no device's update is lost when the copies meet, and folding them is the entire read path. Two limits, stated rather than discovered: file **deletions do not propagate** (there are no tombstones for files), so a device that was offline re-contributes files another device removed - tie a file's lifetime to a record and let the record's deletion drive the cleanup. And everything is in memory, so this is for documents and images, not archives. ### Destinations | Method | Signature | | --- | --- | | `connectDrive` | `(auth: DriveAuth, opts?: { password?: string }) => Promise` | | `connectFile` | `(opts?: { password?: string }) => Promise` | | `connectWebdav` | `(config: WebdavConfig, opts?: { password?: string }) => Promise` | | `connectS3` | `(config: S3Config, opts?: { password?: string }) => Promise` | | `connectTarget` | `(target: BackupTarget, opts?: { password?: string }) => Promise` | | `disconnect` | `() => Promise` | | `addReplica` | `(target: BackupTarget, opts?: { id?: string }) => string` | | `removeReplica` | `(id: string) => void` | An existing backup at the destination is **merged** with this device. An encrypted one needs its password up front: `PASSWORD_REQUIRED` is thrown before anything changes. `disconnect()` goes back to device-only; the destination keeps its last backup. `addReplica` writes the same encrypted backup to a second destination on every save, and a broken copy never gates the store - see [resilience](https://selfstore.dev/docs/resilience). ### Encryption | Method | Signature | Notes | | --- | --- | --- | | `protect` | `(password: string) => Promise` | Encrypt the durable backup end to end. Reversible. | | `unprotect` | `() => Promise` | Remove the backup password. | | `unlock` | `(password: string) => Promise` | For `status.action === 'unlock'`. | | `reconnect` | `() => Promise` | For `status.action === 'reconnect'`: re-run the destination's auth gesture. | ### Backup files | Method | Signature | Notes | | --- | --- | --- | | `exportBackup` | `() => Promise` | A real ZIP; encrypted when `protect()` is on. | | `downloadBackup` | `(filename?: string) => Promise` | **False means nothing was written** - the user closed the save dialog. Do not record a backup. | | `importBackup` | `(file: Blob \| Uint8Array, opts?: { password?: string }) => Promise` | Replaces local data; removals propagate like edits. Throws `PASSWORD_REQUIRED` / `DECRYPT_FAILED`. | The boolean from `downloadBackup` is the one people miss. A save dialog the user dismissed returns `false`, the pending flag stands, and telling them they have a backup would be a lie. ### Status and lifecycle | Member | Type | Notes | | --- | --- | --- | | `status` | `StatusDescriptor` (readonly) | Headless: map `labelKey` to your own copy. | | `error` | `StoreError \| null` (readonly) | The last problem. Show `labelKey`, log `message`. | | `state` | `LocalStoreState` (readonly) | The full underlying state: journal, peers, mode. | | `subscribe` | `(fn) => () => void` | Any state change, status flips included. For framework bindings. | | `flush` | `() => Promise` | Save now. Called for you on tab hide when `autoSync` is on. | | `sync` | `() => Promise` | Converge with the destination now - a pull-to-refresh gesture. | | `dispose` | `() => void` | Drop timers and listeners: tests, SPA teardown. | `subscribe` fires on every state change; `onChange` fires only on data changes. Bind a save badge to the first and a list to the second. ### The escape hatches | Member | Type | What it is for | | --- | --- | --- | | `advanced` | `LocalStore` (readonly) | The full pull-model store this one is built on. See [advanced](https://selfstore.dev/docs/advanced). | | `flowHost` | `{ engine, kv, backupName }` (readonly) | The attachment point for [`selfstore/flows`](https://selfstore.dev/docs/api-flows) and every [widget](https://selfstore.dev/docs/widgets). | `flowHost` is what a widget's `store` property actually consumes. An app built on the advanced store hands a flow the same three members itself. ## Status types ```ts type StorageState = | 'ephemeral' | 'cache-only' | 'saving' | 'saved' | 'pending-download' | 'needs-attention'; type Severity = 'ok' | 'info' | 'warn' | 'danger'; type StatusAction = 'choose-destination' | 'download' | 'reconnect' | 'unlock'; interface StatusDescriptor { state: StorageState; severity: Severity; actionable: boolean; action?: StatusAction; labelKey: string; // stable i18n key; the app owns the copy } ``` The descriptor is **ranked**: when several things are true at once, the most important wins. That is why you read `status.action` rather than deriving a remedy from the flags yourself - and why `` can decide on its own whether to be on screen. `Mode` is `'persistent' | 'ephemeral'`. `TargetKind` is `'device' | 'file-manual' | (string & {})` - deliberately open, so a custom target's kind flows through. ## Errors ```ts interface StoreError { code: SelfstoreErrorCode; labelKey: string; // show this, mapped to your copy message: string; // developer detail for logs. Never display it. } ``` `SelfstoreErrorCode` has 19 members; the full table with what each one means and whether it is transient lives on [error codes](https://selfstore.dev/docs/errors). | Helper | Signature | | --- | --- | | `isSelfstoreError` | `(e: unknown) => e is SelfstoreError` | | `errorLabelKey` | `(code: SelfstoreErrorCode) => string` | | `SelfstoreError` | the thrown class | ## Backup files, without a store `backup()` and `restore()` build and read the file format directly - useful for a one-off export, a migration script, or a Node-side tool. ```ts const blob = await backup(snapshot) .as('my-app', '2.1.0') .encryptedWith(password) .alsoOpenedWith(recoveryCode) .verified() .toBlob(); ``` | Step | Signature | Notes | | --- | --- | --- | | `backup` | `(snapshot: Snapshot) => BackupDraft` | | | `.as` | `(app: string, appVersion?: string) => BackupBuilder` | Required. Stored cleartext in the metadata. | | `.encryptedWith` | `(password: string) => EncryptedBackupBuilder` | AES-256-GCM over an Argon2id-derived key. | | `.alsoOpenedWith` | `(secret: string) => EncryptedBackupBuilder` | A second secret that also opens this backup - a printed recovery code. Call it more than once for more. | | `.withReadme` | `(text: string) => EncryptedBackupBuilder` | Brand the README shipped inside the ZIP. | | `.verified` | `() => BackupBuilder` | Read the backup back before handing it over; throws `VERIFY_FAILED`. | | `.toBytes` | `() => Promise` | | | `.toBlob` | `() => Promise` | | | `.toDisk` | `(filename?: string) => Promise` | Browser only. Defaults to `-.zip`. False when the dialog was dismissed. | **Why `.verified()` exists.** A backup encrypted with a key nobody can reproduce, truncated, or built from an empty snapshot looks exactly like a good one: right name, right date, plausible size. The difference shows up on the day of the disaster. Reading it back costs one decrypt of data the app already holds. **Why `alsoOpenedWith` exists.** A password that lives in one person's memory is the likeliest way a local-first backup dies - no server can reset it. Each secret wraps the same data key, so either opens the file and neither can read the other. Reading needs no change: `withPassword(code)` already tries every slot. ### Reading ```ts const snapshot = await restore(file).withPassword(pw).read(); ``` | Step | Signature | Notes | | --- | --- | --- | | `restore` | `(input: Blob \| Uint8Array) => RestoreBuilder` | | | `.withPassword` | `(password?: string) => this` | Accepts `undefined`, so an optional field passes straight through. | | `.meta` | `() => Promise
` | Cleartext metadata - app, date, encryption - **without decrypting**. | | `.isEncrypted` | `() => Promise` | | | `.read` | `() => Promise` | Reserved `__*` collections are stripped. Throws `PASSWORD_REQUIRED` / `DECRYPT_FAILED`. | ## Standalone helpers | Export | Signature | Notes | | --- | --- | --- | | `saveToDisk` | `(blob: Blob, filename: string) => Promise` | File System Access, else a download. False when dismissed. | | `pickFromDisk` | `() => Promise` | Null when the user cancelled. | | `changePassword` | `(input, { from?, to?, readme? }) => Promise` | Re-key a backup file without a store. Omit `to` to decrypt. | | `gisDriveAuth` | `(opts) => DriveAuth` | Google Identity Services auth. See [Google Drive](https://selfstore.dev/docs/google-drive). | | `checkPasswordPolicy` | `(password, policy) => PasswordCheck` | Pure and synchronous: the same call drives a live UI hint and the store's enforcement. | | `BACKUP_EXTENSION` | `'.zip'` | | | `BACKUP_MIME` | `'application/zip'` | | ### Desktop shell Inside a native webview the File System Access API is usually absent, so the disk file home would degrade to download-on-demand. Register the shell's own filesystem and dialog calls and it writes a real path instead. The narrative version, with the Tauri one-liner, is [desktop shell](https://selfstore.dev/docs/desktop). | Export | Signature | Notes | | --- | --- | --- | | `useDesktopFiles` | `(b: DesktopFileBridge \| null) => void` | Call once at start-up, before opening the store. `null` unregisters. | | `hasDesktopFiles` | `() => boolean` | True once a bridge is registered. The file destination asks this **before** probing the browser. | ```ts interface DesktopFileBridge { readFile(path: string): Promise; writeFile(path: string, data: Uint8Array): Promise; stat(path: string): Promise<{ mtime?: Date | number | null } | null>; exists(path: string): Promise; save(options: { defaultPath?: string; filters?: DesktopDialogFilter[] }): Promise; open(options: { multiple?: boolean; filters?: DesktopDialogFilter[]; }): Promise; } interface DesktopDialogFilter { name: string; extensions: string[]; } ``` Only `mtime` is read out of `stat`, as the version marker, and a shell that cannot report one may leave it absent. `open` may answer with an array, so a host can pass its shell's function through unchanged. ### PasswordPolicy ```ts interface PasswordPolicy { minLength?: number; // in code points, so an emoji counts as one requireLowercase?: boolean; // any script with case requireUppercase?: boolean; requireDigit?: boolean; requireSymbol?: boolean; // anything that is not a letter or a number } interface PasswordCheck { ok: boolean; unmet: PasswordRequirement[]; // stable order; empty when ok } type PasswordRequirement = 'minLength' | 'lowercase' | 'uppercase' | 'digit' | 'symbol'; ``` An empty or absent policy passes everything. Because `checkPasswordPolicy` is the same function the store enforces with, a live strength hint can never disagree with the rejection the user is about to hit. ## The other entry points | Import | Reference | | --- | --- | | `selfstore/flows` | [Flows](https://selfstore.dev/docs/api-flows) | | `selfstore/backups` | [Backups manager](https://selfstore.dev/docs/api-backups) | | `selfstore/sync` | [Sync and merge](https://selfstore.dev/docs/api-sync) | | `selfstore/groups`, `selfstore/households` | [Peers](https://selfstore.dev/docs/api-peers) | | `selfstore/advanced` | [Advanced](https://selfstore.dev/docs/advanced) | | `selfstore/widgets` | [Widgets](https://selfstore.dev/docs/widgets) | Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt