Database
Every project gets its own Postgres database with Row Level Security, a typed query builder, named functions, webhooks, and versioned migrations. PGlite-backed in development, Postgres in production — same SQL, same RLS, no surprises at deploy.
Schema
Schemas are plain SQL DDL, the same model as Supabase. Migration files live under your project source and are applied automatically on deploy.
.atelier/
└── migrations/
├── 20260615120000_init.sql
├── 20260616093000_add_todos.sql
└── 20260620140000_add_index.sql
Studio’s AI writes new migration files when you ask it to add a table, change a column, or add an index. You can also write them by hand or with the CLI:
atelier db migration new add_todos
-- .atelier/migrations/20260616093000_add_todos.sql
CREATE TABLE todos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
title text NOT NULL,
done boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX todos_user_idx ON todos (user_id);
Apply locally with atelier db migrate or just save in Studio — pending migrations run automatically. Each migration is tracked by ID in a system table, so re-running is a no-op.
Row Level Security
Base threads the signed-in end-user’s identity from the OIDC session cookie into every query as a Postgres GUC (atelier.user_id, atelier.user_role, atelier.user_email). Write RLS policies against those and your authz is enforced at the database layer:
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY todos_owner_select ON todos
FOR SELECT
USING (user_id = current_setting('atelier.user_id')::uuid);
CREATE POLICY todos_owner_write ON todos
FOR INSERT WITH CHECK (user_id = current_setting('atelier.user_id')::uuid);
RLS using session context means the database — not your application — is the source of truth for who can see what. Bypass the app server with a different client and the rules still hold.
Query from your app
Base separates client query from server SQL. End-user client code never executes raw SQL or DDL — it uses the typed query builder or calls named functions you’ve defined.
Typed query builder
import { atelier } from '@atelier/sdk';
const todos = await atelier.from('todos')
.select('id, title, done')
.order('created_at', { ascending: false });
await atelier.from('todos').insert({ title: 'Ship the thing' });
await atelier.from('todos')
.update({ done: true })
.eq('id', 'abc');
await atelier.from('todos').delete().eq('id', 'abc');
The builder compiles to a small set of safe operations. The server validates them against your schema and RLS policies. Auth flows through the user’s cookie automatically.
Named functions
For anything that doesn’t fit the builder — joins across many tables, set-based updates, aggregations — define a server function and call it by name:
// .atelier/db-functions/open-todo-count.sql
CREATE FUNCTION open_todo_count(p_user_id uuid)
RETURNS int
LANGUAGE sql
SECURITY INVOKER
AS $$
SELECT count(*)::int FROM todos
WHERE done = false AND user_id = p_user_id;
$$;
import { atelier } from '@atelier/sdk';
const { count } = await atelier.rpc('open_todo_count', { user_id: me.id });
Functions run under the caller’s identity, so RLS still applies.
Webhooks on mutation
Base fires webhooks for inserts, updates, and deletes — useful for downstream systems, audit logs, or wiring side effects without polling.
{
"table": "todos",
"op": "INSERT",
"row": { "id": "...", "title": "Ship the thing", "done": false },
"user_id": "user_abc"
}
Dev vs production
| Aspect | Development | Production |
|---|---|---|
| Backend | PGlite (in-process) | Postgres (RDS) |
| Co-located | Yes, in your dev server | No, networked |
| Latency | In-process | Sub-ms within VPC |
| SQL surface | Postgres-compatible | Native Postgres |
| Migration apply | Auto on save / atelier db migrate | Auto on deploy, tracked by ID |
| Isolation | Per-project schema | Per-project schema |
Limits
Free tier projects: 500 MB storage, 1 GB egress/month, 100k row reads/day. Paid plans lift these significantly. See pricing.