Skip to content

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:

PartForSurvives
kv(scope)Small JSON values by keyRestarts
store(name, schema, …)One typed value that windows follow liveRestarts
memory(key, init)Objects you keep across a reload of the extensionReloads, not restarts
path(scope, …)Files of your ownRestarts

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:

ScopeOne copy per
userUser, across every project
projectProject root
sessionSession

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.

TypeScript
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 })
MemberReturnsDoes
get<T>(key)T | undefinedThe 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)DisposableCalls 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:

ScopeLoaded
user, projectBefore setup
sessionBefore 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):

TypeScript
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}`)
OptionTypeDoes
scope'user' | 'project' | 'session'Required. Where the value lives
sessionIdstringFor session scope: which session
versionnumberThe shape's version. Bump it when the shape changes
migrate(old, fromVersion) => TTurns 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.

MemberDoes
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:

TypeScript
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.

TypeScript
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.

ScopeFolder
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.

TypeScript
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.

AppNotes
Terminal appIn daemon mode, the data is the daemon's. A store write the daemon refuses is put back, and set rejects
Desktop appEvery window shares the data. A change in one shows in the others
Web appA 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.