Collections, filters and totals
Create collections, read and write records, filter, count and total.
Collections
You create collections, each with its safety rule; the SDK never does. Locally, use
npx gemmein collection add <name> or the dev terminal. On Gemmein, use the
dashboard's Data (one click), or carry them there with npx gemmein sync.
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")
Your fields live under data. Everything else on a record is set by the server
and read-only:
| Field | What it is |
|---|---|
id | The record's id |
createdAt / updatedAt | When it was created and last changed |
ownerUserId | The person who wrote it; null for the app's own records (receipts, records added on the dashboard without a person) |
collectionId | The id of the collection it is in (the collection's own id, not its name) |
appId | The id of your app |
environmentId | The id of the environment it lives in, Development or Live |
version | Goes up by 1 on every update; pass it as ifVersion to refuse a stale save |
published | false on a draft (public rules only) |
key | The create-if-absent key it was created with, when one was used |
audienceUserId / recipients | Who an addressed or direct record is for; recipients lists everyone a group message names |
writtenBy | "server" when your server's secret key wrote it |
mine | Your own marks on its open fields (below); only you see it |
mineRetired | For a "one per person of" field whose choice you hold but that was taken off its list: true. mine still says the choice; show it as retired, and let the person choose again or withdraw it |
flaggedBy | Who set each "per person flag" field (the first 100). Only on one record read by your dashboard or a secret key |
expand | The linked records you asked for with expand |
existing | true when a keyed create returned the record you already made |
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. Server-managed
fields are refused. A where outside the
grammar is 400 invalid_filter, and the message names the field and the
operator.
In a live collection with fixed fields, a field's default is
read by eq, ne, in and nin; the ordered
operators, contains, startsWith and exists read the
stored value only.
count({ where?, search? })answers how many records this person could list, under the same safety rule, scope and filters, without paging them.list({ count: true })adds the total to a page:total, exact up to 10,000, or past thattotalAtLeast: 10000(show "10,000+"). It counts under the same safety rule, filters and search as the page, reads at most 10,001 records, rides the first page only (a page fetched withcursorcarries none), and is refused on asincedelta (400 invalid_count).list({ author: "me" })orlist({ author: userId })lists one person's records onshared,community,public_readanddirect, inside the reader's own scope, so it only ever returns fewer records. Onprivate,addressedandadmin_writeit is403 author_not_visible.ownerUserIdstays refused inwhere;authoris the filter for it.stats(field, { where?, search? })reduces one numeric field over the same records.countis how many held a number there (a record whose field is missing or not a number is skipped).sum,avg,minandmaxarenullwhen none did. Asumpast 253 is answered as a string.- Neither pages its results, so
since,sort,cursor,limitandexpandare refused (400 invalid_count/400 invalid_stats). - A
statsfield name starts with a letter and uses only letters, digits and underscores; anything else is400 invalid_stats. - Both share
list's read rate limit, and each call counts as one read for a secret key.
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.
Thanks for telling us.
Thanks for telling us.
Last updated