guide

Data & the rules

Data lives in collections. Each collection has exactly one plain-English safety rule, set in the dashboard, and the server enforces it. Your app never implements authorization.

Collections

Collections are created by the app owner in the dashboard (the Collections page, one click), never by the SDK. A 404 unknown_collection means it doesn't exist yet. Names are lowercase letters, numbers, and underscores only (saved_games, never savedGames), and a bad name throws synchronously.

const tasks = g.collection("tasks")

const task = await tasks.create({ title: "Buy milk", done: false })
console.log(task.id, task.data.title)          // fields live under .data

const { records } = await tasks.list()      // { records, cursor, hasMore }
const recent   = await tasks.list({ limit: 10, sort: "newest" })
const filtered = await tasks.list({ where: { done: false } })   // a filter — see below
const one      = await tasks.get("rec_abc123")
await tasks.update("rec_abc123", { done: true })
await tasks.delete("rec_abc123")

Filters, counts and totals

A where names up to 5 of your data fields (link fields too), and every field must match. A field's value is a literal, an exact match, or an object of operators, 1 to 3 per field, every one of which must hold:

OperatorMatches whenValue
eq / neThe stored value equals / does not equal it, compared as text: { eq: "10" } matches the number 10, as a literal always has. Null-safe: { eq: null } matches a field that is absent or null, { ne: null } one that holds a value; a bare null literal is refusedstring, number, boolean or null
gt gte lt lteThe stored value is greater / at least / less / at most. A number compares as a number, and a text value in that field never matches; a string compares as text in code-unit order (uppercase before lowercase), so an ISO date orders correctlynumber, or a string of at most 200 characters
in / ninThe stored value is one of / none of the set1 to 50 strings, numbers or booleans
contains / startsWithThe stored text holds / begins with it, case-insensitive (ASCII letters fold; other scripts compare as stored)text of 1 to 200 characters
existstrue: the field is present and not null. false: absent or nullboolean
await orders.list({ where: { amount: { gte: 10 } } })
await orders.list({ where: { status: { in: ["paid", "sent"] } } })
await orders.list({ where: { title: { contains: "invoice" } } })
await orders.list({ where: { dueAt: { lt: "2026-10-01" } } })

const open = await orders.count({ where: { status: "open" } })                // a number
const paid = await orders.stats("amount", { where: { status: "paid" } })  // { count, sum, avg, min, max }

The same grammar serves list, watch, count and stats, and a relay's when and where. A sealed shape's default is read by eq, ne, in and nin; the ordered operators, contains, startsWith and exists read the stored value only. Server-managed fields are refused. A where outside the grammar is 400 invalid_filter, and the message names the field and the operator.

count({ where?, search? }) answers how many records this person could list, under the same rule, scope and filters, without paging them. stats(field, { where?, search? }) reduces one numeric field over the same records: count is how many held a number there (a record whose field is missing or not a number is skipped), and sum, avg, min and max are null when none did. A sum past 253 is answered as a string. Neither door pages, so since, sort, cursor, limit and expand are refused (400 invalid_count / 400 invalid_stats), and a field name outside the shape's own field grammar (a letter, then letters, digits or underscores) is 400 invalid_stats. Both doors sit under the list's read limiter and count as a read in a secret key's ledger.

The grammar has no or group and no sort by a data field; search is a case-insensitive substring match over stored text; stats reduces one field per call.

The seven rules

RuleWho can readWho can writeUse case
privateOwner only (app owner/admin reads everything)Owner onlyPersonal data: tasks, notes, settings
sharedAll signed-in usersEach user: own records onlyFeeds, communities, team boards
public_readAnyone, without loginAdmin/owner onlyCatalogs, menus, single-author blogs
communityAnyone, without loginEach signed-in user: own records, plain textMulti-author blogs, public boards, profiles
addressedEach user: records addressed to them (owner sees all)Admin/owner only, naming a recipientNotifications, invoices, order status
directAuthor + the named recipientEach signed-in user, naming a recipientMessages, sharing, requests
admin_writeAll signed-in usersAdmin/owner onlyApp settings, announcements

Owner scoping is automatic

For private collections each user sees only their own records. When the signed-in user is the app's owner or admin, the same list() returns every user's records. Build your admin screen with the calls you already have, with no server needed.

Don't gate admin UI on a role field. Sessions always report member, even for the owner, because elevation happens server-side per request. Render whatever list() returns. Never filter by userId, never set userId/owner/role fields: the server derives them from the session and rejects reserved fields (id, userId, ownerUserId, role, createdAt, and friends) if you try.

Rendering another user's content

When one user's content renders in another user's session (community, shared, direct), render it with text bindings (textContent, or {} in React/Vue/Svelte), never innerHTML. community, addressed, and direct enforce this server-side: any string field containing HTML tags is refused with 400 html_not_allowed.

Inboxes and messages

addressed and direct records carry a recipient, named on the call you already make. The recipient is server-stamped as record.audienceUserId, never a data field.

// addressed (owner → user): the "Mark shipped" button in your admin view
await updates.create({ text: "Your order shipped 🎉" }, { for: userId })

// direct (user → user): DMs, sharing, requests
await messages.create({ text: "hey!" }, { for: otherUserId })

// The reader's side is just list() — the server returns only THEIR inbox:
const { records } = await updates.list({ sort: "newest", limit: 20 })
  • Same message for everyone → admin_write. A specific thing for a specific person → addressed.
  • For inboxes, use watch(): it polls list({ since }) every 10 s (5 s floor), hands you only what changed or was deleted, and sleeps while the tab is hidden. Never write a tight loop by hand. Read-state lives in the user's own private collection.
  • direct is messaging inside someone's app, and the app owner can read it. Never present it as private or encrypted chat.
  • The error names the fix: invalid_audience (recipient isn't a user), reply_only (this collection only allows replying to people who wrote first), sends_disabled (owner turned in-app sends off).

Links between records

Store another record's id in a field and it becomes a link. On collections that learn links (community, shared, direct) you can embed the linked records on reads with expand:

const { records } = await posts.list({ expand: ["authorId"] })
records[0].expand.authorId          // the linked record, or null if gone (tombstone)

expand throws on private, public_read, and admin_write, because those rules have no link shape; join in memory there. Up to 3 fields per call.

Files

const file = await tasks.upload(imageBlob, { name: "avatar.png" })
// { id, ref, contentType, sizeBytes } — store file.ref in a record field

const { url, expiresAt } = await g.files.link(file.ref)
// the ONLY way a file becomes a URL — checked against your rules every time

Images (JPEG, PNG, WebP, GIF, HEIC) and documents (PDF, ZIP, EPUB) are accepted at 25 MB per file. Nothing else is: there is no video or audio here, and no SVG or HTML (zip an office file). A document always downloads, because it is served as an attachment and never opens inside your page, so link it with { intent: "download" }. The server checks the actual bytes, not the filename, so a renamed file is refused with invalid_file_content; anything over the cap gets a 413 file_too_large. A reference is not a URL: files in collections a stranger can read resolve to permanent CDN URLs on your app's own subdomain; everything else resolves to a short-lived signed link, authorized against the collection's rule every time a link is created. Revoked access stops new links immediately.

On addressed and direct collections, upload(blob, { for: userId }) hands the file to that one person. The recipient is stamped at upload, is immutable, and only they (and the owner) can ever resolve it. That's how a direct message carries an attachment, and how an app hands one client a document. A field that holds file references is learned as a file field, image or document, from the real uploads. File references are validated on every rule, locked or not: a made-up ref is refused (unknown_file). Once the shape is sealed, attaching the wrong kind is refused too (invalid_shape, whose message states which kind the field takes); before the seal, what you upload is what the field learns.

Drafts on public collections

On public_read and community, { published: false } saves a draft the public can't see, enforced by the server. The author still sees their own; the owner sees all.

const post = await notes.create({ title: "wip" }, { published: false })  // hidden
await notes.update(post.id, {}, { published: true })                     // now live

post.published   // TOP-LEVEL boolean — same place as id, NOT post.data.published

Never fake drafts with a status field + client-side filtering on a public collection; the data still reaches every reader's network tab. published is an option, not a data field; the server rejects it inside data.

The shape seals at go-live — and grows through promotion

In development, collections learn their field shape from your writes. When the owner flips go-live, the shape is sealed on community, shared and direct, the rules where users write into each other's view. Production writes there with a field the collection never learned are refused (invalid_shape), and links must point at records that exist (unknown_record). Every other rule stays free-shape forever.

Going live protects the shape; it does not freeze development. In development, collections continue learning new fields. When your next version introduces fields that production does not yet know, npx gemmein go-live, run again, presents each change for the owner to approve. Once promoted, production accepts the new shape. Until then, unknown fields are refused (invalid_shape), so newly generated code cannot silently change the live data contract. shared, community and direct promote the same way; what is already sealed never moves, and every promote run is recorded in the audit log.

Field defaults and how they read back through get, list, expand and where: see the reference. The invalid_shape error and what to do about it: see Errors.