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) | Small JSON values by key | Restarts |
store(name, schema, …) | One typed value that windows follow live | Restarts |
memory(key, init) | Objects you keep across a reload of the extension | Reloads, not restarts |
path(scope, …) | Files of your own | Restarts |
Secrets do not belong here. Put an API key in a secret setting or an integration: 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.
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):
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:
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).
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.
const boot = ctx.storage.memory('boot', () => ({ firstLoadAt: Date.now(), reloads: -1 }))
boot.reloads += 1Use 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.
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.
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). 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. Awaitready()for session data read cold. - Do not keep the result of
get()and change it. Callsetwith a new value. - A store's
setrejects when the schema refuses the value. Handle the rejection. memoryis per process. Two windows in daemon mode share the daemon's memory, not each other's.- Large files belong in
path('data')orpath('cache'), not in kv: each kv scope is one JSON file, read and written whole.
Related#
- Settings: values the user sets.
- Lifecycle: what a reload keeps.
- Components:
useStorein the desktop, web and terminal apps.