The database
Your data is in Postgres tables you can psql into, not behind an abstraction. This page is the map: which schema holds what, what happens to a table the moment you create it, and the handful of Postgres details that change how Baselyra behaves.
Two databases, five schemas
| Database | Schema | Holds | Reachable from |
|---|---|---|---|
baselyra | public | Your tables, views and functions | /rest/v1, the Studio, the SQL editor |
baselyra | auth | Your application's users, sessions, identities, one-time tokens | Policies and the SQL editor. Never /rest/v1. |
baselyra | storage | Buckets and object metadata | Policies and the SQL editor. Never /rest/v1. |
baselyra | baselyra | This project's configuration: settings, email templates, realtime tables, webhooks, jobs, the migration ledger | The Studio's purpose-built pages. Excluded from /admin/v1/schema entirely. |
baselyra_control | control | Studio accounts, audit log, import history, request metering, the project registry | Baselyra's own code, through a separate pool. Nothing else. |
GET /admin/v1/schema groups what it returns: user schemas
(public and anything you created) first and expanded, system
(auth, storage) collapsed, and baselyra, pg_catalog,
information_schema and pg_toast excluded from the response. auth and
storage stay visible because writing a policy against auth.users or
storage.objects is a legitimate thing to do.
Creating a table
A table is an API endpoint the moment it exists. There is no generate step, no deploy, and no registration — the catalog is read on demand.
create table public.invoices (
id uuid primary key default gen_random_uuid(),
owner uuid not null default auth.uid() references auth.users(id) on delete cascade,
number text not null unique,
amount numeric(12,2) not null check (amount >= 0),
currency text not null default 'GBP' check (length(currency) = 3),
paid_at timestamptz,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- A policy is a WHERE clause on every row of every query. Index what it filters on.
create index invoices_owner_idx on public.invoices (owner);
alter table public.invoices enable row level security;
create policy invoices_own on public.invoices
for all to authenticated
using (owner = (select auth.uid()))
with check (owner = (select auth.uid()));
curl -s "$URL/rest/v1/invoices?select=number,amount&order=created_at.desc&limit=2" \
-H "apikey: $ANON_KEY" -H "authorization: Bearer $TOKEN"
[{"number":"INV-0042","amount":"1250.00"},
{"number":"INV-0041","amount":"90.00"}]
The catalog snapshot
The REST layer reads columns, primary keys, foreign keys and functions out of
pg_catalog and caches the result for 60 seconds. DDL performed through
/admin/v1 or the Studio invalidates it immediately; DDL run straight from
psql is visible to the API within a minute.
Types on the wire
Baselyra installs identity parsers for two Postgres oids so JSON stays exact rather than lossy:
| Postgres type | JSON | Why |
|---|---|---|
bigint / int8 | string | JavaScript numbers lose precision above 253 |
numeric | string | A float cannot represent 1250.00 exactly, and money must not round |
timestamptz | ISO 8601 string | |
jsonb / json | the value itself | An object or array in a write body is serialised as JSON rather than as a Postgres array literal |
uuid | string | |
text[] and other arrays | JSON array |
The generated TypeScript types from the VS Code extension mirror what the JSON carries, not what Postgres stores, for exactly this reason.
Views
Views are exposed over /rest/v1 the same as tables. There is one thing you
must do to every one of them.
create view public.recent_invoices with (security_invoker = true) as
select id, number, amount, created_at
from public.invoices
order by created_at desc;
-- retrofit an existing one
alter view public.recent_invoices set (security_invoker = true);
Audit them:
select c.relname as view,
coalesce((select option_value from pg_options_to_table(c.reloptions)
where option_name = 'security_invoker'), 'false') as security_invoker
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v'
order by 2, 1;
Functions
Every function in public with prokind = 'f' is callable at
POST /rest/v1/rpc/:name. Arguments are passed by name, resolved against
pg_proc, and always bound as parameters.
create or replace function public.invoice_totals(since timestamptz default now() - interval '30 days')
returns table (currency text, total numeric)
language sql stable
as $$
select i.currency, sum(i.amount)
from public.invoices i
where i.created_at >= since
group by 1
order by 2 desc;
$$;
curl -s -X POST "$URL/rest/v1/rpc/invoice_totals" \
-H "apikey: $ANON_KEY" -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"since":"2026-08-01T00:00:00Z"}'
[{"currency":"GBP","total":"4310.00"},{"currency":"EUR","total":"220.00"}]
An ordinary function is security invoker, so it is still subject to RLS. A
security definer function runs as its owner and bypasses RLS on everything it
touches — which is exactly what you want for a membership helper inside a policy,
and exactly what you do not want for a function you expose over RPC. Pin
search_path on anything you do mark definer.
The auth schema
These are your application's users. They are not Studio operators — those live in
another database entirely. Write policies against them freely; do not expose them
over /rest/v1, because you cannot.
| Table | What it holds |
|---|---|
auth.users | One row per application user: id, email (citext, so equality is case-insensitive), phone, encrypted_password, email_confirmed_at, phone_confirmed_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, banned_until |
auth.sessions | One row per refresh token, with parent_id pointing at the session it replaced, plus user_agent, ip, revoked_at, expires_at |
auth.identities | One row per linked provider account, unique on (provider, provider_id) |
auth.one_time_tokens | SHA-256 digests of confirmation, recovery, email-change, magic-link and OTP tokens. Never the token itself. |
auth.attempts | Failed sign-ins and SMS sends, for the throttles |
auth.oauth_states | One row per third-party sign-in in flight, for ten minutes |
Row level security is on for all of them. auth.users, auth.sessions and
auth.identities carry own-row policies for authenticated;
auth.one_time_tokens, auth.attempts and auth.oauth_states have
no policy at all, and that absence is the denial.
The storage schema
| Table | What it holds |
|---|---|
storage.buckets | id, name, public, file_size_limit, allowed_mime_types, owner |
storage.objects | bucket_id, name, owner, size, mime_type, checksum, metadata, unique on (bucket_id, name) |
Because the metadata is a table with RLS on it, who may read or write a file is an ordinary policy. See Storage and the per-user folder example.
The baselyra schema
This project's own configuration. The Studio has purpose-built pages for most of
it, and /admin/v1/schema never returns it.
| Table | What it holds |
|---|---|
baselyra.settings | Key/value instance settings |
baselyra.email_templates | The six templates, as finished HTML |
baselyra.realtime_tables | Which tables have the NOTIFY trigger |
baselyra.webhooks / baselyra.webhook_deliveries | Database webhooks and their delivery queue |
baselyra.scheduled_jobs / baselyra.job_runs | Cron-scheduled jobs and their history |
baselyra.migrations | This database's migration ledger, with checksums |
Two functions matter to you:
select baselyra.enable_realtime('public.invoices');
select baselyra.disable_realtime('public.invoices');
Both are security definer so the Studio can toggle a table it does not own,
and EXECUTE is restricted to service_role — a project user cannot attach a
trigger to an arbitrary table.
DDL from the admin API
The Studio's table and policy forms are these four routes. They run as
baselyra_sql, exactly as the SQL editor does, so a create policy typed into
the editor and one built by the form reach the database with identical powers.
| Route | Body |
|---|---|
POST /admin/v1/tables | {schema, name, columns} |
DELETE /admin/v1/tables/:schema/:table?cascade=true | Also deletes the baselyra.realtime_tables row |
POST /admin/v1/policies | {schema, table, name, command, roles, using, check} |
DELETE /admin/v1/policies/:schema/:table/:name |
Running SQL
curl -s -X POST "$URL/admin/v1/sql" \
-H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"query":"select count(*) from public.invoices where paid_at is null","read_only":true}'
{"columns":[{"name":"count","type":"int8"}],
"rows":[{"count":"3"}],
"rowCount":1,
"durationMs":4,
"command":"SELECT"}
Three things about this endpoint are worth knowing before you use it:
- It runs as
baselyra_sql, aNOLOGINrole withBYPASSRLSand every object right the owner has — and no access to the host. See The SQL editor. - The audit row is written before the statement runs, on a separate connection, so a statement that fails or rolls back is still recorded. It contains the statement text and its bound parameters: do not type a password into the editor and expect it to be forgotten.
- Passing
paramsforces the extended protocol, which carries exactly one statement. Without them the simple protocol accepts a whole script.
Evolving the schema
Two honest options. Baselyra does not ship a migration tool for your tables.
| Approach | How |
|---|---|
Add files to db/project/ | They are applied in filename order on every boot, each inside one transaction, tracked by checksum in baselyra.migrations. Write them idempotently — an edited file is re-run deliberately, which is the intended way to evolve the schema. |
| Run the statement in the SQL editor | Fine for development. It is audited, and it invalidates the catalog immediately. It leaves no record your next deployment can replay, so promote anything permanent into a file. |
Things that go wrong
| What you see | Why | Fix |
|---|---|---|
404 undefined_table for a table you can see in psql | It is not in public, or the catalog snapshot is up to a minute stale | Move it to public, or wait, or run the DDL through the Studio |
[] from a table with rows | RLS is on and no policy admits your role | Add a policy — Row level security |
403 insufficient_privilege | The role lacks table grants. This is a grant problem, not a policy problem: an RLS denial is an empty result, never a 403. | grant select, insert, update, delete on public.t to anon, authenticated |
| A view returns rows the underlying policy hides | The view is not security_invoker | alter view … set (security_invoker = true) |
408 statement_timeout | Over DATABASE_STATEMENT_TIMEOUT_MS (15s) | Index the columns your filters and policies use, or raise the variable |
numeric arrives as a string in JavaScript | Deliberate — a float would round money | Parse it where you need arithmetic |