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 (data 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 — 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 } })   // exact-match
const one      = await tasks.get("rec_abc123")
await tasks.update("rec_abc123", { done: true })
await tasks.delete("rec_abc123")

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 — no loginApp owner onlyCatalogs, menus, single-author blogs
communityAnyone — no loginEach signed-in user: own records, plain textMulti-author blogs, public boards, profiles
addressedEach user: records addressed to them (owner sees all)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 — and 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; no server needed.

Don't gate admin UI on a role field. Sessions always report member, even for the owner — 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.

User content is data, not markup

When one user's content renders in another user's session (community, shared, direct), render it with text bindings — textContent, {} 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.

Sending to people — inboxes and messages

addressed and direct records carry a recipient, named on the call you already make. The recipient is server-stamped (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.
  • Inboxes are poll-or-refresh: list() on window focus plus a gentle ~60s interval — never a tight loop. Read-state lives in the user's own private collection.
  • direct is messaging inside someone's app — the app owner can read it. Never present it as private or encrypted chat.
  • The errors teach 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 — 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 only — JPEG, PNG, WebP, GIF, HEIC — and 10 MB per file. 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 at every mint. Revoked access stops new links immediately.

Drafts on public collections

On public_read and community, { published: false } saves a draft the public can't see — server-enforced. 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.

Going live seals the shape

In development, collections learn their field shape from your writes. When the owner flips go-live, the shape is sealed: production writes with a field the collection never learned are refused (invalid_shape), and links must point at records that exist (unknown_record). Dev keeps learning; live is law.