Data
Overview
Section titled “Overview”Data gives you a PostgREST-shaped HTTP API over PostgreSQL. If you have used Supabase Data, the query syntax, filters, embedded resources, and RPC calls will feel familiar.
What makes Reactor Data different is where policies live. Row-level access is enforced by a single Rust policy engine—not native Postgres RLS—so the same policy DSL works across backends and composes directly with Identity permissions (auth.has_permission(...), auth.org_id(), and friends).
Every request requires a JWT from Identity. There is no anonymous anon key; public read access is modeled through permissions on a role, not a separate key type.
Key features
Section titled “Key features”- RESTful CRUD —
GET,POST,PATCH,DELETEon any table in your user schema. - Rich filtering — PostgREST operators:
eq,gt,in,like,is.null, nestedand/orgroups. - Embedded resources — Fetch related rows in one round trip:
?select=id,title,author(name). - SQL-defined RPC — Call database functions via
POST /data/v1/rpc/{name}. - Portable migrations — Write SQL once in the Reactor dialect; lint rejects non-portable constructs.
- Policy DSL in migrations — Declare
policy ... on ... using (...)inline with your schema. - Audit on mutations — Inserts, updates, deletes, and RPC calls write audit rows in the same transaction.
Quickstart
Section titled “Quickstart”Define a table with a tenant policy, apply migrations, then query as an authenticated user.
Migration (migrations/001_todos.sql):
create table todos ( id reactor_id primary key, org_id reactor_id not null, title text not null, done bool not null default false, created_at timestamptz not null default now());
policy todos_tenant on todos for select, update, delete using (org_id = auth.org_id());
policy todos_insert on todos for insert check (org_id = auth.org_id());reactor db migratereactor auth login user@example.com --password '...'reactor data insert todos --json '{"title":"Ship v1","org_id":"<your-org-id>"}'reactor data query todos --select 'id,title,done'import { createClient } from '@reactor/client';
const reactor = createClient({ url: process.env.REACTOR_URL! });await reactor.auth.signInWithPassword({ email, password });reactor.auth.setOrg(orgId);
const { data } = await reactor.data.from('todos').insert({ title: 'Ship v1', org_id: orgId,}).select('id,title,done').single();
const todos = await reactor.data.from('todos').select('id,title,done');curl -s -X POST "$REACTOR_URL/data/v1/todos" \ -H "Authorization: Bearer $REACTOR_TOKEN" \ -H "X-Reactor-Org: $ORG_ID" \ -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"title":"Ship v1","org_id":"'"$ORG_ID"'"}'
curl -s "$REACTOR_URL/data/v1/todos?select=id,title,done" \ -H "Authorization: Bearer $REACTOR_TOKEN" \ -H "X-Reactor-Org: $ORG_ID"How-to guides
Section titled “How-to guides”Filter and paginate results
Section titled “Filter and paginate results”reactor data query todos \ --filter 'done=eq.false' \ --order 'created_at.desc' \ --limit 20const open = await reactor.data .from('todos') .select('id,title,created_at') .eq('done', false) .order('created_at', { ascending: false }) .limit(20);curl -s "$REACTOR_URL/data/v1/todos?done=eq.false&order=created_at.desc&limit=20&select=id,title" \ -H "Authorization: Bearer $REACTOR_TOKEN" \ -H "X-Reactor-Org: $ORG_ID"Common filter operators:
| Operator | Example | Meaning |
|---|---|---|
eq | ?status=eq.active | Equals |
gt, gte, lt, lte | ?views=gt.100 | Comparison |
in | ?id=in.(a,b,c) | In list |
like | ?title=like.api* | Pattern match (* → %) |
is | ?deleted_at=is.null | IS NULL / TRUE / FALSE |
and | ?and=(a.eq.1,b.eq.2) | Conjunction |
or | ?or=(a.eq.1,a.eq.2) | Disjunction |
Embed related resources
Section titled “Embed related resources”Traverse foreign keys in a single query. Each joined table’s policies apply independently.
const posts = await reactor.data .from('posts') .select('id, title, author(id, name), comments(id, body)');curl -s "$REACTOR_URL/data/v1/posts?select=id,title,author(id,name),comments(id,body)" \ -H "Authorization: Bearer $REACTOR_TOKEN" \ -H "X-Reactor-Org: $ORG_ID"Call an RPC function
Section titled “Call an RPC function”Register SQL functions in migrations, then invoke them by name with JSON arguments.
reactor data rpc search_posts --json '{"query":"reactor","limit":10}'const results = await reactor.data.rpc('search_posts', { query: 'reactor', limit: 10,});curl -s -X POST "$REACTOR_URL/data/v1/rpc/search_posts" \ -H "Authorization: Bearer $REACTOR_TOKEN" \ -H "X-Reactor-Org: $ORG_ID" \ -H "Content-Type: application/json" \ -d '{"query":"reactor","limit":10}'Configuration
Section titled “Configuration”[data]migrations_dir = "./migrations"user_schema = "public"max_embed_depth = 5max_limit = 1000default_limit = 100
# Monolith mode uses in-process auth; microservices need:# auth_url = "http://reactor-auth:8001"# internal_secret = "shared-secret"Environment variables (override reactor.toml):
| Variable | Default | Description |
|---|---|---|
REACTOR_DATA_DATABASE_URL | — | Postgres connection string |
REACTOR_DATA_BIND | 0.0.0.0:8002 | HTTP bind address |
REACTOR_DATA_MIGRATIONS_DIR | ./migrations | User migration source |
REACTOR_DATA_MAX_LIMIT | 1000 | Maximum rows per read |
REACTOR_DATA_DEFAULT_LIMIT | 100 | Default when no limit specified |
Limits and quotas
Section titled “Limits and quotas”| Limit | Default | Notes |
|---|---|---|
| Max rows per request | 1,000 | max_limit / REACTOR_DATA_MAX_LIMIT |
| Default page size | 100 | When no limit or Range header |
| Embed depth | 5 | Nested FK traversal in ?select= |
| Bulk insert policy denial | 207 Multi-Status | Permitted rows commit; denied rows reported per-row |
| Realtime subscriptions | v0.2 | ?subscribe=1 returns 426 Upgrade Required in v0 |
| SQLite backend | v0.2 | Migrations are portable from day one; Postgres ships first |
Required permissions:
| Operation | Permission |
|---|---|
Read (GET, stable RPC) | data:{table}:read or wildcard |
Write (POST, PATCH, DELETE) | data:{table}:write |
| RPC invoke | data:rpc:{name}:invoke |
API and SDK links
Section titled “API and SDK links”- HTTP base path:
/data/v1/ - OpenAPI reference: Data API
- JavaScript SDK:
reactor.data - CLI:
reactor db,reactor data
| Method | Path | Description |
|---|---|---|
GET | /data/v1/{table} | Select with filters |
POST | /data/v1/{table} | Insert row(s) |
PATCH | /data/v1/{table}?filter=... | Update matching rows |
DELETE | /data/v1/{table}?filter=... | Delete matching rows |
POST | /data/v1/rpc/{function} | Call SQL function |
Prefer header
Section titled “Prefer header”| Value | Effect |
|---|---|
return=representation | Return affected rows on mutation |
return=minimal | Empty body (default) |
count=exact | Include total in Content-Range |
resolution=merge-duplicates | Upsert on primary key conflict |
Troubleshooting
Section titled “Troubleshooting”policy_denied (403)
Section titled “policy_denied (403)”A row-level policy blocked the operation. Check which policy fired in the error details field. Common fixes:
- Ensure
org_idon inserted rows matchesauth.org_id() - Grant the role
data:{table}:writeor a wildcard - Add a
usingpolicy for the operation’s scope (select,insert,update,delete)
ambiguous_embed (400)
Section titled “ambiguous_embed (400)”Multiple foreign keys match the embed name. Disambiguate with the constraint name:
?select=id,author:users!posts_author_id_fkey(name)Migration drift error
Section titled “Migration drift error”If a migration file’s content changed after it was applied, Reactor rejects re-application. Never edit applied migrations—create a new migration file instead.
Empty results for a member role
Section titled “Empty results for a member role”Policies may be too restrictive, or X-Reactor-Org may not match the rows’ org_id. Verify the active org and policy expressions.