Skip to content

Schema as Code

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

Rebase also provides a visual collection editor in Studio mode. When a non-developer uses the visual editor to add a field:

  1. The Studio does not directly modify the database
  2. Instead, it uses ts-morph to parse your TypeScript source file as an AST
  3. It inserts the new property definition precisely into the properties block
  4. All existing code, callbacks, and custom logic are preserved untouched
  5. 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.

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
└─────────────┬─────────────┘
▼
PostgreSQL

db 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.

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.

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:

  1. 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.
  2. Schema Restrictions (schemaFilter): The DB sync is restricted exclusively to the public schema. Internal database tables, custom schemas, and extension-specific tables are left untouched.
  3. Roles and Extension Protection: Drizzle is configured not to manage database roles (entities.roles: false) or helper tables from extensions like PostGIS.
  4. Interactive Dev Mode Confirmation: When running rebase db push in development, the CLI executes with --strict and --verbose flags, which ensures that developers must explicitly review and approve any destructive SQL actions before they are executed.
  5. 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 INDEX for 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.
  • Collections — Full collection configuration reference
  • Properties — Detailed column type mappings
  • Indexes — Declaring the indexes your queries need