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
| 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 — no login | App owner only | Catalogs, menus, single-author blogs |
community | Anyone — no 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) | 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 — 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. directis 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.