Browse documentation

/rest/v1 is not a thin Prisma wrapper. It is a PostgREST-dialect engine with four separated layers, all under apps/server/services/rest-api:

query string ──▶ postgrest-parser ──▶ Filter AST ──▶ sql-compiler ──▶ SQL
                                                  ▲
                        catalog (frozen schema snapshot)
LayerFileKnows about
Catalogcatalog/The live Postgres schema, cached
Parserpostgrest-parser/Syntax only — never the catalog, never SQL
Model / exposurerest-model.tsWhat may be reached at all
Compilersql-compiler.tsAST → SQL with bound parameters
HTTP dialecthttp-semantics.tsPrefer, Range, Content-Range, Accept
RPCrpc-args.tsFunction overload resolution
Servicerest-api.service.tsOrchestration, RLS, outbox
Write capturecapture-write.tsTrigger-buffered row images for realtime, independent of RETURNING

The catalog

One cached snapshot of the live schema (catalog/index.ts), read by REST model resolution, Studio's table list, the schema graph and type generation — so those can never disagree about what a table looks like. The repo previously had two divergent table-list sources; a single snapshot is the structural fix.

Properties that matter:

  • Lazy, never eager. Nothing runs at import time, so the process boots with the database unreachable and heals on the first successful load.
  • Per-process cache with a TTL, plus invalidateCatalog() called from the DDL choke point in schema-ddl.ts — same-process DDL is visible immediately; another process's DDL is picked up when the TTL elapses.
  • Foreign keys recorded in both directions, because PostgREST-style embedding walks parent → child as well as child → parent.

Files: queries.ts (pg_catalog reads), snapshot.ts (rows → frozen snapshot), pg-types.ts (Postgres → TypeScript), types.ts (shapes).

What is exposable

rest-model.ts holds every rule about which relation a request may reach, so the URL path, an embedded relation and a write body all pass the same gate.

  1. Schema: only public. Every Motherbase-managed table lives in auth, storage or platform, so this single rule makes system tables unreachable.
  2. Blocklist (policy-types.ts): defence in depth for names that could be recreated in public — session, account, verification, jwks, app_settings, edge_function_secrets, schema_migrations, realtime_outbox (and their Prisma model names).
  3. Base tables only. Views and materialized views cannot carry row-level security.
  4. INTERNAL_TABLE_RE excludes _prisma_* and pg_*.

Rows also pass through maskRow, so sensitive column names never leave the server.

The parser

A real parser, not split('.'), for three reasons stated in the source:

  • Values may be double-quoted: in.("a,b",c) is two elements. A naive split silently changes a filter's meaning — a security bug on a data plane that leans on RLS.
  • Logic trees nest: or=(a.eq.1,and(b.gt.2,c.is.null)).
  • not. composes with every operator, so it is a node, not a per-operator special case.

Values stay raw strings on purpose. PostgREST sends literals untyped and lets Postgres coerce them from the operand's type — which is why eq.42 matches a text column containing "42" and eq.null matches the literal string null. Coercing to JS numbers would lose bigint/numeric precision and change those semantics. is.null is the only way to reach SQL NULL.

Malformed input raises PGRST100, PostgREST's own parse-error code. Layers: ast.ts, lexer.ts, filters.ts, select.ts, driven by index.ts.

HTTP semantics

http-semantics.ts implements the header half of the dialect.

HeaderHandling
Prefer: count=exact, planned, estimated; drives the Content-Range total
Prefer: return=representation, minimal, headers-only
Prefer: resolution=merge-duplicates, ignore-duplicates — turns an insert into an upsert
Prefer: params=single-objectFor /rpc/
Prefer: max-affected=NRefuse and roll back a write touching more than N rows
Prefer: tx=rollbackRun the write, report it, roll the transaction back
RangeApplied only when ?limit/?offset are absent
Content-RangeComputed on the response
Note

return defaults to minimal for a table write (POST/PATCH/DELETE /rest/v1/:model), exactly as PostgREST does: no rows come back unless the caller asks, which @motherbase/client does by chaining .select(). The default is representation on /rpc/:fn and /batch, where the result is the response and there is nothing else to return. Set Prefer: return=representation or minimal explicitly to override either.

An unknown preference is ignored (RFC 7240 makes preferences advisory), but an unknown value for a preference that is implemented is rejected — silently ignoring count=aproximate would return a response that looks correct and is not.

Two internal projection columns are added and stripped again: __mb_count (the exact-count window function) and __mb_inserted (xmax = 0, so an upsert can tell an insert from an update).

RPC

rpc-args.ts resolves overloads the way PostgREST does — by argument names, not arity:

  • every supplied name must be an input argument of the candidate (IN, INOUT, VARIADIC);
  • every input argument without a default must have been supplied;
  • an overload with a VARIADIC tail is never selectable, because named notation cannot fill it.

No match returns 404 PGRST202 with the PostgREST message text.

Where RLS enters

The service builds an RlsContext from the request user (lib/rls-claims.ts) and runs the compiled SQL through the RlsPool — a non-owner role with request.jwt.claims set for the transaction. The REST engine never queries as the owner. See security model and RLS.

Where realtime enters

Mutations write their events into the RealtimeOutbox inside the same transaction as the data change, and the outbox scheduler drains them. See background jobs.

The row images those events carry do not come from RETURNING, which is why a write that answers return=minimal still broadcasts. capture-write.ts installs a short-lived database trigger that buffers the before/after images for the statement, inside the same transaction and subject to the same rollback. The mutation itself always runs as the caller under native RLS; only the capture buffer runs as the owner, because a caller with no SELECT policy must still produce an event for the subscribers who do have one.

Error codes

The engine emits PostgREST codes so existing clients recognise them: PGRST100 (parse error), PGRST122 (bad Prefer), PGRST202 (function not found). General error shape: errors. Endpoint list: REST API.