host.kv
host.kv is a small, fast, server-persisted JSON store. Each person using your app gets their own, and it is keyed on the content the app is open on, so the same store answers whether they opened the app on a canvas, took it fullscreen, or followed a share link.
It is the first reach for state that must survive a reload: a game's high score, a form draft, a toggle, a cursor position. For binary data or large files use host.content instead. For state several people edit together use host.collab, which is a CRDT (conflict-free replicated data type, so concurrent edits merge).
API
type KvScope = 'private' | 'shared';
interface KvNamespace {
get(key: string, opts?: { scope?: KvScope }): Promise<unknown | null>;
set(key: string, value: unknown, opts?: { ttl?: number; scope?: KvScope }): Promise<void>;
incr(key: string, delta?: number, opts?: { ttl?: number; scope?: KvScope }): Promise<number | null>;
delete(key: string, opts?: { scope?: KvScope }): Promise<void>;
list(prefix?: string, opts?: { scope?: KvScope }): Promise<Array<{ key: string; value: unknown }>>;
subscribe(key: string, handler: (value: unknown) => void): () => void;
}| Method | Returns | What it does |
|---|---|---|
get(key, opts?) | unknown | null | Read a value, null if absent. |
set(key, value, opts?) | void | Write a JSON value, optional ttl in seconds. |
incr(key, delta?, opts?) | number | null | Atomic server-side value = value + delta. Shared by default. |
delete(key, opts?) | void | Remove a key. |
list(prefix?, opts?) | { key, value }[] | Enumerate keys, optionally by prefix. |
subscribe(key, handler) | unsubscribe fn | Fire handler on every change to key. |
Read and write
await host.ready();
await host.kv.set('theme', 'dark');
const theme = await host.kv.get('theme'); // 'dark' | nullValues are JSON, so store objects directly. No manual JSON.stringify:
await host.kv.set('draft', { title, body, savedAt: Date.now() });TTL
set and incr take an optional { ttl } in seconds. The key expires server-side after the window. Default is persistent (no expiry).
await host.kv.set('otp', code, { ttl: 300 }); // expires in 5 minutesAtomic counters
incr applies value = value + delta server-side, so concurrent viewers never clobber each other. The stored value must be a JSON number. delta defaults to 1. It returns the new value.
const plays = await host.kv.incr('playCount'); // +1
const score = await host.kv.incr('score', 10); // +10incr writes the shared store, not your private one. A counter exists to coordinate several actors: a board's next task number must never repeat, so everyone has to draw from one sequence. That is the opposite of what the value operations want, so it is the one operation whose default scope is shared. Writing it needs write access to the content the app is open on, and a read-only viewer is refused with permission.
On content nobody else can see, the default counter is yours. An app added to a canvas points at the app's own catalog row until it is saved to a project, and that row has no shared store (see Scope and sharing below). Rather than fail there, a counter you did not name a scope for falls back to your private store: still atomic, still monotonic, never repeating inside anything you can see.
If your counter must be one sequence for everybody, say so. incr resolves a bare number and does not tell you which store answered, so a defaulted call is not a promise of anything. Ask explicitly and you get a guarantee or a refusal:
try {
const n = await host.kv.incr('issue:seq', { scope: 'shared' }); // or (key, 1, opts)
} catch (err) {
if (err.code === 'scope_unavailable') useLocalNumbering();
}The options object may take the delta's place, as above. If you genuinely need to read the resolved store rather than assert it, host.rpc('kv.incr', { key, delta, scope }) resolves the raw { value, scope }.
Use incr for any shared tally (play counts, vote totals, live scoreboards, an issue number). A read-modify-write with get then set is racy across viewers; incr is not. For a counter only the current person should see, pass { scope: 'private' }.
List by prefix
const entries = await host.kv.list('player:');
// → [{ key: 'player:alice', value: {...} }, { key: 'player:bob', value: {...} }]Namespace keys with a delimiter (player:alice) so list(prefix) can scan a group cheaply. Calling list() with no prefix returns every key in your store for this content.
Live updates
subscribe fires the handler whenever a key changes, including from the same person's other open tabs. It returns an unsubscribe function. Call it on teardown.
const off = host.kv.subscribe('score', (value) => {
scoreEl.textContent = String(value ?? 0);
});
// later, on unmount:
off();Scope and sharing
Two stores, addressed by the same keys:
| Scope | Who sees it | Who may write it | Default for |
|---|---|---|---|
private | the current person only | the current person | get, set, delete, list |
shared | everyone who can open the content | anyone with write access to the content | incr |
- Private is per person, not per copy. Two tiles of the same app on one canvas, the fullscreen view and a share link all read one store, because the store is keyed on the person and the content rather than on the window. Two different people never see each other's values.
- Shared is owned by the content's creator. It is the app's visible state, so anyone who can open the content can read it, and only someone with write access can change it.
- Shared is unavailable on an app's own catalog row, which is the row every copy of the app points at before it is saved to a project. A store owned by that row would be global to every user of the app, which is almost never what "shared" means. Put genuinely global state behind a function instead, where it has an owner and a quota. An operation that did not name a scope falls back to your private store there; one that asked for
sharedexplicitly is refused withscope_unavailable. Name the scope when it matters, rather than trying to detect which store answered. - Saving to a project moves the store. The store is keyed on the content the app is mounted on, so when a tile that was running on the app's catalog row binds to a project or folder, it starts reading that project's store: the keys written before the save are still on the server but are no longer reachable from that tile, and
listreturns only the project's keys. Write anything the person should keep across that boundary into the project's own files rather than into KV, or read the old keys once and carry them over yourself while both are in reach. - Neither store is a secret store. Values are readable by the person they belong to and, for the shared store, by everyone who can open the content. Do not put credentials here.
- A signed-out visitor gets a store too. The first time an app on a share link tries to save, a lightweight visitor account is created for the person then and there, so the save lands and reloads normally for someone who has not signed up. Just looking at a shared app creates nothing. Those stores carry tighter size caps and expire after seven days, so treat them as convenience rather than durable storage and offer a sign-in path for anything that matters; if the account cannot be created (a bot check that does not pass) the write rejects with
no_sessionand the person should be told, not left thinking it saved. - Keep values small. It is a KV store, not a blob store. Reach for host.content once a value is large or binary.
Errors
| Code | When |
|---|---|
quota_exceeded | the per-value or per-store size or count cap is hit |
rate_limited | more than 100 writes a minute to one store |
permission | a shared write without write access to the content |
scope_unavailable | a shared scope on an app's own catalog row |
no_session | no session at all, so there is no store to write |
not_found | this window has no store yet (a window still resolving what it is showing) |
internal | anything else |
Every one of these carries the server's own message, so err.message is worth showing: a refused TTL says "TTL exceeds the plan maximum of 30 days" rather than leaving you to guess.
Reads never raise these. When there is nothing to read from, get resolves null and list resolves [], so an app boots on its defaults. Writes do raise, because an app that cannot save should say so rather than look like it did.
Handle the rejection. set, incr and delete return promises, and a write can be refused for every reason in the table above. An app that calls them without awaiting or catching turns a refusal into an unhandled rejection, which means the person sees the edit accepted on screen and gone on the next reload, with nothing said. Await the write where the result matters, and tell the person when it fails:
try {
await host.kv.set('items', items);
} catch (err) {
if (err.code === 'no_session') showSignInPrompt();
else if (err.code === 'quota_exceeded') showTooBig();
else showSaveFailed();
}See error codes.
Related
- host.content: files and binary data
- host.collab: true multi-user CRDT state