Skip to content

Choosing where data lives

Muhkoo gives you four places to put state, and they’re not interchangeable. The choice comes down to two questions: is the data per-user or shared? and does the server need to read it? Get those right and the primitive picks itself.

PrimitiveScopeServer can read it?Queryable?Reach for it when…
client.kvPer-userNo (encrypted at rest)NoPrivate user state: prefs, drafts, small documents
client.dbApp-wide, sharedYes (by design)Yes (where/order/limit)Shared structured data you need to query
client.storagePer-SpaceNo (encrypted)NoFiles & blobs — attachments, images, exports
client.spaceMulti-partyNo (E2E) / opt-inHistory onlyRealtime: chat, channels, collaboration
  1. Is it one user’s private data? → client.kv. It’s sealed under the user’s identity, synced across their devices, and never readable by the server. No schema, no setup.
  2. Do many users share it, and do you need to query it? → client.db. Define a table in the portal, then query/insert/update/delete typed rows. This is the only primitive the server can filter and sort.
  3. Is it a file, or a live conversation? → client.storage for blobs, client.space for realtime channels.

UI preferences, a draft in progress, a private journal entry — anything scoped to one user. Encrypted at rest by default, with a live change feed across the user’s own devices.

await client.kv.set("prefs", "ui", { theme: "dark", density: "compact" });
const ui = await client.kv.get<UiPrefs>("prefs", "ui");
client.kv.on("change", (e) => reconcile(e)); // another device wrote

Good for small, document-shaped values you fetch by id. Not for data you need to search or aggregate on the server — it’s ciphertext to the platform.

A task board, a catalog, a leaderboard — structured rows multiple users read and filter. Define the schema in the portal, then:

const tasks = client.db.table<Task>("tasks");
await tasks.insert({ title: "Ship docs", done: false, rank: 1 });
const { rows: open } = await tasks.query({
where: [{ column: "done", op: "eq", value: false }],
orderBy: { column: "rank", dir: "asc" },
});

App-scoped and authorized by the app key — every user of the app sees the same tables, so a column like owner is a filter you apply, not a permission. For rows a browser must never read, mark the table backend-only and put a function in front of it. Reach for it whenever you’d otherwise want a WHERE clause.

Images, attachments, exports — bytes too big for a KV value. Files are chunked, encrypted, erasure-coded, and content-addressed; the manifest is the capability to read one back. Files live in a Space:

const { manifest } = await client.storage.writeFile({
spaceId,
data: file, // a File / Blob / Uint8Array
metadata: { name: file.name, type: file.type },
});
// Share `manifest` (e.g. in a message) to let another member read the file.

Thousands of small files are as reasonable here as a handful of large ones. Shard reads issued together coalesce into one batched request, and parity is fetched only when a shard is genuinely missing, so opening a 519-file project costs about two dozen requests rather than the 2,086 it once did. The one thing it asks of you is to read files in parallel rather than in a loop — see How reads are fetched.

A chat channel, a shared cursor, anything users see update live. A Space carries a persisted, end-to-end-encrypted message log and a fan-out websocket. See Messaging.

const channel = await client.space.joinChannel("general");
channel.onMessage((e) => render(e.message));
await channel.sendMessage({ text: "deploy is live" });

Real apps mix these — the skill is using each for what it’s good at:

  • Encrypted notes app — client.kv for the notes; that’s the whole backend.
  • Team task board — client.db for the shared tasks (queryable), client.kv for each user’s private view state, client.space for an activity channel.
  • Chat with attachments — client.space for messages, client.storage for the files, with the manifest shared as a message.