Schema
Your database schema lives in your repo, in TypeScript, under db/ — one file per schema. db/public.ts declares the public schema; db/billing.ts declares billing, and so on. Each file default-exports a defineSchema("<name>", { tables }) call naming its tables, columns, foreign keys, Row-Level Security policies, constraints, indexes and Postgres extensions. There are no migration files anywhere in Palbase: those declarations are the only description of the schema you write, and everything else is derived from them — the typed Database.public.* surface, the generated palbase-env.d.ts, and the change plan computed live against whichever database you are pointing at.
Coming from
db/schema.ts? That layout is gone and a push says so by name. The Schema migration guide walks the four changes with before/after code.
Quick example
// db/public.ts
import {
boolean, defineSchema, defineTable, enumType, integer, jsonb,
ownedByUser, policy, text, timestamp, uuid,
} from "@palbase/backend";
export const lists = defineTable("lists", {
columns: {
id: uuid().primaryKey().defaultRandom(),
user_id: ownedByUser(),
title: text().notNull(),
},
});
export const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
list_id: uuid().references(() => lists.id),
user_id: ownedByUser(),
title: text().notNull(),
status: enumType("todo_status", ["open", "done", "archived"]).default("open"),
priority: integer().default(0),
done: boolean().default(false),
meta: jsonb().nullable(),
created_at: timestamp().defaultNow(),
},
// `policies` is a CALLBACK, and `p` carries this table's own column names —
// so `p.col("user_idd")` is a compile error rather than a deploy-time refusal
// from Postgres.
policies: (p) => [
policy("todos_owner")
.for("all")
.to("authenticated")
.using(p.col("user_id").eq(p.auth.uid()))
.withCheck(p.col("user_id").eq(p.auth.uid())),
],
});
export default defineSchema("public", {
tables: [lists, todos],
extensions: ["pg_trgm"],
});
Five things to notice:
- A table is a value that knows its own name.
defineTable("todos", …)returns a binding, anddefineSchemareads the name off the table rather than from a dictionary key. That is what letstodospoint atlistsin the same file: there is exactly one place a name is written, so a reference can never resolve against a table whose identity is not set yet. defineSchematakes an ARRAY of those bindings, and its first argument is the schema's own name. The name is declared, not derived — and it must match the file it lives in (see One file per schema).- The file must
export defaultthedefineSchema(...)call — this is one of the few places where the default export is genuinely required. - Columns are
NOT NULLby default;.nullable()opts in toNULL. - RLS is on by default, and declaring
policiesforces it on. A table with RLS and no policy denies everything. That is the single most common reason a fresh project's firstPOSTfails and its firstGETreturns[]— read Row-Level Security before you deploy.
One file per schema
db/
public.ts → schema "public"
billing.ts → schema "billing"
Every db/*.ts file is read as one schema declaration. db/public.ts is the one Palbase expects to find; a project with no db/*.ts at all is refused with that file named.
The file name and the declared name must match. The name is carried by the declaration — defineSchema("billing", …) — and the file name is how the reader finds it. A db/billing.ts that declares defineSchema("accounts", …) is refused at push, with both names in the error, so nobody has to open every file to find where a schema is declared.
exposed — reachable over HTTP, or not
exposed decides whether a schema is reachable over the REST table surface at /v1/db. The default is not uniform, and the asymmetry is deliberate:
| Schema | Default | Why |
|---|---|---|
public | exposed: true | it is reachable today and stays reachable — a uniform default would silently 404 every existing project's /v1/db traffic the moment it upgraded |
| every other schema | exposed: false | declaring a schema is not the same as putting it on the internet |
So you write the field only to go against the grain: exposed: false to close public, exposed: true to open billing. Server-side Database.* ignores the flag entirely — your own controllers, jobs and hooks read every schema you declared.
// db/exposed-examples.ts — the three shapes, side by side
import { defineSchema, defineTable, uuid } from "@palbase/backend";
const lists = defineTable("lists", { columns: { id: uuid().primaryKey() } });
// reachable over /v1/db, because it is `public` — you write nothing to get that
const reachable = defineSchema("public", { tables: [lists] });
// the explicit, greppable opt-out
const closed = defineSchema("public", { tables: [lists], exposed: false });
// a second schema: declared, typed, and NOT on the internet
const billing = defineSchema("billing", { tables: [lists] });
export { billing, closed, reachable };
In a real project each of those is the export default of its own file — db/public.ts and db/billing.ts — because a file is one schema.
| Surface | Sees a closed schema? |
|---|---|
/v1/db, the REST table surface | no — a table in a closed schema answers 404, and the response does not name the schema |
Database.* inside your own controllers, jobs and hooks | yes — server-side code reads every schema you declared |
That split is the whole point of a second schema: billing rows are yours to read in a handler and nobody's to fetch from a browser.
Foreign keys across schemas
Import the table binding and point at it. The reference is typed, and it resolves the same way a same-file one does:
// db/billing.ts
import { defineSchema, defineTable, numeric, uuid } from "@palbase/backend";
import { lists } from "./public";
const invoices = defineTable("invoices", {
columns: {
id: uuid().primaryKey().defaultRandom(),
list_id: uuid().references(() => lists.id),
amount: numeric(),
},
});
export default defineSchema("billing", { tables: [invoices] });
Export the tables you want to reference from another file (export const lists = defineTable(…)); a table only used inside its own file does not need to be exported.
defineTable
defineTable(name, input). Only columns is required; everything else has a default.
| Field | Type | What it is |
|---|---|---|
columns | { [name]: ColumnBuilder } | required — the table's columns |
rls | boolean | defaults to true; false is the explicit opt-out |
policies | (p) => (PolicyBuilder | PolicyDef)[] | RLS policies; non-empty forces rls on |
primaryKey | string[] | composite primary key, in order |
unique | { name, columns }[] | named UNIQUE constraint(s) |
checks | { name, expr }[] or (c) => CheckDef[] | CHECK — raw SQL, or the typed check(n, c.col("total").gt(0)) |
indexes | (c) => (IndexBuilder | IndexDef)[] | index("n").on(c.col("x")).where(…) |
foreignKeys | (c) => (ForeignKeyBuilder | ForeignKeyDef)[] | multi-column FOREIGN KEY |
freeze | (c) => (FreezeBuilder | FreezeDef)[] | a tuple that may not change while a condition holds |
guards | (c) => (GuardBuilder | GuardDef)[] | a trigger that refuses a write |
backfills | BackfillDef[] | one-off data fixes, run apart from the DDL |
dropConstraints | string[] | the one way to drop something live the declaration no longer names |
raw | RawConstraintDef[] | the raw-SQL escape hatch |
search | SearchDecl | full-text and/or vector search — see Search |
appendOnly | boolean | refuse UPDATE and DELETE on this table |
ignored | boolean | "the running version no longer reads this table" — the first half of a drop |
Every one of the callback fields is a callback for the same reason. c (or p) carries this
table's own column names as a literal union, so c.col("ownerr") is a compile error. A plain array
could not be given that context — policy("x") called on its own does not know which table it is
in, and a misspelt column would come back from Postgres in the middle of an apply. Passing an array
where a callback is expected is a type error on the line you wrote it.
The returned value is its columns: todos.id is the id column builder, which is what makes references(() => todos.id) an ordinary expression your editor can complete. The table's own metadata (its name, RLS state, constraints) hangs off a symbol rather than a plain field, so a column may be called name, columns, rls or indexes without shadowing anything.
An empty name is refused where you write it, and two tables declaring the same name inside one schema are refused by defineSchema.
defineSchema
defineSchema(name, input).
| Field | Type | What it is |
|---|---|---|
tables | TableHandle[] | required — the defineTable bindings, in any order |
exposed | boolean | public defaults to true, every other schema to false |
extensions | PalbaseExtension[] | the typed allowlist below |
defineSchema is also where every pending reference is resolved: the () => lists.id thunks are invoked here, once all the bindings exist. A target that is not a column of a table declared with defineTable is refused, and the error names the table and column that could not be resolved.
The constraint and index fields are covered in Constraints and indexes below. Per-column builder types are preserved end to end, which is how Database.public.todos.insert({...}) knows exactly which fields are required.
Column types
Nine column factories, all imported from @palbase/backend. The third column is the type the generated palbase-env.d.ts gives the value on Database.public.*.
| Factory | Postgres type | Row type |
|---|---|---|
uuid() | uuid | string |
text() | text | string |
integer() | integer | number |
bigint() | bigint | string |
numeric() | numeric | string |
boolean() | boolean | boolean |
timestamp() | timestamp | string (ISO-8601) |
jsonb() | jsonb | unknown |
enumType(name, values) | a Postgres enum type named name | the union of the literal values |
A .nullable() column's row type becomes T | null.
bigint() and numeric() surface as string, not number: a JavaScript number cannot hold a 64-bit integer past 2^53 or an arbitrary-precision decimal without rounding. Use them for money — integer minor units in bigint(), or a decimal in numeric() — and parse with BigInt(row.amount) or a decimal library. integer() is int4, so it overflows for money at scale.
timestamp() arrives as an ISO-8601 string, not a Date. The driver hands back a Date and the runtime converts it on the way out, because the generated row type, the response schema derived from your return type, and the JSON on the wire all agree on a string.
jsonb
A jsonb() column accepts a plain JavaScript object or array directly on insert and update — do not JSON.stringify it. The runtime serialises it for you, and reading the column back gives you the value, not a string.
Warning:
jsonb<T>()accepts a generic, but the generic does not reachpalbase-env.d.ts. The generated type file emitsunknownfor everyjsonbcolumn, soDatabase.public.settings.prefsisunknownwhatever you wrote in the schema. Narrow it at the read site instead —row.tags as string[], or better,TagList.parse(row.tags)with a zod schema you already have.
Feeding a jsonb column a value Postgres cannot store as JSON returns a datatype_mismatch error naming the column.
Enums
enumType(name, values) creates a real Postgres enum type. The values tuple is kept const, so the TypeScript side is the narrow literal union:
import { defineTable, enumType, uuid } from "@palbase/backend";
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
status: enumType("todo_status", ["open", "done", "archived"]),
// row type: "open" | "done" | "archived"
},
});
The first argument is the Postgres type's name, used in the DDL — it is not the column name.
Chain methods
Every builder returns itself, so modifiers chain.
| Method | Effect |
|---|---|
.primaryKey() | PRIMARY KEY |
.notNull() | NOT NULL — already the default, useful for explicitness |
.nullable() | allow NULL (the row type becomes T | null) |
.default(value) | literal DEFAULT value |
.defaultRandom() | uuid() only — DEFAULT gen_random_uuid() |
.defaultNow() | timestamp() only — DEFAULT now() |
.unique() | single-column UNIQUE |
.references(() => table.column, opts?) | foreign key to another table's column — see Foreign keys |
.selfReferences("column", opts?) | foreign key to a column of this table |
.onDelete(action) | ON DELETE behaviour for a foreign-key column |
.renamedFrom(previous) | this column used to be called previous — see Renaming a column |
onDelete accepts one of four actions, and may also be passed inline as an option:
type OnDeleteAction = "cascade" | "set null" | "restrict" | "no action";
import { defineTable, uuid } from "@palbase/backend";
const profiles = defineTable("profiles", {
columns: { id: uuid().primaryKey().defaultRandom() },
});
const posts = defineTable("posts", {
columns: {
id: uuid().primaryKey().defaultRandom(),
profile_id: uuid().references(() => profiles.id).onDelete("cascade"),
// identical: uuid().references(() => profiles.id, { onDelete: "cascade" })
},
});
Ownership and user references are not written with .references() at all — see Rows that belong to a user.
Foreign keys
The target is a thunk: references(() => lists.id), not references(lists.id). The callback is what makes cycles expressible — in x → y, y → x the second table does not exist yet when the first is built — and it is invoked inside defineSchema, where every binding exists and every table already knows its name.
import { defineTable, text, uuid } from "@palbase/backend";
const lists = defineTable("lists", {
columns: { id: uuid().primaryKey().defaultRandom(), title: text() },
});
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
list_id: uuid().references(() => lists.id),
},
});
Pointing at this same table
A parent pointer — category trees, comment replies, org charts — takes no thunk and no type annotation, because the target table is the one being declared:
import { defineTable, uuid } from "@palbase/backend";
const categories = defineTable("categories", {
columns: {
id: uuid().primaryKey().defaultRandom(),
parent_id: uuid().nullable().selfReferences("id"),
},
});
Naming a column this table does not have is refused where you declare it, with both names in the error.
Two tables that point at each other
A cycle needs an explicit return type on one side, and one is enough — measured:
import { type AnyColumn, defineTable, uuid } from "@palbase/backend";
const users = defineTable("users", {
columns: {
id: uuid().primaryKey().defaultRandom(),
// the annotated side — without it TypeScript chases its own tail (TS7022)
primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
},
});
const orgs = defineTable("orgs", {
columns: {
id: uuid().primaryKey().defaultRandom(),
owner_id: uuid().nullable().references(() => users.id),
},
});
Pick the side that reads better and annotate that one. A self-reference never needs this; selfReferences exists so it does not.
Two foreign keys to the same table
Each foreign key produces two relation names, and they are named by two different options:
| Direction | Default name | Renamed with |
|---|---|---|
| forward — on the child, pointing at the parent | the column, minus its _id (list_id → list) | { as: "…" } |
| reverse — on the parent, pointing back | this table's own name (addresses.orders) | { reverseAs: "…" } |
Two foreign keys from one table onto one parent therefore collide on the reverse side, whatever their columns are called — both reverse edges want the child table's name. Name at least one of them:
import { defineTable, text, uuid } from "@palbase/backend";
const addresses = defineTable("addresses", {
columns: { id: uuid().primaryKey().defaultRandom(), line1: text().notNull() },
});
const orders = defineTable("orders", {
columns: {
id: uuid().primaryKey().defaultRandom(),
billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
},
});
The forward names here (billing_address, shipping_address) are already distinct, so no as is needed. Reach for as when two columns would derive the same forward name. Either collision is refused at build with both columns named, and the refusal names the option that actually fixes that direction — ownedByUser() takes no { as } at all, so a refusal about an owned column asks you to declare userRef({ onDelete }) instead.
Rows that belong to a user
There is no public.users table. Auth users live in your Environment's database, in its auth schema, in the same Postgres as your tables, and three helpers declare a real database-level foreign key to them. They are column factories, not chain methods — the column type, its nullability and its ON DELETE are part of what each one means, so they cannot be written wrong.
| Factory | References | Means |
|---|---|---|
ownedByUser() | auth.users(id) | this row belongs to that user |
userRef({ onDelete }) | auth.users(id) | this row points at a user — created_by, edited_by |
installationRef({ onDelete }) | auth.installations(id) | this row is scoped to an app install (a device) |
import { defineTable, installationRef, ownedByUser, userRef, uuid } from "@palbase/backend";
const notes = defineTable("notes", {
columns: {
id: uuid().primaryKey().defaultRandom(),
user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
edited_by: userRef({ onDelete: "set null" }).nullable(),
device_id: installationRef({ onDelete: "cascade" }),
},
});
ownedByUser() | userRef({ onDelete }) | |
|---|---|---|
ON DELETE | cascade, and it takes no argument | required: "cascade" or "set null" |
| Column type | text implied | text implied |
| Nullability | NOT NULL implied | yours — "set null" needs .nullable() |
| Account erasure follows it | yes | no |
| Per table | at most one | unlimited |
ownedByUser() takes no onDelete because there is only one correct answer. Ownership is what account erasure walks: a row owned by an account has to go when the account does, so the cascade is not a choice you make per table.
One per table, and that is enforced. Two columns on one table declaring ownedByUser() are refused at push with both column names in the error. The rule exists because the alternative was worse than a refusal: when several columns could reference auth.users, the owner was whichever came first in declaration order, so moving a created_by above a user_id silently changed which rows an account deletion took with it.
Warning: An installation foreign key is not user ownership.
installationRef()ties a row to an app install, not to a person. A user-owned row still needs its ownownedByUser(), or erasing the account will leave it behind.
What is optional on insert
The generated insert type makes a column optional when the database can fill it in — that is, when the column is .nullable(), or has any default (.default(...), .defaultRandom(), .defaultNow()). Everything else is required. For the todos table above:
// the argument Database.public.todos.insert(…) accepts
type InsertTodo = {
id?: string; // defaultRandom()
list_id: string;
user_id: string;
title: string;
status?: "open" | "done" | "archived"; // default("open")
priority?: number; // default(0)
done?: boolean; // default(false)
meta?: unknown | null; // nullable()
created_at?: string; // defaultNow()
};
Renaming a column
A comparison shown two names for one column has no way to know they are the same column — from its side they genuinely are not — so it would drop one and add the other, and the data would go with the drop. Say what you mean:
import { defineTable, text, uuid } from "@palbase/backend";
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
// was: notes
remarks: text().nullable().renamedFrom("notes"),
},
});
The plan then says so, and says it first:
rename column todos.notes → remarks (keeps its data)
Renames are performed before anything else and before the comparison is made: the rename runs as an ALTER TABLE … RENAME COLUMN, the database is read again, and the diff computed against that sees one name on both sides and emits nothing about the column. That ordering is not a detail. Measured on a live project in August 2026, a plan without the annotation printed todos.remarks RENAME FROM notes and then, two lines down, todos.notes DROP ⚠ 1 row(s) — the annotation exists precisely so the second line does not appear. The column a rename creates is also deliberately not listed as an addition, because a plan whose lines do not all happen teaches the reader to stop reading them.
Once applied the annotation is inert — the old name is no longer there to rename — so you can delete it whenever you next touch the file, or leave it.
Renaming two columns into each other is not supported. If both names exist in the database, they are treated as two ordinary columns rather than guessed at; turning one into the other would destroy whatever is in the second.
Changes the declarative rail does not make
The comparison is by name: added tables, added columns, dropped tables, dropped columns, RLS state, policies, constraints, indexes and foreign keys. A property change on a column that already exists is not applied. Knowing this list is what keeps you from waiting for a change that is never coming.
| You change | What happens |
|---|---|
| a column's type | not applied. The plan lists it under these are NOT applied by the declarative rail: with the two types named |
| a column's nullability | not applied, and not shown in the plan — see the warning below |
| a column's default | not applied |
an index's definition, under the same name — columns, where, onExpression, include or sort | not applied — indexes are matched by name only |
removing an index, a unique, a check or a raw object | not applied. Those are one-way: declared-and-missing is added, live-and-undeclared is left alone |
a raw body whose name is a function, trigger or grant | applied — the last body is recorded and compared. A raw naming a constraint or index stays name-only |
a check's expression, same name | applied as DROP CONSTRAINT + ADD CONSTRAINT |
| a policy: added, edited or removed | applied. A removed policy is dropped; an edited one is dropped and recreated |
| removing an extension | not applied. Extensions are added only |
Warning: Changing an existing column between
.notNull()and.nullable()currently does nothing and reports nothing — no plan line, no warning, no error, andpalbase db planwill say the database matches your declaration when it does not. To tighten or loosen a live column today, do it as a rename: add the column you want under a new name, backfill it withpalbase db queryor an admin endpoint, then drop the old one.
Anything on that list that you genuinely need is a rename-and-backfill, or a raw() object with a name of its own.
Constraints and indexes
Beyond per-column chain methods, a table can declare composite keys, named UNIQUE and CHECK constraints, plain indexes, and a raw-SQL escape hatch. All of them show up in palbase db plan — you do not hand-write SQL for them.
import { defineSchema, defineTable, index, integer, text, uuid } from "@palbase/backend";
const bookings = defineTable("bookings", {
columns: {
id: uuid().primaryKey().defaultRandom(),
room: text().notNull(),
capacity: integer().notNull(),
slot: text().notNull(),
},
// named composite UNIQUE — a room cannot be booked twice for one slot
unique: [{ name: "bookings_room_slot_key", columns: ["room", "slot"] }],
// CHECK — the expr is raw SQL
checks: [{ name: "bookings_capacity_pos", expr: "capacity > 0" }],
// plain btree index — `indexes` is a CALLBACK, and `c` knows this table's columns
indexes: (c) => [index("bookings_room_idx").on(c.col("room"))],
});
export default defineSchema("public", { tables: [bookings] });
Composite primary key
A single-column primary key uses .primaryKey() on the column. For a composite one, list the columns at the table level — order is significant:
import { defineTable, ownedByUser, uuid } from "@palbase/backend";
const memberships = defineTable("memberships", {
columns: { org_id: uuid().notNull(), user_id: ownedByUser() },
primaryKey: ["org_id", "user_id"],
});
UNIQUE
.unique() on a column is the single-column form. A named or multi-column constraint is declared at the table level:
import { defineTable, text, uuid } from "@palbase/backend";
const memberships = defineTable("memberships", {
columns: {
org_id: uuid().notNull(),
email: text().notNull(),
handle: text().unique(), // the single-column form, on the column
},
// the named / multi-column form, at the table level
unique: [{ name: "memberships_org_email_key", columns: ["org_id", "email"] }],
});
A single-column .unique() is turned into a named constraint before anything is compared, because that is how the live database reports it back — a difference in spelling alone would otherwise show up as a change on every plan.
CHECK
There are two spellings, and they are two defineTable fields' worth of difference:
import { check, defineTable, integer, uuid } from "@palbase/backend";
// the array form — `expr` is raw SQL, emitted verbatim
const items = defineTable("items", {
columns: { id: uuid().primaryKey().defaultRandom(), price: integer().notNull() },
checks: [{ name: "price_positive", expr: "price > 0" }],
});
// the callback form — `c.col("pricee")` would be a compile error
const goods = defineTable("goods", {
columns: { id: uuid().primaryKey().defaultRandom(), price: integer().notNull() },
checks: (c) => [check("price_positive", c.col("price").gt(0))],
});
The array form's expr is raw SQL. It is normalised through Postgres' own parser before being compared, so an unchanged check never re-plans — Postgres stores price > 0 as (price > 0), and comparing the two literally would recreate the constraint on every deploy. Change the expression and the next plan drops and re-adds it under the same name.
The typed form is checked against a narrower context than a policy: CHECK may not contain a subquery, so existsIn and auth.uid() are refused inside check().
Indexes
import { defineTable, index, ownedByUser, text, uuid } from "@palbase/backend";
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
user_id: ownedByUser(),
status: text().notNull(),
},
indexes: (c) => [index("todos_user_idx").on(c.col("user_id"))],
});
indexes is a callback returning index(name) builders — an array is a type error on the line you wrote it, because a plain array cannot be given this table's column names. The callback may also return plain { name, columns } objects, but everything past a plain column list is only reachable through the builder:
| what you want | how |
|---|---|
| multi-column | index("i").on(c.col("a"), c.col("b")) |
partial (WHERE …) | index("i").on(c.col("a")).where(c.col("status").eq("pending")) |
expression (lower(email)) | index("i").onExpression("lower(email)") |
covering (INCLUDE …) | index("i").on(c.col("a")).include(["b"]) |
sort matching an ORDER BY | index("i").on(c.col("a")).desc().nulls("last") |
.where() takes the same typed predicate language as policy().using(), so column names are checked at compile time; .onExpression() takes SQL text emitted verbatim. Both are probed against the live database before the push — once as a prepared statement, once as a real CREATE INDEX on an empty LIKE clone of the table, which is what catches a subquery predicate or a non-IMMUTABLE function before an apply gets halfway through.
index(n).unique() gives a UNIQUE index, and with .where() a partial UNIQUE index — the one declarative way to say "NULLs are free, non-NULLs are unique". It is a separate shape from the table-level unique:, which renders a UNIQUE constraint: PostgreSQL has no partial unique constraint, only a partial unique index, so a predicate can only ride on the index form.
Still outside the builder: another index method (gin, gist, brin, hash), an opclass, and CONCURRENTLY.
Indexes are matched by name only, and the name is the whole diff key. A declared index the database does not have is created; one the database has and the declaration does not is left in place; and editing an existing name — its columns, its predicate, its expression, its INCLUDE, its sort — changes nothing. Give a reshaped index a new name.
The raw() escape hatch
For DDL the typed builders cannot express — views, EXCLUDE constraints, range types, and PL/pgSQL functions you call yourself — declare a named raw object. It lives in the schema file and is emitted verbatim on the privileged DDL connection.
Reach for the typed field first. Most of what this list used to name is declarative today:
| you want | write |
|---|---|
a composite FOREIGN KEY | foreignKeys: (c) => [foreignKey(n).on(…).references(…)] |
a partial UNIQUE index | index(n).unique().where(…) |
| a CHECK | checks: (c) => [check(n, c.col("total").gt(0))] |
| a conditional FK — freeze a tuple while a condition holds | freeze: (c) => [freeze(n).when(…).columns(…).references(…)] |
| a trigger that refuses a write | guards: (g) => [guard(n).on("update").when(…).refuse(msg)] |
| a one-off data fix at migration time | backfills: [backfill(name, sql)] |
| dropping a constraint on purpose | dropConstraints: ["name"] |
Those are diffed, validated against the live database before the push, and their mistakes answer you where you write them. A raw() is none of that — and its body now passes an AST authority gate that refuses statements changing who can do what (roles, ownership, grants, search_path).
import { defineTable, raw, text, uuid } from "@palbase/backend";
const bookings = defineTable("bookings", {
columns: {
id: uuid().primaryKey().defaultRandom(),
room: text().notNull(),
slot: text().notNull(),
},
raw: [
raw(
"bookings_no_room_clash",
"CREATE EXTENSION IF NOT EXISTS btree_gist; " +
"ALTER TABLE bookings ADD CONSTRAINT bookings_no_room_clash " +
"EXCLUDE USING gist (room WITH =, slot WITH =)",
),
],
});
raw(name, up) — up is one or more statements. There is no down. One used to be accepted and was carried into a generated down-migration that nothing ever ran, so an author who wrote one believed they had a teardown they did not have; the option is gone from the call and from the type, and passing it is a compile error. Drop what a raw() created by declaring the drop as its own raw(). The body is trusted and emitted byte for byte; only the name is validated. Two raw() objects sharing a name are refused where you declare them, rather than failing later inside a statement you did not write.
How a changed body is treated depends on what the name addresses:
- It names a constraint or an index (the
EXCLUDEabove). Tracked by name only — the plan sees the catalog object and skips, so editing the body does nothing. Give it a new name, or drop the old one deliberately withdropConstraints. - It names nothing in the catalog — functions, triggers, grants, compound statements. The last applied body is recorded in
public.palbase_raw_objectsinside the same DDL transaction and compared on the next plan, so an edited body is re-applied. You do not need a new name to change a trigger.
A raw() body that only reads like a grant is still refused: the gate parses it with PostgreSQL's own parser and classifies every top-level statement. The one grant it accepts is GRANT EXECUTE ON FUNCTION … TO <a platform role> — opening your own function to your own backend creates no privilege, and after the REVOKE … FROM PUBLIC a safe SECURITY DEFINER function requires, it is the only way the function stays callable.
Postgres extensions
Declare extensions in the schema and the apply installs them with its privileged connection — CREATE EXTENSION needs rights your runtime does not (and should not) hold. The field is a typed allowlist, so a typo fails typecheck.
| Extension | What it gives you |
|---|---|
vector | embeddings and vector similarity search — see Search |
pg_trgm | trigram fuzzy and typo-tolerant text search |
unaccent | accent-insensitive text search |
citext | case-insensitive text type |
cube | multi-dimensional cubes — a dependency of earthdistance |
earthdistance | great-circle distance (needs cube) |
hstore | key/value pairs in one column |
ltree | hierarchical labels |
btree_gist | GiST operator classes for scalar types — needed for EXCLUDE constraints mixing = with an overlap |
pgcrypto | hashing and encryption functions |
uuid-ossp | UUID generation functions |
import { defineSchema, defineTable, text, uuid } from "@palbase/backend";
const embeddings = defineTable("embeddings", {
columns: { id: uuid().primaryKey().defaultRandom(), source: text().notNull() },
});
export default defineSchema("public", {
tables: [embeddings],
extensions: ["vector", "cube", "earthdistance"],
});
Extensions are installed into an extensions schema, ahead of any table, because a column may use a type an extension provides.
Warning: The extension behind pgvector is named
vector— declare"vector", not"pgvector".
Note:
earthdistancedepends oncube, and the install order is resolved either way; listing both is clearer. Removing an extension from the list does not drop it.
Note: The list above is the whole allowlist — a name that is not on it fails typecheck. It names what the database image can actually install, which is why
pg_cronandpostgisare not there: an extension that cannot be created would otherwise pass authoring and fail at deploy.
Row-Level Security in the schema
Two fields carry it — rls and policies — and three rules decide the outcome:
| You declare | Result |
|---|---|
neither rls nor policies | RLS on, deny-all. Nothing is readable or writable until a policy says who may |
policies: (p) => [...] returning a non-empty array | RLS forced on, whatever rls says — a policy on a table without RLS is inert |
rls: false and no policies | RLS off. An explicit opt-out for a genuinely public table |
The rule in one line, as the SDK computes it once the callback has run: rls = policies.length > 0 || table.rls !== false.
import { defineTable, text, uuid } from "@palbase/backend";
const public_prices = defineTable("public_prices", {
columns: { id: uuid().primaryKey().defaultRandom(), label: text().notNull() },
rls: false, // deliberate: anyone may read this
});
rls: false is an explicit statement a reviewer can grep for, rather than something you get by forgetting. The stack also warns about it in its own log on every apply, not just the one that created the table, so an opt-out declared months ago keeps announcing itself:
row security disabled by declaration on: public_prices
That line goes to the stack's log rather than to your terminal — palbase logs is where to look for it.
rls: false and exposed: false answer different questions. rls decides whether Postgres itself filters rows; exposed decides whether /v1/db will serve the schema at all. A table you mean to be world-readable wants the first; a schema no client should reach wants the second.
The builder reference, auth.uid(), the roles a policy has to name, and the Database.$asService() bypass are on the Row-Level Security page.
From a declaration to a database
Editing a file under db/ changes nothing by itself, and there are exactly two rails that act on it — one for the database on your machine, one for a project in the cloud.
# Regenerate the types, so Database.public.* matches what you just wrote.
palbase build
# The stack running on this machine: see the change, then make it.
palbase db plan
palbase db apply
# The linked project: one request applies the schema and activates the code.
palbase push
palbase db plan and db apply act only on the local stack palbase start brings up, and refuse by name in a checkout linked to a cloud project. palbase push is the only thing that changes a project's schema: it diffs what db/ declares against that project's own live database and applies the result in the same request that activates the code, so the code never serves against a schema older than itself. Nothing is generated, committed or replayed in between — see Schema changes for the whole story, and Database for the commands.
Related
- Schema migration guide — moving a
db/schema.tsproject to the current DSL - Database — the typed
Database.public.*surface your schema powers - Row-Level Security —
policy(),auth.uid(), and$asService() - Schema changes — how a change reaches a database, and what a refusal looks like
- Database —
palbase db plan,db applyanddb query - Deploying —
palbase push, and what travels with it - Test Users — seed data typed against these tables