Skip to content

KV store

Every app has one key-value store. Keys are strings, values are any JSON-serialisable data, and everyone the app reaches shares the same store — whether they opened it from a group, a direct invite, or a link. It is the app’s database: lists, votes, scores, settings, references to Drive files.

interface MaypopKvEntry {
key: string;
value: unknown;
author: string | null;
updatedAt: string;
}
interface MaypopKv {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
list(opts?: { prefix?: string }): Promise<MaypopKvEntry[]>;
subscribe(prefix: string, cb: (entries: MaypopKvEntry[]) => void): () => void;
}
await maypop.ready();
// Render whatever is in the store now, and again on every change —
// including changes other people make.
const stop = maypop.kv.subscribe("todo/", (entries) => render(entries));
async function add(text: string) {
if (maypop.mode !== "read-write") return;
await maypop.kv.set(`todo/${crypto.randomUUID()}`, {
text,
done: false,
authorId: maypop.user!.id,
});
}
  • Local first. Each device keeps a replica. set and delete resolve as soon as the change is in that replica; it syncs in the background and survives going offline.
  • Live. subscribe calls back immediately with the current entries and again whenever anything under the prefix changes, on any device.
  • Subscribe for anything on screen. get and list read the local replica once and can be empty or stale while it is still loading.
  • Last write wins. When two people set the same key, the one the server commits last stays. There are no transactions or atomic increments; give each person or item its own key instead of editing one shared counter.
  • A rejected write rolls back. If Maypop refuses a write — too large, or not allowed by the app’s policy — the value disappears from the replica after set has already resolved. Validate size and permission up front.
  • list returns entries sorted by key. author is the writer’s app-scoped user.id.
  • Keys starting with ~ are reserved for the SDK; don’t write them.
Limit Value
Key 1,024 bytes (UTF-8)
Value 256 KiB of serialised JSON
Synced to one device 5,000 entries or 5 MiB, when a policy is active

Store large things — photos, audio, long documents — in Drive and keep the file’s cid in KV.

There is one store, so a key containing someone’s id is not private by itself. To keep data per person:

  1. Put the viewer’s id in the key: private/${maypop.user.id}/settings.
  2. Add a saved-data policy that only lets that person sync it.

A policy decides who can read and write which keys. It lives in your app’s source as .maypop/kv-policy.json and takes effect when you publish a version that contains it. A version without the file keeps the policy that is already active; an invalid file fails the publish. Without any policy, everyone who can open the app can read every key, and writers can change any of them.

{ "schemaVersion": 1, "mode": "owner_private", "rules": [] }
mode Who can do what
shared_group_data Everyone with access reads everything; writers and above change anything
owner_private Each person reads and writes only private/{memberId}/**; visitors see nothing
group_read_admin_write Everyone reads; only editors and admins write
advanced Your own rules

A rule allows actions to roles on keys matching key. Anything no rule allows is denied.

Field Values
actions read, sync, create, update, delete
roles guest (readers and visitors), user (writers), admin (editors), owner (app admins)
key /-separated pattern: literals, * for one segment, {memberId} for the viewer’s id, ** as the last segment for everything below
owner null, { "source": "entryAuthor" }, or { "source": "jsonPointer", "pointer": "/authorId" }

Roles match exactly — list every role a rule applies to. sync is what lets data reach a device at all, so it decides what a person can see; give it alongside read. An owner condition limits a rule to records the viewer wrote, or whose field at pointer holds their id. On update, both the old and new record must match, so nobody can hand a record to someone else.

Private to-dos per person, plus a shared board where people edit their own posts and the app admin can remove any:

{
"schemaVersion": 1,
"mode": "advanced",
"rules": [
{
"actions": ["read", "sync", "create", "update", "delete"],
"roles": ["user", "admin", "owner"],
"key": "todo/private/{memberId}/**",
"owner": null
},
{
"actions": ["read", "sync"],
"roles": ["guest", "user", "admin", "owner"],
"key": "todo/shared/**",
"owner": null
},
{
"actions": ["create", "update", "delete"],
"roles": ["user", "admin", "owner"],
"key": "todo/shared/**",
"owner": { "source": "jsonPointer", "pointer": "/authorId" }
},
{
"actions": ["delete"],
"roles": ["owner"],
"key": "todo/shared/**",
"owner": null
}
]
}

A policy can have up to 128 rules. Anyone who can publish the app can change its policy, and the change applies as soon as that version is live.

Test a policy locally with "strictStorage": true in .maypop/local/dev.json.