Data
Data is your app’s database. It stores records — flat JSON objects — grouped into
collections you declare (todos, posts, entries, whatever the project needs).
It’s the app’s source of truth — strongly consistent, and authoritative (see also Files for bytes).
Declare the shape
Section titled “Declare the shape”A collection is declared in data.json, at the root of the project:
{ "collections": { "todos": { "fields": { "title": "string", "done": "boolean", "due": "string" }, "indexed": ["done", "due"] }, "posts": { "scope": "public", "fields": { "slug": { "type": "string", "required": true, "unique": true }, "status": { "type": "string", "enum": ["draft", "live"], "default": "draft" }, "views": "number", "meta": "json" }, "indexed": ["status", "meta.category"] } }}Field types are string, number, boolean, json, and ref. A field is either a bare
type, or an object when it needs rules:
required— a write without it (or withnull) is a400.default— filled in when a create or replace omits the field.enum— the value must be one of the listed members.unique— no two records may share the value. Enforced by the database itself, as a real unique index, and surfaced as a409.
indexed lists the fields you filter or sort by — index what you query, and nothing else.
A dotted path like meta.category indexes inside a json field. Unique fields are
indexed automatically.
Declaring the shape is what lets the server filter, sort and aggregate for you. Adding a
field is one line in data.json; there is no migration to run.
id, createdAt, updatedAt and owner are set for you. Never declare them.
Relations: one record pointing at another
Section titled “Relations: one record pointing at another”A ref field holds another record’s id and names the collection it points at:
{ "collections": { "posts": { "scope": "public", "fields": { "title": "string" } }, "comments": { "scope": "public", "fields": { "post": { "type": "ref", "to": "posts" }, "body": "string" }, "indexed": ["post"] } }}You write it like any other field — the value is the other record’s id:
const comments = collection("comments");
await comments.create({ post: "rec_abc", body: "nice one" });and you read it back either as the id, or as the record itself with expand:
await comments.list({ post: "rec_abc" });// → { items: [{ id: "…", post: "rec_abc", body: "nice one" }] }
await comments.list({ expand: ["post"] });// → { items: [{ id: "…", post: { id: "rec_abc", title: "Hello", … }, body: "nice one" }] }Up to 3 fields can be expanded at once (?expand=post,author). Index a ref you filter
by, so ?post=rec_abc stays fast.
An expanded ref that points at a record which has been deleted — or at one this caller is
not allowed to read — comes back as null. Expansion never widens what you can see: the
referenced collection is read under its own scope, so expanding a user-scoped ref gives you
your own records and nobody else’s, and a staff collection refuses the expansion outright.
A ref is an id, not a foreign key. Nothing stops you deleting the record it points at; the pointer simply stops resolving. That is deliberate — enforcing the constraint would mean a read on every write, which is exactly the per-row cost this feature exists to remove.
Scope: who sees a collection
Section titled “Scope: who sees a collection”Every collection declares who can reach it — the access rule lives on the collection, not in your front-end code:
user(the default) — each signed-in user reads and writes only their own records. Private notes, personal todos, settings.team— one pool per team, invisible to every other team. The build requires Teams to be on rather than collapsing everyone into one pool.shared— one pool that every signed-in user reads and writes. Collaborative lists, a team board, a wiki.public— one pool every signed-in user can read. Every record carries its writer in a read-onlyownerfield, and only the owner can change or delete it. Blogs, marketplaces, leaderboards, feeds.staff— the back office. Only a staff user may read it or write it; everyone else gets a403. Internal notes, moderation queues, an ops log.
Scope says how wide the pool is among people who are signed in. It says nothing about
people who are not: a signed-out visitor is refused every collection with a 401 until
that collection opts them in with anonymous (below). That includes public, whose name
is about the shared pool, not about the front door. A staff collection additionally fails
closed: with nobody able to be staff, every read of it is refused, so it needs Sign-in on
to mean anything.
Letting strangers in
Section titled “Letting strangers in”anonymous is how a collection says what someone with no account may do. Leave it out and
the answer is nothing — which is the default, because a collection the whole internet can
reach is a decision worth writing down.
| Value | A signed-out visitor may |
|---|---|
| (omitted) | nothing — every method gets 401 |
"read" |
GET. Writing still needs sign-in. |
"write" |
POST, and nothing else — they cannot read the collection back |
"read-write" |
both |
{ "collections": { "posts": { "scope": "public", "anonymous": "read", "fields": { "title": "string" } }, "contact": { "scope": "shared", "anonymous": "write", "fields": { "email": "string" } } }}"write" is the shape of a contact form, a waitlist or an order: the visitor hands
something in and cannot read anyone else’s. "read-write" is a guestbook — reach for it
last, and turn Bot protection on when you do.
Every anonymous visitor is one and the same person as far as the store is concerned, so
anonymous records all land in a single space and nothing separates one stranger’s from
another’s. That is why anonymous is refused on team and staff collections, which are
addressed by who you are. An app with no sign-in at all still needs this on each
collection: the app being open does not make its data open.
Writes are capped at 120 per minute per caller — counted per person when they are
signed in, per address when they are not — and a caller past it gets a 429. So a script
cannot fill your database through a public form faster than that, and the cap is the floor
under your bill rather than the whole answer: Bot protection is what
keeps the script from getting a row in at all. Reads are not capped.
On public collections the owner field is also queryable: ?owner=me for the caller’s
own records, ?owner=<id> for anyone’s, and groupBy=owner in aggregates — which is how
a leaderboard is one query.
Share: who sees one record
Section titled “Share: who sees one record”Scope decides who can reach a collection. Sharing decides who can reach a record —
“share this doc with Sam”, “invite a client to this one project”, “assign this ticket to
Kim”. Add "share": true to a user or team collection:
{ "collections": { "docs": { "share": true, "fields": { "title": "string", "body": "string" } } }}The owner of a record grants it to an address:
const docs = collection("docs");
await docs.share(id, "sam@acme.com", { write: true });
await docs.sharedWith(id);// → { shared: [{ to: "sam@acme.com", write: true, createdAt: 1750000000000 }] }
await docs.unshare(id, "sam@acme.com");// → { revoked: true }From then on the record just appears in Sam’s ordinary list. There is no second
endpoint and no “shared with me” view to build — docs.list() returns their own records
and the ones granted to them, and every record of a shareable collection carries owner so
a screen can tell them apart:
await docs.list(); // yours + shared with youawait docs.list({ owner: "me" }); // only yoursFilters, ?q= search, sorting, cursors and _aggregate all follow the grant, so a count
never contradicts the page it summarises.
| Rule | Why |
|---|---|
| A grant names an address, not an account | Share with someone who has not signed up yet and it is waiting the day they do — the same rule as a team invite. A forwarded link grants nobody. |
| Only the owner (or staff) may grant | A grantee cannot pass the record on. Re-sharing is a 403; someone with no relationship to the record gets a 404, so ids cannot be probed. |
write: true edits the owner’s record |
Not a copy. Two people editing one doc see one doc. Without it, a grantee may only read. |
| Revoking, and deleting, are immediate | A revoked grantee loses the record on their next request; deleting the record takes its grants with it. |
| Needs Sign-in on | An address is who a grant names, and a proven address is what sign-in gives you. |
| At most 100 people per record |
A Realtime socket is the one thing that does not follow a grant: a socket watches one space, so a grantee’s socket carries their own records only. Refresh a shared view on its own terms, and never promise it live — the same caveat as a staff screen.
Staff read everything
Section titled “Staff read everything”A signed-in user whose role is staff reads every user’s records in a user
collection, and can change or delete anyone’s record in a public one. Nothing about
the request changes: the same GET /api/_data/<collection> that returns a member their own
slice returns staff the whole collection, each record carrying owner so the screen can
say whose it is — and ?owner=<id>, ?sort=owner and groupBy=owner work there too.
So an admin screen is the ordinary list your app already makes, rendered behind a role check. There is no second endpoint, no admin key, and nothing for the app to authorize itself with. You appoint staff in the Auth tab; the app cannot promote anybody from its own code.
Two honest edges. A staff ?q= search over a user collection ranks by words alone, even
where the collection has "meaning": true, because the meaning half is indexed per user.
And a ?data= Realtime socket still follows the caller’s own records
on a user collection, so an all-users staff view refreshes rather than streams.
Notify: the app tells you
Section titled “Notify: the app tells you”A collection declared with "notify": true emails you — the human who owns the
project — a short heads-up whenever a record is created in it:
{ "collections": { "signups": { "notify": true, "fields": { "email": "string", "note": "string" } } }}This is what makes a waitlist, a contact form, an order button, or a feedback box complete: the visitor submits, the record lands, and you hear about it without polling the console. The email goes to the project owner’s account address, carries the new record’s fields (long values truncated), and its subject is fixed by the platform — a visitor never chooses who is mailed or what the subject says.
The mail leaves from your app, not from us. It goes out through the same Email capability the app sends everything else with, on your own Cloudflare account — so the entry’s fields never reach Typillar. That also means notify is silent until Email is on, and Email needs a domain you own. Until then the records are simply there in the console, which reads the app’s live data; the Data pane says as much and names the collections that are waiting.
Delivery is best-effort and throttled (10 a minute per collection — bursts collapse and
the record itself is never at risk), and it fires only on creates (POST) — not on
updates, imports via _batch, or records delivered by
Scheduled jobs. Use it where a submission deserves your attention,
never as a data channel.
Gate: paid collections
Section titled “Gate: paid collections”A collection declared with "gate": "<Stripe product id>" is paid: every read and
write requires a signed-in user who has bought that product through
Payments:
{ "collections": { "lessons": { "scope": "public", "gate": "prod_ABC123", "fields": { "title": "string", "video": "string" } } }}A signed-out caller gets a 401; a signed-in non-buyer gets a 403 {"error":"this needs a purchase","product":"prod_ABC123"} — the same contract as a
gated asset, so the app renders the same way out: a buy button
whose checkout uses the price belonging to that product. The gate holds everywhere the
collection is reachable — the record API, aggregates, batches, scheduling into it, and
Realtime ?data= sockets.
Scope still applies among buyers: user gates each buyer’s own records (a pro feature),
shared gates one pool all buyers share (a paid community), public + gate is premium
content every buyer can read. It needs Sign-in and
Payments on — an entitlement is checked in the app’s own
database, written there by Stripe’s webhook, never by the browser.
The API
Section titled “The API”Records live at /api/_data/<collection>, served from your app’s own origin — but the app
does not write those fetches. It imports data, and the module calls them:
import { collection, inc, batch, DataError } from "data";
const todos = collection("todos");
// Create a record — returns the whole recordconst todo = await todos.create({ title: "Ship the docs", done: false });// → { id: "rec_…", title: "Ship the docs", done: false, createdAt: …, updatedAt: … }
// Read one — the SAME shapeconst one = await todos.get(todo.id);
// List — items have the SAME shape againconst page = await todos.list({ done: false, sort: "-createdAt" });// → { items: [ { id, title, done, createdAt, updatedAt }, … ], next }
// Deleteawait todos.delete(todo.id);Every ?key=value in the endpoint table below is one { key: value } in a query object, and
a key whose value is undefined or null is left out — so an optional filter needs no if.
A failed request throws a DataError carrying .status and the server’s own message.
Every record is flat. Fields sit directly on the record — never nested under a data
key — and a list item, a single get, and the result of a write all have exactly the same
shape.
| Method & path | Body | Returns |
|---|---|---|
POST /api/_data/:collection |
{...fields} |
201 {id, ...fields, createdAt, updatedAt}; 409 if the id or a unique field is taken |
GET /api/_data/:collection |
— | {items: [{id, ...fields, createdAt, updatedAt}], next} |
GET /api/_data/:collection/:id |
— | {id, ...fields, createdAt, updatedAt} or 404 |
PUT /api/_data/:collection/:id |
{...fields} |
the full replacement — an upsert, so retries and imports land exactly once |
PATCH /api/_data/:collection/:id |
{...fields} — a number field takes {"inc": n} |
the merged record; the merge and every inc are atomic in the database |
DELETE /api/_data/:collection/:id |
— | 204 |
POST /api/_data/_batch |
{ops: [{collection, op: "put"/"patch"/"delete", id?, fields?}]} |
{results} — one transaction across collections, all or nothing |
GET /api/_data/:collection/_aggregate |
— | {groups: [{...groupBy, <agg>_<field>}]} |
GET /api/_data/:collection/_export |
— | a csv download, or a json array with ?format=json — the same rows a read returns, filters and all |
POST /api/_data/:collection/_import |
a csv body, or a json array — content-type says which |
{"imported":n,"results":[…]} — every row upserted |
POST /api/_data/:collection/:id/_share |
{"to":"sam@acme.com","write?":false} |
201 {"shared":{"to","write"}} — owner only; grants one record to one address |
GET /api/_data/:collection/:id/_share |
— | {"shared":[{"to","write","createdAt"}]} |
DELETE /api/_data/:collection/:id/_share/:email |
— | {"revoked":true} — owner only |
GET /api/_data/_schema returns the declared schema.
An undeclared collection, an unknown field, a missing required field, a value outside its
enum, or a filter on a field you never declared is a 400 that names what is declared —
the contract is enforced, not guessed.
Writes that can’t race
Section titled “Writes that can’t race”POST creates, and never overwrites. A POST may carry an id in the body — that
becomes the record’s id instead of a generated rec_… — but posting an id that already
exists is a 409, not a silent replacement. (An id can’t start with _.)
PUT is the idempotent upsert. A retried request, or an import keyed by ids you
already hold, lands exactly once — the second write replaces the first instead of failing
or duplicating.
PATCH merges atomically. The merge happens inside the database in a single
statement, so two concurrent patches to different fields both land. A number field takes
{"inc": n} to add to whatever is stored — counters, votes and stock never
read-modify-write:
await collection("posts").patch(id, { views: inc(1) });_batch is one transaction, across collections. Up to 100 put/patch/delete ops
succeed or fail as a unit. Each op names the collection it writes, so an order and its line
items either all land or none do — no half-applied state to clean up:
await batch([ { collection: "orders", op: "put", id: "o_1", fields: { total: 4200 } }, { collection: "items", op: "put", fields: { order: "o_1", sku: "A" } }, { collection: "items", op: "put", fields: { order: "o_1", sku: "B" } }, { collection: "carts", op: "delete", id: "c_9" },]);// → { results: [ {...}, {...}, {...}, { id: "c_9", deleted: true } ] }Every collection in the batch is checked the way a direct write to it would be — its scope,
its gate, and whether you may write it at all. results answers in the order you sent.
Query on the server
Section titled “Query on the server”Filter, sort, paginate and aggregate in the database. Never pull every row and do it in the browser.
?<field>=<op>:<value> ops: eq ne gt gte lt lte like in has null (bare ?date=x means eq)?meta.color=navy dots reach into json fields; ?tags=has:sale matches inside a json array?due=null:true true = field unset, false = set; public collections: ?owner=me or ?owner=<id>?q=oak+chair searches the collection's "searchable" fields, best match first?meaning=off with ?q=, ranks by words alone and spends nothing on embedding?expand=author a "ref" field comes back as the RECORD, not the id (max 3)?sort=-createdAt "-" = descending; default -createdAt (newest first)?limit=50&cursor=<next> pass back the "next" you got; null means no more pages?sum=kcal&groupBy=date on /_aggregate; aggregates: count sum avg min maxjson fields are queryable, not opaque: a dotted path (?meta.color=navy) filters, sorts,
groups and indexes inside them, has: matches inside a json array (?tags=has:sale), and
null: finds records with a field unset (true) or set (false).
// Today's calories, grouped by meal — one query, computed in SQLiteconst { groups } = await collection("entries").aggregate({ date: "2026-07-13", sum: "kcal", count: 1, groupBy: "meal",});// → { groups: [ { meal: "breakfast", sum_kcal: 200, count: 2 }, … ] }Lists are keyset-paginated: pass the returned next back as ?cursor= for the following
page. When next is null, there are no more pages. limit is 1–500, default 50.
Getting data out, and back in
Section titled “Getting data out, and back in”Every collection exports itself. No code builds a CSV in the browser, and none parses an uploaded one.
const entries = collection("entries");
// A download link — point it straight at the url the module builds<a href={entries.exportUrl()} download>Export CSV</a>
// The same rows as JSONconst rows = await fetch(entries.exportUrl({ format: "json" })).then((r) => r.json());An export carries exactly the rows a read would, so every filter, search and sort works on it
too — ?status=live&sort=-createdAt exports what that screen shows, and nothing a person
cannot already see. It streams, so a large one never has to fit in memory.
Importing is the same endpoint backwards:
await entries.importRows(await file.text());// → { "imported": 120, "results": [ … ] }importRows() sends a string as CSV and an array as JSON, so neither content type is yours
to remember.
What an export writes, an import reads. The CSV header is the collection’s declared fields,
and every row is upserted by its id column — so re-importing a file you exported changes
nothing, and importing an edited one updates exactly the rows that changed. Drop the id
column, or leave it empty, and each row becomes a new record instead.
Every cell is coerced to its declared type, and every row is checked against the schema before any of them is written — a file with one bad row writes nothing at all, and the 400 names the row. An import is at most 5,000 rows and 5 MB; an export at most 50,000 records. Past either you get a 413 that says what it matched, so narrow it with a filter.
What other capabilities store
Section titled “What other capabilities store”Orders and entitlements are not collections you write — they’re written by Stripe’s
webhook alone, and read at GET /api/_pay/orders and GET /api/_pay/entitlements. See
Payments.
Received mail, by contrast, is a collection. Each mailbox you configure in Email writes arriving messages into the collection you name, so you read, filter, sort and subscribe to your mail exactly as you do everything else.
Search
Section titled “Search”Search is not a separate store you keep in step with your records, and not something to
switch on. It is a property of a collection: you say which fields are searchable, and the
route you already read gains ?q=.
{ "collections": { "products": { "scope": "public", "searchable": { "fields": ["name", "blurb"] }, "fields": { "name": "string", "blurb": "string", "brand": "string" }, "indexed": ["brand"] } }}const { items, next } = await collection("products").list({ q: "oak chair", brand: "acme", limit: 20,});That is the whole API. Records are searchable the instant they are written — the index lives in the same database, updated in the same transaction, so there is no lag to design around and nothing to keep in sync.
The first field carries the most weight
Section titled “The first field carries the most weight”searchable.fields is ordered, and the order is the ranking weight. The first field is the lead —
a product’s name, a post’s title — and a match there outranks a match in the supporting
text. Put the name first and a search for “leather” finds the Leather chair before the desk
lamp whose description happens to mention one.
Only string fields can be searchable. Numbers, booleans and dates are for filtering, which
q composes with: { q: "chair", brand: "acme", price: "lt:20000" } searches, narrows and
ranks in one query.
Facets
Section titled “Facets”?q= also works on _aggregate, so a filter sidebar counts within the search, not across
the whole collection:
await collection("products").aggregate({ q: "chair", groupBy: "brand", count: 1 });// → { groups: [ { brand: "acme", count: 42 }, { brand: "bolt", count: 17 } ] }Paging
Section titled “Paging”Results come back best-match first, and page with the same next cursor as any other list.
Relevance paging stops at 200 results — past that next is null. Nobody reaches page
eleven of a search; if they would, they need a filter, not another page.
Ask for an explicit sort and q becomes a plain filter, ordered your way instead of by
relevance.
What the visitor types is text, never syntax
Section titled “What the visitor types is text, never syntax”Whatever someone types is treated as words to find. Search operators, quotes and punctuation
carry no special meaning and cannot produce an error — you can bind an input straight to ?q=
without sanitising it first. An empty or whitespace-only q is not a search at all: it lists,
so a cleared search box shows the full list again.
Words match on their stem, so a plural finds the singular and a tense finds its verb: chairs
finds the Oak chair, run finds the Running shoes, and neither needs the person searching to
guess which form you happened to store. The last word they typed also matches as a prefix, so
results narrow while they are still typing it.
Ranking by meaning
Section titled “Ranking by meaning”Everything above ranks by words. A collection can also rank by meaning, and asking for it changes nothing you write — same route, same query, same shape of results. It is declared where the cost is, collection by collection:
"searchable": { "fields": ["name", "blurb"], "meaning": true }Those records are embedded as well as indexed, and the word match is blended with a meaning
match, so a search for “couch” finds the record that only ever says “sofa”. A collection that
leaves meaning out goes on ranking by words alone and costs nothing to run.
Declaring meaning is what creates the vector index, on the next ship, in your own Cloudflare
account. There is no separate switch to find: the schema is the switch.
Because the word half is transactional, a new record is always findable immediately; the meaning half catches up within a few seconds and only ever improves the order.
Turning meaning on for a collection that already holds records backfills them in the
background — the Data tab shows the progress as “420 of 5,000 understood”, and until it
finishes those records still rank by words. Turning it off stops the cost and discards what was
embedded; the fields stay searchable, by words.
The search box
Section titled “The search box”A box someone types into is not one request, it is a race. Keystrokes overlap, the answer for
ch can arrive after the answer for chair, and a list that renders whatever landed last shows
results for what was typed two keystrokes ago. searchBox owns that timing so you never write
it:
import { searchBox } from "data";
const box = searchBox("products", { onResults: (items) => { found = items; }, onBusy: (b) => { busy = b; }, filters: () => ({ brand }),});<input oninput={(e) => box.type(e.target.value)} /><button onclick={() => box.submit()}>Search</button>box.type(value) on every keystroke — debounced, and ranked by words alone, so a keystroke
embeds nothing and costs nothing even on a meaning collection. box.submit() runs at once and
brings meaning in, where the collection declares it. box.clear() empties the box and lists
again; box.close() when the component goes away.
Between those two it cancels the request a newer keystroke replaced, and discards a slow answer
that would have landed after a newer one. filters is read fresh on every query, so the state of
your sidebar at that moment is what narrows the search.
It renders nothing at all — no markup, no styles, no classes. The input, the results, the empty state and every class on them are yours, so it carries none of its own look into your design.
Showing why a record matched
Section titled “Showing why a record matched”A record that came back from a search carries _match: a fragment of the text the query hit,
already split into the parts that matched and the parts around them.
[ { text: "a wide ", hit: false }, { text: "chair", hit: true }, { text: " for a long desk…", hit: false },]{#each item._match as part} {#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}It arrives split rather than as a string of markup on purpose: the surrounding text is whatever someone typed into your app, so a highlighted string would have to be rendered as HTML to show the mark, and that is an injection waiting to happen. Split, every part renders as text and the highlight is a class you choose.
The mark lands on the word the record holds, not the word that was typed — search chairs and
chair comes back marked. A record only the meaning half found has no _match at all, since no
word in it was hit. Neither does a plain list: with no q there is nothing to mark.
Who can search what
Section titled “Who can search what”Nothing new to learn: a search obeys the collection’s scope exactly as a list does. A user
collection searches only the caller’s records, shared searches the one pool every signed-in
user shares, and public is searchable by anyone, signed in or not. A collection gated on a
purchase stays gated — searching it needs the same entitlement reading it does.
How search is built
Section titled “How search is built”The word index is SQLite’s own full-text search, in the same database as your records and
maintained by the database itself, so it cannot drift from them. For a meaning collection,
meanings are stored in Cloudflare’s vector search and embedded with Workers AI, both in your
account, and the two rankings are combined into one order. You never handle a vector, a
dimension or an embedding model.
Live updates
Section titled “Live updates”When Realtime is on, every write to a collection is pushed to any client
subscribed to it over a WebSocket — so a list can re-render the moment a record changes,
with no polling. The socket follows the collection’s scope: a user collection pushes your
own changes, a shared or public one pushes everyone’s. You don’t wire anything up; it
rides on the same write. See Realtime.
How it’s built
Section titled “How it’s built”Data is backed by SQLite (Cloudflare D1) in your own Cloudflare account. Each project has
one database — <project>-db — and everything that persists lives in it: your records,
plus whatever Sign-in, Payments and
Email keep. Each indexed field becomes a real SQLite index — a
dotted json path becomes an expression index, and a unique rule becomes a unique index the
database enforces — so a filter or a sort is answered by the database rather than by
scanning. Every deploy shares that one database, so dropping a ticket branch never touches
your live data.