dabadataDocs
Concepts / Rules

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:

OpApplies toChecked against
readLists, single-row reads and live subscriptions.Each row (compiled to a filter, below).
createInserts.The values being inserted, after defaults and user ids are filled in.
updateUpdates.The existing row, before the change.
deleteDeletes.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

text
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
RulePasses 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:

ts
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:

ts
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.

text
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 | false

Keywords (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:

text
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
OperandTypeNotes
a columnits typeThe table's declared columns, plus id (text), created_by (userId), created_at and updated_at (datetime). json columns are refused.
$user.idtextNull for anonymous callers, so author = $user.id is false for them and $user.id is null tells them apart.
$user.roletext"anonymous" without a user token.
$nowdatetimeThe instant of the call, from the server's clock.
$todaydateThe UTC date of $now.
'string'from the other sideA text, a date 'YYYY-MM-DD' or an ISO datetime, according to what it is compared with.
a numberint, floatAn int column takes integers only.
true, falseboolWith = 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. $now and $today may 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 != b is not (a = b), so it is true when either side is null. a in (x, y) is true only when a is non-null and equals an item; not in is its negation (true when a is null). A missing value is null; test it with is null / is not null (= null is refused).
  • Ordering: text by Unicode code point (the same in SQL, with collate "C"), numbers by value, dates as YYYY-MM-DD, datetimes as instants at millisecond precision (offsets honoured).
  • Which row: on create the condition reads the values being inserted, after defaults and the caller's userId columns are filled (system columns are null - the row does not exist yet). On read, update and delete it reads the row as it is before the write; column update rules read the same row.
  • Safe SQL: every value is a typed bound parameter; nothing from the definition is pasted into the query text, and $now is bound from the call's instant, never SQL now().

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 expect 409 body, the row it answers with), a whole /v1/transact and 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 change with op: "update" (treat it as an insert when the id is new); a row that leaves arrives as op: "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 a fanout with detail time. 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/simulate takes a now; 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:

text
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, nobody and role branches - 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 owner branch becomes column = <user id> in the SQL query.
  • A where branch becomes its SQL, every value a bound parameter.
  • any becomes OR and all becomes AND, nested as written. A false branch contributes nothing to an any, and denies everything in an all. 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:

text
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 the any.
  • A denied any lists 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 all names 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 create values.status "draft"), user "u7" (anonymous: user null), role "member", now "2026-10-16T19:00:00.000Z", today "2026-10-16", constants as written, and is true / is false at the end. An or names its first true part (or every false one), an and its 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).
text
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 false

The 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.

bash
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"}}'
json
{
  "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.

json
{
  "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:

json
"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 admins pinned, and nobody taken - 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:

text
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:

ts
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, _seq and demo_room are always present (demo_room refuses a read rule).
  • Per row and per instant. owner and conditions on columns are decided on each row at the call's $now; role, anyone, authenticated and 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": [...] }:

text
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 caller

That 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:

ts
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'" } }] },
  },
},
  • table is the membership table, a declared table.
  • match names at least one of its columns, each with a value: "$row.<column>" (the row the rule is about; $row.id is 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's ref(projects) fits a ref(projects) column, a plain text column does not.
  • where is an optional condition on the membership row (its columns, $user.*). $row.* belongs in match, and $now / $today are not supported there yet - put time conditions on the row itself: all: [{ via: ... }, { where: "due_at > $now" }].
  • On create $row.team reads the value being inserted: members add tasks to their own teams only.
  • One hop. The membership table's own rules and access are 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.id never holds for them. In a demo room the lookup stays in the room.
  • Where it applies. Table rules, column rules (read and update) and view rows. 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:

text
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:

json
{
  "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:

js
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:

json
"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.

json
"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:

ts
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"] },
  },
});
  • access is "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's files_<bucket> table and the profiles users table are always open.
  • On a strict table rules are 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 403 insert questions: denied, questions is written through actions only (access: views) (update / delete the same), before the row is locked or the values are checked.
  • The same holds on every surface: /v1/db lists, ?since= and single rows, /v1/mutate, each op of /v1/transact (a check op as a read), table subscriptions on /v1/live and the realtime tier, presence, the simulator for end-user roles, and the MCP user toolset (no table tools for it, describe_app leaves it out). GET /v1/schema shows 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.

dabadata documentation, written against API contract v1.