Baselyra Docs

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

DatabaseSchemaHoldsReachable from
baselyrapublicYour tables, views and functions/rest/v1, the Studio, the SQL editor
baselyraauthYour application's users, sessions, identities, one-time tokensPolicies and the SQL editor. Never /rest/v1.
baselyrastorageBuckets and object metadataPolicies and the SQL editor. Never /rest/v1.
baselyrabaselyraThis project's configuration: settings, email templates, realtime tables, webhooks, jobs, the migration ledgerThe Studio's purpose-built pages. Excluded from /admin/v1/schema entirely.
baselyra_controlcontrolStudio accounts, audit log, import history, request metering, the project registryBaselyra'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 typeJSONWhy
bigint / int8stringJavaScript numbers lose precision above 253
numericstringA float cannot represent 1250.00 exactly, and money must not round
timestamptzISO 8601 string
jsonb / jsonthe value itselfAn object or array in a write body is serialised as JSON rather than as a Postgres array literal
uuidstring
text[] and other arraysJSON 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.

TableWhat it holds
auth.usersOne 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.sessionsOne row per refresh token, with parent_id pointing at the session it replaced, plus user_agent, ip, revoked_at, expires_at
auth.identitiesOne row per linked provider account, unique on (provider, provider_id)
auth.one_time_tokensSHA-256 digests of confirmation, recovery, email-change, magic-link and OTP tokens. Never the token itself.
auth.attemptsFailed sign-ins and SMS sends, for the throttles
auth.oauth_statesOne 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

TableWhat it holds
storage.bucketsid, name, public, file_size_limit, allowed_mime_types, owner
storage.objectsbucket_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.

TableWhat it holds
baselyra.settingsKey/value instance settings
baselyra.email_templatesThe six templates, as finished HTML
baselyra.realtime_tablesWhich tables have the NOTIFY trigger
baselyra.webhooks / baselyra.webhook_deliveriesDatabase webhooks and their delivery queue
baselyra.scheduled_jobs / baselyra.job_runsCron-scheduled jobs and their history
baselyra.migrationsThis 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.

RouteBody
POST /admin/v1/tables{schema, name, columns}
DELETE /admin/v1/tables/:schema/:table?cascade=trueAlso 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, a NOLOGIN role with BYPASSRLS and 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 params forces 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.

ApproachHow
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 editorFine 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 seeWhyFix
404 undefined_table for a table you can see in psqlIt is not in public, or the catalog snapshot is up to a minute staleMove it to public, or wait, or run the DDL through the Studio
[] from a table with rowsRLS is on and no policy admits your roleAdd a policy — Row level security
403 insufficient_privilegeThe 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 hidesThe view is not security_invokeralter view … set (security_invoker = true)
408 statement_timeoutOver DATABASE_STATEMENT_TIMEOUT_MS (15s)Index the columns your filters and policies use, or raise the variable
numeric arrives as a string in JavaScriptDeliberate — a float would round moneyParse it where you need arithmetic

Edit this page Report a problem

Esc
navigate open Esc close