# Extension storage

> Keep an extension's data per user, project or session with key/value data, typed reactive stores, in-memory values and files.

`ctx.storage` keeps an extension's data. Everything in it belongs to the extension: another extension cannot read it. It has four parts:

| Part | For | Survives |
| - | - | - |
| [`kv(scope)`](#keyvalue-data) | Small JSON values by key | Restarts |
| [`store(name, schema, …)`](#typed-stores) | One typed value that windows follow live | Restarts |
| [`memory(key, init)`](#memory) | Objects you keep across a reload of the extension | Reloads, not restarts |
| [`path(scope, …)`](#files) | Files of your own | Restarts |

Secrets do not belong here. Put an API key in a [secret setting](https://zidane.si/docs/extensions-settings.md#string) or an [integration](https://zidane.si/docs/extensions-integrations.md): both keep it in the auth store.

## Scopes

`kv` and `store` take a scope:

| Scope | One copy per |
| - | - |
| `user` | User, across every project |
| `project` | Project root |
| `session` | Session |

## Key/value data

`ctx.storage.kv(scope, sessionId?)` returns a `KeyValue`. Reads are synchronous: they come from a cache the app fills before you read.

```ts
const user = ctx.storage.kv('user')
await user.set('loads', (user.get<number>('loads') ?? 0) + 1)

const runs = ctx.storage.kv('session', 'a-session-id')
await runs.ready()
ctx.logger.info('runs in this session', { runs: runs.get<number>('runs') ?? 0 })
```

| Member | Returns | Does |
| - | - | - |
| `get<T>(key)` | `T \| undefined` | The value, from the cache |
| `set(key, value)` | `Promise<void>` | Stores a JSON value. The cache changes at once |
| `delete(key)` | `Promise<void>` | Removes the key |
| `scan(prefix?)` | `{ key, value }[]` | Entries whose key starts with `prefix`, sorted by key |
| `subscribe(listener)` | `Disposable` | Calls `listener({ key, value })` after each change, from any window or client |
| `ready()` | `Promise<void>` | Resolves once the scope is loaded. At once when it already is |

### When reads are ready

The app loads some scopes before you read them:

| Scope | Loaded |
| - | - |
| `user`, `project` | Before `setup` |
| `session` | Before each run of that session and each `/` command in it, and when a window opens it |

Any other `kv()` call starts a load, and reads return `undefined` until it ends. Await `ready()` before reading when that matters: a session read from `run:settled` after a restart, or a session no window has open.

## Typed stores

`ctx.storage.store(name, schema, initial, opts)` holds one typed value. The schema checks every write. Every window and client sees changes at once, and components bind to it. From the kitchen-sink example (`examples/extension-kitchen-sink/storage.ts`):

```ts
import { z } from 'zod'

const CounterSchema = z.object({ count: z.number(), updatedAt: z.number() })

const counter = ctx.storage.store('counter', CounterSchema, { count: 0, updatedAt: 0 }, {
  scope: 'project',
  // Version 1 stored a bare number.
  version: 2,
  migrate: (old, fromVersion) => (fromVersion === 1 && typeof old === 'number' ? { count: old, updatedAt: 0 } : { count: 0, updatedAt: 0 }),
})

await counter.set(c => ({ count: c.count + 1, updatedAt: Date.now() }))
const label = counter.select(c => `Counter: ${c.count}`)
```

| Option | Type | Does |
| - | - | - |
| `scope` | `'user' \| 'project' \| 'session'` | Required. Where the value lives |
| `sessionId` | `string` | For `session` scope: which session |
| `version` | `number` | The shape's version. Bump it when the shape changes |
| `migrate` | `(old, fromVersion) => T` | Turns a value stored at an older version into the new shape |

The schema is a Standard Schema (zod, valibot, arktype), which types the value, or a plain JSON Schema, which types it `unknown`. `initial` is the value while nothing is stored.

| Member | Does |
| - | - |
| `get()` | The value |
| `set(value)` | Stores a value, or the result of a function of the current one. Rejects when the schema refuses it |
| `subscribe(listener)` | Calls `listener(value)` after each change. Returns the unsubscribe |
| `select(pick)` | A `Readable` over part of the value |

A `Readable` from `select` fits any bindable field: a command's `title`, a slot's `text`. The field follows the store, with no update calls:

```ts
import { Slot } from 'zidane'
import { z } from 'zod'

const counter = ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })
ctx.add(Slot, { at: 'app.statusbar', text: counter.select(c => `Count ${c.count}`) })
```

Components read stores with `useStore` ([components](https://zidane.si/docs/extensions-components.md)).

## Memory

`ctx.storage.memory(key, init)` returns an object that survives a reload of the extension, not a restart. The first call runs `init`. Later calls, after a reload too, return the same object.

```ts
const boot = ctx.storage.memory('boot', () => ({ firstLoadAt: Date.now(), reloads: -1 }))
boot.reloads += 1
```

Use it for caches, open connections you hand over to the next load, or counters while you develop. It never reaches the disk.

## Files

`ctx.storage.path(scope, ...segments)` returns a path in a folder of the extension's own, next to its kv and store data. The folder is not created for you.

| Scope | Folder |
| - | - |
| `data`, `user` | `<dataDir>/extension-storage/<id>/files` |
| `project`, `session` | `<dataDir>/extension-storage/_projects/<project-key>/<id>/files`, `<project-key>` a hash of the project root; else the `data` folder |
| `cache` | `<cacheDir>/extension-storage/<id>`: files the app may delete |

`..` cannot leave the folder: a path that tries returns the folder itself.

The folder is never the one the extension is installed in, so an update or a reinstall keeps your files. Earlier versions used `<dataDir>/extensions/<id>` (the install folder for an extension in `~/.zidane/extensions`), `<configDir>/extensions/<id>` and `<projectRoot>/extensions/<id>`. When you ask for a path, zidane first moves the file or folder you named from there, unless the new folder already has it. It never moves the extension's own files: sources, `node_modules`, `package.json`, `extension.json` and dot files stay. An old folder that is not an install folder moves whole. The cache starts empty.

Project and session data used to live in `<projectRoot>/.zidane/extension-storage`, inside the repository. zidane moves that folder to the data folder the first time it opens the project, and warns you if git tracks it, so you can commit its removal. Entries the new folder already had stay in the old one, and zidane tells you where.

The project key comes from the project's absolute path, so project and session data no longer travel with the repository: after you move or rename a project, it starts with empty project storage. The old data stays in `<dataDir>/extension-storage/_projects/<old-key>`; move its contents to the new key's folder to keep them.

```ts
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'

const file = ctx.storage.path('data', 'last-bump.json')
await mkdir(dirname(file), { recursive: true })
await writeFile(file, JSON.stringify({ at: Date.now() }))
```

Paths are on the machine that runs the extension. For files in the session's workspace (a sandbox, a remote machine), use [`ctx.workspace`](https://zidane.si/docs/extensions-sessions.md#workspace).

## Values

kv and store values are JSON: strings, numbers, booleans, `null`, arrays and plain objects. `kv.set` throws a `TypeError` for anything else, such as a `Date`, a `Map` or a class instance. Store a timestamp as a number.

## In each app

Data lives with the process that runs the extension ([where the code runs](https://zidane.si/docs/extensions.md#where-the-code-runs)). Every window and client of that process sees the same data.

| App | Notes |
| - | - |
| Terminal app | In daemon mode, the data is the daemon's. A store write the daemon refuses is put back, and `set` rejects |
| Desktop app | Every window shares the data. A change in one shows in the others |
| Web app | A store write shows in the page at once, then settles to the value the server stored. A refused write is put back, and `set` rejects with the reason |

Other processes that write the same files (a terminal app and the desktop app on one machine) see each other's changes: the app watches the files and reads them again.

## Pitfalls

- `kv('session')` without a session id throws: the hosts do not track a current session for storage. Pass the id from the event or the command.
- A read before the scope loaded returns `undefined`, not the stored value. Await `ready()` for session data read cold.
- Do not keep the result of `get()` and change it. Call `set` with a new value.
- A store's `set` rejects when the schema refuses the value. Handle the rejection.
- `memory` is per process. Two windows in daemon mode share the daemon's memory, not each other's.
- Large files belong in `path('data')` or `path('cache')`, not in kv: each kv scope is one JSON file, read and written whole.

## Related

- [Settings](https://zidane.si/docs/extensions-settings.md): values the user sets.
- [Lifecycle](https://zidane.si/docs/extensions-lifecycle.md#reload): what a reload keeps.
- [Components](https://zidane.si/docs/extensions-components.md): `useStore` in the desktop, web and terminal apps.
