Concepts
Rules
dabadata's access rules read like membership: who may read, create, update and delete the rows of a table. They are small enough to say out loud, and every time one is applied dabadata tells you which part decided and why.
One rule per operation
Each table has up to four rules, one per operation:
| Op | Applies to | Checked against |
|---|---|---|
read | Lists, single-row reads and live subscriptions. | Each row (compiled to a filter, below). |
create | Inserts. | The values being inserted, after defaults and user ids are filled in. |
update | Updates. | The existing row, before the change. |
delete | Deletes. | The existing row. |
A missing create, update or delete rule means "nobody". read is the exception: it must be written out, even if it is "nobody", so that nothing becomes readable (or unreadable) by accident.
Calls made with the project key skip the rules entirely and are recorded as bypass; so are reads made with a view token. See Identity and tokens.
The grammar
Rule := "anyone" | "authenticated" | "nobody"
| { "role": string | string[] } user.role is in the list
| { "owner": "<column>" } row[column] == user.id
(create: values[column] == user.id;
if absent it is filled with user.id)
| { "where": "<condition>" } the condition holds (below)
| { "via": { "table", "match", "where"? } }
a matching row of another table exists
(relations, below)
| { "any": Rule[] } at least one branch passes
| { "all": Rule[] } every branch passes| Rule | Passes when |
|---|---|
"anyone" | Always, including callers with no token. |
"authenticated" | The caller has a user token (any role). |
"nobody" | Never. Only the project key gets through, as a bypass. |
{ role: "admin" }{ role: ["admin", "editor"] } | The caller's role is in the list. "anonymous" may be listed to let callers with no token in. |
{ owner: "owner" } | The named column holds the caller's id. The column must be a userId column. On create it is checked on the inserted values - and since a missing userId column is filled with the caller's id, leaving it out passes. Anonymous callers own nothing. |
{ where: "status = 'published'" } | The condition holds on the row (or, on create, the values) at the instant of the call. See Row conditions. |
{ via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } } | A row of the membership table exists whose match columns hold those values. See Relations. |
{ any: [ ... ] } | At least one branch passes. |
{ all: [ ... ] } | Every branch passes. |
Rules nest. Here admins may update any row, and editors only the rows they own; a viewer who owns a row still may not:
update: {
any: [
{ role: "admin" },
{ all: [{ role: "editor" }, { owner: "owner" }] },
],
},Row conditions
A rule may decide on the row's values and on the time. { where: "<condition>" } is a rule node like role and owner, and combines with them inside any and all:
posts: {
columns: { title: t.text(), status: t.text({ default: "draft" }), author: t.userId() },
rules: {
read: { any: [{ where: "status = 'published'" }, { owner: "author" }] }, // published, or your own
create: "authenticated",
update: { all: [{ role: "editor" }, { where: "status = 'draft'" }] }, // editors, and only drafts
},
},Every rule still compiles to SQL for lists, and the same semantics run in JavaScript for single rows, writes and live fan-out - the two give identical answers.
condition := or
or := and ("or" and)*
and := unary ("and" unary)*
unary := "not" unary | "(" condition ")" | "true" | "false" | operand tail
tail := op operand
| "is" ["not"] "null"
| ["not"] "in" "(" operand ("," operand)* ")"
op := = | != | < | <= | > | >=
operand := <column> | $user.id | $user.role | $now | $today
| 'string' ('' is a quote) | number | true | falseKeywords (and or not is null in true false) are case-insensitive; column names are as declared. ==, <>, functions, casts and subqueries do not exist. Some conditions:
status = 'published'
publish_at <= $now and (expires_at is null or expires_at > $now)
role_needed in ('member', 'admin')
author = $user.id or $user.role = 'admin'
due >= $today| Operand | Type | Notes |
|---|---|---|
| a column | its type | The table's declared columns, plus id (text), created_by (userId), created_at and updated_at (datetime). json columns are refused. |
$user.id | text | Null for anonymous callers, so author = $user.id is false for them and $user.id is null tells them apart. |
$user.role | text | "anonymous" without a user token. |
$now | datetime | The instant of the call, from the server's clock. |
$today | date | The UTC date of $now. |
'string' | from the other side | A text, a date 'YYYY-MM-DD' or an ISO datetime, according to what it is compared with. |
| a number | int, float | An int column takes integers only. |
true, false | bool | With = and != only. |
- Types compare in families: int and float (numbers); text, userId and ref (text); bool; date; datetime. A date column compares with
$today, a datetime column with$now.$nowand$todaymay only be compared with a column or a constant, and two constants are never compared with each other. - Nulls:
a = b,<,<=,>,>=are true only when both sides are non-null and the comparison holds.a != bisnot (a = b), so it is true when either side is null.a in (x, y)is true only whenais non-null and equals an item;not inis its negation (true whenais null). A missing value is null; test it withis null/is not null(= nullis refused). - Ordering: text by Unicode code point (the same in SQL, with
collate "C"), numbers by value, dates asYYYY-MM-DD, datetimes as instants at millisecond precision (offsets honoured). - Which row: on
createthe condition reads the values being inserted, after defaults and the caller'suserIdcolumns are filled (system columns are null - the row does not exist yet). Onread,updateanddeleteit reads the row as it is before the write; columnupdaterules read the same row. - Safe SQL: every value is a typed bound parameter; nothing from the definition is pasted into the query text, and
$nowis bound from the call's instant, never SQLnow().
Roles and conditions together. A role and a condition: { all: [{ role: "editor" }, { where: "status = 'draft'" }] }. A condition or the owner: { any: [{ where: "status = 'published'" }, { owner: "author" }] }. A condition alone may test the caller too: author = $user.id or $user.role = 'admin'.
Rules without a row. Some rules are decided before any row exists: actions.<name>.allow, a view's allow, auth.users.invite.rule, auth.users.manage, auth.users.profiles.read, trace.read and the storage bucket rules. A condition there may read $user.*, $now and $today but no column (columns do not apply here (there is no row)). { where: "$now < '2026-12-01T00:00:00Z'" } as an action's allow closes it at a date.
What writes answer. POST /v1/mutate, every op of POST /v1/transact and the MCP write tools answer with the row only when the caller's read rule lets them read it (after the write; for a delete, before it). Otherwise the row is { "id": "<id>" }. The secret key, view tokens and console sessions always get the full row; actions keep answering null for a step row the caller may not read.
Time-based visibility
A condition with $now or $today makes rows appear and disappear by themselves: reveal_at <= $now lets a row in when reveal_at passes, expires_at > $now takes it away. dabadata follows those instants everywhere:
- One instant per call. A list, a single read, a write (its rule, its
expect409 body, the row it answers with), a whole/v1/transactand an action read the clock once. Lists are exact at that instant, at any table size. - Live. A row that enters a subscriber's view by time arrives as a
changewithop: "update"(treat it as an insert when the id is new); a row that leaves arrives asop: "delete"with{ id }. They come within about a second of the instant, with no write. These pushes write no trace row; the realtime log records each pass as afanoutwith detailtime. See Live subscriptions. - Catching up.
GET /v1/db/<table>?since=<seq>for a caller whose read depends on time answers{ "reset": true, "cursor", "traceId" }and no rows: time moves rows in and out without a new_seq, so no delta can say what changed. Load the rows again. - Testing.
POST /v1/simulatetakes anow; the console's Rules page calls it Evaluate at. Ask "what will a player see at 20:01?" before 20:01 comes.
Column read rules may compare with $now as well (below): a friend's score prediction can stay hidden until the game starts and then reach every open screen at once. The full pattern is on Access patterns.
Validation
A definition with a broken rule is rejected as a whole, with one message per problem, each naming where it is:
tasks: no read rule (rules.read must be explicit, even "nobody")
tasks.rules.delete: unknown role "superuser"
tasks.rules.update.any[1]: owner column "author" does not exist
tasks.rules.update.any[1]: owner column "title" is not userId
tasks.rules.create: unknown rule "everyone" (expected anyone, authenticated, nobody or an object)
tasks.rules.read: malformed rule (an object rule has exactly one of role, owner, where, via, any, all)
tasks.rules: unknown op "insert" (expected read, create, update, delete)
tasks.rules.read.where: unknown column "stauts"
tasks.rules.read.any[0].where: use "is null" instead of "= null"
tasks.rules.update.where: column "due" is date; compare it with $today
tasks.rules.read.where: unknown operator "==" (use =)
tasks.notes.read: unknown role "boss"
tasks: unknown key "acess" (expected columns, rules, access, unique, checks)
tasks.title: unknown key "reed" (expected type, table, optional, default, unique, onDelete, update, ops, read)Unknown keys of a table or a column are refused, because a typo like reed or acess would otherwise silently weaken a rule. A condition is checked against the table's columns and types; its messages are exact (column "done" is bool; use = or !=, "n" is int; compare it with an integer, $now may only be compared with a column or a constant, compares two constants, functions are not allowed ("lower(")), and a condition is at most 1000 characters, 50 comparisons and 20 levels deep.
The same checks cover the rest of the definition: unknown column types, reserved or malformed names, and a ref to a table that does not exist. You see these messages from defineProject itself, in the 400 from POST /v1/schema/reload, and in the server log at start.
How read rules become a row filter
Checking a read rule row by row would mean loading rows the caller may not see. Instead, dabadata compiles the read rule, for the caller and the instant of the call, into one SQL condition:
anyone,authenticated,nobodyandrolebranches - and conditions that read no column, such as$user.role = 'admin'- depend only on who is calling and when, so they are decided right away, true or false.- An
ownerbranch becomescolumn = <user id>in the SQL query. - A
wherebranch becomes its SQL, every value a bound parameter. anybecomes OR andallbecomes AND, nested as written. A false branch contributes nothing to anany, and denies everything in anall. A demo room user's filter is the rule AND their room.
So with read: { any: [{ role: "admin" }, { owner: "owner" }] }, an admin gets every row with no filter, bob (an editor) gets where owner = 'bob', and a caller with no token gets nothing at all - the query is not even run. With read: { any: [{ where: "status = 'published'" }, { owner: "author" }] }, u7 gets where status = 'published' or author = 'u7' and an anonymous caller where status = 'published'.
Every rule compiles, so lists, ?since= catch-ups and subscriptions are exact at any table size. (Rules that mixed AND inside OR used to be checked row by row over at most 10,000 rows, explained as rule evaluated per row; that path is gone.)
When the rule leaves nothing a caller could ever read, a list answers with no rows (and the trace records denied), a live subscription is refused with a forbidden error, and a single-row read of an existing row answers 403.
Explanations
Every evaluation returns a decision - allowed, denied or bypass - and one sentence that names the path through the rule:
update tasks: allowed by any[1] owner(owner): row.owner "u7" == user "u7"
delete tasks: denied, role "viewer" not in ["admin"]
read tasks: bypass (service key)- An allowed decision names the branch that passed:
any[1]is the second branch of theany. - A denied
anylists every failed branch, joined with;, e.g.update tasks: denied, any[0] role "editor" not in ["admin"]; any[1] row.owner "alice" != user "bob". - A denied
allnames its first failed branch. - A list read through a filter says what the filter was:
read tasks: allowed, rows where owner == "bob",read posts: allowed, rows where status = 'published' or author == "u7", with nested groups in parentheses (rows where (status = 'published' or author == "u7") and demo_room == "r1"). - A condition prints as
where(<condition>), then the comparison that decided, with each operand's value:row.status "draft"(on createvalues.status "draft"),user "u7"(anonymous:user null),role "member",now "2026-10-16T19:00:00.000Z",today "2026-10-16", constants as written, andis true/is falseat the end. Anornames its first true part (or every false one), anandits true parts (or the one that failed). - A column the caller may not read prints
(hidden)instead of its value:row.internal_notes (hidden) != 'archived' is false. The decision still uses the real value; the logic is visible, the data is not. - A bypass says why:
(service key)or(view token).
read posts: allowed by any[0] where(status = 'published'): row.status "published" = 'published' is true
read posts: denied, any[0] where(status = 'published'): row.status "draft" = 'published' is false; any[1] row.author "u3" != user "u7"
read notices: denied, where(publish_at <= $now and (expires_at is null or expires_at > $now)): row.publish_at "2026-10-16T20:00:00.000Z" <= now "2026-10-16T19:00:00.000Z" is false
read memos: denied, where(internal_notes != 'archived'): row.internal_notes (hidden) != 'archived' is falseThe explanation is in the 403 body of a denied call, in the error message of a denied subscription, on the client's DabadataError, and on the call's row in the trace.
The simulator
To ask "would this person be allowed?" without changing anything, use POST /v1/simulate with the project key or a view token. Give it a user (any role, including anonymous), a table, an op and the row or values to judge. row may be a row object or the id of a real row, which is then loaded. Simulations are not recorded in the trace.
curl -X POST https://dabadata.com/v1/simulate \
-H "Authorization: Bearer <project key>" \
-H "Content-Type: application/json" \
-d '{"user":{"id":"bob","role":"editor"},"table":"tasks","op":"update","row":{"owner":"alice"}}'{
"decision": "denied",
"explanation": "update tasks: denied, any[0] role \"editor\" not in [\"admin\"]; any[1] row.owner \"alice\" != user \"bob\"",
"rule": { "any": [{ "role": "admin" }, { "owner": "owner" }] }
}The viewer's Rules page is built on the same endpoint: a matrix of every role against every table and op, and a simulator form. The MCP server exposes it as the simulate tool.
With "op": "update" and a values object, the simulator applies the column rules below too.
At another instant. Add "now": "2026-10-16T20:00:00Z" (an ISO datetime with a zone) to evaluate the rules at that instant; the answer then carries now, the instant used. Anything else is a 400 now must be an ISO datetime. In the console this is the simulator's Evaluate at field.
Column by column. A read of a given row on a table with column read rules adds columns: one decision and explanation per such column, headed read <table>.<column>:. A column is visible when both the row and the column pass.
{
"decision": "allowed",
"explanation": "read questions: allowed by role: role \"player\" in [\"player\",\"host\"]",
"rule": { "role": ["player", "host"] },
"columns": {
"answer": {
"decision": "denied",
"explanation": "read questions.answer: denied, any[0] role \"player\" not in [\"host\"]; any[1] where(reveal_at <= $now): row.reveal_at \"2026-10-16T20:00:00.000Z\" <= now \"2026-10-16T19:00:00.000Z\" is false"
}
},
"now": "2026-10-16T19:00:00.000Z"
}Column rules
A table's update rule may be narrowed per column. A column's own update rule replaces the table's for that column, and ops limits writes of that column to those operators:
"posts": {
"columns": {
"title": { "type": "text" },
"owner": { "type": "userId" },
"likes": { "type": "int", "default": 0, "update": "authenticated", "ops": ["$inc", "$dec"] },
"pinned": { "type": "bool", "default": false, "update": { "role": "admin" } },
"taken": { "type": "int", "default": 0, "update": "nobody" }
},
"rules": { "read": "anyone", "create": "authenticated", "update": { "owner": "owner" } }
}- Anyone signed in may send
{ "likes": { "$inc": 1 } }- but not{ "likes": 1000 }(a plain value counts as$set) and not{ "likes": { "$max": 5 } }. - Only the owner may change
title(the table rule decides columns without their own rule), only adminspinned, and nobodytaken- only an action can change it. - An update touching several columns must pass the rule of every column it touches.
{ "likes": { "$inc": 1 }, "title": "x" }from someone who does not own the post is denied as a whole.
ops takes the operators of the column's type: $inc $dec $min $max $set for int and float, $min $max $set for date and datetime, $append $remove $set for json, $set for the others. A column may have ops without its own update rule (the table rule then decides who, ops how).
The denial names the column:
update posts: denied, column likes: a plain value ($set) is not allowed (ops: $inc, $dec)
update posts: denied, column pinned: role "member" not in ["admin"]
update posts: denied, column title (table rule): row.owner "alice" != user "bob"Column rules apply to updates through /v1/mutate, /v1/transact and the MCP T_update tools. Inserts take the table's create rule (bound the inserted values with checks and defaults); the secret key and action steps skip column rules like any rule.
Column read rules
A column may carry its own read rule: who may see that column's value. It uses the whole rule grammar - role, owner, where with $now, any, all - over the table's columns:
questions: {
columns: {
text: t.text(),
reveal_at: t.datetime(),
answer: t.text({ read: { any: [{ role: "host" }, { where: "reveal_at <= $now" }] } }),
internal_notes: t.text({ optional: true, read: { role: "admin" } }),
api_key: t.text({ optional: true, read: "nobody" }),
draft: t.text({ optional: true, read: { owner: "author" } }),
author: t.userId(),
},
rules: { read: "authenticated", create: { role: "host" } },
},In JSON a column read rule sits beside type: { "type": "text", "read": { "role": "admin" } }.
- An extra requirement. The table's read rule decides the row; the column rule then decides the value. It never widens access: a row the table rule hides stays hidden.
- The key is left out. Where a caller's column rule fails on a row, that row comes back without the key - absent, not null.
id,created_at,updated_at,created_by,_seqanddemo_roomare always present (demo_roomrefuses a read rule). - Per row and per instant.
ownerand conditions on columns are decided on each row at the call's$now;role,anyone,authenticatedand conditions without a column once per caller. - Writes are not limited. A caller may insert or update a column it cannot read (its write rules decide); the row the write answers with leaves it out.
- Bypass. The secret key, view tokens and console sessions see every column. Inside an action's steps nothing is hidden or refused; the rows an action returns are projected for the caller.
Every exit is projected: lists, ?since=, single rows, the rows /v1/mutate, /v1/transact and actions answer with, 409 bodies, views, live rows and changes (both transports), the MCP table, transact and action tools, and MCP describe_app, which leaves out columns the caller can never see.
No filtering on what you cannot see. A column whose read rule is not statically true for the caller - nobody, another role, or one that depends on the row (owner, a where on a column), even on rows where it would pass - may not be named where the caller could learn its value from which rows come back. These answer 400 { "error": "validation", "messages": [...] }:
where: column "answer" is not readable by this caller
orderBy: column "answer" is not readable by this caller
expect: column "answer" is not readable by this caller
ifAbsent: column "answer" is not readable by this callerThat covers where on lists, ?since=, subscriptions and MCP <table>_list; orderBy; expect on mutate, transact (check ops too) and MCP _update; and ifAbsent. The check comes after the unknown-column check, and does not apply to bypass identities or inside an action's steps. A refused subscription answers { "type": "error", "id", "error": "where: column \"answer\" is not readable by this caller" }.
Explanations and the trace. A column that is not statically readable prints row.<column> (hidden) in every explanation - 403 texts, trace rows and the simulator. Callers who read the trace through trace.read get each row without what they may not read: values, before, after and a check's expectations lose the hidden columns. The secret key, view tokens and console sessions read the trace unchanged.
Live. Each subscriber gets rows and changes in their own projection. An update that only touches columns a subscriber cannot see is not sent to them, and a column that appears or disappears by time arrives as an update carrying the whole visible row (replace it, do not merge). See Live subscriptions.
Relations
A via rule grants access through another table: "a row of team_members links this task's team to the caller". It is a rule node like role or where, so it combines with any and all:
tasks: {
columns: { team: t.ref("teams"), title: t.text() },
rules: {
read: { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } },
create: { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } },
update: { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" }, where: "role = 'editor'" } },
delete: { any: [{ role: "admin" }, { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" }, where: "role = 'editor'" } }] },
},
},tableis the membership table, a declared table.matchnames at least one of its columns, each with a value:"$row.<column>"(the row the rule is about;$row.idis a ref to this table),"$user.id","$user.role"or a literal ("editor",2,true). The types must fit, as for action placeholders: a row'sref(projects)fits aref(projects)column, a plaintextcolumn does not.whereis an optional condition on the membership row (its columns,$user.*).$row.*belongs inmatch, and$now/$todayare not supported there yet - put time conditions on the row itself:all: [{ via: ... }, { where: "due_at > $now" }].- On create
$row.teamreads the value being inserted: members add tasks to their own teams only. - One hop. The membership table's own rules and
accessare not evaluated: the lookup reads it with the server's rights, so a strict membership table works too. Protect who may write the membership table with its own rules, or make it strict and add members through an action. - Anonymous callers have no id: a relation on
$user.idnever holds for them. In a demo room the lookup stays in the room. - Where it applies. Table rules, column rules (
readandupdate) and viewrows. Rules without a row refuse it:actions.go.allow: via does not apply to actions (there is no row).
Lists run the relation inside the query (a correlated exists); a single row, a write (inside its transaction, after the row lock), a transaction's check, the rows writes and actions answer with and the simulator look each relation up once per request. Declare a unique constraint on the membership table's match columns - unique: [["team", "user"]] - so the lookup has an index and each person is in a team once.
Explanations name the lookup:
read tasks: allowed by via team_members(team = row.team "t1", user = user "u7"): a matching row exists
update tasks: denied, via team_members(team = row.team "t1", user = user "u7", where role = 'editor'): no matching row
create tasks: denied, via team_members(team = values.team "t2", user = user "u7"): no matching row
read tasks: denied, via team_members(team = row.team "t1", user = user null): anonymous has no id
read tasks: allowed, rows where via team_members(team = row.team, user = "u7")Validation messages carry the path: tasks.rules.read.via.table: unknown table "x", tasks.rules.read.via.match: must name at least one column, tasks.rules.read.via.match.team: "$row.title" is text, the column team_members.team is ref(teams), tasks.rules.read.via.match: "$row.nope": "nope" is not a column of tasks, tasks.rules.read.via.where: $row.team belongs in match.
Live. A membership change reloads only that user's subscriptions that read through the relation: they receive their rows again with "reason": "relation" (on the realtime tier, { "type": "resync", "table" } on the user's own lane). A row moving to another team is an ordinary change. See Access patterns: teams for the whole worked example.
Actions: protecting counters, capacity and stock
A rule decides who may write a row, not what the write means. With update: "authenticated" on a seat counter, an honest client sends { "$inc": 1 } in the same transaction as its RSVP - and a dishonest one sends { "taken": 0 } with one curl, or inserts an RSVP without touching the counter. Atomic operators and transactions keep honest clients correct under concurrency; they cannot stop a client that skips them.
An action is a transaction declared in the definition: a fixed list of steps the client calls by name with parameters. The steps run with the server's rights (the tables' rules are not evaluated for them); the action's own allow rule decides who may call it. Give the counter table no client writes at all, and change it only through actions:
{
"roles": ["member", "admin"],
"tables": {
"events": {
"columns": { "title": { "type": "text" }, "creator": { "type": "userId" } },
"rules": { "read": "anyone", "update": { "owner": "creator" }, "delete": { "owner": "creator" } }
},
"event_stats": {
"columns": { "capacity": { "type": "int" }, "taken": { "type": "int", "default": 0 } },
"checks": { "taken_range": "taken >= 0 and taken <= capacity" },
"rules": { "read": "anyone" }
},
"rsvps": {
"columns": {
"event": { "type": "ref", "table": "events", "onDelete": "cascade" },
"user": { "type": "userId" }
},
"unique": [["event", "user"]],
"rules": { "read": "anyone" }
}
},
"actions": {
"create_event": {
"allow": "authenticated",
"params": { "title": { "type": "text" }, "capacity": { "type": "int" } },
"steps": [
{ "op": "insert", "table": "events", "values": { "title": "$params.title", "creator": "$user.id" }, "ref": "e" },
{ "op": "insert", "table": "event_stats", "id": { "$ref": "e" }, "values": { "capacity": "$params.capacity" } }
]
},
"rsvp": {
"allow": "authenticated",
"params": { "event": { "type": "ref", "table": "events" } },
"steps": [
{ "op": "check", "table": "event_stats", "id": "$params.event", "expect": { "taken": { "$lt": "$row.capacity" } } },
{ "op": "update", "table": "event_stats", "id": "$params.event", "values": { "taken": { "$inc": 1 } } },
{ "op": "insert", "table": "rsvps", "values": { "event": "$params.event", "user": "$user.id" } }
]
}
}
}event_stats and rsvps have no create, update or delete rule, so no client can write them directly. create_event makes the event and its stats row with the same id (an action's insert step may set id), and rsvp takes a seat:
const { results } = await db.action("create_event", { title: "Meetup", capacity: 30 });
await db.action("rsvp", { event: results[0].row.id });The check step locks the stats row and holds while taken < capacity; the $inc and the RSVP insert happen in the same transaction. Fifty people pressing the button at once fill exactly 30 seats; the others get a 409 conflict with index: 0 (the step that did not hold), and a second RSVP by the same person is a 409 at step 2 (the unique pair). A refused caller gets a 403 with the explanation (action rsvp: denied, anonymous is not authenticated).
Deleting an event works the same way: give events no delete rule and declare an action whose steps delete the event (its RSVPs follow through onDelete: "cascade") and its stats row; expect keeps it to the creator:
"delete_event": {
"allow": "authenticated",
"params": { "event": { "type": "ref", "table": "events" } },
"steps": [
{ "op": "delete", "table": "events", "id": "$params.event", "expect": { "creator": "$user.id" } },
{ "op": "delete", "table": "event_stats", "id": "$params.event" }
]
}Placeholders. Step values may be $params.<name>, $user.id, $user.role, $user.name, $now, and in expect $row.<column> (the locked row's own value). Any other string starting with $ is a validation error, and so is a placeholder of the wrong type ("$params.title" is text, the column is int). A step is an op of /v1/transact: check, insert, update (operators allowed), delete, with expect, ifAbsent and ref; at most 20 steps.
Calling. POST /v1/actions/<name> with { "params": { ... } } and any credential the data routes take; db.action(name, params) in the client; one MCP tool action_<name> per action. The answer is { ok: true, results, traceId, traceIds }, one { row } per step (null when your read rule does not let you see that row). The trace shows one row op: "action" (name, params, decision) and one row per step marked with values._action; live subscribers get the changes after the commit.
Many rows at once. An update or delete step may name its rows with where instead of id: one step changes every row matching the condition, up to limit (1-1000, default 1000), oldest first. The condition is a row condition with $params, $user, $now and $today, and - only here - relative times: $now - 30 days, $now - 2 hours, $now - 1 month (calendar months, UTC), $today - 1 week. Rows beyond the limit stay for the next call; zero matches is not an error.
"expire_old_likes": {
"allow": { "role": "maintenance" },
"steps": [{ "op": "delete", "table": "likes", "where": "created_at < $now - 30 days" }]
},
"mark_stale": {
"allow": { "role": "maintenance" },
"steps": [{ "op": "update", "table": "profiles", "where": "updated_at < $now - 1 month", "values": { "stale": true }, "limit": 500 }]
}Each row goes through the same path as a one-row step (values and operators, checks, onDelete, live updates, webhooks) and gets its own trace row with values._bulk; one more trace row sums it up (row_count, values._where). The step's result is { "row": null, "count": 37 }. Run it on a timetable with a schedule. /v1/transact itself does not take where.
Calling a model. An action may end with one call to Claude (Anthropic), OpenAI or Gemini (Google), made by the server with your API key kept as a project secret: the definition fixes the model, the prompts and the token budget, the client sends only params, and the answer comes back in the response (ai) or is written into a row. See AI calls in actions.
Strict tables
A strict table is one end users never reach directly: they read it through views and write it through actions, and nothing else. Strictness is per table, with a project default, so one project mixes strict and open tables freely:
defineProject({
name: "trivia",
roles: ["player", "host"],
access: "open", // the project default (optional, default "open")
tables: {
questions: {
access: "views", // strict: no rules needed, end users never meet them
columns: { text: t.text(), answer: t.text(), reveal_at: t.datetime() },
},
chat: { // open: plain rules as before
columns: { body: t.text() },
rules: { read: "authenticated", create: "authenticated" },
},
},
views: {
open_questions: { table: "questions", allow: { role: ["player", "host"] }, rows: "anyone", columns: ["text", "reveal_at"] },
},
});accessis"open"or"views"on a table and on the project; a table's own value wins, and without either it is"open". Anything else:tasks.access: must be "open" or "views"(access: must be "open" or "views"for the project). A bucket'sfiles_<bucket>table and the profilesuserstable are always open.- On a strict table
rulesare optional; rules given are still validated, but they have no effect for end users. - It applies to end users: user tokens (any issuer, hosted users, demo room users) and anonymous callers. Not to the secret key, console sessions, view tokens, action steps or views.
- Reads answer 403
read questions: denied, questions is read through views only (access: views), also for an id that does not exist (no 404 says whether it does). Writes answer 403insert questions: denied, questions is written through actions only (access: views)(update/deletethe same), before the row is locked or the values are checked. - The same holds on every surface:
/v1/dblists,?since=and single rows,/v1/mutate, each op of/v1/transact(a check op as a read), table subscriptions on/v1/liveand the realtime tier, presence, the simulator for end-user roles, and the MCP user toolset (no table tools for it,describe_appleaves it out).GET /v1/schemashows end users the open tables only. - Making a table strict ends end users' open table subscriptions of it at once; view subscriptions over it reload and continue.
Use strict tables for anything where a leak costs money or fairness - answers, scores, payments, personal data. Worked examples, including a project that is strict by default with one open table, are on Access patterns.
Views
A view is a declared, read-only shape of one table that clients read by name (GET /v1/views/<name>, db.view(name), a live subscription or an MCP tool): which rows (with params the caller fills in), which columns, computed boolean fields, and a different shape per role. A view reads with the server's rights, the way an action writes with them - the table's read rule and its access are not evaluated, the view's allow and rows decide - while the table's column read rules still apply. Together with actions it is how end users reach a strict table. The full reference is on Views.