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:
| Operator | Matches when | Value |
|---|---|---|
eq / ne | The 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 refused | string, number, boolean or null |
gt gte lt lte | The 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 correctly | number, or a string of at most 200 characters |
in / nin | The stored value is one of / none of the set | 1 to 50 strings, numbers or booleans |
contains / startsWith | The stored text holds / begins with it, case-insensitive (ASCII letters fold; other scripts compare as stored) | text of 1 to 200 characters |
exists | true: the field is present and not null. false: absent or null | boolean |
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
| Rule | Who can read | Who can write | Use case |
|---|---|---|---|
private | Owner only (app owner/admin reads everything) | Owner only | Personal data: tasks, notes, settings |
shared | All signed-in users | Each user: own records only | Feeds, communities, team boards |
public_read | Anyone, without login | Admin/owner only | Catalogs, menus, single-author blogs |
community | Anyone, without login | Each signed-in user: own records, plain text | Multi-author blogs, public boards, profiles |
addressed | Each user: records addressed to them (owner sees all) | Admin/owner only, naming a recipient | Notifications, invoices, order status |
direct | Author + the named recipient | Each signed-in user, naming a recipient | Messages, sharing, requests |
admin_write | All signed-in users | Admin/owner only | App 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 pollslist({ 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. directis 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.