Schema as Code
The Core Idea
Section titled “The Core Idea”In Rebase, your TypeScript collection definitions are the single source of truth. From one set of TypeScript objects, Rebase generates:
- PostgreSQL tables via Drizzle ORM schema generation
- CRUD UI — forms, tables, validation, field types
- REST API endpoints with filtering, sorting, and pagination
- Typed SDK — type-safe data operations
- RLS policies — Row Level Security in Postgres
This means your schema is:
- Version controlled — every change is a git commit
- Type-safe — TypeScript catches errors at compile time
- Reviewable — schema changes go through pull requests
- Portable — the same definition works across frontend, backend, and CLI
Visual Editing with AST Manipulation
Section titled “Visual Editing with AST Manipulation”Rebase also provides a visual collection editor in Studio mode. When a non-developer uses the visual editor to add a field:
- The Studio does not directly modify the database
- Instead, it uses ts-morph to parse your TypeScript source file as an AST
- It inserts the new property definition precisely into the
propertiesblock - All existing code, callbacks, and custom logic are preserved untouched
- The file is saved, triggering hot reload
This “UI as Code Generator” approach means visual edits produce the same clean TypeScript a developer would write by hand.
Schema Generation Pipeline
Section titled “Schema Generation Pipeline”Your collections are read once and emitted twice — as a Drizzle schema the running server queries through, and as SQL that describes the database you meant to have. It is the SQL that the database is brought into line with, by Atlas, which diffs your desired schema against the live one and plans the change:
Collections (TypeScript) │ ┌─────────────────────┴─────────────────────┐ ▼ ▼ rebase schema generate (db push and db generate write │ the SQL below on every run, into ▼ .rebase/sql/, which is not committed) backend/src/schema.generated.ts │ the Drizzle schema the runtime ▼ reads and writes rows through schema.sql ← Atlas's desired state policies.sql ← RLS, applied separately search.sql, vector.sql, ← Atlas cannot manage these triggers.sql │ ┌─────────────┴─────────────┐ ▼ ▼ rebase db push rebase db generate Atlas plans the diff Atlas writes the diff and applies it now to drizzle/migrations/ │ │ │ ▼ │ rebase db migrate │ applies them in order └─────────────┬─────────────┘ ▼ PostgreSQLdb push is the development loop; db generate + db migrate is the
reviewable one, and the one to use in production. Both go through the same
generated SQL, so they cannot disagree about what your collections mean, and
both write it afresh from the collections before reading it, so there is no copy
in your repository to fall behind. The migrations are what you commit. See
Schema Generation for every flag.
Example
Section titled “Example”Given this collection:
import { defineCollection } from "@rebasepro/cms-types";const productsCollection = defineCollection({ slug: "products", name: "Products", table: "products", properties: { name: { type: "string", name: "Name", validation: { required: true } }, price: { type: "number", name: "Price", columnType: "numeric" }, active: { type: "boolean", name: "Active", defaultValue: true }, createdAt: { type: "date", name: "Created", autoValue: "on_create" } }});Rebase generates this Drizzle schema — the whole file, id column, default
policies and all:
// schema.generated.ts// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.
import { boolean, numeric, pgPolicy, pgTable, text, timestamp } from 'drizzle-orm/pg-core';import { relations as drizzleRelations, sql } from 'drizzle-orm';
export const products = pgTable("products", { name: text("name").notNull(), price: numeric("price"), active: boolean("active").default(sql`TRUE`), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`now()`), id: text("id").primaryKey().default(sql`gen_random_uuid()::text`)}, (table) => ([ pgPolicy("products_default_admin_read", { as: "permissive", for: "select", to: ["public"], using: sql`(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))` }), pgPolicy("products_default_admin_write_insert", { as: "permissive", for: "insert", to: ["public"], withCheck: sql`(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))` }), pgPolicy("products_default_admin_write_update", { as: "permissive", for: "update", to: ["public"], using: sql`(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))`, withCheck: sql`(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))` }), pgPolicy("products_default_admin_write_delete", { as: "permissive", for: "delete", to: ["public"], using: sql`(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))` }),])).enableRLS();
export const tables = { products };export const enums = { };export const relations = { };Three things in there are worth reading twice. The id column you did not
declare: every collection gets a text primary key unless a property claims
isId, and the database fills it with a uuid (gen_random_uuid()::text), so a
row created from the admin panel, the REST API or the SDK needs no key of its
own. A key you do send is used as given. On 0.23 the column has no default, and
a create that sends no key fails. The pgPolicy block: row level security is enabled on every table, and
those baseline policies are what keep the trusted server context and the admin
role able to read it at all — see
Security Rules. And active, which carries
the defaultValue you wrote as a column default: a literal defaultValue
compiles to a real DEFAULT, so a row inserted by the REST API, a seed script
or psql gets it too, not only one typed into the admin panel. A reference or
vector default is not a column literal and stays an application-layer value.
And this SQL, which is what Atlas is handed as the desired state:
-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.
CREATE SCHEMA IF NOT EXISTS "rebase";
CREATE TABLE "public"."products" ( "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, "name" TEXT NOT NULL, "price" NUMERIC, "active" BOOLEAN DEFAULT TRUE, "created_at" TIMESTAMP WITH TIME ZONE DEFAULT now());Policies, search columns, vector indexes and updated_at triggers are written to
files of their own and applied by the CLI in their own right — Atlas manages
none of the four.
Safety & Unmapped Database Objects
Section titled “Safety & Unmapped Database Objects”When Rebase updates the database schema, it maps TypeScript collection definitions to database tables. To ensure that unmapped database objects (e.g., tables, views, enums) are never dropped or modified, Rebase implements several layers of safety:
- Strict Table Filtering (
tablesFilter): The generated Drizzle configuration dynamically restricts synchronization to only the tables exported in the generated schema. Any unrecognized tables or legacy systems’ tables existing in the database are ignored by the sync engine. - Schema Restrictions (
schemaFilter): The DB sync is restricted exclusively to thepublicschema. Internal database tables, custom schemas, and extension-specific tables are left untouched. - Roles and Extension Protection: Drizzle is configured not to manage database roles (
entities.roles: false) or helper tables from extensions like PostGIS. - Interactive Dev Mode Confirmation: When running
rebase db pushin development, the CLI executes with--strictand--verboseflags, which ensures that developers must explicitly review and approve any destructive SQL actions before they are executed. - Index Ownership by Name: Indexes are the one object that lives on a mapped table, so table filtering cannot protect them — and a push used to plan
DROP INDEXfor any index missing from the desired state, which meant every hand-written one. Rebase now names the indexes it generates<table>_<columns>_ix_<7 hex>(_ux_when unique), a shape no other namer here produces, and excludes everything else from the diff by name. An index you wrote by hand, or that came in through introspection, is never touched; a declaration you delete still drops, which is the intent. See Indexes.
Next Steps
Section titled “Next Steps”- Collections — Full collection configuration reference
- Properties — Detailed column type mappings
- Indexes — Declaring the indexes your queries need