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 open = await tasks.query({ where: { done: false }, order: { rank: "asc" } });

App-scoped and authorized by the app key — every user of the app sees the same tables. 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.

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.on("message", render);
await channel.send({ text: "deploy is live 🎉" });

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

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