# Rebase Documentation > Rebase is an open-source TypeScript backend built on Postgres: REST & GraphQL APIs, authentication, > row-level security, realtime subscriptions, storage, cron jobs, and an MCP server for AI agents — > all generated from your schema, with a full admin panel and SQL editor included on top. > Connect an existing PostgreSQL database or start fresh; Rebase uses Drizzle ORM under the hood and > keeps your TypeScript collection definitions as the single source of truth. > Scoped API keys with per-collection permissions plus Postgres-enforced RLS make it safe for > autonomous agents to read and write production data. ## Introduction ### What is Rebase? Rebase is an **open-source Backend-as-a-Service (BaaS)** and **admin panel** built with React and TypeScript. It gives you everything you need to build production-ready back-office applications on top of **PostgreSQL**: - **Schema as Code** — Define your data models as TypeScript collections. Rebase generates PostgreSQL tables, a full CRUD UI, REST API endpoints, and type-safe client SDKs from a single source of truth. - **Instant Admin Panel** — A beautiful, fast React-based admin UI with spreadsheet tables, lists, forms, Kanban boards, inline editing, and real-time sync. - **Integrated Backend** — Authentication (JWT + Google OAuth), file storage (local or S3), Row Level Security, entity history, and WebSocket real-time — all built in. - **Radically Extensible** — If you can build it in React, you can add it to Rebase. Custom fields, custom views, plugins, and full access to internal hooks. :::tip Rebase is **not** a hosted SaaS. You self-host it, you own your data, and you own your code. Think of it as an open-source alternative to Supabase or Retool that you control end-to-end. ::: ### How It Works ``` ┌──────────────────────────────────────────────────────┐ │ Client Applications │ │ ┌──────────────┐ ┌───────────────────────────────┐ │ │ │ Your App │ │ Rebase Admin UI │ │ │ │ (Any Tech) │ │ Tables / Lists / Kanban │ │ │ └──────┬───────┘ └──────────────┬────────────────┘ │ └─────────┼──────────────────────────┼─────────────────┘ │ HTTP + WebSocket │ ▼ ▼ ┌──────────────────────────────────────────────────────┐ │ Rebase Backend (Node.js + Hono) │ │ ┌─────────┐ ┌──────┐ ┌─────────┐ ┌───────────────┐ │ │ │ REST API│ │ Auth │ │ Storage │ │ WebSocket RT │ │ │ └────┬────┘ └──┬───┘ └────┬────┘ └──────┬────────┘ │ └───────┼─────────┼──────────┼─────────────┼───────────┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────────────────────────────────────────────────┐ │ PostgreSQL (Drizzle ORM) │ └──────────────────────────────────────────────────────┘ ``` ### Quick Start ```bash pnpm dlx @rebasepro/cli init my-app cd my-app ``` `init` generates a ready-to-run `.env` for you (secrets, database password, a free local port) — no editing needed to get started. Start the bundled PostgreSQL container, create the tables, then run the dev servers: ```bash docker compose up -d db # start the database pnpm run db:push # create tables from your collections pnpm dev # start backend + frontend ``` Your admin panel is running at `http://localhost:5173` and the API at `http://localhost:3001`. The first account you register becomes the admin. → Follow the full [Quickstart guide](/docs/getting-started/quickstart) for a complete walkthrough, including how to connect your own database. ### Core Features | Feature | Description | |---------|-------------| | **Spreadsheet Views** | Fast, virtualized table with inline editing, filtering, sorting, and text search | | **List View** | Clean, responsive vertical list view for collections | | **Kanban Board** | Drag-and-drop board view grouped by any enum property | | **Relations** | One-to-one, one-to-many, many-to-many with junction tables and multi-hop joins | | **Row Level Security** | Supabase-style RLS policies defined in your collection config | | **Authentication** | JWT + refresh tokens, Google OAuth, role-based access control | | **File Storage** | Local filesystem or S3-compatible with upload fields and browser | | **Real-time Sync** | WebSocket-powered live updates across all connected clients | | **Entity History** | Full audit trail for every create, update, and delete | | **REST API** | Auto-generated CRUD endpoints for every collection | | **Data Import/Export** | CSV, JSON, and Excel import with field mapping; CSV/JSON export | | **Collection Editor** | Visual schema editor that generates TypeScript via AST manipulation | | **Client SDK** | Type-safe JavaScript SDK for your frontend apps | | **Plugin System** | Extend every part of the UI — toolbar, forms, fields, home page | | **AI Data Enhancement** | LLM-powered autocompletion for text fields | | **Cron Jobs** | Scheduled background tasks with monitoring, logging, and Studio dashboard | | **Custom Functions** | File-based Hono routes auto-mounted with auth middleware and DB access | | **Email Service** | Built-in SMTP for password reset, verification, and transactional emails | | **Database Branching** | Instant isolated copies of your database for dev, staging, or testing | | **Webhooks** | HMAC-signed outbound notifications on INSERT, UPDATE, DELETE | | **Broadcast Channels** | WebSocket channels for real-time messaging between connected clients | | **Presence Tracking** | Track online users and sync shared state across participants | ### Next Steps - **[Quickstart](/docs/getting-started/quickstart)** — Get running in 2 minutes - **[Project Structure](/docs/getting-started/project-structure)** — Understand the generated code - **[Collections](/docs/collections)** — Define your data schema - **[Backend](/docs/backend)** — Configure auth, storage, and the API - **[Architecture](/docs/architecture)** — Understand how it all fits together ## Quickstart ### Create a New Project ```bash pnpm dlx @rebasepro/cli init my-app ``` This scaffolds a project with three packages: | Folder | Description | |--------|-------------| | `frontend/` | React SPA — Vite + TypeScript with the Rebase admin UI | | `backend/` | Node.js server — Hono, PostgreSQL via Drizzle ORM, WebSocket | | `config/` | Config files and collection definitions shared by both sides | ### Prerequisites - **Node.js** 18+ - **Docker** — to run the included PostgreSQL container. (Or bring your own PostgreSQL: local install, Neon, Supabase, etc.) - **pnpm** (recommended) or npm ### Your Environment Is Already Configured `init` generates a ready-to-run `.env` at the project root with a real `JWT_SECRET`, a database password, and a free local database port. You don't need to create or edit anything to get started. :::caution Don't run `cp .env.example .env`. `.env.example` is a reference for the available variables — copying it over your `.env` discards the generated secrets and points `DATABASE_URL` at a database that doesn't exist. Edit `.env` directly if you want to change a value. ::: If you'd rather point at your own PostgreSQL instead of the bundled container, edit `DATABASE_URL` in `.env`: ```bash DATABASE_URL=postgresql://username:password@localhost:5432/your_database ``` ### Start the Database The scaffold ships a `docker-compose.yml` with a PostgreSQL service. Start it: ```bash docker compose up -d db ``` (Skip this if you pointed `DATABASE_URL` at your own database.) ### Create the Tables Push your collections to the database. This creates the tables for the example `posts`, `authors`, and `tags` collections: ```bash pnpm run db:push ``` Without this step the admin panel still opens, but every collection is empty and its API calls fail until the tables exist. ### Introspect an Existing Database (Optional) If you are connecting to an existing database with pre-existing tables, you can introspect it to automatically generate your TypeScript collection files: ```bash pnpm rebase schema introspect ``` This will analyze your database tables and generate corresponding TypeScript files in `config/collections/` so you don't have to write them manually. ### Start the Dev Servers ```bash pnpm dev ``` This starts both together: - **Backend** — REST API, auth, storage, WebSocket - **Frontend** — the Rebase admin panel - **Hot reload** for both — changes take effect instantly Both ports are **derived from this project's path** rather than fixed, so several Rebase projects can run side by side. `rebase dev` prints the two URLs it bound — use those, not `localhost:3001`/`localhost:5173`. (`PORT` and `VITE_API_URL` in `.env` configure `rebase start`, the production server, and are ignored here.) Pin a port with `rebase dev --port 3001`. ### First Login When you open the frontend URL `rebase dev` printed, you'll see the login screen. The **first user** to register automatically becomes an admin — this is the bootstrap flow. 1. Click **Sign Up** 2. Enter your email and password 3. You're in — with full admin access ### Define Your First Collection Open `config/collections/` and create a new file. Export the collection as the **default export** — that's how the registry picks it up: ```typescript title="config/collections/products.ts" const productsCollection = defineCollection({ slug: "products", name: "Products", singularName: "Product", table: "products", properties: { name: { type: "string", name: "Name", validation: { required: true } }, price: { type: "number", name: "Price", validation: { required: true, min: 0 } }, description: { type: "string", name: "Description", admin: { multiline: true } }, active: { type: "boolean", name: "Active", defaultValue: true }, created_at: { type: "date", name: "Created At", autoValue: "on_create" } } }); export default productsCollection; ``` Then register it in `config/collections/index.ts` so both the backend and the admin panel know about it: ```typescript title="config/collections/index.ts" {2,5} // ...existing imports export const collections = [ postsCollection, authorsCollection, tagsCollection, usersCollection, productsCollection ]; ``` ### Create the Table Push the new collection to the database: ```bash pnpm run db:push ``` This regenerates the schema from your collections and applies it. Restart the dev servers and your new **Products** collection appears in the navigation. ### Database Commands Reference | Command | Description | |---------|-------------| | `rebase schema generate` | Generate Drizzle schema from your TypeScript collections | | `rebase schema introspect` | Generate TypeScript collections from an existing database | | `rebase db push` | Push schema changes directly to the database (dev only) | | `rebase db generate` | Generate SQL migration files | | `rebase db migrate` | Run pending migrations | ### What's Next - **[Project Structure](/docs/getting-started/project-structure)** — Understand the generated code - **[Collections](/docs/collections)** — Deep dive into schema definition - **[Environment & Configuration](/docs/getting-started/configuration)** — All configuration options - **[Deployment](/docs/getting-started/deployment)** — Deploy to production ## Project Structure A Rebase starter project has three interconnected packages: ``` my-app/ ├── .env # Environment variables (DATABASE_URL, JWT_SECRET, etc.) ├── package.json # Root workspace config │ ├── frontend/ # React admin panel (Vite) │ ├── src/ │ │ ├── App.tsx # Main application component │ │ ├── main.tsx # React entry point │ │ └── index.css # Global styles │ ├── package.json │ └── vite.config.ts │ ├── backend/ # Node.js API server (Hono) │ ├── src/ │ │ ├── index.ts # Server entry — initializes Rebase backend │ │ └── schema.generated.ts # Auto-generated Drizzle schema │ ├── drizzle.config.ts # Drizzle ORM configuration │ ├── Dockerfile │ └── package.json │ └── config/ # Collection definitions and configurations └── collections/ ├── index.ts # Exports all collections └── products.ts # Example: products collection ``` ### Frontend (`frontend/`) The frontend is a standard **Vite + React + TypeScript** application. The key file is `App.tsx`, which wires together all Rebase controllers: ```typescript title="frontend/src/App.tsx" // The client connects to your backend API and WebSocket const rebaseClient = createRebaseClient({ baseUrl: "http://localhost:3001", websocketUrl: "ws://localhost:3001" }); // Collections are imported via a Vite virtual module // that reads from the config/ directory ``` #### Key Concepts - **`createRebaseClient`** — Creates the SDK client that handles HTTP requests, WebSocket connections, and auth token management - **`virtual:rebase-collections`** — A Vite plugin that auto-imports your shared collections at build time - **Controllers** — `useBuildNavigationStateController`, `useBuildCollectionRegistryController`, etc. — these configure routing, collection resolution, and UI configuration ### Backend (`backend/`) The backend is a **Node.js server** built on [Hono](https://hono.dev/) (a fast, lightweight HTTP framework). The entry point `index.ts` initializes everything: ```typescript title="backend/src/index.ts" const app = new Hono(); await initializeRebaseBackend({ app, server, collectionsDir: "./config/collections", database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations } }), auth: { jwtSecret: process.env.JWT_SECRET, google: process.env.GOOGLE_CLIENT_ID ? { clientId: process.env.GOOGLE_CLIENT_ID } : undefined, }, storage: { type: "local", basePath: "./uploads" }, history: true }); ``` `initializeRebaseBackend` sets up: - **REST API** routes at `/api/data/*` — auto-generated CRUD for each collection - **Auth** routes at `/api/auth/*` — signup, login, refresh, Google OAuth - **Storage** routes at `/api/storage/*` — file upload/download - **WebSocket** server — real-time entity sync via Postgres LISTEN/NOTIFY - **History** — audit trail recording on every entity change ### Collections (`config/collections/`) Collections are the **single source of truth** for your data model. They are defined as TypeScript and consumed by both the frontend (for UI generation) and the backend (for schema generation and API routing). ```typescript title="config/collections/products.ts" export const productsCollection = defineCollection({ slug: "products", name: "Products", table: "products", properties: { name: { type: "string", name: "Name" }, price: { type: "number", name: "Price" } } }); ``` The `slug` becomes the URL path in the admin UI and the REST API endpoint (`/api/data/products`). The `table` maps to the PostgreSQL table name. ### How They Connect 1. **You define** collections in `config/collections/` 2. **The backend** reads them to generate Drizzle schemas and mount REST routes 3. **The frontend** reads them (via Vite plugin) to render tables, forms, and navigation 4. **The CLI** reads them to generate migration files with `rebase schema generate` Changes to collections propagate everywhere automatically. ### Next Steps - **[Quickstart](/docs/getting-started/quickstart)** — Get started with a new Rebase project - **[Configuration](/docs/getting-started/configuration)** — All environment variables and options ## Environment & Configuration ### Environment Variables All configuration is done via environment variables in your `.env` file at the project root. > **Important**: Rebase uses **Zod** to validate environment variables at startup in `src/env.ts`. If any required variables are missing or incorrectly formatted (like URLs or ports), the server will fail to start and provide a clear error message. #### Required | Variable | Description | Example | |----------|-------------|---------| | `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/mydb` | | `JWT_SECRET` | Secret key for signing JWT tokens. Use a strong random string (min 32 chars). **Required in production** (auto-generated in development). | `a1b2c3d4e5...` | > **`sslmode=no-verify` is a node-postgres spelling, not a libpq one.** > > Rebase and the Node driver accept it — encrypt, but do not check the > certificate. `psql`, `pg_dump`, `pg_restore` and Atlas do not, and they do not > degrade: they refuse to start with `invalid sslmode value: "no-verify"`. > > Rebase's own commands (`rebase db push`, `rebase db backup`, `rebase db > restore`) rewrite it to the equivalent `sslmode=require` before shelling out, > so they work with the URL as configured. Reaching for `psql` by hand does not > — swap in `sslmode=require` there, which encrypts without verifying in exactly > the same way. #### Frontend | Variable | Description | Default | |----------|-------------|---------| | `VITE_API_URL` | Backend API URL for the client SDK. **Set this in development only** — see below. | page origin | | `VITE_GOOGLE_CLIENT_ID` | Google OAuth client ID. Enables "Sign in with Google". | — | > **Leave `VITE_API_URL` unset in production builds.** > > In development the frontend and backend are separate origins, so the dev > server injects this. In production the Rebase backend serves the SPA, so the > API is the page's own origin and the client resolves it that way on its own. > > Baking an absolute URL into a production bundle works right up until a second > hostname points at the same app: a custom domain then loads the page from > `example.com` and calls the API on `example.rebase.website`, which is > cross-origin, so every request fails preflight. Allowing the origin in CORS > does **not** fix it either — the refresh cookie is `SameSite=Lax` and is not > sent cross-site, so you would clear the console errors and still have broken > auth. Unset, every domain pointing at the app works with no CORS > configuration at all. #### Backend | Variable | Description | Default | |----------|-------------|---------| | `PORT` | Port for the backend HTTP server | `3001` | | `LOG_LEVEL` | Logging verbosity: `error`, `warn`, `info`, `debug` | `info` | | `NODE_ENV` | Environment: `development`, `production`, or `test` | `development` | | `CORS_ORIGINS` | Comma-separated list of allowed origins. **Required in production** if different from backend domain. | — | | `FRONTEND_URL` | URL of the frontend app. Used as an alternative to CORS_ORIGINS. | — | | `ADMIN_CONNECTION_STRING` | Admin-level database connection string (used for schema introspection and admin operations). | `DATABASE_URL` | | `DISABLE_DB_ROLE_SWITCHING` | Disable PostgreSQL role-switching in SQL Editor (useful for custom authentication where DB roles are not mapped). | `false` | #### Authentication | Variable | Description | Default | |----------|-------------|---------| | `JWT_SECRET` | Secret for JWT signing (required in production, auto-generated in development) | — | | `JWT_ACCESS_EXPIRES_IN` | Access token lifetime | `1h` | | `JWT_REFRESH_EXPIRES_IN` | Refresh token lifetime | `30d` | | `ALLOW_REGISTRATION` | Allow new users to register (`true`/`false`). First user can always register. | `true` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID (backend validation) | — | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | — | | `REBASE_SERVICE_KEY` | Static admin API key. Bypasses normal JWT auth for server-to-server calls when passed as `Authorization: Bearer `. (Auto-generated in development). | — | #### Storage | Variable | Description | Default | |----------|-------------|---------| | `STORAGE_TYPE` | Storage backend: `local`, `s3` or `gcs`. In production `local` disables storage unless `FORCE_LOCAL_STORAGE=true` | `local` | | `STORAGE_PATH` | Base path for local storage | `./uploads` | | `FORCE_LOCAL_STORAGE` | Allow local storage in production — only with a durable volume mounted at `STORAGE_PATH` | `false` | | `S3_BUCKET` | S3 bucket name (when `STORAGE_TYPE=s3`) | — | | `S3_REGION` | AWS region | — | | `S3_ACCESS_KEY_ID` | AWS access key | — | | `S3_SECRET_ACCESS_KEY` | AWS secret key | — | | `S3_ENDPOINT` | Custom S3 endpoint (for MinIO, Cloudflare R2, etc.) | — | | `S3_FORCE_PATH_STYLE` | Force path-style URLs for S3 bucket (`true`/`false`) | `false` | #### Email (Optional) | Variable | Description | |----------|-------------| | `SMTP_HOST` | SMTP server host | | `SMTP_PORT` | SMTP server port | | `SMTP_SECURE` | Enable secure connection (`true`/`false`) | | `SMTP_USER` | SMTP username | | `SMTP_PASS` | SMTP password | | `SMTP_FROM` | Sender address for system emails | ### Backend Config Object The `RebaseBackendConfig` passed to `initializeRebaseBackend()` provides programmatic control: ```typescript await initializeRebaseBackend({ app, server, collectionsDir: "./config/collections", basePath: "/api", // Base path for all API routes (default: "/api") database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations } }), auth: { // Authentication config jwtSecret: env.JWT_SECRET, accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN, refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN, requireAuth: true, // Require auth for data API (default: true) allowRegistration: env.ALLOW_REGISTRATION, google: env.GOOGLE_CLIENT_ID ? { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } : undefined, serviceKey: env.REBASE_SERVICE_KEY }, // No bucket configured in production means storage is off, not local: // uploads answer 501 rather than landing on a filesystem that is erased // on the next redeploy. storage: env.STORAGE_TYPE === "s3" ? { type: "s3", bucket: env.S3_BUCKET!, region: env.S3_REGION, accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY, endpoint: env.S3_ENDPOINT } : env.STORAGE_TYPE === "gcs" ? { type: "gcs", bucket: env.GCS_BUCKET!, projectId: env.GCS_PROJECT_ID, keyFilename: env.GCS_KEY_FILENAME } : isProduction && !env.FORCE_LOCAL_STORAGE ? undefined : { type: "local", basePath: env.STORAGE_PATH || "./uploads" }, history: true, // Enable entity change history enableSwagger: true, // Enable OpenAPI docs at /api/data/docs logging: { level: "info" } }); ``` #### Changing `basePath` `basePath` moves every API route, so the client has to be told the same thing — otherwise it keeps asking for `/api/...` and gets a 404 for everything: ```typescript export const rebase = createRebaseClient({ baseUrl: "https://api.example.com", apiPath: "/v1" // must match the backend's basePath }); ``` The admin panel picks this up from the client it is given; nothing else needs configuring. If you build a request URL by hand, join it from the client rather than writing `/api` yourself: ```typescript function Widget() { const apiBase = useApiBase(); // e.g. "https://api.example.com/v1" // fetch(`${apiBase}/data/products`) } ``` ### Troubleshooting #### SQL Editor Permission Denied (`permission denied for table `) * **Symptoms:** Custom queries executed in the Rebase Studio SQL Editor fail with `cause: error: permission denied for table `, even though the spreadsheet CMS view loads data successfully. * **Cause:** By default, Rebase attempts to execute SQL Editor queries by temporarily switching database roles to match the active user's application role (e.g., `SET LOCAL ROLE "admin"`). If you are using custom authentication where roles exist only in database tables rather than actual PostgreSQL roles, the role switch fails or database privileges are missing. The CMS spreadsheet view executes under the default connection owner user and bypasses this. * **Solution:** Add `DISABLE_DB_ROLE_SWITCHING=true` to your backend `.env` configuration. This forces Rebase to run SQL Editor queries using the connection owner's privileges (typically a superuser/owner). #### SQL Editor Schema Fetch Failed (`Cross-database execution requires adminConnectionString`) * **Symptoms:** Studio fails to load the schema tree, or SQL Editor throws `Failed to fetch schema: Cross-database execution requires adminConnectionString to be configured in the backend.` * **Cause:** Rebase requires administrative privileges to query database system catalogs and run administrative commands. If `adminConnectionString` is not provided to the bootstrapper, or `getAdmin()` is overridden to return `undefined`, these operations fail. * **Solution:** Ensure `adminConnectionString` is configured during backend bootstrapper initialization: ```typescript createPostgresBootstrapper({ connection: db, schema: { tables, enums, relations }, adminConnectionString: process.env.ADMIN_CONNECTION_STRING || process.env.DATABASE_URL }) ``` ### Next Steps - **[Deployment](/docs/getting-started/deployment)** — Production deployment guide - **[Backend Overview](/docs/backend)** — Full backend configuration reference ## Upgrading ## Upgrading an existing app This release removes the `auth` schema, renames packages, changes what `id` means, drops CJS, moves the admin panel to react-router 8, and — most importantly — **fixes three ways access could be granted that you did not ask for**: a security rule that was silently permissive, and two on the realtime socket. Read the first three sections before anything else. Section 0 is the one that changes SQL you may have written by hand; sections 1 and 2 alter who can read your data, and none of them announces itself. Work through the sections in order. Each one names the symptom you would otherwise debug from the wrong end. --- ### 0. The `auth` schema is gone — read this first #### What changed Rebase's RLS helper functions moved out of the `auth` schema and into `rebase`: | Before | Now | |---|---| | `auth.uid()` | `rebase.uid()` | | `auth.roles()` | `rebase.roles()` | | `auth.jwt()` | `rebase.jwt()` | `auth` is Supabase's schema name. Borrowing it meant Rebase could not be pointed at a database that already had one: applying `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` over Supabase's `RETURNS uuid` is something Postgres refuses outright, and the refusal used to be swallowed — leaving a database with auth tables, no helper functions, and policies calling functions that did not exist. Rebase now creates exactly one schema in your database: `rebase`. Nothing else. #### What you have to do **If your `securityRules` use the structured helpers** — `policy.authUid()`, `policy.rolesOverlap()`, `ownerField`, `roles` — **nothing.** They never spelled a schema name. Re-run `rebase db push` (or redeploy) and your policies are recompiled. **If you wrote raw policy SQL**, it keeps working: the compiler rewrites `auth.uid()` to `rebase.uid()` on its way into the database. The boot log names every collection still carrying the old spelling so you can update it. Do — the rewrite is a migration aid, not a second supported spelling. ```ts // Works, and warns. securityRules: [{ operation: "select", using: "owner_id = auth.uid()" }] // The fix. securityRules: [{ operation: "select", using: "owner_id = rebase.uid()" }] // Better: no schema name to get wrong. securityRules: [{ operation: "select", condition: policy.compare(policy.field("owner_id"), "eq", policy.authUid()) }] ``` **Hand-written policies you created outside Rebase** — a SQL migration, the Studio editor — are the one thing nothing can rewrite for you. Until you update them, Postgres will not drop the functions they depend on, and the `auth` schema stays. Boot tells you exactly which policies, by name: ``` The pre-1.0 `auth` schema cannot be removed yet: 1 policy still calls `auth.uid()` and friends … anything listed here is hand-written SQL that has to be updated to `rebase.uid()` by hand, after which the schema goes on its own: • public.posts → "posts_legacy" ``` #### What happens to the old schema It is dropped automatically once nothing references it, and only when Rebase is what created it. Each function is identified by its result type and body before being dropped, and the schema goes by `DROP SCHEMA auth RESTRICT` — never CASCADE — so a Supabase installation, or anything else living in `auth`, keeps it untouched. #### Also: the scaffold's database role is now `rebase_app` Postgres resolves unqualified names through `search_path`, which defaults to `"$user", public` — and `$user` is the connection role. A role named `rebase` therefore put the `rebase` **schema** ahead of `public`, so unqualified SQL from anything that does not pin the path (psql, `pg_dump`, drizzle-kit, a hand-written migration) silently landed in the wrong schema. Existing projects need no change: every connection Rebase opens already pins `search_path=public`. New projects get `rebase_app`, and boot now warns if your connection role shares a name with a schema. --- ### 1. `policy.authenticated()` — read this first #### What changed `policy.authenticated()` used to compile to: ```sql auth.uid() IS NOT NULL ``` On the user path that is a **tautology**. `applyAuthContext` coerces a blank user id to the `'anonymous'` sentinel — deliberately, so that it can never read back as `NULL` and be mistaken for the trusted server context. So `IS NOT NULL` was true for anonymous visitors too. A rule that reads as "logged-in users only" therefore granted access to **everyone, including signed-out visitors**. It now compiles to: ```sql auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous' ``` `policy.not(policy.authenticated())` was separately special-cased to mean "the server context". It no longer is — use `policy.serverContext()` for that. #### Why this is the dangerous one The compiled SQL lives in your **database**, not in your application code. Upgrading the packages does not change it. The two failure modes are opposite, and both are quiet: | What you do | What happens | |---|---| | Upgrade packages, **do not** re-run `db push` | Your database keeps `auth.uid() IS NOT NULL`. **Anonymous visitors keep the access they should never have had.** Nothing warns you. | | Upgrade packages **and** re-run `db push` | The policy tightens. Anything that was relying on the permissive behaviour — an unauthenticated read your frontend does on page load, a public listing, a webhook without a session — starts returning zero rows or 403. | > **`rebase doctor --policies` catches this, from 0.10.0 on.** It reads the live > `qual` and `with_check` straight out of `pg_policies` and reports, under > **Insecure**, any policy still carrying the bare `auth.uid() IS NOT NULL` > tautology. It reports the other half of the problem under **Orphaned**: a > policy an earlier push superseded but never dropped. Editing a rule renames its > policy — the generated name is a hash of the rule — so the old one is left > behind, and Postgres ORs permissive policies together, which makes an abandoned > grant outrank the tightening that replaced it. The command exits non-zero, so > CI can gate on it. > > Plain `rebase doctor` runs the same policy checks alongside the schema diff; > `--policies` is the policies-only form, and the one to point at a deployed > database. Both need `DATABASE_URL` (or `ADMIN_CONNECTION_STRING`) — without it > the policy checks are *skipped with a warning*, not failed. > **What the scan does not catch.** It matches that one expression shape — in > whatever whitespace Postgres stored it in — and treats an `<> 'anonymous'` (or > `!= 'anonymous'`) guard anywhere in the same clause as the corrected form. A > fail-open policy written by hand in some other spelling — `USING (true)`, > `USING (1 = 1)`, `USING (current_setting('rebase.uid', true) IS NOT NULL)` — is > **not** flagged, and neither is a compound expression that happens to mention > `'anonymous'` in an unrelated branch. It also needs collections to compare > against: a project whose collections generate no policies at all gets no scan. > The `pg_policies` read in Step 3 is how you see the expressions yourself. > **Nothing applies the fix for you.** Policies are not re-run at container boot. > Upgrading the packages, redeploying and restarting all leave `pg_policies` > exactly as it was. Only `db push` rewrites it — and it is what drops the > superseded policies too. #### What to do **Step 1 — find every affected rule.** From your project root: ```bash grep -rn "authenticated()" config/collections/ ``` Every hit is a rule whose meaning changed. Also check for the raw spelling, which was the other way to write the same tautology: ```bash grep -rn "auth.uid() IS NOT NULL" config/collections/ ``` **Step 2 — decide what each one meant.** For each rule, ask which you intended: - *"Any signed-in user"* → `policy.authenticated()`. No code change; the behaviour is now what you wrote. Re-run `db push`. - *"Anyone at all, including anonymous"* → you were relying on the bug, whether you knew it or not. Make it explicit: `{ operation: "select", access: "public" }`. - *"Only the trusted server context"* → replace `policy.not(policy.authenticated())` with `policy.serverContext()`. **Step 3 — check what your database actually has,** before and after: ```sql SELECT tablename, policyname, cmd, qual FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename, policyname; ``` Any `qual` containing `auth.uid() IS NOT NULL` **without** the `<> 'anonymous'` clause is a stale permissive policy. `rebase doctor --policies` reports exactly those, and the superseded policies alongside them; this query is how you read the expressions yourself, which is what catches a fail-open policy written in a spelling the detector does not match. **Step 4 — re-run `db push` and re-run the query.** `db push` applies the current policies and then drops the ones an earlier push superseded. Confirm every policy you expected to change did change, then run: ```bash rebase doctor --policies ``` It should exit 0 with no Insecure or Orphaned entries. **Step 5 — test signed-out.** Open your app in a private window with no session and exercise the read paths. This is where you find the public listing that quietly depended on the old behaviour. --- ### 2. The realtime socket was open — check who could subscribe Two separate defects, both of which granted socket access rather than withholding it, and neither of which logged anything. **`realtime.requireAuth: true` opened the socket.** The connection handler seeds each session with `authenticated: !requireAuth`, so the flag does not gate a later check — it decides whether a connecting client is treated as *already* authenticated. It was computed as: ```ts authConfig.requireAuth !== false && !!authConfig.jwtSecret ``` On a server that authenticates through an `AuthAdapter`, or through anything other than a local `auth.jwtSecret`, that is `false` — so every client that connected was marked authenticated. Setting `requireAuth: true` was what granted access. **The socket and `/api/data` disagreed.** Each computed "does this server require an authenticated caller?" separately. With no auth configured at all, the HTTP routes answered 401 to every read while the socket admitted everyone and served the same rows. #### Are you affected? You were exposed if **either** holds: - you set `realtime.requireAuth: true` while authenticating through an `AuthAdapter` (or any path other than `auth.jwtSecret`), or - you run with no auth configuration at all and assumed the socket matched the 401 you get from `/api/data`. RLS still applied to what a subscription returned, so a collection whose policies are correct leaked nothing. The exposure is the collections whose protection was "the socket requires auth" rather than a policy. #### What to do ```bash # Every collection reachable over the socket relies on RLS, not on the gate. pnpm rebase doctor --policies ``` Then exercise your app **signed out, in a private window**, with the network panel open on the websocket — the same check section 1 asks for, for the same reason. Nothing needs to change in your code: both enforcement points call `resolveRequireAuth` now and the tests pin that they agree. --- ### 3. The authenticated principal is `uid`, not `userId` Tokens now carry a `uid` claim and `c.get("user")` returns `{ uid, roles }`. ```bash grep -rn "\.userId\|payload.userId\|user.userId" src/ config/ ``` Anything reading `payload.userId` or `user.userId` gets `undefined` — which, in a permission check, usually fails open or fails silently rather than throwing. Search for the defensive `a ?? b` spelling too; several places had independently grown one to cope with the two names: ```bash grep -rn "uid ?? \|?? .*userId" src/ config/ ``` --- ### 4. `id` is an address, not a column Rows now carry their own columns under their own names and types. Previously a synthesized `id` was written into rows on the way out, which collided with your data three ways: it renamed the key (a `sku` primary key was served as `id`, with `sku` absent), it changed the type (an integer key arrived as `"42"`), and it destroyed real values (`drizzleResultToRow` spread it last, so it won over a genuine `id` column). **If your tables are keyed on `id`, nothing changes for you.** If any table is keyed on something else, code reading `row.id` must read the real key. Note the type change too: a numeric primary key now arrives as a `number`, so `row.id === "42"` becomes `row.sku === 42`. Strict equality against a string will silently stop matching. --- ### 5. ESM only `main`, `module` and the `import` condition all point at `index.es.js`; the `require` condition is gone. The CJS/UMD half was never loadable anyway — the output banner injects `import` / `import.meta.url`, which a UMD bundle cannot parse as CommonJS — so this removes a build target that could not have been working for you. A CommonJS consumer must use dynamic `import()` or move to ESM. --- ### 6. `react-router` 8, and `react-router-dom` is gone Only relevant if you use the admin panel — `@rebasepro/admin`, `app`, `studio` or `plugin-ai`. A headless install has no router. react-router 8 deletes the `react-router-dom` package. It was only ever a v6-compatibility shim; everything DOM-specific had already collapsed into `react-router` itself in v7. Drop the dependency and change two imports: ```diff - import { createBrowserRouter, RouterProvider } from "react-router-dom"; + import { createBrowserRouter } from "react-router"; + import { RouterProvider } from "react-router/dom"; ``` `RouterProvider` is the only name that moves to a subpath. Everything else — `useNavigate`, `useLocation`, `useSearchParams`, `useParams`, `Link`, `NavLink`, `Outlet`, `Navigate`, `Route`, `Routes`, `MemoryRouter`, `useBlocker` — keeps its name and comes from `react-router`. So for most files this is one specifier: ```bash grep -rl '"react-router-dom"' src | xargs sed -i '' 's|"react-router-dom"|"react-router"|g' ``` Then fix up the `RouterProvider` import wherever you mount the router, which is usually one file. The floors underneath move with it, because react-router 8 requires them: `react` and `react-dom` at **19.2.7** or later, and Node **22.22.0** or later. #### If your tests use Jest This is the part that will cost you an afternoon if it surprises you. react-router 8 is ESM-only, and it breaks ts-jest's CommonJS output two different ways: - react-router guards a Vite HMR hook with `import.meta.hot`. In CommonJS `import.meta` is a **syntax** error, and ts-jest cannot help — TypeScript emits the expression verbatim under `module: commonjs` rather than rejecting or rewriting it. - react-router depends on `cookie-es` 3, which ships `.mjs` only, with no CJS build to resolve to instead. TypeScript keys module format off the file extension, so it will not emit CommonJS for a `.mjs` input whatever `module` says. Every affected suite fails at **module load, with zero tests run**, so the output reads as a broken Jest config rather than as a dependency-format problem. The fix is a transformer that strips the HMR guard after ts-jest runs and transpiles `.mjs` under a `.js` filename; Rebase's own is [`scripts/jest/react-router-esm-transform.cjs`](https://github.com/rebasepro/rebase/blob/main/scripts/jest/react-router-esm-transform.cjs) and is meant to be copied. You will also need react-router and `cookie-es` lifted out of the blanket `node_modules` exclusion in `transformIgnorePatterns`, and `mjs` added to `moduleFileExtensions`. Vitest needs none of this. --- ### 7. Package renames Import paths only — no behaviour moved with them. | Old | New | |---|---| | `@rebasepro/core` | `@rebasepro/app` | | `@rebasepro/server-core` | `@rebasepro/server` | | `@rebasepro/server-postgresql` | `@rebasepro/server-postgres` | | `@rebasepro/server-mongodb` | `@rebasepro/server-mongo` | | `@rebasepro/client-postgresql` | `@rebasepro/client-postgres` | | `@rebasepro/client-firebase` | `@rebasepro/firebase` | | `@rebasepro/formex` | `@rebasepro/forms` | | `@rebasepro/sdk-generator` | `@rebasepro/codegen` | | `@rebasepro/schema-inference` | `@rebasepro/inference` | | `@rebasepro/mcp-server` | `@rebasepro/mcp` | | `@rebasepro/plugin-data-enhancement` | `@rebasepro/plugin-ai` | Unchanged: `types`, `utils`, `common`, `client`, `admin`, `admin`, `studio`, `cli`, `plugin-insights`. The retired names are deprecated on npm, so installing one tells you rather than resolving to an abandoned version. Additionally, **`@rebasepro/auth` is removed**. `useRebaseAuthController`, `fetchAuthConfig`, `createAuthConfigCache` and `clearAuthConfigCache` now come from `@rebasepro/app`, beside the `RebaseAuth` and `LoginView` components they are used with. **`RebaseCMS` is now `RebaseAdmin`.** `mode: "cms"` on `RebaseBackendConfig` is unchanged — it describes where collections come from, not the UI. --- ### 8. Every deprecated export is removed Eleven symbols that carried `@deprecated` are gone rather than carried across the 1.0 line, where removing one would cost a major. #### `rebase.data` → `rebase.dataAsAdmin` The one to grep for first, because it is the one with a security shape. The server singleton had two names for a single **RLS-bypassing** accessor, and the shorter one gave no hint of that — while on a browser client, `data` is the *user-scoped* accessor. The same expression meant two very different things depending on which side of the wire it ran. ```diff - const { data: rows } = await rebase.data.projects.find(); + const { data: rows } = await rebase.dataAsAdmin.projects.find(); ``` ```bash grep -rn "rebase\.data\b" src config backend ``` `RebaseServerClient` extends `Omit` now, so this is a compile error rather than a silent privilege. The property is still there at runtime, aliasing `dataAsAdmin`, so a plain-JavaScript backend keeps running while you migrate — but do not rely on that. **Leave these alone.** They are user-scoped and were never deprecated: - `context.client.data` in an entity callback — runs under the caller's RLS - `client.data` in a cron handler — a `RebaseClient` - `rebase.data` in a generated SDK or browser app — a different object And for user-scoped queries inside a request handler, neither name is what you want: use `c.var.driver`, which carries the caller's identity. #### The other ten Each is a rename at the import site: | Removed | From | Use instead | |---|---|---| | `buildCollection` | `@rebasepro/common` | `defineCollection` | | `buildProperty` | `@rebasepro/common` | a plain property object | | `RebaseUser` | `@rebasepro/client` | `User` | | `RebaseTokens` | `@rebasepro/client` | `AuthTokens` | | `UserInfo` | `@rebasepro/app` | `User` | | `Session` | `@rebasepro/app` | `DeviceSession` | | `AuthApiError` | `@rebasepro/app` | `RebaseApiError` | | `DatabaseConnection` | `@rebasepro/server` | `DriverConnection` | | `createApiKeyRateLimiter` | `@rebasepro/server` | `createDataRateLimiter` | | `resolveChannelBusConfig` | `@rebasepro/server-postgres` | `resolveChannelBusSetting` | `User`, `AuthTokens`, `DeviceSession` and `RebaseApiError` are all exported from `@rebasepro/client` and `@rebasepro/app` directly — you do not need to add `@rebasepro/types` to your `package.json` to name them. Three of these are worth reading past the table: **`createApiKeyRateLimiter` skipped every request that was not API-key-authenticated** — on a normal deployment, nearly all of them. If you wired it expecting protection, you had none for browser traffic. `createDataRateLimiter` covers signed-in users and anonymous callers too. **`buildCollection` and `buildProperty` were announced as removed in 0.11 and were not.** If you migrated then, nothing changes now. If you did not, your build kept working and breaks here. **`DatabaseConnection` is still importable from `@rebasepro/server`** — that is the point. Two shapes answered to the name; the local alias for `DriverConnection` is gone and the canonical type from `@rebasepro/types` remains. If your code still type-checks, you were already using the right one. ```bash grep -rnE "buildCollection|buildProperty|RebaseUser|RebaseTokens|UserInfo|AuthApiError|createApiKeyRateLimiter|resolveChannelBusConfig" src config backend ``` --- ### 9. `defaultSecurityRules` moved off the server config It used to live on `RebaseBackendConfig`, where it enforced nothing: `db push` generates the Postgres policies — the only thing that actually enforces access — from the collection *files*, and never sees the running server. Declare it in `config/collections/index.ts` instead, where the loader reads it and both the runtime and `db push` see the same thing: ```ts // config/collections/index.ts export const defaultSecurityRules: SecurityRule[] = [ { operation: "select", access: "public" }, { operations: ["insert", "update", "delete"], roles: ["admin"] } ]; ``` The old documentation claimed collections without rules were "unrestricted". They are not — the generator locks them to admin-only. In `baas` mode there are no collection files and no `db push`, so the database's own RLS is the whole model and there is nothing to default. --- ### 10. Smaller behaviour changes **A write naming a field the collection lacks is now a 400.** Unknown keys used to travel into the INSERT, so a typo came back as `column "titel" does not exist` — phrased by Postgres, from a stack the caller cannot see, and only when the column really was absent. Bulk writes are checked before the transaction opens and report the offending row index. > **Auth collections are checked too, with one narrow exemption.** A signup body > carries credential fields — `password` above all — that the users collection > does not declare as columns, so the auth adapter names those explicitly and > everything else is validated as usual. A typo like `emial` on a signup is a > 400, same as on any other collection. (An auth collection wired to a custom > `onCreateUser` hook opts out of the check, because the hook, not the > collection, then defines the body's shape.) **A collection file that fails to import is now a hard error.** The loader used to log and continue, turning a broken file into a missing API route and a missing policy with a successful exit code. Both read as "no data" rather than as a failure. **BaaS mode does not serve tables without row-level security.** A table with RLS disabled is skipped and named at boot. `baas: { unprotectedTables: "serve" }` restores the old behaviour. --- ### Upgrade checklist ``` [ ] grep for authenticated() and auth.uid() IS NOT NULL in config/collections/ [ ] decide the intent of each rule; rewrite the ones that meant "public" [ ] replace not(authenticated()) with serverContext() [ ] if realtime.requireAuth was true with an AuthAdapter, treat the socket as having been open — verify RLS on every subscribable collection (section 2) [ ] SELECT ... FROM pg_policies — record the qual of every policy BEFORE [ ] update package names and imports [ ] grep for rebase.data — it is now rebase.dataAsAdmin (section 8) [ ] grep for the other ten removed deprecated exports (section 8) [ ] drop react-router-dom; import RouterProvider from "react-router/dom" [ ] check node >= 22.22.0 and react >= 19.2.7 (react-router 8 requires both) [ ] move defaultSecurityRules into config/collections/index.ts [ ] grep for .userId [ ] grep for row.id on tables not keyed on id [ ] run db push [ ] run rebase doctor --policies — expect no Insecure or Orphaned entries [ ] SELECT ... FROM pg_policies again — confirm every intended change landed [ ] exercise the app signed OUT, in a private window — watch the websocket too ``` `rebase doctor --policies` is the one to wire into CI: it exits non-zero on a policy still carrying the permissive tautology, and on one an earlier push superseded. The `pg_policies` reads before and after are still worth the minute — they show you the expressions themselves, which is the only way to spot a fail-open policy written in a spelling the detector does not match. Neither the packages nor the type system will tell you: the change lives in the database, and `db push` is the only thing that puts it there. ## Collections ### What is a Collection? A **collection** is a TypeScript object that describes a database table and how it should appear in the admin UI. It defines: - **Schema** — Properties (columns), their types, and validation rules - **Relations** — Foreign keys, junction tables, and join paths - **Security** — Row Level Security policies - **Lifecycle hooks** — Callbacks for create, update, delete operations - **Admin UI behavior** — View modes, inline editing, entity views, actions — all under `admin` ### Declaring one: `defineCollection` Wrap the literal in `defineCollection`. At runtime it is the identity function — it returns the object unchanged — so it costs nothing. What it buys is inference: a `const` type parameter captures your `properties` keys as literal types, and the key-shaped fields of the `admin` block are then checked against them. A name that is not one of your properties is a **compile error**, not just a missing suggestion. ```typescript const products = defineCollection({ name: "Products", slug: "products", table: "products", properties: { name: { name: "Name", type: "string" }, price: { name: "Price", type: "number" } }, admin: { display: { title: "name" }, // completion: "name" | "price" sort: ["price", "asc"], // completion on the first element propertiesOrder: ["name", "price"] } }); ``` ```typescript admin: { display: { title: "nmae" } // ~~~~~~ Type '"nmae"' is not assignable to type // 'PropertyPath<…>'. Did you mean '"name"'? } ``` The checked fields are `display`, `sort`, `propertiesOrder` and `listProperties`. Three forms are accepted besides a plain property key: | Form | Example | Notes | | --- | --- | --- | | Dotted path into a `map` | `"profile.displayName"` | The **root** must be a real property; the path below it is not checked. | | Child-collection column | `"subcollection:orders"` | `propertiesOrder` / `listProperties` only. | | An `additionalFields` key | `"score" as AdditionalFieldKey` | Needs the cast — see below. | `AdditionalFieldDelegate.key` is a plain `string`, so the type system has no way to know which extra keys a collection declares. Rather than reopen these fields to every string, the cast makes the exception explicit: ```typescript propertiesOrder: ["title", "score" as AdditionalFieldKey] ``` Import it from `@rebasepro/admin-types` in a project that has an admin panel — that is the copy that also typechecks the `admin` block. A headless BaaS project, which has no admin block and no React, imports the same function from `@rebasepro/common` instead. Annotating the type directly still works and is still checked: ```typescript const products: PostgresCollectionConfig = { name: "Products", slug: "products", table: "products", properties: { name: { name: "Name", type: "string" } } }; ``` but an annotation only *validates the shape* — it cannot see your property names, so the `admin` key fields fall back to accepting any string. Prefer `defineCollection` unless you need to name the type. :::note `buildCollection` and `buildProperty` no longer exist. `buildCollection` is `defineCollection` without the inference; `buildProperty` wrapped a property in a type it already had. See the [changelog](/docs/changelog) for the one-line migration. ::: ### Anatomy: the contract, and the panel One file, two audiences. Everything the *database and the API* care about sits at the top level; everything the *admin panel* renders sits inside `admin`. ```typescript const posts = { // ── The backend reads these ────────────────────────────── slug: "posts", table: "posts", properties: { /* … */ }, relations: [ /* … */ ], securityRules: [ /* … */ ], callbacks: { /* … */ }, history: true, // ── The admin panel reads these ────────────────────────── admin: { icon: "FileText", listProperties: ["title", "status"], defaultViewMode: "table", entityViews: ["preview"] } }; ``` The split is not cosmetic. It is what lets Rebase be a backend on its own: - A **BaaS or headless** project never writes an `admin` block. Its collections — or no collections at all, since BaaS mode introspects the database — describe data and authorization, nothing else. `@rebasepro/types` contains no React, so there is no React anywhere in the dependency tree. - The **backend never reads inside the block**. It is dropped before a collection is serialized to the contract endpoint or into a build bundle, and it is excluded from the schema version — so changing an icon does not report every generated SDK as stale. #### The `admin` block exists only if you install the admin types `@rebasepro/types` declares no `admin` field — not on a collection, not on a property. In a BaaS project, writing one is a **type error**. `@rebasepro/admin-types` adds it back by declaration merging, so one line per project turns it on: ```typescript no-verify // config/admin.d.ts /// ``` After that, plain core types carry a fully typed block — a typo like `icoon` is an error, and you get completion: ```typescript const posts = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { title: { name: "Title", type: "string", admin: { multiline: true } } }, admin: { icon: "FileText" } }); ``` An augmentation applies to the whole TypeScript *program*, and `config/` and `frontend/` are separate programs — which is why the reference belongs in the config package. There is no `AdminCollectionConfig` wrapper type: with the field merged in, `CollectionConfig` is the authoring type. :::note[Why a BaaS project pays nothing] A property type in a BaaS install has no `Field`, no `columnWidth`, no `hideFromCollection` — those live in `AdminPropertyOptions` in the admin package. The guarantee is asserted, not claimed: `e2e/baas-typecheck/src/admin_absent.ts` uses `@ts-expect-error` on `admin`, so the build fails if the field ever becomes writable in core again. ::: #### Migrating from a flat collection Before 0.11 these fields sat at the top level. To move them: ```bash node scripts/codemod/collections-admin-block.mjs config/collections ``` It reports anything it cannot move safely — notably presentation inside `relations[].overrides`, which needs `overrides: { admin: { … } }` by hand. ```typescript export const productsCollection = defineCollection({ slug: "products", // URL path and API endpoint name: "Products", // Display name (plural) singularName: "Product", // Display name (singular) table: "products", // PostgreSQL table name properties: { name: { type: "string", name: "Product Name", validation: { required: true } }, price: { type: "number", name: "Price", validation: { required: true, min: 0 } }, category: { type: "string", name: "Category", enum: [ { id: "electronics", label: "Electronics", color: "blue" }, { id: "clothing", label: "Clothing", color: "pink" }, { id: "books", label: "Books", color: "orange" } ] }, description: { type: "string", name: "Description", admin: { multiline: true } }, active: { type: "boolean", name: "Active", defaultValue: true }, created_at: { type: "date", name: "Created At", autoValue: "on_create", admin: { readOnly: true } } }, admin: { icon: "inventory_2" // Material icon key } }); ``` ### Key Properties #### Identification | Property | Type | Description | |----------|------|-------------| | `slug` | `string` | **Required.** URL-safe identifier. Used in the admin UI URL and REST API path (`/api/data/{slug}`). | | `name` | `string` | **Required.** Display name (plural). Shown in navigation and page headers. | | `singularName` | `string` | Display name for a single entity. Used in "New Product", "Edit Product", etc. | | `table` | `string` | **Required.** PostgreSQL table name. If different from `slug`, allows you to decouple URLs from table names. | | `admin.icon` | `string` | Icon key. See [Google Fonts Icons](https://fonts.google.com/icons). | #### Schema | Property | Type | Description | |----------|------|-------------| | `properties` | `Properties` | **Required.** Map of property key → property definition. Each key becomes a database column. | | `relations` | `Relation[]` | SQL relations — foreign keys, junction tables. See [Relations](/docs/collections/relations). | | `securityRules` | `SecurityRule[]` | Row Level Security policies. See [Security Rules](/docs/collections/security-rules). | | `auth` | `boolean | AuthCollectionConfig` | Mark collection as authentication collection (user management, reset password, etc.) | #### UI Configuration All of the following go inside `admin`. | Property | Type | Default | Description | |----------|------|---------|-------------| | `defaultViewMode` | `"list" \| "table" \| "cards" \| "kanban"` | `"table"` | Default view mode | | `enabledViews` | `ViewMode[]` | All four | Which view modes are available | | `kanban` | `KanbanConfig` | — | Kanban configuration (column property) | | `openEntityMode` | `"side_panel" \| "full_screen" \| "split" \| "dialog"` | `"full_screen"` | How entities open for editing | | `sideDialogWidth` | `number \| string` | — | Width of the side dialog | | `inlineEditing` | `boolean` | `true` | Enable inline editing in the spreadsheet view | | `defaultSize` | `"xs" \| "s" \| "m" \| "l" \| "xl"` | `"m"` | Default row height in the table | | `pagination` | `boolean \| number` | `true` (50) | Enable pagination and/or set page size | | `listProperties` | `string[]` | — | Properties to display in the list view | | `propertiesOrder` | `string[]` | — | Column order in the table view | | `selectionEnabled` | `boolean` | `true` | Enable row selection | | `hideFromNavigation` | `boolean` | `false` | Hide from the sidebar navigation | | `defaultSelectedView` | `string \| function` | — | Default view or subcollection to open | #### Entity Options Inside `admin`, except `history`, which is a backend feature and stays at the top level. | Property | Type | Default | Description | |----------|------|---------|-------------| | `formAutoSave` | `boolean` | `false` | Auto-save on field change | | `localChangesBackup` | `"manual_apply" \| "auto_apply" \| false` | `"manual_apply"` | Backup unsaved changes | | `hideIdFromForm` | `boolean` | `false` | Hide the entity ID from the form | | `hideIdFromCollection` | `boolean` | `false` | Hide the ID column from the table | | `includeJsonView` | `boolean` | `true` | Offer the raw values in the record inspector | | `history` | `boolean` | `false` | Track changes in entity history | | `alwaysApplyDefaultValues` | `boolean` | `false` | Apply default values on every save | | `previewProperties` | `string[]` | — | Properties to display in reference previews | | `display` | `EntityDisplay` | — | What fills each display role — see [Entity display](#entity-display) | #### Advanced | Property | Type | Description | |----------|------|-------------| | `callbacks` | `CollectionCallbacks` | Lifecycle hooks (`beforeSave`, `afterSave`, `beforeDelete`, etc.) | | `entityActions` | `EntityAction[]` | Custom actions on entities (archive, publish, etc.) | | `Actions` | `React.ComponentType` | Custom toolbar actions component | | `entityViews` | `EntityCustomView[]` | Custom tabs in the entity detail view | | `additionalFields` | `AdditionalFieldDelegate[]` | Computed/virtual columns | | `childCollections` | `() => CollectionConfig[]` | Nested child collections | | `subcollections` | `() => CollectionConfig[]` | Nested collections (e.g., order → line items) | | `exportable` | `boolean \| ExportConfig` | Enable data export | | `ownerId` | `string` | Owner user ID (used by plugins/custom code) | | `overrides` | `EntityOverrides` | Overrides for the entity view | | `components` | `CollectionComponentOverrideMap` | Collection-scoped UI component overrides | | `driver` | `string` | Database driver to use (default: `"(default)"`) | | `databaseId` | `string` | Database/schema ID within the driver | ### Entity display Every surface that draws a record draws some subset of six roles: **title**, **subtitle**, **image**, **status**, **date** and **tags**. A list row is image + title + subtitle + status + date, a card is the same with the image on top, a reference picker is title + subtitle, and a page heading is the title alone. Each role is derived from your properties, and each can be stated instead — as a property path, or as a function: ```typescript const exercises = defineCollection({ name: "Exercises", slug: "exercises", table: "exercises", properties: { name: { name: "Name", type: "string" }, cover: { name: "Cover", type: "string", storage: { storagePath: "covers/" } }, city: { name: "City", type: "string" } }, admin: { display: { title: "name", // a property path image: "cover", subtitle: ({ entity }) => `in ${entity.values.city}` // computed } } }); ``` Anything you leave out keeps its derived value, so stating one role does not mean stating all six. #### Computed and async roles A resolver may be `async`, which is what lets a role read something the record does not carry — a document in a subcollection, a value behind an API: ```typescript admin: { display: { // The exercise's name lives one document down, per locale. title: async ({ entity, context }) => { const locale = await context.data.exercise_locales.get(`${entity.id}/de-DE`); return locale?.exercise_title; } } } ``` While the promise is in flight the surface shows the derived value and swaps the resolved one in when it lands — a title is never a spinner. Results are cached per record and per role, and concurrent asks for the same pair share one call, so a list of fifty rows resolves each row once rather than once per render. Return `undefined` when a record has nothing for the role; the surface's own fallback is better informed about what belongs there instead (a heading uses the singular collection name, a link uses the id). A resolver that throws is treated as `undefined` and logged once — a title that cannot be fetched must not take down the row that shows it. Prefer a path whenever the value is on the record: a path keeps the property's own rendering, so an enum status stays a coloured chip and a date stays formatted, which a resolver returning a bare string cannot express. :::note[Renamed from `titleProperty`] `admin.titleProperty` is now `admin.display.title`. The same string works there, and the new field also takes a resolver. The old key is still read, and warns once per collection at startup. ::: #### Title Property Selection When `display.title` is not set, the property used as the entity's display title (previews, headers) is resolved automatically: 1. If `propertiesOrder` is explicitly defined, the first non-ID property that is either a `relation` or `string` type is chosen as the title. 2. If no `propertiesOrder` is defined, the framework searches the properties in order and picks the first string type property. #### Relation Previews in Tables When `propertiesOrder` is explicitly set, relation properties are **not** automatically filtered out of the default preview columns (whereas they are excluded from unordered defaults to avoid slow join operations). #### resolveTitleToString Utility Rebase provides a `resolveTitleToString(title: any): string` helper to turn complex entity title values (including dates, arrays, or relation shapes like `{ __type: "relation", id, data: { values } }`) into clean, human-readable strings. It prioritizes common fields like `name`, `title`, `label`, and `displayName` from nested relation data. ### Collection Builder For dynamic collections that change based on the user or external data, use a builder function: ```typescript const collectionsBuilder: CollectionConfigsBuilder = ({ user, authController }) => { const collections = [productsCollection]; if (authController.extra?.role === "admin") { collections.push(adminSettingsCollection); } return collections; }; ``` ### Filtering and Sorting You can set default or forced filters: ```typescript { // Default filter — users can change it defaultFilter: { active: ["==", true] }, // Fixed filter — cannot be changed fixedFilter: { tenant_id: ["==", currentTenantId] }, // Default sort sort: ["created_at", "desc"] } ``` ### Next Steps - **[Entity Callbacks](/docs/collections/callbacks)** — Lifecycle hooks for syncing data between collections, validation, side effects - **[Properties](/docs/collections/properties)** — All property types and options - **[Relations](/docs/collections/relations)** — Foreign keys, junction tables, joins - **[Security Rules](/docs/collections/security-rules)** — Row Level Security - **[View Modes](/docs/frontend/view-modes)** — List, Table, Cards, Kanban ## Properties ### Overview Properties define the columns in your database table and how they are rendered in the admin UI. Each property has a `type` that determines: - The **database column type** (via Drizzle schema generation) - The **form field** component - The **table cell** renderer - The **validation** rules ### Property Types | Type | Description | PostgreSQL Column | |------|-------------|-------------------| | `string` | Text, select, markdown, file upload, URL, email | `varchar`, `text`, `jsonb` | | `number` | Integer, decimal, currency | `integer`, `numeric`, `bigint`, `serial` | | `boolean` | True/false toggle | `boolean` | | `date` | Date, datetime, timestamp | `timestamp`, `date` | | `array` | Ordered list of values | `jsonb` | | `map` | Key-value object | `jsonb` | | `geopoint` | Latitude/longitude pair | `jsonb` | | `reference` | Embedded reference to another entity | `varchar` (stores ID) | | `relation` | SQL foreign key relation | Uses the `relations` array | ### Common Properties All property types share these options: | Property | Type | Description | |----------|------|-------------| | `type` | `string` | **Required.** Data type (see above) | | `name` | `string` | **Required.** Display label | | `description` | `string` | Help text shown below the field | | `defaultValue` | `any` | Default value for new entities | | `validation` | `object` | Validation rules | | `propertyConfig` | `string` | Registered property config key | | `columnName` | `string` | Explicit database column name (bypasses snake_case conversion) | | `callbacks` | `PropertyCallbacks` | Hooks for `afterRead` and `beforeSave` transforms | | `dynamicProps` | `function` | Dynamic property builder (see Conditional Fields) | | `conditions` | `PropertyConditions` | Declarative JSON Logic conditions | #### UI Configuration (`admin`) UI-related options are nested under the `admin` sub-object: ```typescript price: { type: "number", name: "Price", admin: { readOnly: true, columnWidth: 120, hideFromCollection: false } } ``` | Property | Type | Description | |----------|------|-------------| | `admin.readOnly` | `boolean` | Prevent editing | | `admin.disabled` | `boolean \| PropertyDisabledConfig` | Disable with optional tooltip | | `admin.hideFromCollection` | `boolean` | Hide from table view | | `admin.columnWidth` | `number` | Column width in pixels (table view) | | `admin.span` | `1 \| 2 \| 3 \| 4` | Field width over the four-column form grid | | `admin.Field` | `React.ComponentType` | Custom field component | | `admin.Preview` | `React.ComponentType` | Custom table cell component | ### String Properties The `string` type is the most versatile — depending on the options you set, it renders as different widgets. #### Text field A basic single-line text input. ```typescript name: { type: "string", name: "Name", validation: { required: true, min: 2, max: 200 } } ``` #### Multiline text Set `multiline: true` to render as a textarea. ```typescript description: { type: "string", name: "Description", multiline: true } ``` #### Markdown editor Set `markdown: true` to render a full markdown editor with toolbar. ```typescript body: { type: "string", name: "Blog text", markdown: true } ``` #### Email field Set `email: true` to add email format validation and render with an email icon. ```typescript email: { type: "string", name: "User email", email: true, validation: { required: true } } ``` #### URL field Set `url: true` to add URL format validation and render with a link icon. ```typescript website: { type: "string", name: "Amazon link", url: true } ``` #### File upload Set `storage` to render a file upload dropzone. ```typescript avatar: { type: "string", name: "Main image", storage: { storagePath: "avatars", acceptedFiles: ["image/*"], maxSize: 2 * 1024 * 1024 } } ``` #### Select (enum) Set `enum` to render a select dropdown. See the [Enum Values](#enum-values) section for details. ```typescript no-verify category: { type: "string", name: "Category", enum: [ { id: "electronics", label: "Electronics", color: "blueDark" }, { id: "clothing", label: "Clothing", color: "pink" }, ] } ``` #### Multi-select Set `enum` + `multiSelect: true` to allow picking multiple values. ```typescript no-verify locales: { type: "string", name: "Available locales", multiSelect: true, enum: [ { id: "es", label: "Spanish", color: "pink" }, { id: "en", label: "English", color: "blueLight" }, { id: "fr", label: "French", color: "purpleLight" }, ] } ``` #### String Options | Property | Type | Description | |----------|------|-------------| | `admin.multiline` | `boolean` | Render as textarea | | `admin.markdown` | `boolean` | Render as markdown editor | | `email` | `boolean` | Email format validation | | `url` | `boolean` | URL format validation | | `storage` | `StorageConfig` | Enable file upload | | `enum` | `EnumValues` | Render as select dropdown | | `multiSelect` | `boolean` | Allow multiple enum selections | | `columnType` | `string` | Database column: `"varchar"`, `"text"` | | `isId` | `string` | ID generation: `"uuid"`, `"cuid"`, `"increment"`, `"manual"` | | `userSelect` | `boolean` | Render as a user picker | | `admin.previewAsTag` | `boolean` | Render this string as a tag in previews | | `admin.clearable` | `boolean` | Add an icon to clear the value (set to null) | ### Number Properties ```typescript price: { type: "number", name: "Price", validation: { required: true, min: 0 } } quantity: { type: "number", name: "Quantity", columnType: "integer" // Store as integer } ``` Number fields render as a standard text input with numeric validation. #### Number Options | Property | Type | Description | |----------|------|-------------| | `enum` | `EnumValues` | Render as select with numeric values | | `columnType` | `string` | `"integer"`, `"bigint"`, `"numeric"`, `"serial"`, `"smallint"` | | `isId` | `string` | ID generation strategy | | `admin.clearable` | `boolean` | Add an icon to clear the value (set to null) | ### Boolean Properties ```typescript active: { type: "boolean", name: "Selectable", defaultValue: true } ``` Booleans render as a toggle switch. ### Date Properties #### Date only Set `mode: "date"` to show a date picker without time. ```typescript event_date: { type: "date", name: "Expiry date", mode: "date" } ``` #### Date and time The default mode `"date_time"` includes both date and time. ```typescript arrival_time: { type: "date", name: "Arrival time", mode: "date_time" } ``` #### Auto timestamps Use `autoValue` to automatically set timestamps on create or update. ```typescript created_at: { type: "date", name: "Created At", autoValue: "on_create", admin: { readOnly: true } } updated_at: { type: "date", name: "Updated At", autoValue: "on_update" } ``` #### Date Options | Property | Type | Description | |----------|------|-------------| | `mode` | `"date" \| "date_time"` | Date only or date + time (default: `"date_time"`) | | `autoValue` | `"on_create" \| "on_update"` | Auto-set timestamps | | `columnType` | `string` | `"timestamp"`, `"date"` | | `timezone` | `string` | Timezone string to evaluate the date in | | `admin.clearable` | `boolean` | Add an icon to clear the value (set to null) | ### Array Properties #### Simple repeat Use `of` to define a repeatable list of items. ```typescript tags: { type: "array", name: "Tags", of: { type: "string" } } ``` #### Multi file upload Combine `of` with `storage` for a multi-file upload. ```typescript images: { type: "array", name: "Images", of: { type: "string", storage: { storagePath: "images", acceptedFiles: ["image/*"] } } } ``` #### Block editor Use `oneOf` to create a block editor with multiple content types. Each key creates a card type that users can pick from. ```typescript content: { type: "array", name: "Content", oneOf: { properties: { text: { type: "map", properties: { body: { type: "string", name: "Text", markdown: true } } }, image: { type: "map", properties: { src: { type: "string", name: "Image", storage: { storagePath: "content" } }, caption: { type: "string", name: "Caption" } } } } } } ``` #### Array Options | Property | Type | Description | |----------|------|-------------| | `of` | `Property \| Property[]` | Property schema for array items | | `oneOf` | `object` | Array of typed objects with multiple discriminator types | | `admin.expanded` | `boolean` | Should the field be initially expanded (default: true) | | `admin.minimalistView` | `boolean` | Display child properties directly without extendable panel | | `admin.sortable` | `boolean` | Can elements be reordered (default: true) | | `admin.canAddElements` | `boolean` | Can new elements be added (default: true) | ### Map Properties #### Group (structured) Use `properties` to define a structured object with named fields. ```typescript address: { type: "map", name: "Address", properties: { street: { type: "string", name: "Street" }, zip: { type: "string", name: "Postal code" } } } ``` #### Key-value (free-form) Set `keyValue: true` to render an arbitrary key-value pairs editor. ```typescript metadata: { type: "map", name: "Key value", keyValue: true } ``` #### Map Options | Property | Type | Description | |----------|------|-------------| | `properties` | `Properties` | Record of properties included in the map | | `propertiesOrder` | `string[]` | Ordered keys for rendering | | `admin.previewProperties` | `string[]` | Which properties to show in the table preview | | `admin.spreadChildren` | `boolean` | Render child properties as separate columns in table view | | `admin.minimalistView` | `boolean` | Display properties without a wrapping panel | | `admin.expanded` | `boolean` | Should the field be initially expanded (default: true) | | `keyValue` | `boolean` | Render as arbitrary key-value pairs editor | ### Reference Properties References link to entities in another collection. They render as a preview card showing the referenced entity's details. ```typescript client: { type: "reference", name: "Related client", path: "clients", admin: { previewProperties: ["first_name", "last_name", "email"] } } ``` #### Reference & Relation Options These apply to both `reference` and `relation` properties. | Property | Type | Description | |----------|------|-------------| | `admin.fixedFilter` | `FilterValues` | Filter the entities offered in the selection widget | | `admin.widget` | `"select" \| "dialog"` | Which widget selects the related entity (relations only) | | `admin.includeId` | `boolean` | Show the related entity's id in previews (default: true) | | `admin.includeEntityLink` | `boolean` | Show a link that opens the related entity (default: true) | | `admin.previewProperties` | `string[]` | Which of the target's properties appear in the preview (max 3) | ### Enum Values Used with string or number properties to render selects: ```typescript no-verify // Simple array enum: ["draft", "published", "archived"] // With labels enum: [ { id: "draft", label: "Draft" }, { id: "published", label: "Published" }, { id: "archived", label: "Archived" } ] // With colors (for Kanban columns and chips) enum: [ { id: "draft", label: "Draft", color: "grayDark" }, { id: "published", label: "Published", color: "greenDark" }, { id: "archived", label: "Archived", color: "orangeDark" } ] ``` ### Validation ```typescript validation: { required: true, // Field is required unique: true, // Must be unique in the table requiredMessage: "Custom error message", // String-specific min: 2, // Minimum length max: 200, // Maximum length matches: /^[a-z]+$/, // Regex pattern email: true, // Email format url: true, // URL format // Number-specific min: 0, // Minimum value max: 1000, // Maximum value integer: true, // Must be integer // Array-specific min: 1, // Minimum items max: 10, // Maximum items } ``` ### Conditional Fields You can make fields dynamic so they react to the entity's values. There are two ways to do this: #### 1. JSON Logic Conditions (Declarative) You can use the `conditions` property to define declarative JSON Logic rules that can be serialized and modified visually in the collection editor. ```typescript price: { type: "number", name: "Price", conditions: { disabled: { "==": [{ "var": "values.is_free" }, true] }, required: { "!=": [{ "var": "values.is_free" }, true] }, min: 0, clearOnDisabled: true // Set to null if field gets disabled } } ``` The conditions object gives you access to: - `disabled`, `hidden`, `readOnly` - `required`, `min`, `max` - `defaultValue` - `enumConditions`, `allowedEnumValues`, `excludedEnumValues` - `referencePath`, `referenceFilter` - `canAddElements`, `sortable` (for arrays) #### 2. Property Builders (Programmatic) For complex behavior that can't be expressed via JSON Logic, you can use `dynamicProps` which evaluates a Javascript function. ```typescript price: { type: "number", name: "Price", dynamicProps: ({ values, user }) => ({ disabled: values.is_free === true || !user.roles.includes("admin"), validation: values.is_free ? {} : { required: true, min: 0 } }) } ``` ### Next Steps - **[Relations](/docs/collections/relations)** — Foreign keys and joins - **[Security Rules](/docs/collections/security-rules)** — Row Level Security - **[Custom Fields](/docs/frontend/custom-fields)** — Build custom field components ## Relations ### Overview Relations define how collections are connected at the database level. They enable Rebase to: - Render **relation picker fields** in entity forms - Resolve **related entities** when displaying previews - Generate **foreign key constraints** in the Drizzle schema - Support **cascade delete/update** behaviors Relations can be defined either inline within the property, or explicitly in the `relations` array of a collection: #### 1. Inline Relations (Recommended) Declare the link on the property, nested under `relation`. Pick the `kind` and the type offers exactly the fields that kind needs. ```typescript const postsCollection = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { title: { type: "string", name: "Title" }, content: { type: "string", name: "Content", admin: { multiline: true } }, author: { type: "relation", name: "Author", relation: { kind: "belongsTo", target: () => usersCollection } } } }); ``` #### 2. Explicit Relations Array For a link with no property of its own — nothing to name it by in the form or in a table column — declare it in `relations`: ```typescript const usersCollection = defineCollection({ slug: "users", name: "Users", table: "users", properties: { name: { type: "string", name: "Name" } }, relations: [ { kind: "hasMany", relationName: "posts", target: () => postsCollection } ] }); ``` ### The five kinds A relation is one of five kinds. The kind decides where the key lives, whether one row or many come back, and what a write through it may touch. | Kind | The key lives | Returns | Notes | |---|---|---|---| | `belongsTo` | on **this** table | one | `localKey`, defaults to `_id` | | `hasOne` | on the **target's** table | one | `foreignKeyOnTarget`, defaults to `_id` | | `hasMany` | on the **target's** table | many | children belong to this parent alone | | `manyToMany` | in a **junction table** | many | rows are shared; you own the link | | `via` | an explicit `joinPath` | either | read-only; state `cardinality` yourself | Every field is optional except `kind` and `target` — the rest is derived. #### belongsTo — the key is on this table ```typescript author: { type: "relation", name: "Author", relation: { kind: "belongsTo", target: () => usersCollection } } // → posts.author_id ``` #### hasMany / hasOne — the key is on theirs ```typescript relations: [ { kind: "hasMany", relationName: "posts", target: () => postsCollection } ] // → reads posts.user_id ``` `hasOne` is the same link with at most one row on the far side. ##### Joining on a natural key By default the target's foreign key holds the source row's **id**. When the two sides are joined on something else — an external identity id, a SKU, a tenant slug — name that column with `sourceKey`: ```typescript relations: [ { kind: "hasMany", relationName: "applications", target: () => applicationsCollection, sourceKey: "auth_user_id", // column on THIS table foreignKeyOnTarget: "auth_user_id" // column on the TARGET's table } ] // → reads applications.auth_user_id = talents.auth_user_id ``` `sourceKey` is the mirror of `localKey` on `belongsTo`: that one names the column this side reads *from*, this one names the column the other side points *at*. Without it a link like the above is not expressible as `hasMany` at all and has to drop to [`via`](#via--an-explicit-join-chain), which is read-only. The column must be unique. A link that addresses more than one source row cannot say which one a related row belongs to, and Postgres will not accept a foreign key against a non-unique column either. Rebase checks this at read time and refuses rather than picking one. A parent whose `sourceKey` is `NULL` reaches no rows, and writing through the relation is an error — there is nothing for the related rows to point at. #### manyToMany — through a junction ```typescript tags: { type: "relation", name: "Tags", relation: { kind: "manyToMany", target: () => tagsCollection } } // → junction `posts_tags` (both table names, sorted), columns post_id / tag_id ``` Both sides declare their own, and each writes `through` **from its own point of view** — `sourceColumn` always names *this* collection: ```typescript // on posts { kind: "manyToMany", relationName: "tags", target: () => tagsCollection, through: { table: "posts_tags", sourceColumn: "post_id", targetColumn: "tag_id" } } // on tags { kind: "manyToMany", relationName: "posts", target: () => postsCollection, through: { table: "posts_tags", sourceColumn: "tag_id", targetColumn: "post_id" } } ``` #### via — an explicit join chain For links the four shapes above cannot express: multi-hop paths, composite keys, or a join whose condition is not a plain foreign key. Read-only — Rebase will not infer how to write through an arbitrary chain. ```typescript { kind: "via", relationName: "permissions", target: () => permissionsCollection, cardinality: "many", joinPath: [ { table: "user_roles", on: { from: "id", to: "user_id" } }, { table: "role_permissions", on: { from: "role_id", to: "role_id" } }, { table: "permissions", on: { from: "permission_id", to: "id" } } ] } ``` ### Relation Properties To render a relation field in a form, add a property with `type: "relation"`: ```typescript properties: { author: { type: "relation", name: "Author", relation: { kind: "belongsTo", target: () => usersCollection }, widget: "select" // "select" (dropdown) or "dialog" (full picker) } } ``` ![Relation field in form](/img/features/relation-form-field.png) When rendering a preview (like in a table cell or a reference chip), Rebase handles hydration automatically: ![Relation preview in table](/img/features/relation-table-preview.png) #### To-one gets a picker, many gets a tab The cardinality decides the surface, and only one surface is used: - **`belongsTo` / `hasOne`** — one row, so the property is a foreign key the author edits. It renders as the picker above. - **`hasMany` / `manyToMany`** — many rows, so the entity view lists them as a **tab** of their own. The property is not rendered in the form: a collection's children are a list, not a value the record holds, and selecting them from a dropdown is not something the form can meaningfully offer. Declaring a many-relation as a property is still worth doing — it is what names the tab, and what gives the relation a column in the collection table, which the list fetch hydrates so the child rows show up as chips on the row. Only the form field is dropped. In the table, a relation with a property of its own gets **one** column: its own. Every tab also has a jump-to-tab button column, but for a property-declared relation that button repeated the same heading beside a column already showing the children, so it is dropped. Hide the relation's column (`admin: { hideFromCollection: true }`) and the button comes back, so the relation never falls out of the table entirely. If you want the inline picker anyway, ask for it: ```typescript properties: { tags: { type: "relation", name: "Tags", relation: { kind: "manyToMany", target: () => tagsCollection }, admin: { renderInForm: true } // off by default; the tab is the default treatment } } ``` ### Multi-Hop Joins For relationships that traverse multiple tables, use `kind: "via"` with a `joinPath`. These are read-only: Rebase will not infer how to write through an arbitrary chain. ```typescript // Users → Permissions through Roles relations: [ { kind: "via", relationName: "permissions", target: () => permissionsCollection, cardinality: "many", joinPath: [ { table: "user_roles", on: { from: "id", to: "user_id" } }, { table: "roles", on: { from: "role_id", to: "id" } }, { table: "role_permissions", on: { from: "id", to: "role_id" } }, { table: "permissions", on: { from: "permission_id", to: "id" } } ] } ] ``` #### Composite Key Joins ```typescript joinPath: [ { table: "customers", on: { from: ["company_code", "region_id"], // Multiple columns to: ["code", "region_id"] } } ] ``` ### Cascade Rules Control what happens when related entities are updated or deleted: ```typescript relations: [ { kind: "belongsTo", relationName: "author", target: () => usersCollection, localKey: "author_id", onDelete: "cascade", // Delete posts when user is deleted onUpdate: "cascade" // Update FK when user ID changes } ] ``` | Action | Behavior | |--------|----------| | `"cascade"` | Propagate the change to related rows | | `"restrict"` | Prevent the operation if related rows exist | | `"no action"` | Same as restrict (defer to constraint check) | | `"set null"` | Set the FK column to NULL | | `"set default"` | Set the FK column to its default value | ### Fetching Relations in the SDK When querying data through the Rebase Client SDK, relations are **not** included by default. Use the `include()` method to request related entities alongside the primary data. #### Include specific relations ```typescript const { data } = await client.data.articles .include("author", "categories") .find(); ``` #### Include all relations ```typescript const { data } = await client.data.articles .include("*") .find(); ``` #### Using params syntax ```typescript const { data } = await client.data.articles.find({ include: ["author", "categories"] }); ``` #### Response structure When included, the response contains both the **scalar foreign key** and the **hydrated relation object**: ```typescript const { data } = await client.data .collection<{ id: string; author_id: string; author?: { name: string } }>("articles") .include("author") .find(); // The SDK returns flat rows — there is no `.values` wrapper. (`Entity`, with // `id`/`path`/`values`, is an admin-UI view model, not what the client hands back.) for (const article of data) { // Scalar FK — always present article.author_id; // "uuid-1234" // Hydrated relation — only present when included article.author?.name; // "Jane Doe" } ``` > The relation names passed to `include()` must match the `relationName` defined in the collection's `relations` array. For the full query builder reference (filtering, sorting, pagination, real-time), see the [Client SDK documentation](/docs/sdk). ### Relations in the admin panel Every to-many relation — `hasMany`, `manyToMany`, or a to-many `via` — becomes a **tab** under a record in the admin panel, listing the rows that record reaches. #### The path segment is the relation name A child list is addressed as `parent/parentId/relationName`: ``` /c/authors/a-1/posts the posts of author a-1 /c/posts/p-1/tags the tags of post p-1 ``` The last segment is the **relation name**, not the target collection's slug. They are often the same, because an unnamed relation takes its target's slug — but an inline relation property takes the *property key*: ```typescript properties: { featuredTags: { type: "relation", relation: { kind: "manyToMany", target: () => tagsCollection } } } // tab and path segment: featuredTags (not "tags") ``` This is also what makes two relations to the same collection work: each has its own name, so each gets its own tab and its own path. #### Owned rows versus shared rows What a tab lets you do depends on how the relation is stored, because the two cases mean different things: | | One-to-many (`foreignKeyOnTarget`) | Many-to-many (`through`) | |---|---|---| | The child belongs to | this parent alone | every parent that links it | | Create | creates the row under this parent | creates the row and links it | | Add existing | — | links an existing row | | Remove | **deletes** the row | **unlinks** it; the row is untouched | The admin panel renders each accordingly: a many-to-many tab offers **Add existing** and **Remove from this record**, and never a delete that would take the row away from other parents. #### The same rules over REST Child lists are ordinary collection queries narrowed to one parent, so they accept everything a root list does — filters, `orderBy`, `limit`, `offset`, `include` — and `meta.total` counts the filtered rows. Filter either per field (`?field=op.value`) or with a whole-object `?where={"field":["op","value"]}`; both reach the same query: ``` GET /api/data/authors/a-1/posts?status=eq.published&orderBy=title&limit=20 GET /api/data/authors/a-1/posts?where={"status":["==","published"]}&orderBy=title GET /api/data/authors/a-1/posts/p-1 POST /api/data/authors/a-1/posts create under this parent PUT /api/data/authors/a-1/posts/p-1 update; will not reparent DELETE /api/data/authors/a-1/posts/p-1 delete (one-to-many) / unlink (many-to-many) ``` The parent segment is enforced, not decorative. Addressing a row that is not under that parent returns `404`, and `PUT` never moves a row from one parent to another — set the foreign key explicitly if that is what you want. For a many-to-many, `PUT parent/id/child/childId` is *set membership*: it links the row if it is not linked yet, and is idempotent. That is how you attach a row that already exists. #### What does not become a tab - **To-one relations** — they are a field on the record, not a list. Writing through a to-one path is rejected: the foreign key lives on the parent's table. - **Relations declared inside a `map`** — they are a field of that map. ### Full Relation Interface `Relation` is a closed union — one member per kind, each carrying only the fields that kind has. There is no combination of fields that describes two different links, and no field you can set that the kind does not use. ```typescript type Relation = | BelongsToRelation | HasOneRelation | HasManyRelation | ManyToManyRelation | ViaRelation; interface RelationBase { relationName?: string; // defaults to the property key, then the target's slug target: () => CollectionConfig; onUpdate?: OnAction; onDelete?: OnAction; overrides?: Partial; // applied when rendered as a tab validation?: { required?: boolean }; } interface BelongsToRelation extends RelationBase { kind: "belongsTo"; localKey?: string; // column on THIS table } interface HasOneRelation extends RelationBase { kind: "hasOne"; foreignKeyOnTarget?: string; // column on the TARGET's table sourceKey?: string; // column on THIS table; defaults to the primary key } interface HasManyRelation extends RelationBase { kind: "hasMany"; foreignKeyOnTarget?: string; // column on the TARGET's table sourceKey?: string; // column on THIS table; defaults to the primary key } interface ManyToManyRelation extends RelationBase { kind: "manyToMany"; through?: { table?: string; sourceColumn?: string; targetColumn?: string }; } interface ViaRelation extends RelationBase { kind: "via"; cardinality: "one" | "many"; // a join chain cannot imply it joinPath: JoinStep[]; } ``` #### The resolved form What you write above is the *authoring* shape. Internally Rebase works with `ResolvedRelation`: the same link with every default filled in and nothing optional, plus `cardinality`, `targetSlug`, and two flags — `writable` (false only for `via`) and `shared` (true when the target rows belong to other parents too, so a removal unlinks rather than deletes). `sourceKey` is the one exception to "nothing optional": its default is the source's primary key, and resolving that needs the driver's schema, which resolution does not have. `undefined` there means "the primary key" and nothing else. You never write a `ResolvedRelation`. On a relation property, `relation` is yours and `resolvedRelation` is the filled-in one, stamped during normalization. ### Next Steps - **[Security Rules](/docs/collections/security-rules)** — Row Level Security - **[Properties](/docs/collections/properties)** — Property types reference ## Entity Callbacks ### Overview Callbacks let you hook into the entity lifecycle to: - **Sync data between collections** — copy or move entities across tables on status changes - **Transform data** before saving (computed fields, slugification) - **Validate** business rules beyond schema validation - **Trigger side effects** after writes (send emails, sync APIs, update caches) - **Filter/transform** data after reading - **Cascade operations** — clean up related records on delete ### Defining Callbacks ```typescript // The row shape. Without it every `values.x` below is `unknown`. type Article = { title: string; slug: string; created_at: string; updated_at: string; }; const articlesCollection: PostgresCollectionConfig
= { slug: "articles", name: "Articles", table: "articles", properties: { title: { name: "Title", type: "string" }, slug: { name: "Slug", type: "string" }, created_at: { name: "Created at", type: "string" }, updated_at: { name: "Updated at", type: "string" } }, callbacks: { beforeSave: async ({ values, id, status }) => { // Auto-generate slug from title if (values.title) { values.slug = values.title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); } // Set timestamps if (status === "new") { values.created_at = new Date().toISOString(); } values.updated_at = new Date().toISOString(); return values; }, afterSave: async ({ values, id }) => { // Send notification console.log(`Article ${id} saved: ${values.title}`); }, beforeDelete: async ({ id }) => { // Prevent deletion of published articles // Throw to block the deletion }, afterRead: async ({ row }) => { // Transform data after loading return row; } }, properties: { /* ... */ } }); ``` ### Callback Reference #### `beforeSave` Called before a entity is written to the database. Return the modified values. ```typescript beforeSave: async ({ values, // Entity values id, // Entity ID (null for new entities) status, // "new" | "existing" | "copy" previousValues, // Previous values (for updates) context // Full Rebase context }) => { // Return modified values return { ...values, updated_at: new Date() }; } ``` Throw an error to **block the save**: ```typescript beforeSave: async ({ values }) => { if (values.price < 0) { throw new Error("Price cannot be negative"); } return values; } ``` #### `afterSave` Called after a successful save. Use for side effects. ```typescript afterSave: async ({ values, // Saved values id, // Entity ID previousValues, // Previous values (null for new entities) status, // "new" | "existing" | "copy" context }) => { // Send webhook await fetch("https://api.slack.com/webhook", { method: "POST", body: JSON.stringify({ text: `New article: ${values.title}` }) }); } ``` #### `afterSaveError` Called when a save operation fails. ```typescript afterSaveError: async ({ values, id, error, context }) => { console.error("Save failed:", error); } ``` #### `afterRead` Called after reading entities from the database. Transform the data for display. ```typescript afterRead: async ({ row, // The row to transform context }) => { // Add computed fields return { ...row, displayName: `${row.first_name} ${row.last_name}` }; } ``` #### `beforeDelete` Called before a entity is deleted. Throw to block deletion. ```typescript beforeDelete: async ({ id, row, context }) => { if (row.status === "published") { throw new Error("Cannot delete published articles. Unpublish first."); } } ``` #### `afterDelete` Called after a successful deletion. ```typescript afterDelete: async ({ id, row, context }) => { // Cleanup related data console.log(`Article ${id} deleted`); } ``` ### Property Callbacks You can also define callbacks at the property level for field-specific transformations: ```typescript properties: { email: { type: "string", name: "Email", callbacks: { beforeSave: ({ value }) => value?.toLowerCase().trim(), afterRead: ({ value }) => value // Could decrypt, etc. } } } ``` ### The `context.data` API Every callback receives a `context` object that includes `context.data` — a unified data access layer for performing **cross-collection operations** from within lifecycle hooks. #### Accessing Collections `context.data` uses a JavaScript Proxy, so you can access any collection by its slug as a property: ```typescript afterSave: async ({ values, entityId, context }) => { // Dynamic property access — works for any collection slug const jobs = context.data.jobs; const users = context.data.users; // Alternatively, use the .collection() method for dynamic slugs const collectionName = "jobs"; const accessor = context.data.collection(collectionName); } ``` #### Available Methods Each collection accessor (`context.data.`) provides these methods: | Method | Signature | Description | |--------|-----------|-------------| | `.find()` | `find(params?: FindParams) → FindResponse` | Query entities with filters, sorting, and pagination | | `.findById()` | `findById(id: string \| number) → Entity \| undefined` | Fetch a single entity by ID | | `.create()` | `create(data: Partial, id?: string) → Entity` | Create a new entity | | `.update()` | `update(id: string \| number, data: Partial) → Entity` | Update an existing entity | | `.delete()` | `delete(id: string \| number) → void` | Delete a entity | | `.count()` | `count(params?: FindParams) → number` | Count matching entities | | `.listen()` | `listen(params, onUpdate, onError?) → unsubscribe` | Real-time subscription (where supported) | | `.listenById()` | `listenById(id, onUpdate, onError?) → unsubscribe` | Listen to a single entity | #### Querying with `.find()` The `find()` method supports rich filtering: ```typescript afterSave: async ({ values, context }) => { // Simple equality const { data: activeJobs } = await context.data.jobs.find({ where: { status: "published" }, limit: 10, orderBy: ["created_at", "desc"] }); // PostgREST-style operators const { data: recentJobs } = await context.data.jobs.find({ where: { status: "eq.published", salary: "gte.50000" } }); // Tuple syntax const { data: expensiveJobs } = await context.data.jobs.find({ where: { salary: [">=", 100000], role: ["in", ["admin", "manager"]] } }); } ``` #### Creating Entities ```typescript afterSave: async ({ values, entityId, previousValues, context }) => { // Promote an approved submission to a published job if (values.status === "approved" && previousValues?.status !== "approved") { const newJob = await context.data.jobs.create({ title: values.title, description: values.description, company_id: values.company_id, status: "published", source_submission_id: entityId, }); // Link back to the original submission await context.data["job-submissions"].update(entityId, { promoted_job_id: newJob.id, }); } } ``` #### Security: which privileges `context.data` runs with :::important **`context.data` inherits the privileges of whatever triggered the callback.** It is not a fixed trust level. - Triggered by a **user request** (REST, realtime, an admin-panel edit) → **user-scoped**. The callback runs inside the RLS-bound transaction opened for that request, so policies apply to reads *and* writes. A callback cannot see a row its caller could not. - Triggered by **`rebase.dataAsAdmin` or a cron job** (the same singleton) → **admin-scoped**, not unscoped. That driver is scoped as `{ uid: "service", roles: ["admin"] }`, so the callback still runs on an RLS-bound transaction — your policies are evaluated, against that identity. - Triggered by **the base driver** (built-in auth flows, migrations) → **unscoped**. It runs on the owner connection and bypasses RLS. ::: This matters most in the direction that fails quietly. RLS *filters*, it does not raise — so a callback that reads a sibling row will find it when an admin task saves and may find nothing when an end user saves, with no error either way. Write callbacks that tolerate an empty result, or reach for the admin plane deliberately: ```typescript afterSave: async ({ context }) => { // User-scoped when a user triggered this save: RLS applies. await context.data.audit_logs.create({ action: "approved" }); // Deliberately admin-scoped — for work the caller genuinely may not see, // such as an audit trail they must not be able to read or edit. Note this // is an admin's reach, not a bypass: a collection whose only rule is // `policy.serverContext()` stays closed to it, since that compiles to // `auth.uid() IS NULL` and this accessor's uid is `service`. await context.client.dataAsAdmin.audit_logs.create({ action: "approved" }); } ``` :::caution[This page used to say the opposite] Earlier versions of this page stated that callbacks always bypass RLS and have "full database access regardless of the triggering user's permissions". That was wrong, and wrong in the unsafe direction — it invited callbacks written on the assumption that they could always see everything. The behaviour above is verified end-to-end against Postgres by the `"scopes context.data to the caller when a callback runs on a user request"` case in `@rebasepro/server-postgres`' RLS-enforcement suite. ::: #### Transaction Semantics :::warning **`context.data` operations are NOT automatically wrapped in the same transaction as the triggering save.** The original entity save completes its database transaction first. Then `afterSave` runs and any `context.data` calls open **separate transactions**. If a `context.data` operation fails in `afterSave`, the original save is **not rolled back**. ::: This means: - ✅ The triggering save always succeeds independently - ⚠️ Side-effect writes may fail without affecting the original operation - ⚠️ There is no atomicity guarantee between the original save and subsequent `context.data` calls For operations that must be atomic, wrap them in error handling: ```typescript afterSave: async ({ values, entityId, context }) => { try { await context.data.jobs.create({ title: values.title, status: "published", }); } catch (error) { // Log the failure — the original save already succeeded console.error(`Failed to promote job from submission ${id}:`, error); // Optionally: mark the submission as "promotion_failed" await context.data["job-submissions"].update(id, { promotion_status: "failed", promotion_error: String(error), }); } } ``` ### Syncing Data Between Collections One of the most powerful uses of callbacks is **syncing data across collections** using `context.data`: ```typescript type Submission = { title: string; description: string; company_id: string; status: string; promoted_job_id: string; }; const submissionsCollection: PostgresCollectionConfig = { slug: "job_submissions", name: "Job Submissions", table: "job_submissions", properties: { title: { name: "Title", type: "string" }, description: { name: "Description", type: "string" }, company_id: { name: "Company", type: "string" }, status: { name: "Status", type: "string" }, promoted_job_id: { name: "Promoted job", type: "string" } }, callbacks: { afterSave: async ({ values, id, previousValues, context }) => { // When a submission is approved, create a published job if (values.status === "approved" && previousValues?.status !== "approved") { const newJob = await context.data.collection>("jobs").create({ title: values.title, description: values.description, company_id: values.company_id, status: "published", source_submission_id: id, }); // Update the submission with the promoted job reference await context.data.collection>("job_submissions").update(id, { promoted_job_id: newJob.id, }); } } }, properties: { /* ... */ } }); ``` Other cross-collection patterns: - **Cascade delete**: Use `afterDelete` to remove related records in child collections - **Denormalization**: Use `afterSave` to update summary fields in a parent collection - **Audit logging**: Use `afterSave` / `afterDelete` to write to an audit log collection - **Counters**: Use `afterSave` / `afterDelete` to update count fields on related entities ### Full Context Reference Every callback receives a `context` object of type `RebaseCallContext`: ```typescript interface RebaseCallContext { /** The authenticated user, if any */ user?: User; /** The underlying data driver (PostgresBackendDriver) */ driver: DataDriver; /** Unified data access — context.data..create/update/find/delete */ data: RebaseData; } ``` ### Next Steps - **[Security Rules](/docs/collections/security-rules)** — Row Level Security - **[Entity History](/docs/backend/history)** — Audit trail - **[Custom Functions](/docs/backend/custom-functions)** — Add custom API endpoints ## Security Rules (RLS) ### Overview Security rules let you define **Row Level Security (RLS)** policies for your PostgreSQL tables directly in your collection definitions. When the Drizzle schema is generated, Rebase creates the corresponding `CREATE POLICY` statements. ```typescript const postsCollection = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { /* ... */ }, securityRules: [ { operation: "select", access: "public" }, { operations: ["insert", "update", "delete"], ownerField: "author_id" } ] }); ``` ### How It Works 1. You define `securityRules` on a collection 2. `rebase schema generate` creates Drizzle schema with RLS enabled 3. `rebase db push` or `rebase db migrate` applies the policies to PostgreSQL 4. Every query is filtered by the current user's context automatically The authenticated user's identity is available in SQL via: | Function | Returns | |----------|---------| | `rebase.uid()` | The current user's ID | | `rebase.roles()` | Comma-separated app role IDs | | `rebase.jwt()` | Full JWT claims as JSONB | These are set automatically per-transaction by the Rebase backend. ### Convenience Shortcuts #### Owner-based Access The simplest pattern — users can only access rows they own: ```typescript securityRules: [ { operation: "all", ownerField: "user_id" } ] ``` This generates: `USING (user_id = rebase.uid())` #### Public Access Allow anyone (including unauthenticated users) to read: ```typescript securityRules: [ { operation: "select", access: "public" } ] ``` This generates: `USING (true)` #### Authenticated Access Allow any authenticated user: ```typescript securityRules: [ { operation: "select", access: "authenticated" } ] ``` #### Role-based Access Restrict operations to specific roles: ```typescript securityRules: [ { operation: "all", roles: ["admin"] }, { operation: "select", roles: ["editor", "viewer"] } ] ``` #### Membership / Relational Access To scope access by membership in a *related* collection — e.g. "only rows whose team the caller belongs to" — use the structured `condition` with `policy.existsIn`. It compiles to a single correlated `EXISTS` subquery (no per-row lookups), and is the safe, first-class alternative to hand-writing the raw SQL shown below. ```typescript // documents visible only to members of the document's team: securityRules: [ { operation: "select", condition: policy.existsIn({ collection: "team_members", // the join / membership collection where: policy.and( // correlate to the row being checked: policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")), // …and to the caller: policy.compare(policy.field("user_id"), "eq", policy.authUid()), ), }), }, ] ``` Inside `where`, `policy.field(...)` refers to a column of the joined collection (`team_members`), while `policy.outerField(...)` refers to a column of the row being checked (`documents`). Combine with `policy.authUid()` to scope to the current user. Because it is enforced by the database, the admin UI treats it as server-authoritative. ### Raw SQL Expressions For complex logic, use `using` and `withCheck`: ```typescript securityRules: [ { operation: "select", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" } ] ``` - **`using`** — Filters which existing rows are visible (applies to SELECT, UPDATE, DELETE) - **`withCheck`** — Validates new row values (applies to INSERT, UPDATE) Column references use `{column_name}` syntax which gets resolved to the full table-qualified column. ### Combining Shortcuts and SQL Mix convenience shortcuts with raw SQL: ```typescript securityRules: [ // Admins can do anything { operation: "all", roles: ["admin"], using: "true" }, // Regular users can only see their own rows { operation: "select", ownerField: "user_id" }, // Users can insert, but only for themselves { operation: "insert", withCheck: "{user_id} = rebase.uid()" }, // Locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false" } ] ``` ### Permissive vs Restrictive PostgreSQL has two policy modes: - **Permissive** (default) — Multiple permissive policies are **OR'd** together. If any one passes, access is granted. - **Restrictive** — Restrictive policies are **AND'd** together. All must pass. ```typescript securityRules: [ // Permissive: owners can access their rows { operation: "all", ownerField: "user_id" }, // Restrictive: but locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false", withCheck: "{is_locked} = false" } ] ``` ### Operations | Operation | SQL Equivalent | Description | |-----------|---------------|-------------| | `"select"` | `SELECT` | Read rows | | `"insert"` | `INSERT` | Create new rows | | `"update"` | `UPDATE` | Modify existing rows | | `"delete"` | `DELETE` | Remove rows | | `"all"` | All of the above | Shorthand for all operations | You can also use `operations` (plural) to apply one rule to multiple operations: ```typescript { operations: ["insert", "update", "delete"], ownerField: "author_id" } ``` ### Full SecurityRule Interface ```typescript interface SecurityRule { name?: string; // Human-readable policy name operation?: SecurityOperation; // Single operation operations?: SecurityOperation[]; // Multiple operations mode?: "permissive" | "restrictive"; // Default: "permissive" access?: "public" | "authenticated"; ownerField?: string; // Column containing the owner user ID roles?: string[]; // App roles that this policy applies to using?: string; // Raw SQL USING expression withCheck?: string; // Raw SQL WITH CHECK expression } ``` ### Examples #### Blog Platform ```typescript securityRules: [ // Anyone can read published posts { operation: "select", using: "{status} = 'published'" }, // Authors can see their own drafts { operation: "select", ownerField: "author_id" }, // Authors can create and edit their own posts { operations: ["insert", "update"], ownerField: "author_id" }, // Only admins can delete { operation: "delete", roles: ["admin"] } ] ``` #### Multi-Tenant SaaS ```typescript securityRules: [ { operation: "all", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" } ] ``` ### Anonymous Access (Public Inserts) A common need is allowing **unauthenticated users** to submit data — contact forms, newsletter signups, public applications. Rebase provides a clean pattern for this. #### Recommended: a raw `withCheck` rule ```typescript const contactMessagesCollection: PostgresCollectionConfig = { slug: "contact_messages", name: "Contact Messages", table: "contact_messages", securityRules: [ // Anyone can submit a contact message { operation: "insert", // A raw rule carries `using` (which rows are visible) and `withCheck` // (what a write must satisfy); an insert only exercises the latter. using: "true", withCheck: "true" }, // Only admins can read, update, or delete messages { operations: ["select", "update", "delete"], roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } } }; ``` The `access: "public"` shortcut generates a policy that allows the operation without requiring authentication. #### For Lead Capture / Signups ```typescript const leadSignupsCollection: PostgresCollectionConfig = { slug: "lead_magnet_signups", name: "Lead Magnet Signups", table: "lead_magnet_signups", securityRules: [ // Allow anonymous inserts { operation: "insert", using: "true", withCheck: "true" }, // Admins can view all signups { operation: "select", roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } } }; ``` #### How Anonymous Requests Work When a request arrives without a JWT token, the Rebase backend sets the PostgreSQL session variables to: | Variable | Value | |----------|-------| | `app.user_id` | `'anonymous'` | | `app.user_roles` | `''` (empty) | This means: - `rebase.uid()` returns `'anonymous'` - `rebase.roles()` returns an empty string - `access: "public"` policies pass because they generate `USING (true)` / `WITH CHECK (true)` - `access: "authenticated"` policies fail because they check for a real user ID - `ownerField` policies fail because no row will have `user_id = 'anonymous'` (unless explicitly set) #### Advanced: Raw SQL for Anonymous If you need more granular control, use raw SQL: ```typescript securityRules: [ { operation: "insert", withCheck: "rebase.uid() = 'anonymous' OR rebase.uid() IS NOT NULL" } ] ``` :::tip Avoid the legacy pattern of checking `string_to_array(rebase.roles(), ',')` for anonymous access. The `access: "public"` shortcut is simpler and generates the correct policy automatically. ::: ### Next Steps - **[Relations](/docs/collections/relations)** — Foreign keys and joins - **[Entity Callbacks](/docs/collections/callbacks)** — Lifecycle hooks - **[Custom Functions](/docs/backend/custom-functions)** — Custom API endpoints ## Backend Overview ### Overview The Rebase backend is a **Node.js server** built on [Hono](https://hono.dev/) that provides: - **REST API** — Auto-generated CRUD endpoints for each collection - **Authentication** — JWT tokens, Google OAuth, user/role management - **Storage** — File upload/download with local filesystem or S3 - **WebSocket** — Real-time data sync via PostgreSQL LISTEN/NOTIFY - **Entity History** — Audit trail for every data change - **Database Branching** — Instant, isolated database copies for dev/staging/testing - **Cron Jobs** — Scheduled background tasks with monitoring dashboard Everything is initialized with a single function: ```typescript const instance = await initializeRebaseBackend({ app, server, collectionsDir: "./config/collections", database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations } }), auth: { jwtSecret: env.JWT_SECRET, }, storage: { type: "local", basePath: "./uploads" }, history: true, enableSwagger: env.NODE_ENV !== "production" }); ``` ### What Gets Created After initialization, these routes are mounted: | Path | Purpose | |------|---------| | `/api/auth/*` | Authentication (signup, login, refresh, Google OAuth) | | `/api/admin/*` | User and role management (admin-only) | | `/api/storage/*` | File upload, download, and deletion | | `/api/data/collections` | Collection metadata endpoint | | `/api/data/:slug` | CRUD operations per collection (GET, POST, PUT, DELETE) | | `/api/data/:slug/:id/history` | Entity change history (when enabled) | | `/api/data/docs` | OpenAPI spec (when `enableSwagger: true`) | | `/api/data/swagger` | Swagger UI (dev mode, when `enableSwagger: true`) | | `/api/functions/*` | Custom function routes (when `functionsDir` is set) | | `/api/cron/*` | Cron job management (admin-only, when `cronsDir` is set) | | WebSocket on upgrade | Real-time subscriptions | --- ### The Initialization Lifecycle When you invoke `initializeRebaseBackend()`, the framework triggers a sequential, 5-stage boot sequence: ``` [Start Boot] │ ▼ 1. ENV validation (Zod parsing of jwt, databases, cors) │ ▼ 2. Dynamic Collection Loading (Chokidar watches .ts files, AST parsing) │ ▼ 3. Database Bootstrapping (Acquires advisory lock, creates schemas/auth/helper SQL functions) │ ▼ 4. Service Initialization (Auth, Storage S3/Local client instances, Cron store seeding) │ ▼ 5. Route Mounting & Edge Loading (Hono controllers, custom functions, WebSocket binding) │ ▼ [Boot Complete] ``` --- ### Startup Fail-Closed Protection Rebase enforces a strict **fail-closed security posture** during database connection outages. If the database is unreachable during boot (e.g., PostgreSQL is starting or network routes are severed): - The server does **not** crash or enter a restart loop. Instead, the bootstrapper transitions the backend into a **degraded status mode**. - The HTTP server starts successfully to preserve health check endpoints, but all REST and WebSocket controllers are immediately locked. - Any client attempting to read or write data gets a uniform `503 Service Unavailable` response: ```json { "error": { "message": "Database connection is not ready.", "code": "service-unavailable", "status": 503 } } ``` - The framework attempts to re-establish the connection pool in the background, recovering automatically when the database becomes healthy. --- ### Configuration Reference ```typescript interface RebaseBackendConfig { // HTTP framework app: Hono; // Hono application instance server: Server; // Node.js HTTP server (for WebSocket attachment) basePath?: string; // Route prefix (default: "/api") // Collections collections?: CollectionConfig[]; // Your collection definitions collectionsDir?: string; // Auto-load collections from a directory // Database adapter (PostgreSQL, SQLite, etc.) database?: DatabaseAdapter; // Authentication configuration or custom adapter auth?: RebaseAuthConfig | AuthAdapter; // File storage storage?: BackendStorageConfig | Record; // Entity history history?: boolean | HistoryConfig; // OpenAPI/Swagger enableSwagger?: boolean; // Custom API endpoints functionsDir?: string; // Auto-load Hono routes from a directory // Scheduled tasks cronsDir?: string; // Auto-load cron jobs from a directory // Logging logging?: { level?: "error" | "warn" | "info" | "debug" }; } ``` ### The Backend Instance `initializeRebaseBackend` returns a `RebaseBackendInstance` with access to internal services: ```typescript const instance = await initializeRebaseBackend(config); // Internal service access instance.driver // Default data driver instance.driverRegistry // All drivers (for multi-database) instance.realtimeService // Default realtime service instance.auth?.userService // User management instance.auth?.roleService // Role management instance.storageController // Default storage instance.storageRegistry // All storage backends instance.collectionRegistry // Collection metadata instance.history?.historyService // Entity history instance.cronScheduler // Cron job scheduler (when cronsDir is set) ``` > **Note:** While the `instance` exposes these internal services, application code (such as custom functions and cron jobs) should use the global `rebase` singleton from `@rebasepro/server` to interact with the backend API. ### REST API The REST API is auto-generated from your collections. Every collection gets these endpoints: | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/data/:slug` | List entities (with filter, sort, limit, search) | | `GET` | `/api/data/:slug/:id` | Get a single entity | | `POST` | `/api/data/:slug` | Create a new entity | | `PUT` | `/api/data/:slug/:id` | Update a entity | | `DELETE` | `/api/data/:slug/:id` | Delete a entity | #### Query Parameters | Param | Description | Example | |-------|-------------|---------| | `filter` | JSON-encoded filter conditions | `?filter={"active":["==",true]}` | | `orderBy` | Sort field | `?orderBy=created_at` | | `order` | Sort direction | `?order=desc` | | `limit` | Page size | `?limit=25` | | `startAfter` | Cursor for pagination | `?startAfter=encodedCursor` | | `search` | Full-text search | `?search=laptop` | ### WebSocket The WebSocket server attaches to the same HTTP server and provides real-time subscriptions: - Subscribe to **collection changes** — get notified when any entity in a collection is created, updated, or deleted - Subscribe to **entity changes** — get notified when a specific entity changes - Automatic **reconnection** handling in the client SDK The backend uses PostgreSQL `LISTEN/NOTIFY` internally. For multi-instance deployments, provide a `connectionString` in your `PostgresBootstrapper` to enable cross-instance broadcasting. ### Error Handling The backend includes an error handler that catches all exceptions and returns structured error responses: ```json { "error": { "message": "Entity not found", "code": "not-found", "status": 404 } } ``` If initialization fails (e.g., database connection error), the server still starts but returns 503 for all API requests, with a descriptive error message in the logs. ### Next Steps - **[Authentication](/docs/backend/authentication)** — JWT, Google OAuth, user management - **[Storage](/docs/backend/storage)** — Local and S3 file storage - **[Entity Callbacks](/docs/collections/callbacks)** — Lifecycle hooks and `context.data` API - **[Entity History](/docs/backend/history)** — Audit trail - **[Custom Functions](/docs/backend/custom-functions)** — Add custom API endpoints - **[Cron Jobs](/docs/backend/cron-jobs)** — Scheduled background tasks - **[Database Branching](/docs/backend/branching)** — Instant database copies for dev/staging ## REST API ### Overview Rebase automatically generates a complete API from your collection definitions: - **REST API** — CRUD endpoints for every collection at `/api/data/:slug` - **OpenAPI spec** — Machine-readable spec at `/api/docs` - **Swagger UI** — Interactive API explorer at `/api/swagger` (dev mode only) No code is required — define your collections and the API appears automatically. ### REST Endpoints For each collection, the following endpoints are generated: | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/data/:slug` | List entities | | `GET` | `/api/data/:slug/count` | Count entities | | `GET` | `/api/data/:slug/:id` | Get a single entity | | `POST` | `/api/data/:slug` | Create a entity | | `PATCH` | `/api/data/:slug/:id` | Update a entity (partial — only the properties you send are written) | | `PUT` | `/api/data/:slug/:id` | Same handler as `PATCH`, kept because every shipped SDK sends it | | `DELETE` | `/api/data/:slug/:id` | Delete a entity | | `POST` | `/api/data/:slug/bulk` | Create many entities in one transaction | | `PATCH` | `/api/data/:slug/bulk` | Update many entities in one transaction | | `POST` | `/api/data/:slug/bulk/delete` | Delete many entities in one transaction | #### Subcollection Routes Nested relations are accessible via URL paths: ``` GET /api/data/authors/42/posts → list author's posts GET /api/data/authors/42/posts/7 → get a specific post by author POST /api/data/authors/42/posts → create a post for author PATCH /api/data/authors/42/posts/7 → update the post (PUT also accepted) DELETE /api/data/authors/42/posts/7 → delete the post ``` ##### Routing Mechanics & Segment Parsing To handle arbitrary nested subcollection depths, Rebase routes incoming requests using Hono's `:rest{.+}` parameter regex. The internal segment parsing engine analyzes paths by counting slash-separated segments: - **Odd segment count** (e.g., `authors/42/posts` -> 3 segments) represents a collection list request. - **Even segment count** (e.g., `authors/42/posts/7` -> 4 segments) represents an operation on a specific entity ID. The last segment is popped as the target `entityId`. The engine filters out reserved system namespaces (e.g., `history`) from the path segment analysis to prevent collisions with built-in endpoints. ### Authentication All data endpoints require authentication by default. Include a Bearer token in the `Authorization` header: ```bash curl -H "Authorization: Bearer " \ https://api.example.com/api/data/products ``` For server-to-server calls, use the service key: ```bash curl -H "Authorization: Bearer " \ https://api.example.com/api/data/products ``` ### Filtering Use PostgREST-style query parameters to filter results. The format is `?field=operator.value`: ```bash # Exact match GET /api/data/products?active=eq.true # Comparison operators GET /api/data/products?price=gt.100 GET /api/data/products?price=lte.50 # Multiple filters (AND) GET /api/data/products?active=eq.true&price=gt.10 # IN operator — match any value in a set GET /api/data/products?status=in.(draft,published) # NOT IN GET /api/data/products?status=nin.(archived,deleted) # Array contains GET /api/data/products?tags=cs.electronics # Array contains any GET /api/data/products?tags=csa.(electronics,books) ``` #### Filter Operators | Operator | Meaning | Example | |----------|---------|---------| | `eq` | Equals (`==`) | `?active=eq.true` | | `neq` | Not equals (`!=`) | `?status=neq.draft` | | `gt` | Greater than (`>`) | `?price=gt.100` | | `gte` | Greater or equal (`>=`) | `?price=gte.100` | | `lt` | Less than (`<`) | `?price=lt.50` | | `lte` | Less or equal (`<=`) | `?price=lte.50` | | `in` | In array | `?status=in.(a,b,c)` | | `nin` | Not in array | `?status=nin.(a,b)` | | `cs` | Array contains | `?tags=cs.value` | | `csa` | Array contains any | `?tags=csa.(a,b)` | #### Logical Operators Use `or` and `and` for complex conditions: ```bash # OR: match products that are either cheap or on sale GET /api/data/products?or=(price.lt.10,on_sale.eq.true) # AND: explicit conjunction GET /api/data/products?and=(active.eq.true,price.gt.0) ``` ### Sorting Use `orderBy` with the format `field:direction`: ```bash # Sort by price descending GET /api/data/products?orderBy=price:desc # Sort by name ascending (default) GET /api/data/products?orderBy=name:asc ``` ### Pagination Use `limit` and `offset`, or `page`: ```bash # Limit and offset GET /api/data/products?limit=20&offset=40 # Page-based (uses default limit of 20) GET /api/data/products?page=3 ``` The default limit is **20**, the maximum is **100**. #### Response Format List responses include pagination metadata: ```json { "data": [ { "id": 1, "name": "Widget", "price": 29.99 }, { "id": 2, "name": "Gadget", "price": 49.99 } ], "meta": { "total": 150, "limit": 20, "offset": 0, "hasMore": true } } ``` Single entity responses return a flat object: ```json { "id": 1, "name": "Widget", "price": 29.99, "created_at": "2026-01-15T10:30:00Z" } ``` ### Text Search Use `searchString` for full-text search across string fields: ```bash GET /api/data/products?searchString=wireless%20keyboard ``` ### Vector Search If a collection defines a property with a type of `vector`, you can perform high-speed similarity searches using pgvector distance operations compiled directly in the database query. ```bash GET /api/data/products?vector_search=embedding&vector=[0.15,0.22,-0.05]&vector_distance=cosine&vector_threshold=0.8 ``` #### Vector Query Parameters | Parameter | Type | Description | |-----------|------|-------------| | `vector_search` | `string` | The name of the vector property to query against. | | `vector` | `string` | A JSON-serialized array of floats representing the query vector. | | `vector_distance` | `string` | The distance metric to evaluate. Supported values: `cosine` (default, `<=>`), `l2` (`<->`), `inner_product` (`<#>`). | | `vector_threshold` | `number` | Maximum distance threshold. Only records with distance less than this threshold are returned. | ### Relation Inclusion Use the `include` parameter to embed related entities: ```bash # Include specific relations GET /api/data/articles?include=author,categories # Include all relations GET /api/data/articles?include=* ``` Included relations are embedded directly in the response: ```json { "id": 1, "title": "Getting Started", "author_id": 42, "author": { "id": 42, "name": "Jane Doe", "email": "jane@example.com" } } ``` ### Field Selection Use `fields` to select specific columns: ```bash GET /api/data/products?fields=id,name,price ``` ### Lifecycle Hook Pipeline Every REST mutation operation (`POST`, `PUT`, `DELETE`) runs through a strict, sequential hook execution pipeline: ``` Request ──► beforeSave/beforeDelete (blocking) ──► DB Operation ──► afterSave/afterDelete (deferred) ──► Response ``` #### Blocking vs. Deferred Hooks 1. **Blocking Hooks (`beforeSave`, `beforeDelete`)** These hooks are executed synchronously in the main request cycle *before* committing the database transaction. They can modify incoming payloads, run custom validations, or abort the request entirely by throwing an error. 2. **Deferred Hooks (`afterSave`, `afterDelete`)** These hooks execute asynchronously after the database transaction has successfully committed. They use deferred promises (fire-and-forget), meaning they run in the background and do not block the client's HTTP response. Ideal for sending webhooks, triggering push notifications, or queuing external tasks. ### OpenAPI / Swagger - **OpenAPI spec**: `GET /api/docs` — Returns the full OpenAPI 3.0 JSON specification - **Swagger UI**: `GET /api/swagger` — Interactive API explorer (dev mode only) The OpenAPI spec is auto-generated from your collection definitions: it describes the list, read, create, update, delete and bulk endpoints of every collection the backend serves, with their query parameters and response schemas. It is not a complete map of the HTTP surface — the auth, storage, functions and cron routes are documented on this site only — and columns marked `excludeFromApi` are left out of it. ### API Keys API keys provide machine-to-machine authentication for agents, MCP servers, CI pipelines, and external integrations. They support per-collection permission scoping and optional full admin access. #### Creating an API Key ```bash # Via CLI rebase api-keys create --name "My Integration" \ --permissions '[{"collection":"orders","operations":["read","write"]}]' # Via REST (requires admin auth) curl -X POST http://localhost:3000/api/admin/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "My Integration", "permissions": [{ "collection": "orders", "operations": ["read", "write"] }] }' ``` The response includes the full plaintext key (`rk_live_...`) **exactly once** — store it immediately. #### Using an API Key ```bash curl http://localhost:3000/api/data/orders \ -H "Authorization: Bearer rk_live_abc123..." ``` #### Permissions and RLS: two independent gates An API key's request passes through **two** authorization checks, and both must allow it: 1. **The key's permission list** — collection × operation, checked at the route layer. 2. **Row-Level Security** — API keys do *not* bypass RLS. A key runs as `uid: "api-key:"` with the `service` role (plus `admin` when `admin: true`). Admin keys pass via the built-in admin policies; a non-admin key only sees rows that a security rule explicitly grants to the `service` role or to the public. Owner-style rules (`owner_id = rebase.uid()`) never match an API key. So a non-admin key with `"*"` permissions can still get empty results — that's RLS working, not a bug. Either grant the `service` role in the relevant collections' security rules, or use an admin key. #### Custom Functions Function invocations are scoped like collections, under the `functions` namespace: `{"collection": "functions", "operations": ["write"]}` grants every function, `"functions/"` grants one, and the global `"*"` wildcard grants all. A key without such an entry cannot invoke functions at all. #### Storage Storage works the same way, under the `storage` namespace: `{"collection": "storage", "operations": ["read", "write"]}` lets the key download/list (`read`), upload and create folders (`write`), and delete files (`delete`). The global `"*"` wildcard also grants storage. A key without such an entry cannot touch storage. TUS resumable-upload routes count as `write` for every step (including the offset check and cancel), so a write-scoped key can complete an upload on its own. #### Agents and MCP Servers An agent wants the *narrowest* key that does its job, not an admin one. Start scoped, and give it an expiry: ```bash rebase api-keys create -n "My Agent" \ --permissions '[{"collection":"articles","operations":["read"]}]' \ --expires 30d ``` Operations are `read`, `write` and `delete`, derived from the HTTP method: `GET`/`HEAD`/`OPTIONS` → `read`, `POST`/`PUT`/`PATCH` → `write`, `DELETE` → `delete`. ##### A scoped key reads zero rows until a rule grants `service` This is the step that makes a correctly scoped key look broken. A non-admin key runs as `uid: "api-key:"` with the roles `["service"]`, and the RLS policy injected into every collection by default compiles to: ```sql rebase.uid() IS NULL OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']) ``` — the server context, or an admin. A non-admin key matches neither arm, so on a collection with no `securityRules` the request succeeds with an empty result set and no error explaining why. Grant the role explicitly: ```ts securityRules: [ { operation: "select", roles: ["service"], using: "true" } ] ``` Because `rebase.uid()` carries the key's id, a rule can also scope rows to one specific key: ```ts securityRules: [ { operation: "select", condition: policy.compare(policy.authUid(), "eq", policy.literal("api-key:")) } ] ``` ##### Don't use `"*"` for a read-only key The `"*"` wildcard is not "every collection" — it also matches the `functions` namespace and `storage`. A `GET` counts as `read`, and a custom function's handler is arbitrary code that can write, so a wildcard "read-only" key can mutate through a function. Naming collections explicitly gives the key no function access at all. ##### `--admin --full-access`: CI, migrations, first-party tooling `"admin": true` grants the key the admin role — `/api/admin/*` routes for schema management, user management, and more, plus cron, backups, and logs. Combined with `--full-access` (`{"collection": "*", "operations": ["read", "write", "delete"]}`) the key holds every collection plus all storage and every custom function. That is the right shape for CI, migrations, and trusted first-party tooling — not for agents. ```bash # CLI rebase api-keys create -n "CI" --admin --full-access # REST curl -X POST http://localhost:3000/api/admin/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "CI", "admin": true, "permissions": [{ "collection": "*", "operations": ["read", "write", "delete"] }] }' ``` ##### No realtime over API keys The realtime WebSocket does not parse `rk_` tokens — it accepts user JWTs and the service key only. An agent authenticated with an API key polls the REST endpoints instead of subscribing. #### Key Options | Field | Type | Description | |---|---|---| | `name` | `string` | Human-readable label | | `permissions` | `ApiKeyPermission[]` | Per-collection access (`"*"` = everything; `"functions/"` = one function; `"storage"` = file storage) | | `admin` | `boolean` | Grant admin role — admin routes + RLS admin policies | | `rate_limit` | `number \| null` | Requests per 15-min window (`null` = the server default, 1000) | | `expires_at` | `string \| null` | ISO-8601 expiration timestamp | The CLI requires an explicit scope: pass `--permissions ''` or opt into `--full-access` — there is no silent full-access default. Keys can be listed, updated, and revoked via `/api/admin/api-keys` or the `rebase api-keys` CLI commands — but not by an API key. Any request to `/api/admin/api-keys` authenticated with an `rk_` key is refused with `403 API_KEY_SELF_MANAGEMENT_FORBIDDEN`, whatever its `admin` flag. Key management requires an admin user's session or the service key. ### Metadata Endpoint Get a list of all available collections and their structure: ```bash GET /api/collections ``` ### Next Steps - **[Client SDK](/docs/sdk)** — Type-safe client for the REST API - **[Collections](/docs/collections)** — Define your data schema - **[Security Rules (RLS)](/docs/collections/security-rules)** — Control access per row ## Authentication ### Overview Rebase includes a complete backend authentication system: - **JWT tokens** — Access and refresh token flow with configurable expiration - **OAuth providers** — Google, LinkedIn, GitHub, Microsoft, Apple, and more - **SMTP email** — Password reset and email verification flows - **Auth hooks** — Lifecycle hooks for user creation and more - **Custom auth adapters** — Plug in Firebase Auth, Auth0, Clerk, or any external provider - **Service key** — Static key for server-to-server authentication - **Auto-bootstrapping** — First user automatically gets the admin role ### Configuration The `auth` block in `initializeRebaseBackend` controls all backend authentication: ```typescript no-verify const backend = await initializeRebaseBackend({ // ... auth: { collection: usersCollection, // Your users collection definition jwtSecret: env.JWT_SECRET, // Required — signing secret accessExpiresIn: "1h", // Access token lifetime (default: 1h) refreshExpiresIn: "30d", // Refresh token lifetime (default: 30d) serviceKey: env.REBASE_SERVICE_KEY, // Optional — for server-to-server calls allowRegistration: true, // Allow new signups (default: false) // OAuth providers google: env.GOOGLE_CLIENT_ID ? { clientId: env.GOOGLE_CLIENT_ID } : undefined, // SMTP email (for password reset, email verification) email: env.SMTP_HOST ? { from: env.SMTP_FROM || `${env.APP_NAME} `, smtp: { host: env.SMTP_HOST, port: env.SMTP_PORT, // 587 for TLS, 465 for SSL secure: env.SMTP_SECURE, // true for port 465 auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS! } : undefined, name: env.SMTP_NAME, // Optional EHLO/HELO hostname }, appName: env.APP_NAME, resetPasswordUrl: env.FRONTEND_URL, // URL for password reset page } : undefined, // Lifecycle hooks hooks: { afterUserCreate: async (user) => { console.log(`New user registered: ${user.email}`); } } } }); ``` :::caution[Collection callbacks do not fire for auth users] User creation and updates through the auth system — registration, admin user management, and OAuth — write **directly** to the user store and bypass the collection save pipeline. A `beforeSave`/`afterSave`/`beforeDelete`/`afterDelete` callback on the auth (users) collection will **not** run for these paths. For side effects like provisioning a personal team on signup, use the auth lifecycle hooks (`afterUserCreate`, `beforeUserCreate`, `afterUserDelete`, …), which receive the fully-populated user record. ::: #### OAuth Providers Each OAuth provider is configured with at minimum a `clientId`. Some providers require a `clientSecret`: ```typescript auth: { google: { clientId: "..." }, linkedin: { clientId: "...", clientSecret: "..." }, github: { clientId: "...", clientSecret: "..." }, microsoft: { clientId: "...", clientSecret: "...", tenantId: "..." }, apple: { clientId: "...", teamId: "...", keyId: "...", privateKey: "..." }, facebook: { clientId: "...", clientSecret: "..." }, twitter: { clientId: "...", clientSecret: "..." }, discord: { clientId: "...", clientSecret: "..." }, gitlab: { clientId: "...", clientSecret: "..." }, bitbucket: { clientId: "...", clientSecret: "..." }, slack: { clientId: "...", clientSecret: "..." }, spotify: { clientId: "...", clientSecret: "..." }, } ``` #### Account Linking Across Sign-In Methods What happens when someone registers with email/password as `ada@example.com`, then later clicks "Sign in with Google" on a Google account with that same address? Rebase **links the two into one account** — but only when the provider asserts the email as verified. It never silently creates a second account for the same address. On `POST /api/auth/` the resolution order is: 1. **Known provider identity** — if this exact provider identity has signed in before, that user is returned. The email is not consulted. 2. **Existing account with the same email, provider verified it** — the identity is attached to the existing account and the user is signed in to it. One account, two ways in. 3. **Existing account with the same email, provider did NOT verify it** — rejected with `403 EMAIL_NOT_VERIFIED`. Nothing is created or modified. 4. **No account with that email** — a new account is created. Step 3 is the security-critical case. If an unverified provider email were enough to link, anyone who could get a provider to emit an address they don't own could take over the matching Rebase account. Google always asserts `email_verified` for real Google accounts, so step 2 is the normal path for Google sign-in; step 3 mostly catches providers that let users supply an arbitrary unconfirmed address. This behavior is not configurable — there is deliberately no option to link on unverified emails. To recover from a step-3 rejection, the user signs in with their existing method and calls the explicit link endpoint: ```http POST /api/auth/link/google Authorization: Bearer { "idToken": "..." } ``` Linking while authenticated intentionally does **not** require a verified email, and does not require the emails to match at all — a user's Google address is often not their app address. The asymmetry is deliberate: on sign-in the provider's email is the only evidence tying the incoming identity to an account, whereas here the caller has already proven ownership by holding a valid session. It returns `409 IDENTITY_ALREADY_LINKED` if that provider identity belongs to another user, and is idempotent if it is already linked to the caller. ##### The reverse direction A user who signed up with Google and has no password: - **Registering with the same email** is refused with `409 EMAIL_EXISTS`. - **`POST /api/auth/change-password`** returns `400 INVALID_ACCOUNT` — there is no existing password to verify against. - **`forgot-password` → `reset-password` is the supported way to add one.** It re-proves ownership of the address by email, after which the account has both sign-in methods. ### Auth Endpoints All auth endpoints are mounted at `/api/auth/`: | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/auth/register` | Create a new account | | `POST` | `/api/auth/login` | Login with email/password | | `POST` | `/api/auth/refresh` | Refresh the access token | | `POST` | `/api/auth/` | OAuth sign-in (e.g., `/api/auth/google`, `/api/auth/linkedin`) | | `POST` | `/api/auth/link/` | Link an OAuth provider to the authenticated account | | `POST` | `/api/auth/logout` | Revoke refresh token | | `POST` | `/api/auth/forgot-password` | Send password reset email | | `POST` | `/api/auth/reset-password` | Reset password with token | | `POST` | `/api/auth/find-user` | Resolve an email to a minimal public profile (opt-in) | | `POST` | `/api/auth/mfa/enroll` | Start TOTP enrolment (returns the secret and recovery codes) | | `POST` | `/api/auth/mfa/verify` | Confirm an enrolment with a code from the authenticator | | `GET` | `/api/auth/mfa/factors` | List the caller's enrolled factors | | `POST` | `/api/auth/mfa/challenge` | Open a challenge against a verified factor | | `POST` | `/api/auth/mfa/challenge/verify` | Answer a challenge — this is what issues the session | | `DELETE` | `/api/auth/mfa/unenroll` | Remove a factor (requires an `aal2` session) | All data API endpoints require a valid `Authorization: Bearer ` header when `requireAuth: true` (the default). #### Multi-factor authentication (TOTP) **A second factor gates sign-in, not just individual operations.** Once an account has one *verified* TOTP factor, no route issues it a session until a code is presented — password login, every OAuth provider, magic link and anonymous-link all refuse with `401 MFA_REQUIRED`: ```json { "error": { "code": "MFA_REQUIRED", "message": "Multi-factor authentication is required to complete sign-in.", "details": { "mfaToken": "", "factors": [{ "id": "…", "factorType": "totp", "friendlyName": "Phone" }] } } } ``` `mfaToken` is **not a session**: it is purpose-scoped, expires in five minutes, and is rejected by every authenticated route. Send it as the bearer token to `POST /api/auth/mfa/challenge` (with a `factorId`) and then to `POST /api/auth/mfa/challenge/verify` (with the `challengeId` and the six-digit code, or a recovery code). That last call is what mints the access and refresh tokens, at `aal2`; the level is stored on the session and carried across `POST /api/auth/refresh`. Enrolment is gated too. The first factor on an account may be enrolled from an ordinary session, but once one is verified, `enroll`, `verify` and `unenroll` all require an `aal2` session — otherwise a stolen password could enrol a factor of its own, step up on it, and delete the real one. Verification is bounded on both axes: a challenge dies after five failed guesses, each account is limited to ten verification attempts per 15 minutes (counted per user, so rotating IPs does not help), and an accepted code is recorded against the factor so it cannot be replayed for the rest of its ±1-step window. Set `MFA_ENCRYPTION_KEY` (32+ random characters) to encrypt stored TOTP secrets. Without it the server falls back to `JWT_SECRET` and warns. Set it **before** anyone enrols: stored secrets carry no key id, so changing the key afterwards leaves existing factors undecryptable and their owners unable to complete a challenge. #### Inviting teammates by email Invite flows need to turn an email address into a user id, but the `users` collection is RLS-protected from the client. Instead of hand-rolling an admin server function, opt into the built-in lookup: ```typescript no-verify await initializeRebaseBackend({ auth: { // ... allowUserLookup: true, // enables POST /api/auth/find-user }, }); ``` Then, from the client: ```typescript const profile = await rebase.auth.findUserByEmail("teammate@example.com"); // → { uid, displayName, photoURL } | null (never email/roles/metadata) if (profile) { await rebase.data.team_members.create({ team_id, user_id: profile.uid }); } ``` The endpoint is **authenticated-only** and returns just `uid`, `displayName`, and `photoURL` — never the email, roles, or metadata of the looked-up user. It is **off by default** because it lets any signed-in user probe which emails have accounts; enable it only when your invite UX needs it. ### Auto-Created Tables On first startup, Rebase automatically provisions the `auth` schema and the following tables in the database (bound to the schema defined in your collection, e.g., `rebase`): - **`rebase.users`** — User accounts with email, password hash, metadata, and a `roles` text[] column (roles are stored as inline text arrays to optimize queries and avoid joins). - **`rebase.refresh_tokens`** — Long-lived sessions carrying hashed refresh tokens, user agents, and IP addresses. Includes a unique index on `token_hash` and a unique constraint on `(user_id, user_agent, ip_address)` to track active device sessions. - **`rebase.password_reset_tokens`** — Expirable single-use tokens for password recovery flows. - **`rebase.mfa_factors`** — Enrolled multi-factor authentication methods (e.g. TOTP secrets encrypted with AES-256). - **`rebase.mfa_challenges`** — Verification logs tracking active MFA verification attempts. - **`rebase.recovery_codes`** — Hashed multi-factor backup/recovery codes. - **`rebase.app_config`** — Key-value store for system configurations. ### Row-Level Security (RLS) Database Context Rebase bridges request authentication directly down to PostgreSQL Row-Level Security (RLS). Every database query executed through a user-scoped driver runs inside a database transaction (`db.transaction()`) that configures transaction-local configuration parameters: * `app.user_id` — The authenticated user's unique ID (`uid`). Defaults to `'anon'` for unauthenticated requests. * `app.user_roles` — A comma-separated string listing the user's assigned roles. * `app.jwt` — A JSON string containing the full JWT claims payload (`{"sub": "", "roles": [...]}`). These parameters are configured locally for the duration of the transaction using Postgres's `set_config` function: ```sql SELECT set_config('app.user_id', $1, true), set_config('app.user_roles', $2, true), set_config('app.jwt', $3, true); ``` #### PostgreSQL Policy Helper Functions To make writing Row-Level Security policies simple, Rebase creates helper functions under the `auth` schema during database bootstrapping: * **`rebase.uid()`** — Returns the authenticated user's ID as `text`, or `NULL` if not set: ```sql CREATE OR REPLACE FUNCTION rebase.uid() RETURNS text AS $$ SELECT NULLIF(current_setting('app.user_id', true), ''); $$ LANGUAGE sql STABLE; ``` * **`rebase.roles()`** — Returns the comma-separated roles string: ```sql CREATE OR REPLACE FUNCTION rebase.roles() RETURNS text AS $$ SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), ''); $$ LANGUAGE sql STABLE; ``` * **`rebase.jwt()`** — Returns the full JWT payload as a `jsonb` object: ```sql CREATE OR REPLACE FUNCTION rebase.jwt() RETURNS jsonb AS $$ SELECT COALESCE(NULLIF(current_setting('app.jwt', true), ''), '{}')::jsonb; $$ LANGUAGE sql STABLE; ``` You can use these helpers directly in your custom security rules or database migrations: ```sql CREATE POLICY owner_access ON posts FOR ALL TO public USING (author_id = rebase.uid() OR string_to_array(rebase.roles(), ',') && ARRAY['admin']); ``` ### First User Bootstrap When no users exist in the database, the first person to register automatically becomes an admin. After that, registration is controlled by the `allowRegistration` setting. This ensures you can always bootstrap a fresh deployment without needing to seed the database manually. To prevent concurrent runs and schema generation race conditions on hot reloading (HMR) or startup, bootstrapping operations are synchronized using a Postgres advisory lock: ```sql SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init')); ``` ### Collection-Level Auth Configuration Instead of relying solely on the default database auth rules, you can mark any Postgres collection (such as `users.ts` or a custom `members.ts` collection) as the authentication collection. This is configured via the `auth` property on the collection itself: ```typescript const membersCollection = defineCollection({ name: "Members", slug: "members", table: "members", auth: { enabled: true, // Customize what happens when an admin creates a user via the REST API onCreateUser: async (values, ctx) => { const hash = await ctx.hashPassword("welcome123"); return { values: { ...values, passwordHash: hash, emailVerified: true }, temporaryPassword: "welcome123" }; }, // Customize what happens when an admin resets a user's password in the admin panel onResetPassword: async (userId, ctx) => { const tempPassword = "reset_" + Math.random().toString(36).substring(2, 8); return { temporaryPassword: tempPassword, invitationSent: false }; }, // Inject/override auth-specific actions (e.g. show/hide the reset password button) actions: { resetPassword: true // Or false to disable, or a custom EntityAction } }, properties: { ... } }); ``` When custom hooks (`onCreateUser`, `onResetPassword`) are called, they receive an `AuthCollectionContext` facade containing: - `hashPassword(password: string): Promise` — Hash password using the configured hashing algorithm (e.g. scrypt). - `sendEmail?: (options) => Promise` — Send an email (only available when email service is configured). - `emailConfigured: boolean` — Whether email service is configured. - `appName: string` — The app name from email config. - `resetPasswordUrl: string` — The password reset link base URL. ### Service Key Authentication For server-to-server communication (e.g., cron jobs, external services), configure a static service key: ```typescript auth: { serviceKey: process.env.REBASE_SERVICE_KEY, // ... } ``` Clients authenticate with the `Authorization: Bearer ` header. #### Internal Per-Boot Key If `REBASE_SERVICE_KEY` is not provided in your configuration, Rebase automatically generates a random **internal per-boot key**. This key is never logged and never leaves the process. It is used by the `rebase` singleton to authenticate against the server's own control-plane APIs (auth, storage, etc.). This ensures that administrative tasks (like sending a welcome email or generating a storage URL) always function out-of-the-box in development and production without requiring manual key management. #### Timing-Attack Protection & Key Requirements To prevent timing attacks, Rebase validates both the user-configured service key and the internal key using constant-time string comparison (`safeCompare`). The user-configured service key **must be at least 32 characters long**; if a key shorter than 32 characters is configured, Rebase will throw a configuration error on startup and fail-closed. ### Custom Auth Adapters Rebase allows complete replacement of the built-in authentication system via a pluggable authentication architecture. This decouples authentication verification from the database and REST/WebSocket layers, enabling seamless integration with external providers such as **Clerk**, **Auth0**, **Firebase Auth**, or custom JWT identity services. #### The AuthAdapter Contract You can implement the `AuthAdapter` interface directly for complete control. The interface definition is as follows: ```typescript export interface AuthAdapter { /** Unique identifier for this auth adapter (e.g., "clerk", "custom") */ readonly id: string; /** * Verifies an incoming HTTP request and returns the authenticated user payload. * Called by Hono authentication middleware on every REST endpoint. */ verifyRequest(request: Request): Promise; /** * Verifies a raw token string (e.g. for WebSocket connection handshake phase 1). * If omitted, a synthetic request is automatically constructed. */ verifyToken?(token: string): Promise; /** Optional user management operations (CRUD) for the Admin Dashboard panel */ userManagement?: UserManagementAdapter; /** Optional: Mount adapter-specific custom public routes (e.g. callback paths) */ createAuthRoutes?(): Hono | undefined; /** Optional: Mount adapter-specific admin-only routes */ createAdminRoutes?(): Hono | undefined; /** Advertise supported capabilities (to customize Admin Dashboard UI visibility) */ getCapabilities(): AuthAdapterCapabilities | Promise; /** Lifecycle hooks called during backend start and graceful shutdown */ initialize?(): Promise; destroy?(): Promise; /** Custom user lifecycle hooks (e.g., hash passwords before collection writes) */ prepareUserCreation?( values: Record, collectionAuth?: unknown ): Promise; finalizeUserCreation?( entity: { id: string; values: Record }, clearPassword?: string ): Promise; /** Static service key to bypass checks for server-to-server calls */ serviceKey?: string; } ``` #### The Authenticated User Payload Regardless of the external authentication provider chosen, your adapter must resolve successful token verifications to a uniform `AuthenticatedUser` object. The Rebase RLS Scope Injector maps these values directly to PostgreSQL session variables inside transactions: ```typescript export interface AuthenticatedUser { uid: string; // Maps to pg local 'app.user_id' -> rebase.uid() email: string; // User email address displayName?: string | null; // Optional display name photoUrl?: string | null; // Optional avatar URL roles: string[]; // Maps to pg local 'app.user_roles' -> rebase.roles() isAdmin: boolean; // Grants global superuser privileges if true rawToken?: string; // The original token string (for downstream forwarding) claims?: Record; // Custom claims/metadata (available in rebase.jwt()) } ``` --- #### Quick Integration via `createCustomAuthAdapter` For standard scenarios (such as validating JWTs from a third-party service), you can use the `createCustomAuthAdapter` utility. This utility handles capabilities defaults and implements WebSocket token validation out-of-the-box by wrapping your `verifyRequest` implementation. ##### Example: Integrating with Clerk To connect a Rebase backend with **Clerk**, you can verify Clerk JWT tokens using Clerk's JSON Web Key Set (JWKS): ```typescript no-verify // Clerk JWKS URL const CLERK_JWKS_URL = "https://clerk.your-domain.com/.well-known/jwks.json"; const JWKS = createRemoteJWKSet(new URL(CLERK_JWKS_URL)); const clerkAuthAdapter = createCustomAuthAdapter({ serviceKey: process.env.REBASE_SERVICE_KEY, verifyRequest: async (request) => { const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); if (!token) return null; try { // Verify Clerk JWT token against JWKS const { payload } = await jwtVerify(token, JWKS); const metadata = payload.metadata as Record | undefined; const roles = Array.isArray(metadata?.roles) ? metadata.roles as string[] : []; return { uid: payload.sub!, email: (payload as Record).email as string || "", displayName: (payload as Record).name as string || null, roles: roles, isAdmin: roles.includes("admin"), claims: payload as Record }; } catch (error) { console.error("Clerk token verification failed:", error); return null; // Fail-closed } }, capabilities: { hasBuiltInAuthRoutes: false, // Login is managed by Clerk UI emailPasswordLogin: false, registration: false, passwordReset: false, profileUpdate: false, sessionManagement: false } }); const backend = await initializeRebaseBackend({ auth: clerkAuthAdapter, // ... }); ``` ##### Example: Integrating with Firebase Auth To verify Firebase Auth tokens using Firebase's public certificates: ```typescript no-verify const FIREBASE_JWKS_URL = "https://www.googleapis.com/robot/v1/metadata/jwk/securetoken@system.gserviceaccount.com"; const JWKS = createRemoteJWKSet(new URL(FIREBASE_JWKS_URL)); const FIREBASE_PROJECT_ID = "my-firebase-project-id"; const firebaseAuthAdapter = createCustomAuthAdapter({ serviceKey: process.env.REBASE_SERVICE_KEY, verifyRequest: async (request) => { const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); if (!token) return null; try { const { payload } = await jwtVerify(token, JWKS, { issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`, audience: FIREBASE_PROJECT_ID }); const roles = Array.isArray((payload as Record).roles) ? (payload as Record).roles as string[] : []; return { uid: payload.sub!, email: (payload as Record).email as string || "", displayName: (payload as Record).name as string || null, photoUrl: (payload as Record).picture as string || null, roles: roles, isAdmin: roles.includes("admin"), claims: payload as Record }; } catch (error) { console.error("Firebase token verification failed:", error); return null; } } }); const backend = await initializeRebaseBackend({ auth: firebaseAuthAdapter, // ... }); ``` --- #### Mounting Auth Routes and Admin UI Actions If your custom auth provider requires mounting redirect endpoints (like OAuth callback routes or SAML login loops), implement the `createAuthRoutes` method on your adapter: ```typescript const myOauthAdapter: AuthAdapter = { id: "custom-oauth", verifyRequest: async (req) => ({ // validate the token, then return the caller uid: "…", email: "user@example.com", roles: [], isAdmin: false }), getCapabilities: () => ({ hasBuiltInAuthRoutes: true, emailPasswordLogin: false, registration: false, passwordReset: false, adminPasswordReset: false, sessionManagement: false, profileUpdate: false, emailVerification: false, magicLink: false, enabledProviders: [] }), createAuthRoutes: () => { const app = new Hono(); // Mounted automatically under /api/auth/callback app.get("/callback", async (c) => { const code = c.req.query("code"); // Exchange code for provider tokens and set cookies/redirect return c.redirect("/dashboard"); }); return app; } }; ``` If you wish to allow user CRUD operations directly inside the Rebase Admin Dashboard, implement the `userManagement` helper within the adapter options, which provides hooks for `listUsers`, `createUser`, `updateUser`, and `deleteUser`. ### Next Steps - **[Frontend Authentication](/docs/frontend/authentication)** — Login UI, auth controller, user management - **[Security Rules (RLS)](/docs/collections/security-rules)** — Row-level access control - **[Client SDK Authentication](/docs/sdk/authentication)** — Auth methods in the client SDK ## Storage Configuration ### Overview Rebase supports three storage backends: - **Local filesystem** — Files stored on disk (great for development) - **S3-compatible** — AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces - **Google Cloud Storage / Firebase Storage** — Native GCS support via `@google-cloud/storage` ### Configuration Storage is configured in the `storage` block of `initializeRebaseBackend`: #### Local Storage ```typescript no-verify const backend = await initializeRebaseBackend({ // ... storage: { type: "local", basePath: "./uploads" // Directory for file storage } }); ``` #### S3 Storage ```typescript no-verify const backend = await initializeRebaseBackend({ // ... storage: { type: "s3", bucket: env.S3_BUCKET!, region: env.S3_REGION || "auto", accessKeyId: env.S3_ACCESS_KEY_ID || "", secretAccessKey: env.S3_SECRET_ACCESS_KEY || "", endpoint: env.S3_ENDPOINT, // For MinIO, R2, etc. forcePathStyle: env.S3_FORCE_PATH_STYLE // Required for MinIO } }); ``` #### GCS / Firebase Storage ```typescript no-verify const backend = await initializeRebaseBackend({ // ... storage: { type: "gcs", bucket: env.GCS_BUCKET!, projectId: env.GCS_PROJECT_ID, } }); ``` On GCP (Cloud Run, GCE, GKE), the default service account credentials are used automatically. Outside GCP, set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your service account key file. #### Multiple Storage Backends You can configure multiple named backends and route different fields to different storage: ```typescript storage: { "(default)": { type: "local", basePath: "./uploads" }, "media": { type: "s3", bucket: "media-bucket", region: "us-east-1", ... } } ``` Then in your collection properties, reference a specific backend: ```typescript image: { type: "string", name: "Image", storage: { storagePath: "products", storageSource: "media" // Routes to the "media" S3 backend } } ``` ### Storage Endpoints | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/storage/upload` | Direct file upload | | `POST` | `/api/storage/upload?storageId=` | Upload to a specific named backend | | `GET` | `/api/storage/files/:path` | Retrieve a file | | `GET` | `/api/storage/files/:path?storageId=` | Retrieve a file from a specific backend | | `DELETE` | `/api/storage/files/:path` | Delete a file | | `OPTIONS` | `/api/storage/tus` | Query supported TUS protocol capabilities | | `POST` | `/api/storage/tus` | Initiate a resumable TUS upload session | | `HEAD` | `/api/storage/tus/:id` | Check upload progress (byte offset) | | `PATCH` | `/api/storage/tus/:id` | Append data chunk to temporary file | | `DELETE` | `/api/storage/tus/:id` | Terminate/abort TUS upload session | ### On-the-Fly Image Transformations Rebase includes a built-in image processing pipeline powered by **Sharp**. When serving image assets from storage, you can apply dynamic operations using query parameters: ```bash # Serve image scaled to 300px width in webp format GET /api/storage/files/products/laptop.jpg?width=300&format=webp ``` #### Supported Parameters - `width`, `height`: Resize bounds, `1`–`4096` (the image is never enlarged). - `quality`: `1`–`100`. - `format`: Converts the image format. Supported formats: `webp`, `jpeg`, `png`, `avif`. - `fit`: `cover`, `contain`, `fill`, `inside` or `outside`. A parameter outside these bounds is a **400**, not a silently clamped value — `?width=99999` used to return a 4096px image and `?format=tiff` a webp one, and neither said so. #### Performance & LRU Caching Transforming is CPU- and memory-heavy, and on a public object the endpoint is reachable anonymously, so the work is bounded rather than merely cached: - **Capacity**: an LRU capped at **500 entries** globally, keyed by storage source, bucket and canonical key. - **TTL (Time to Live)**: Cached variants expire after **1 hour**. - Concurrent requests for the same uncached variant produce **one** transform, not one each. - A small number of transforms run at once; beyond a bounded backlog the server answers **503 `TRANSFORM_OVERLOADED`** rather than accepting work it will not get to. #### What gets served The stored content type is whatever the uploader declared — nothing sniffs the bytes — so `/api/storage/file/*` will only render a **narrow allowlist** inline: images (except SVG), video, audio, `application/pdf` and `text/plain`. Anything else, `text/html` and `image/svg+xml` included, is served as `application/octet-stream` with `Content-Disposition: attachment`, and every response carries `X-Content-Type-Options: nosniff`. Storage is not a web host: an uploaded page rendered on the API origin can read that origin's cookies and call its endpoints. ### TUS Resumable Upload Protocol For uploading large files (up to **5GB**) or handling unstable network conditions, Rebase implements the **TUS v1.0.0** open protocol including the `Creation` and `Termination` extensions. ``` Client Rebase Server │ │ │─── POST /api/storage/tus (Upload-Length: 50000000) ──────>│ (Generates session ID) │<── 201 Created (Location: /api/storage/tus/uuid-abc) ────│ │ │ │─── PATCH /api/storage/tus/uuid-abc (Upload-Offset: 0) ───>│ (Appends chunk via open/write) │<── 204 No Content (Upload-Offset: 1500000) ───────────────│ │ │ │─── PATCH /api/storage/tus/uuid-abc (Upload-Offset: 1.5M) ─>│ (Upload finishes) │<── 204 No Content (Upload-Offset: 50000000) ──────────────│ (Copies to storage, unlinks temp) ``` #### Upload Lifecycle Mechanics 1. **Session Initialisation (`POST`)**: The client sends the total file size in the `Upload-Length` header and base64 metadata via `Upload-Metadata`. The server creates an empty placeholder file under a hidden temporary directory `.tus-uploads/` and returns the upload URL. 2. **Progress Inquiries (`HEAD`)**: If an upload is interrupted, the client queries the upload URL using a `HEAD` request. The server returns the current byte position in the `Upload-Offset` header. 3. **Data Appending (`PATCH`)**: The client resumes sending binary data starting at the returned offset with `Content-Type: application/offset+octet-stream`. The server writes incoming chunks directly to the temporary file using Node's low-level `open` and `write` file system APIs at the specified byte offset. 4. **Finalisation**: When the accumulated `Upload-Offset` matches the declared `Upload-Length`, Rebase reads the completed temporary file, wraps it as a standard JavaScript `File` object, and saves it to the configured storage backend (local disk or S3). The temporary file is then deleted. 5. **Periodic Sweep**: A background cleaner runs every **60 seconds** to delete orphaned, incomplete temporary uploads that have exceeded the **24-hour** retention threshold. ### Environment Variables | Variable | Description | |----------|-------------| | `STORAGE_TYPE` | `"local"`, `"s3"`, or `"gcs"` | | `STORAGE_PATH` | Local storage directory (default: `./uploads`) | | `S3_BUCKET` | S3 bucket name | | `S3_REGION` | AWS region (default: `"auto"`) | | `S3_ACCESS_KEY_ID` | AWS access key | | `S3_SECRET_ACCESS_KEY` | AWS secret key | | `S3_ENDPOINT` | Custom S3 endpoint (for MinIO, R2) | | `S3_FORCE_PATH_STYLE` | Use path-style URLs (required for MinIO) | | `GCS_BUCKET` | Google Cloud Storage bucket name | | `GCS_PROJECT_ID` | GCP project ID for GCS | | `GCS_KEY_FILENAME` | Path to a GCP service account key file (omit on GKE — Workload Identity/ADC supplies credentials) | | `GOOGLE_APPLICATION_CREDENTIALS` | Standard ADC variable, read by the Google SDK itself (not needed on GCP with default credentials) | | `FORCE_LOCAL_STORAGE` | Allow `STORAGE_TYPE=local` in production — see below | ### Several Buckets A project can have more than one bucket. Declare them in `rebase.json` — the one place the platform, the runtime and the console all read: ```json { "rebase": "^1", "storage": { "(default)": { "engine": "s3" }, "media": { "engine": "s3", "label": "Media" } }, "apps": {} } ``` Each source is configured from the **same variable names carrying its own suffix**. The default source takes no suffix, so a single-bucket project keeps using the plain names above and needs to declare nothing at all: ```bash S3_BUCKET=app-uploads # (default) S3_BUCKET__MEDIA=app-media # media S3_ACCESS_KEY_ID__MEDIA=… S3_SECRET_ACCESS_KEY__MEDIA=… ``` The suffix is derived from the key: uppercased, non-alphanumerics collapsed to underscores, behind a **double** underscore (`media-cdn` → `__MEDIA_CDN`). A single underscore would collide with real variable names — `S3_BUCKET_NAME` would parse as bucket `name`. Route a property to a source with `storageSource`: ```ts { name: "Cover", dataType: "string", storage: { storageSource: "media", acceptedFiles: ["image/*"] } } ``` A source you declare but never configure is **skipped**, not fatal: uploads routed to it answer `501 STORAGE_NOT_CONFIGURED`. Declaring a bucket usually happens before anyone attaches storage to it, and a boot error there would crash-loop the backend until someone did. A source the environment configures *wrongly* — a type with no bucket, or a bucket with no credentials — is refused at boot, because that is a mistake rather than an absence. ### Frontend Storage Sources When using multiple storage backends, pass `storageSources` to the `` provider so the frontend knows how to route uploads directly: ```tsx {() => } ``` Each source's `key` must match a backend key registered in the server's `storage` map. The `StorageSourcesContext` React context resolves the active source for each upload field. ### Production Tips :::caution **In production, `type: "local"` disables file storage instead of using it.** On an ephemeral platform (Cloud Run, Heroku, a Kubernetes pod) the filesystem is wiped on every deploy, restart and eviction — so uploads would succeed, read back fine, and be gone at the next rollout, with no error at any point. So no storage backend is registered, and `/api/storage/*` answers **`501 STORAGE_NOT_CONFIGURED`**. Uploads fail loudly and recoverably; the rest of the app keeps serving. File storage is opt-in in production: it exists once a bucket does. Set `STORAGE_TYPE=s3` or `gcs`. If a **durable volume** really is mounted at `STORAGE_PATH`, set `FORCE_LOCAL_STORAGE=true` to say so explicitly. ::: - Mount a **persistent volume** if using local storage on Docker/Kubernetes, and set `FORCE_LOCAL_STORAGE=true` - Use **S3** or compatible (R2, MinIO), or **GCS**, for production deployments - Configure a **CDN** (CloudFront, Cloudflare) in front of your bucket for performance - **Any app with storage in production must declare an access model** — see below. Not just multi-tenant ones: the server *refuses to boot* without one. ### Per-Object Authorization `requireAuth` and `publicRead` are *global* switches: they decide whether a caller must be signed in, not what that caller may touch. Without an authorization hook, **any authenticated user can read any key they can name** — the only thing separating two tenants' files is key unguessability, which is not an access-control model. Worse, they can `GET /storage/list?prefix=` first, so the keys do not even have to be guessed. :::caution[Storage will not boot in production without one] Collections are protected by row-level security; storage is not. There is no per-object equivalent in the bucket, so this hook *is* the model — and `initializeRebaseBackend` **throws at startup** under `NODE_ENV=production` when storage is configured and none of these is set: - `storageAuthorize` — a hook, per object. Recommended. - `storagePublicRead: true` — the bucket genuinely is a public read-only CDN. - `storageInsecureAllowAnyAuthenticated: true` — a single-tenant app where every signed-in user is trusted with every file. Named to be read twice. In development it logs a warning instead, so a project can be wrong about this and work fine locally right up until it is deployed. A scaffolded project ships a hook in `config/storage.ts` already — read it before you replace it, and note that it models a CMS's *shared content library*, which is not the same shape as per-user files. ::: `storageAuthorize` is the storage analogue of a collection's security rules, and runs after authentication on every storage route: ```typescript no-verify await initializeRebaseBackend({ storage: { type: "s3", bucket: "app-files", /* ... */ }, storageAuthorize: async ({ key, bucket, operation, user }) => { if (!user) return false; // Keys are laid out as `{teamId}/{docId}/...` const [teamId] = key.split("/"); return isTeamMember(user.uid, teamId); } }); ``` | Field | Description | |-------|-------------| | `key` | Object key, bucket prefix stripped and traversal sanitized | | `bucket` | Resolved bucket (`"default"` when unspecified) | | `operation` | `"read"`, `"write"`, `"delete"` or `"list"` | | `user` | `{ userId, email?, roles? }`, or `null` where the route allows anonymous access | | `storageId` | The named backend, when the request targeted one | Return `false` to deny with a **403**. Throwing also denies — an ownership lookup that fails does not fall open. Worth knowing: - **The metadata route is where read access is really decided.** It mints the short-lived path-scoped download token that the file route trusts, so the hook gates it there. Requests already carrying such a token, or hitting a declared public path, skip the hook — the token was minted under it and is valid only for its own path. - **`list` is gated on the prefix.** Listing is how you discover keys nobody told you about. - **Resumable (TUS) uploads are gated at create time**, so a denied upload leaves no temp file behind. - Omitting the hook preserves the previous behaviour, so single-tenant apps are unaffected. ### Next Steps - **[Frontend Storage & File Uploads](/docs/frontend/storage)** — File upload fields and hooks - **[Properties](/docs/collections/properties)** — Storage property configuration ## Multiple Databases and Buckets ### Overview A project is not limited to one database and one bucket. Collections already route by `dataSource`, and file properties route by `storageSource`; this page is about how each named source gets its configuration. Two steps: **declare** the sources in your config package, then **configure** each one with environment variables derived from its key. ### Declaring sources Export `dataSources` and `storageSources` from your config package's `index.ts`. They are shared with the frontend, which uses the same declarations to decide whether it talks to a source through the Rebase API or directly. ```ts // config/index.ts export const dataSources: DataSourceDefinition[] = [ { key: "(default)", engine: "postgres" }, { key: "analytics", engine: "postgres", label: "Analytics warehouse" } ]; export const storageSources: StorageSourceDefinition[] = [ { key: "(default)", engine: "local", transport: "server" }, { key: "media", engine: "s3", transport: "server", label: "Public media" } ]; ``` Then point a collection at one: ```ts const pageViewsCollection = defineCollection({ name: "Page Views", slug: "page_views", table: "page_views", dataSource: "analytics", properties: { /* … */ } }); ``` ...or a file property: ```ts coverImage: { name: "Cover image", type: "string", storage: { storageSource: "media", acceptedFiles: ["image/*"] } } ``` ### Configuring each source Environment variable names are derived from the source key, so there is nothing to keep in sync by hand: ``` the default source DATABASE_URL, S3_BUCKET __ a named source DATABASE_URL__ANALYTICS, S3_BUCKET__MEDIA ``` The key is upper-cased and non-alphanumeric characters become underscores, so `media-cdn` reads `S3_BUCKET__MEDIA_CDN`. The separator is a **double** underscore on purpose. A single one would collide with real variable names — `S3_BUCKET_NAME` would parse as the bucket for a source called `name`. #### Databases ```bash DATABASE_URL=postgres://localhost/app DATABASE_URL__ANALYTICS=postgres://warehouse.internal/analytics # Optional, per source: DB_POOL_MAX__ANALYTICS=5 ADMIN_CONNECTION_STRING__ANALYTICS=postgres://… REBASE_DRIVER__ANALYTICS=@rebasepro/server-postgres ``` The driver is chosen from the declared `engine` (`postgres` and `mongodb` are known), and `REBASE_DRIVER__` overrides it for anything else. #### Storage ```bash STORAGE_TYPE__MEDIA=s3 S3_BUCKET__MEDIA=my-media-bucket S3_REGION__MEDIA=eu-central-1 S3_ACCESS_KEY_ID__MEDIA=… S3_SECRET_ACCESS_KEY__MEDIA=… ``` `STORAGE_TYPE__` may be omitted when the declaration already names the engine. ### Failure behaviour A declared server-transport data source with no connection string **fails the boot**, naming the variable to set. This is deliberate and worth understanding: the alternative is that collections routed to the missing source quietly fall back to the default database. That is data landing in the wrong place behind a server that reports itself healthy — far worse than a container that refuses to start. Two keys that would derive the same variable name are also rejected, because one of them would silently read the other's configuration. Sources declared with `transport: "direct"` are skipped entirely: the client talks to those itself, so the backend holds no connection and demands no configuration for them. ### Storage access control Storage keys share one flat namespace and are not under row-level security, so without an explicit access-control model the default would be "any signed-in user may read, overwrite, delete or list any object". Production refuses to boot rather than assume that. The way to say what access means for your project is a `storageAuthorize` export from the config package — a function, because no environment variable can express "this user may read this key": ```ts // config/index.ts export const storageAuthorize: StorageAuthorize = async ({ key, user, operation }) => { if (!user) return false; const [ownerId] = key.split("/"); return ownerId === user.uid || operation === "read"; }; ``` Two environment escapes exist for the cases where that really is the model: - `STORAGE_PUBLIC_READ=true` — the bucket is a public, read-only CDN. Writes, deletes and listing still require authentication. - `STORAGE_ALLOW_ANY_AUTHENTICATED=true` — every signed-in user is trusted with every file. Defensible for a single-tenant app, never for a multi-tenant one. ### Storage in production With no bucket configured, storage is **off** in production and uploads answer `501`. Local disk is the container filesystem, so files written there vanish on the next restart — an upload that fails loudly can be retried, one that succeeded into a disk about to be wiped cannot. Set `FORCE_LOCAL_STORAGE=true` only when a durable volume really is mounted. One consequence worth knowing if you declare storage sources explicitly: no default bucket is invented for you. Declaring only a `media` source means there is no `(default)` source, and a property that does not name one has nowhere to go — deliberately, and identically in development and production. Declare `(default)` too if you want one. ## Realtime & WebSocket Rebase includes a built-in realtime engine that pushes data changes to connected clients over WebSocket. When any record is created, updated, or deleted, every subscriber watching that collection or entity receives the update instantly — no polling required. ### How It Works The realtime pipeline has three stages: 1. **Database trigger** — A mutation hits the PostgreSQL database (via REST API, SDK, or Studio). 2. **Server fan-out** — The Rebase server detects the change and fans it out to every active WebSocket subscription that matches the affected collection or entity. 3. **Client callback** — The client SDK fires your `onUpdate` callback with the fresh data. ``` ┌──────────────┐ ┌────────────────────┐ ┌──────────────┐ │ PostgreSQL │─────▶│ Rebase Server │─────▶│ Client SDK │ │ LISTEN/NOTIFY│ │ RealtimeService │ │ WebSocket │ └──────────────┘ └────────────────────┘ └──────────────┘ ``` For multi-instance deployments, Rebase uses PostgreSQL's `LISTEN/NOTIFY` to broadcast changes across server instances. This is handled automatically — a dedicated PostgreSQL connection listens on the `rebase_entity_changes` channel and relays updates to local subscribers. #### Zero Configuration Realtime is enabled out of the box. There is no flag to flip or service to start — if your Rebase server is running, the WebSocket endpoint is available. > By default, Rebase also emits realtime events for writes made **outside** the API (via `psql`, another service, or Studio's SQL editor) whenever the database connection supports it — see [database-level change capture](#database-level-change-capture-cdc). ### Client SDK Subscriptions The Rebase client SDK exposes two subscription methods on every collection accessor: - **`listen()`** — Subscribe to an entire collection (with optional filters). - **`listenById()`** — Subscribe to a single entity by its ID. Both methods return an **unsubscribe function** you call to stop receiving updates. #### Subscribing to a Collection Use `listen()` to receive updates whenever records in a collection change: ```typescript const unsubscribe = client.data.products.listen( undefined, // FindParams — pass undefined for all records (response) => { console.log("Products updated:", response.data); console.log("Total:", response.meta.total); }, (error) => { console.error("Subscription error:", error); } ); ``` The callback receives a `FindResponse` containing: - `data` — Array of `Entity` objects. - `meta` — Pagination info (`total`, `limit`, `offset`, `hasMore`). #### Subscribing to a Collection with Filters Pass `FindParams` as the first argument to filter the subscription: ```typescript const unsubscribe = client.data.products.listen( { where: { status: ["==", "published"] }, orderBy: ["created_at", "desc"], limit: 50, }, (response) => { console.log("Published products:", response.data); } ); ``` The server respects these filters — only matching records are included in updates. #### Subscribing to a Single Entity Use `listenById()` to watch a specific record: ```typescript const unsubscribe = client.data.products.listenById( "product-123", (entity) => { if (entity) { console.log("Product updated:", entity.values); } else { console.log("Product was deleted"); } }, (error) => { console.error("Subscription error:", error); } ); ``` The callback receives `Entity | undefined`. A value of `undefined` means the entity was deleted. #### Unsubscribing Both `listen()` and `listenById()` return an unsubscribe function. Call it to stop receiving updates and clean up server-side resources: ```typescript const unsubscribe = client.data.products.listen(undefined, (response) => { // handle updates }); // Later, when you no longer need updates: unsubscribe(); ``` :::tip Always call the unsubscribe function when a component unmounts or a page navigates away. This prevents memory leaks and unnecessary server-side work. ::: ### Query Builder `.listen()` The fluent query builder also supports realtime subscriptions. Chain your filters, then call `.listen()` instead of `.find()`: ```typescript const unsubscribe = client.data.orders .where("status", "==", "pending") .orderBy("created_at", "desc") .limit(20) .listen( (response) => { console.log("Pending orders:", response.data); }, (error) => { console.error("Error:", error); } ); ``` :::note The `.listen()` method on the query builder is only available when the `RebaseClient` is configured with a `websocketUrl`. If the WebSocket connection is not configured, calling `.listen()` will throw an error. ::: ### Update Delivery: Instant Patch + Correctness Refetch Rebase uses a two-phase update strategy for collection subscriptions to combine extreme speed with absolute correctness: 1. **Phase 1 — Instant entity patch:** When a single entity changes (created, updated, deleted), the server immediately pushes a lightweight `collection_patch` message containing the modified entity values directly to subscribers. The client merges this into its cached collection data for near-instant cross-tab feedback — bypassing the database entirely for sub-millisecond perceived updates. 2. **Phase 2 — Debounced RLS refetch:** After a short delay of **300ms** (`REFETCH_DEBOUNCE_MS`), the server performs an authoritative database refetch of the collection matching your original filters and sort order. This is critical because field mutations might alter the entity's visibility (e.g. if its status changed and no longer matches a `where` filter). To maintain strict security boundaries, this refetch query is executed inside a transaction setting the transaction-local variables `app.user_id` and `app.user_roles` mapped from the subscriber's `SubscriptionAuthContext`. This ensures PostgreSQL Row-Level Security (RLS) constraints are evaluated correctly under the client's auth session, and only the records the user is authorized to see are sent in the final `collection_update`. This approach guarantees that list filters and access policies remain perfectly consistent while maintaining high UI responsiveness. ### Broadcast Channels Broadcast channels let clients send arbitrary messages to each other in real time — useful for features like typing indicators, cursor positions, or custom notifications. Broadcast is managed at the WebSocket protocol level. The server supports these message types: | Message Type | Direction | Description | |-----------------|-----------------|------------------------------------------| | `join_channel` | Client → Server | Join a named channel | | `leave_channel` | Client → Server | Leave a channel | | `broadcast` | Client → Server | Send a message to all channel members | | `broadcast` | Server → Client | Receive a message from another member | | `channel_history` | Client → Server | Request retained messages after a sequence | | `channel_history` | Server → Client | The retained messages a client missed | When a client sends a `broadcast` message, the server relays it to **all other members** of that channel (the sender does not receive its own message). ```typescript // Broadcast message structure (sent by client) { type: "broadcast", payload: { channel: "room-42", event: "typing", payload: { userId: "user-1", isTyping: true } } } // Received by other clients in the channel { type: "broadcast", channel: "room-42", event: "typing", payload: { userId: "user-1", isTyping: true } } ``` ### Channel Retention By default a broadcast reaches currently-connected members and is then gone. That is the right trade for notifications and cursors, and it costs nothing. For an operation stream — collaborative editing, anything where a silent gap causes divergence — a channel can be configured to **retain** its messages. Retained broadcasts are given a per-channel sequence number and stored, so a client that reconnects can ask for everything after the last one it saw. Retention is opt-in and configured here, on the server: ```typescript await initializeRebaseBackend({ app, server, database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations }, realtime: { channels: [ // Most specific first — the first match wins. { match: "doc:draft:*", limit: 100 }, { match: "doc:*", limit: 500, ttl: "24h" } ] } }) }); ``` | Field | Description | |---------|-----------------------------------------------------------------------------| | `match` | Exact channel name (`"doc:42"`) or a trailing-`*` prefix (`"doc:*"`) | | `limit` | Keep at most this many of the most recent messages per channel | | `ttl` | Keep messages for at most this long — `"30s"`, `"15m"`, `"24h"`, `"7d"`, or milliseconds | A rule needs at least one of `limit` or `ttl`. One with neither is ignored and logged, because unbounded retention is almost never intended and cannot be walked back once the table has grown. :::note[Why not let clients ask for history?] A channel is created by whoever names it. If a client could choose its own history depth, any visitor could commit your backend to unbounded storage. Configuring it here also means presence and notification channels — the overwhelming majority — pay nothing: with no rules configured, no table is created and broadcast runs the same synchronous path it always did. ::: #### Storage Retained channels use two tables in the `rebase` schema, created automatically on startup when at least one rule is configured: | Table | Contents | |---------------------------|-----------------------------------------------------------------| | `rebase.channel_messages` | The retained messages, keyed by `(channel, seq)` | | `rebase.channel_cursors` | The highest sequence issued per channel | Pruning happens as messages arrive, throttled per channel so cost tracks elapsed time rather than write volume. It only ever removes rows from `channel_messages` — cursors are kept indefinitely (they are one small row per channel), because restarting a channel's sequence would change what a client's saved resume point means. #### Delivery guarantees - **Ordered.** Sequence numbers are allocated per channel, and delivery order matches sequence order. - **Durable before delivered.** A message that cannot be stored is not delivered to anyone, and the sender is told. Delivering it would put it in front of live subscribers while leaving it out of every future replay, and no later message could repair that gap. - **At-least-once on catch-up.** A replay range may overlap messages a client already received; the SDK discards ones it has already delivered. :::caution[History has the same access model as the channel] A client that has joined a channel may replay its retained messages, including those broadcast before it arrived — membership is the only check, and joining is open to any client that can name the channel. Retention is opt-in per channel pattern, so enabling it makes that channel's past readable to any visitor who guesses the name. Retained channels are the case where this becomes durable rather than momentary, so treat a retained channel's contents as public to your users. ::: ### Presence Tracking Presence tracks which users are currently online in a channel and lets each user share custom state (e.g., cursor position, status). | Message Type | Direction | Description | |-------------------|-----------------|------------------------------------------------------| | `presence_track` | Client → Server | Start tracking presence with custom state | | `presence_untrack`| Client → Server | Stop tracking presence | | `presence_state` | Client → Server | Request the full presence state for a channel | | `presence_state` | Server → Client | Full entity of all presences in a channel | | `presence_diff` | Server → Client | Incremental update (joins and leaves) | When a client sends `presence_track`, the server automatically joins them to the channel (no separate `join_channel` needed) and broadcasts a `presence_diff` to all channel members. ```typescript // Track presence { type: "presence_track", payload: { channel: "document-edit-42", state: { name: "Alice", cursor: { line: 10, col: 5 } } } } // Presence diff received by other clients { type: "presence_diff", channel: "document-edit-42", joins: { "client-abc": { name: "Alice", cursor: { line: 10, col: 5 } } }, leaves: {} } // Full presence state response { type: "presence_state", channel: "document-edit-42", presences: { "client-abc": { name: "Alice", cursor: { line: 10, col: 5 } }, "client-def": { name: "Bob", cursor: { line: 22, col: 0 } } } } ``` Stale presences are automatically cleaned up after 30 seconds of inactivity. ### Auto-Reconnect The client SDK automatically reconnects when the WebSocket connection drops: - **Exponential backoff** — Reconnect delays start at 1 second and double on each attempt, capping at 30 seconds. - **Maximum 5 attempts** — After 5 failed reconnection attempts, the client stops trying. - **Automatic resubscription** — On successful reconnect, all active subscriptions are re-registered with the server. No manual intervention needed. - **Message queuing** — Messages sent while disconnected are queued and delivered after reconnection. You can listen to connection lifecycle events: ```typescript const ws = client.ws; // Access the WebSocket client ws.on("connect", () => console.log("Connected")); ws.on("disconnect", () => console.log("Disconnected")); ws.on("reconnect", () => console.log("Reconnected")); ws.on("error", (error) => console.error("Error:", error)); ``` ### Authentication & RLS WebSocket subscriptions automatically respect Row-Level Security (RLS) policies. When the client is authenticated: 1. The WebSocket connection authenticates using the same JWT token as the REST API. 2. Every subscription refetch runs inside a PostgreSQL transaction with `set_config('app.user_id', ...)` and `set_config('app.user_roles', ...)` — ensuring RLS policies are enforced. 3. If a token expires during an active session, the client automatically re-authenticates and re-subscribes. This means each user only receives updates for records they have permission to see. ### Cross-Instance Broadcasting & LISTEN/NOTIFY Architecture For multi-instance cluster environments (e.g., running inside Kubernetes or Docker containers behind a load balancer), Rebase relies on PostgreSQL `LISTEN/NOTIFY` to synchronize **row changes** across instances. Collection and entity subscriptions therefore span instances with no configuration — that is what this section describes. **Broadcast channels and presence are separate**, and are per-instance until you turn on a channel bus. See [Channels and presence across instances](#channels-and-presence-across-instances) below. #### Bypassing pgBouncer Pools Because connection poolers like **pgBouncer** do not support the persistent connection model required for long-lived SQL `LISTEN` sessions, the real-time supervisor opens a dedicated, unpooled Postgres client (`PgClient`) directly to the database. This direct connection utilizes the `DATABASE_DIRECT_URL` environment variable if configured, ensuring stability and preventing pool exhaustion or abrupt drops. #### Notification Mechanics & Payload Layout When a entity is modified on Instance A, it broadcasts a notification on the `rebase_entity_changes` channel. To minimize database overhead and network bandwidth, the notification payload is kept extremely compact: ```json { "sid": "inst_7a9c1b", "p": "posts", "eid": "45", "db": null } ``` *Note: `sid` represents the server's unique random instance ID generated at startup, `p` is the collection slug (path), and `eid` is the target entity ID.* - **Self-Filtering**: Upon receiving a message, each instance reads the `sid`. If it matches its own instance ID, the server discards the notification to prevent infinite routing loops. - **Relay and Fan-out**: If the notification came from another instance, the server schedules a debounced refetch and relays the update to its locally connected WebSocket subscribers. - **Supervisor Reconnection Loop**: If the database connection drops, a background connection supervisor monitors the state and triggers an auto-reconnect sequence after a fixed **3-second** delay, restoring the `LISTEN` loop without affecting the main Hono application lifecycle. ### Channels and presence across instances Row changes cross instances on their own (above). Broadcast channels and presence do **not**: by default they fan out only to the clients connected to the instance that received them. On a single instance that is exactly right and costs nothing. Behind a load balancer it is a bug you will not see in development: two collaborators land on different replicas, join the same channel, and see an empty room while broadcasting to each other perfectly. Nothing errors. The fix is a **channel bus** — an opt-in transport that carries channel frames and presence between instances: ```typescript database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations }, realtime: { bus: { type: "postgres" } } }) ``` | Bus | When to use it | |--------------|--------------------------------------------------------------------------------------------------------| | `memory` | **Default.** Single instance. No cross-instance delivery, no overhead. | | `postgres` | Two or more instances. Uses `LISTEN/NOTIFY` on the database you already have — no new service to deploy. | The transport can also be set per deployment with `REALTIME_CHANNEL_BUS=memory|postgres`, which overrides the configured value. #### Why there is no Redis option in the box Rebase deploys as Postgres + backend + frontend. A bus that needed a message broker would put a second stateful service into every `docker-compose.yml` the CLI scaffolds, for a feature most applications never use — so the bar for adding one is that the database genuinely cannot carry the load. It can. Measured across two backend instances against a single Postgres container, the Postgres bus delivered **~10,000 cross-instance messages per second with no losses**, and stayed flat out to **eight instances** (14,000 deliveries, no losses). Twenty people dragging cursors at 60 fps generate around 1,200 messages per second — roughly an eighth of that. The limit worth watching is not capacity, it is that every notification is a query against your primary database, competing with your application's real queries. The Postgres bus therefore **coalesces** outgoing frames (see below), which is what keeps that cost proportional to elapsed time rather than message count. Per client, the socket accepts up to **7,200 channel frames a minute** (120/s — 60 fps of cursor broadcasts plus the presence update each one carries), counted separately from the budget queries and subscriptions share. Frames past that are refused with a `RATE_LIMITED` error rather than queued. If you are still pushing it after that, throttle cursor-grade events on the client (last-write-wins state does not need 60 updates a second), and consider routing a document's collaborators to the same instance — sticky routing drops cross-instance traffic to nearly nothing regardless of user count. Only past that is another transport worth it, and then the answer is a transport package, not a fork. See [Writing your own transport](#writing-your-own-transport). #### Coalescing Frames published while a short window is open leave together in a single notification. The window is **leading-edge**: a frame arriving when no window is open is sent immediately, so an idle channel pays no added latency and only a sustained stream is ever batched. Measured across two instances, 3,000 broadcasts, all delivered in every case: | Traffic shape | Coalescing off | Coalescing on | Reduction | |---|---|---|---| | Burst (as fast as possible) | 3,000 queries | 68 queries | **44×** | | Paced (~500 msg/s, spread out) | 3,000 queries | 240 queries | **12.5×** | The burst case also finished ~11× faster in wall-clock, because the database round-trips were the bottleneck rather than the work. The window defaults to 10 ms and is not a sensitive setting — 5 ms, 10 ms and 20 ms produced identical query counts in both shapes, because a batch is bounded by the 8 KB payload ceiling or by the natural shape of the traffic well before the timer matters. Change it only if you have a reason: ```typescript realtime: { bus: { type: "postgres", batchWindowMs: 20 } // 0 disables coalescing } ``` One deployment note: a batch travels in a different wire shape from a single frame, and an instance running an older build does not understand it. Single frames are always sent unwrapped, so a rolling deploy only risks dropped frames if the cluster is under sustained load *during* the restart — and retained channels repair themselves through history replay regardless. ### Writing your own transport `realtime.bus` accepts any object implementing the `ChannelBus` interface, so a transport can ship as its own package — `@rebasepro/types` declares the contract, and nothing else is required to implement it: ```typescript export class MyChannelBus implements ChannelBus { readonly kind = "my-transport"; readonly maxFrameBytes = Infinity; async start(handler: ChannelBusHandler): Promise { // Connect. Reject if you cannot — the caller falls back to in-process // delivery, which is far better than a cluster that believes it is // connected and silently is not. } async publish(frame: ChannelBusFrame): Promise { // Reach every other instance, or reject. } async stop(): Promise { // Idempotent; release anything holding the event loop open. } } ``` Pass the instance where a built-in name would go: ```typescript database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations }, realtime: { bus: new MyChannelBus(process.env.MY_TRANSPORT_URL!) } }) ``` **What your implementation must guarantee:** `start()` rejects when the transport is unusable; `publish()` reaches every other instance or rejects; `stop()` is idempotent; and a malformed message is dropped and logged rather than thrown, so one bad frame cannot take the listener down. **What it does not have to guarantee:** ordering (retained channels carry `seq` and the SDK orders by it), durability (a lost frame is a missed live update, repaired by the client's history replay), or exactly-once delivery (retained frames are deduped by `seq`; presence diffs are idempotent). `maxFrameBytes` is how the framework knows whether to send a large retained message inline or as a pointer. Return `Infinity` when your transport has no meaningful ceiling, so the pointer path is never taken needlessly. Delivery to local clients is not your concern — the realtime service owns which subscribers receive a frame. A transport only moves frames between instances. #### The 8 KB limit on the Postgres bus `pg_notify` refuses a payload of 8000 bytes or more. Cursors and presence fit with room to spare; a document snapshot does not. Rebase handles this the same way it handles large entity changes — by sending an address instead of a body: - **On a retained channel** (see [Channel Retention](#channel-retention)) the message is already stored with a sequence number, so the notification carries only `(channel, seq)` and each receiving instance reads the body back. There is no size limit at all. - **On an ephemeral channel** there is nothing to point at. The broadcast is delivered locally, the sender receives a `CHANNEL_BUS_PAYLOAD_TOO_LARGE` error, and a warning names the channel — rather than the message silently reaching half the cluster. If you broadcast large messages, give that channel a retention rule. That is the whole fix. #### Presence is shared state, not just fan-out `presence_state` has to answer "who is in this channel?" for the whole cluster, which per-instance memory cannot do. When a bus is active, Rebase keeps the roster in `rebase.channel_presence` (created automatically) and answers roster requests from it. | Column | Contents | |---------------|------------------------------------------------| | `channel` | Channel name | | `client_id` | The tracked client | | `instance_id` | Which backend instance it is connected to | | `state` | The client's presence state | | `last_seen` | Refreshed by the SDK's presence heartbeat | The SDK heartbeats presence every ~20 seconds against a 30-second timeout. Rows that stop being refreshed are reaped, and the departures announced to every instance — which doubles as crash recovery: a pod that dies leaves rows behind that look, after one timeout window, exactly like any other client that went quiet. A graceful shutdown clears its own rows immediately, so a rolling deploy does not show a window of ghosts. :::caution[The LISTEN connection must bypass your pooler] `LISTEN` is session state, so the Postgres bus needs a direct connection — not pgBouncer or any transaction-mode pooler. Rebase uses `DATABASE_DIRECT_URL` when it is set; behind a pooler, point it at the database service itself. Without a usable direct URL the bus logs a warning and stays in memory mode. ::: ### Database-Level Change Capture (CDC) **Change Data Capture is on by default.** Rebase captures changes at the database and emits realtime events for **every committed write, regardless of how it was made** — REST, SDK, Studio, `psql`, a cron job in another service, raw Drizzle/SQL, or Studio's **SQL editor**. This is the same model as Supabase Realtime tailing the write-ahead log. No configuration is required. On a database connection that supports it, CDC self-provisions at startup; on one that doesn't (e.g. a restricted role that can't create triggers), Rebase quietly uses application-level realtime instead — nothing to turn on, nothing that breaks. #### Configuration CDC is controlled by the `REALTIME_CDC` environment variable: | Value | Behavior | | --- | --- | | `auto` *(default)* | Enable database-level capture where the connection supports it; **silently fall back** to application-level realtime otherwise. Zero-config. | | `trigger` | Force trigger-based capture. Works on any PostgreSQL, including managed instances without logical replication. Warns (rather than silently falling back) if it can't provision. | | `wal` | Prefer WAL logical replication. Not yet bundled — degrades to `trigger` and logs the active mode. | | `off` | Application-level realtime only. Use this to avoid the per-write trigger overhead on write-heavy workloads. | On boot you'll see a log line stating the active mode, e.g.: ``` 📡 [CDC] Realtime source = database-level change capture (mode: trigger). All writes now emit realtime events regardless of origin. ``` If the connection can't support it, `auto` logs an informational line instead and continues with application-level realtime: ``` ℹ️ [CDC] Database-level change capture unavailable (likely insufficient privileges to create triggers…) — using app-level realtime. ``` #### How It Works 1. **Self-provisioning** — At startup (server/owner context), Rebase installs an idempotent `AFTER INSERT/UPDATE/DELETE` trigger on each managed table. The trigger emits a compact change notification on the `rebase_cdc` channel. A payload that would exceed PostgreSQL's 8 KB `NOTIFY` limit falls back to an identity-only message, so CDC can never abort the triggering write. 2. **Capture** — A dedicated, unpooled `LISTEN` client per instance consumes `rebase_cdc`, maps the changed table back to its collection, and feeds the change into the same `RealtimeService` pipeline used by API mutations. Like the cross-instance listener, it prefers `DATABASE_DIRECT_URL` and auto-reconnects. 3. **RLS-safe delivery** — The raw row from the change stream is **never** forwarded to subscribers. The change is marked invalidated, and each subscription re-reads the row under its **own** auth context. Filtering is therefore per subscriber, never per publisher: a client only ever receives rows its RLS policies permit. 4. **Cross-instance** — Because every instance observes every commit through the change stream, CDC also *is* the cross-instance channel; the legacy per-mutation `rebase_entity_changes` broadcast is not used while CDC is active. 5. **De-duplication** — A mutation made through the Rebase API is delivered locally the instant it commits and is also echoed back through the change stream. The originating instance suppresses that echo (a short-lived record of its own emits), so subscribers never see an API write twice. #### Requirements & Notes - CDC requires a direct connection string (`DATABASE_DIRECT_URL` or the primary connection) for the `LISTEN` client — connection poolers in transaction mode do not support long-lived `LISTEN` sessions. - Triggers are installed only on tables backed by a registered collection. Writes to unmapped tables are ignored. - A collection whose table has not yet been migrated is skipped with a warning rather than blocking CDC for the rest. - Native WAL logical-replication streaming (`wal2json`/`pgoutput`) is planned; today `REALTIME_CDC=wal` degrades to the trigger-based path, which provides equivalent database-level coverage. ### Pending Request Timeout To prevent client requests from hanging indefinitely, all pending WebSocket operations that expect a server response (such as one-shot collection fetches `FETCH_COLLECTION`, single entity fetches `FETCH_ONE`, creating/updating `SAVE`, deletes `DELETE`, counts `COUNT`, and uniqueness checks `CHECK_UNIQUE_FIELD`) have a default timeout of 30 seconds. If the server does not respond within this 30-second window, the client automatically deletes the pending request and rejects the promise with an `ApiError` with the message `"Request timed out"`. One-way messages that do not expect a response (like `subscribe_collection`, `subscribe_one`, `unsubscribe`, `join_channel`, `leave_channel`, `broadcast`, `presence_track`, `presence_untrack`, and `presence_state`) resolve immediately upon transmission and do not trigger timeouts. ### Next Steps - [Client SDK](/docs/sdk) — Full SDK reference including typed collection accessors. - [Authentication](/docs/backend/authentication) — Set up JWT auth and RLS policies. - [Backend Architecture](/docs/backend) — Overview of the Rebase server architecture. ## Search `.search("term")` works on every collection without configuration. What it compiles to depends on whether the collection has asked for anything more. ### The default With no configuration, `.search()` is a **case-insensitive substring match**, OR-ed across the collection's top-level `string` properties: ```sql WHERE name ILIKE '%term%' OR description ILIKE '%term%' ``` This is enough for a small collection with its text in plain columns. It has three limits that no setting inside it can fix: - **It cannot see inside `map` or `array` properties.** A collection that keeps its searchable content in JSONB — tags, certifications, a questionnaire — has a search box that silently matches nothing. - **It has no relevance.** Rows come back in `orderBy` order, so the best match can be on page seven. - **It cannot use an index.** A leading `%` defeats a B-tree, so every search is a sequential scan. Fine at a thousand rows; a cliff at a million. The term is matched **literally**: `%` and `_` are LIKE metacharacters, and they are escaped before the pattern is built, so searching for `50%` searches for `50%` rather than returning every row. If you want wildcards, the `like` filter operator takes a pattern (`.where("title", "like", "post-%")`); `.search()` does not. The default does not change, and a collection that has not opted in compiles to exactly the SQL it always did. ### Opting in Declare a `search` block on a Postgres collection, naming the fields you want indexed: ```typescript const talents: PostgresCollectionConfig = { slug: "talents", table: "talents", name: "Candidates", properties: { id: { name: "ID", type: "string", isId: "uuid" }, full_name: { name: "Full name", type: "string" }, bio: { name: "Bio", type: "string" }, interests: { name: "Interests", type: "array", of: { name: "Interest", type: "string" } }, questionnaire: { name: "Questionnaire", type: "map", properties: {} } }, search: { language: "spanish", unaccent: true, fields: [ { path: "full_name", weight: "A" }, { path: "bio", weight: "D" }, "interests", "questionnaire.certifications" ] } }; ``` Nothing is inferred. A field is searched if and only if you name it, and a path that does not resolve fails at boot rather than being quietly skipped — a search field you believe is live and is not is exactly the failure this block exists to prevent. `.search()` then compiles to a ranked full-text match, and rows come back with a `_score`: ```typescript const { data } = await client.data.talents .search("auditor iso 14001") .orderBy("_score", "desc") .find(); ``` #### What declaring it creates One `tsvector` column, `GENERATED ALWAYS AS … STORED`, and one GIN index on it. Postgres recomputes the column on every write of a source field and refuses any attempt to write it directly, so the index cannot drift from the row. The column is never returned by the API. They are generated into `drizzle/search.sql`, next to `schema.sql` and `policies.sql`, and `rebase db push` applies them for you — nothing extra to run. They get their own file because a generated `tsvector` column needs an `IMMUTABLE` helper function to exist first (`unaccent` is only `STABLE`, and flattening a `jsonb` document needs a set-returning function), and Atlas — the engine behind `db push` — cannot manage functions on its free tier. One consequence worth knowing if you deploy by migration rather than by push: adding a `search` block on its own produces no migration, because the schema Atlas compares has not changed. `rebase db generate` says so when it happens. The block is still applied by `rebase db push` and by the boot-time schema ensure; to put it in a migration explicitly, append `drizzle/search.sql` to one. #### Changing the block later A generated column carries its expression, and Postgres cannot alter that expression in place — so adding a field, moving a weight, changing the language or turning `unaccent` on is **not** something `ADD COLUMN IF NOT EXISTS` can apply to a column that already exists. Rebase records a fingerprint of the expression on the column when it creates it, and compares it on every boot and every `db push`. A change is refused, loudly, with the two statements that apply it — a `DROP COLUMN` and an `ADD COLUMN`, which rewrite the table and rebuild the GIN index. Run them at a time you choose; nothing rewrites a live table on your behalf. (Turning `fuzzy` on is additive — a second column — and applies without any of this.) Boot refuses rather than serving, because the alternative is what this check replaced: a column that keeps indexing the previous field set, and a search that returns nothing for content plainly in the row. ### What you can name in `fields` | Path | Resolves to | Example | |------|-------------|---------| | A `string` property | the column | `"full_name"` | | A `string[]` property | every element | `"interests"` | | A `map` property | every string value in the document | `"questionnaire"` | | A path inside a `map` | every string value at or below that point | `"questionnaire.certifications"` | A path into a map indexes **string values at any depth** below it — arrays of strings, nested objects, arrays of objects. JSON *keys* are never indexed, only values, so a field name common to every row does not become a term that matches every row. Naming an enum, a UUID, a `json` (rather than `jsonb`) column, or an array of numbers is a boot-time error explaining why. Enums in particular are a fixed vocabulary: filter on them with `where`, which is exact and uses an index. ### Options #### `language` The Postgres text search configuration, which decides stemming and stopwords. `"spanish"` stems `auditores` to `auditor` and drops `de`; the default, `"simple"`, does neither. `"simple"` is the default because it is the only choice that is never wrong — a stemmer applied to the wrong language silently mangles lexemes. Set it to your content's language to get stemming. #### `unaccent` Fold accents before indexing, so `auditoria` matches `auditoría`. This is not cosmetic in an accented language. Postgres stems the two spellings to **different lexemes** — `to_tsvector('spanish', 'auditoría')` yields `auditor` while `'auditoria'` yields `auditori` — so without it, a query typed without accents misses every row that carries them, which is most queries most users type. Requires the `unaccent` extension. #### `fuzzy` Also match on trigram similarity, so near-misses still rank: `iso14000` reaching `ISO 14001`, which no amount of stemming will do because they are simply different lexemes. ```typescript search: { fields: ["full_name", "questionnaire.certifications"], fuzzy: true, fuzzyThreshold: 0.3 // default } ``` Adds a second generated column and a trigram index, and requires `pg_trgm`. Costs write time and disk; buys the most common class of failed search. #### `weight` Each field carries one of Postgres's four weight classes, `A` (strongest) through `D`. `ts_rank` scores an `A` hit far above a `D` one, which is how a name outranks a passing mention in a long description. Fields default to `B`. #### `column` The generated column is named `search_vector`. Change it only if that collides with a column you already have — it is part of your schema once created, and renaming it later is a drop and recreate, which rewrites the table. ### Ranking `_score` is `ts_rank` against the same query the rows were matched with, and is present only when the collection opted in *and* the request carried a search string. With `fuzzy` on, the trigram similarity is **added** to that rank. This is not a refinement — it is what makes `fuzzy` a ranking at all. A typo matches nothing on the exact path, so every row it finds has a `ts_rank` of exactly zero; ordering by rank alone would return the best match in whatever order the table felt like. The two terms are summed rather than weighted, so a row that matched exactly contributes both and outranks a merely-similar row without needing a coefficient to say so. Outside those two conditions `orderBy: "_score"` is an unknown field and returns 400 rather than silently returning unsorted rows. `_score` cannot be combined with cursor pagination (`startAfter`). Relevance is computed per query rather than stored, so there is no value on the cursor row to compare the next page against, and two requests with different search strings produce scores that are not on the same scale. Use `limit`/`offset` for relevance-ordered pages. ### Why did this row match? A ranked list tells you *which* rows, never *why* one is there. Ask each row to explain itself: ```typescript const { data } = await client.data.talents .search("iso 14001", { explain: true }) .orderBy("_score", "desc") .find(); data[0]._matches; // [{ field: "questionnaire.certifications", // snippet: "ISO 14001 Lead Auditor" }] ``` `field` is the path exactly as declared in `fields`, so you can map it to a label for display. Fields come back in the order you declared them. Per-query, not per-collection, because the cost is per-query: one `ts_headline` per declared field per returned row, and `ts_headline` re-parses the document rather than reading the index. Right for a page of results, wrong for an export. **The snippet contains markup by construction** — each hit is wrapped in ``. Render it as HTML or strip the tags, but do not treat it as plain text, and do not trust the surrounding text: it is whatever the user typed. Splitting on `` and rendering the parts is safer than `dangerouslySetInnerHTML`. With `unaccent` on, snippets read with accents folded — `Auditoria`, not `Auditoría`. `ts_headline` over the original text cannot find a hit that an unaccented query produced, so it would return the text with nothing marked at all; a readable snippet that highlights beats a prettier one that silently doesn't. ### Adding the block to a live collection The generated column is added by the boot-time schema ensure, like any other column, and its index is built with `CREATE INDEX CONCURRENTLY` so writes are not blocked. Adding a *stored* generated column does rewrite the table, so on a large one, plan it like any other rewrite. ### Which engines The `search` block is Postgres-only, and is rejected at boot on other engines rather than silently ignored. MongoDB collections keep their regex-based matching; Firestore collections use the external text-search controller. ## Cron Jobs ### Overview Rebase includes a built-in **cron job scheduler** for running recurring background tasks — data cleanup, report generation, health checks, external API syncs, and more. Cron jobs follow the same **file-based discovery** pattern as custom functions: drop a TypeScript file in your `crons/` directory, and Rebase automatically registers and schedules it. - **Zero dependencies** — No external scheduler libraries required - **Admin API** — REST endpoints to list, trigger, enable/disable, and view logs - **Studio dashboard** — Monitor all jobs, view execution history, and trigger runs manually - **Database persistence** — Execution logs stored in PostgreSQL, surviving restarts - **In-memory cache** — Fast ring buffer (last 50 runs) for the dashboard, backed by the DB ### Defining a Cron Job Create a file in your `backend/crons/` directory that default-exports a cron definition. Use the `defineCron` helper from `@rebasepro/server` for type inference and autocomplete: ```typescript // backend/crons/health-check.ts export default defineCron({ schedule: "*/5 * * * *", // every 5 minutes name: "System Health Check", description: "Monitors uptime and memory usage", async handler(ctx) { ctx.log("Running health check..."); const uptime = process.uptime(); const mem = process.memoryUsage(); ctx.log(`Uptime: ${Math.round(uptime)}s`); ctx.log(`Heap: ${Math.round(mem.heapUsed / 1024 / 1024)}MB`); return { uptimeSeconds: Math.round(uptime), heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024), }; }, }); ``` :::note `defineCron` is an identity function — it returns the same object you pass in. A plain default-exported `CronJobDefinition` object works identically; `defineCron` simply provides compile-time type checking and editor autocomplete. ::: The **filename** (without extension) becomes the job's unique ID — e.g., `health-check`. ### Configuration Enable cron jobs by adding `cronsDir` to your backend config: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config functionsDir: path.resolve(__dirname, "../functions"), cronsDir: path.resolve(__dirname, "../crons"), // ← add this }); ``` That's it. Rebase will: 1. Scan the directory for `.ts` / `.js` files 2. Register each default export as a cron job 3. Auto-create the `rebase.cron_logs` table in PostgreSQL (if the driver supports SQL) 4. Start the scheduler and seed counters from existing DB logs 5. Mount admin REST routes at `/api/cron` ### Schedule Syntax Cron expressions use the standard **5-field format**: ``` ┌───────────── minute (0–59) │ ┌─────────── hour (0–23) │ │ ┌───────── day of month (1–31) │ │ │ ┌─────── month (1–12) │ │ │ │ ┌───── day of week (0–6, Sunday = 0) │ │ │ │ │ * * * * * ``` | Expression | Meaning | |------------|---------| | `* * * * *` | Every minute | | `0 * * * *` | Every hour | | `0 3 * * *` | Daily at 3:00 AM | | `0 0 * * 1` | Every Monday at midnight | | `0 9 1 * *` | First day of each month at 9:00 AM | | `0,30 * * * *` | Every 30 minutes (on :00 and :30) | | `0 9-17 * * 1-5` | Hourly, 9 AM–5 PM, weekdays only | Step values (`*/n`), ranges (`a-b`), and lists (`a,b,c`) are all supported. ### CronJobDefinition Reference ```typescript interface CronJobDefinition { // Cron schedule expression (5-field format) schedule: string; // Human-readable name shown in Studio name: string; // Optional description shown in Studio description?: string; // Whether the job starts enabled (default: true) enabled?: boolean; // Max execution time in seconds (default: 300) timeoutSeconds?: number; // How far back to look on startup for a slot that elapsed while no // instance was ticking (default: off). See "Recovering Missed Slots". catchUpWindowSeconds?: number; // The function to run on each tick handler: (ctx: CronJobContext) => Promise | unknown; } ``` ### Handler Context Each handler receives a `CronJobContext` containing utility methods and the Rebase Client instance: ```typescript interface CronJobContext { // The job's unique ID (derived from filename) jobId: string; // The scheduled tick timestamp scheduledAt: Date; // Logger — captured lines appear in Studio and the logs API log: (...args: unknown[]) => void; // Backing RebaseClient instance running with full admin privileges client: RebaseClient; } ``` Use `ctx.log()` to emit structured output. These lines are captured in the execution log and visible in Studio and via the REST API. #### Interacting with Database & Services via `ctx.client` The `ctx.client` parameter provides direct, server-side access to all Rebase services under administrative privileges. This means database operations run with bypass of Row-Level Security (RLS) policies: ```typescript // backend/crons/expire-users.ts export default defineCron({ schedule: "0 0 * * *", // Daily at midnight name: "Expire Inactive Accounts", async handler(ctx) { ctx.log("Checking for expired trial users..."); // Fetch using the pre-initialized data driver. `collection(slug)` // gives the query builder the row type — `where` keys are checked // against it. Every filter is an `[operator, value]` tuple; a bare // value is passed straight through and builds a malformed query. const users = ctx.client.data.collection<{ id: string; email: string; trial_status: string; trial_ends_at: string; status: string; }>("users"); const { data: trials } = await users.find({ where: { trial_status: ["==", "active"], trial_ends_at: ["<", new Date().toISOString()] } }); ctx.log(`Found ${trials.length} users with expired trials.`); for (const user of trials) { await users.update(user.id, { trial_status: "expired", status: "disabled" }); // Send email notification using Rebase email service if (ctx.client.email) { await ctx.client.email.send({ to: user.email, subject: "Your trial has expired", html: "

Please upgrade your subscription to continue.

" }); } } } }); ``` :::tip The handler can return any JSON-serializable value. It will be stored in the log entry as `result` and displayed in Studio's execution history. ::: ### REST API All cron routes require **admin authentication** (`requireAuth` + `requireAdmin`). | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/cron` | List all registered cron jobs | | `GET` | `/api/cron/:id` | Get a single job's status | | `POST` | `/api/cron/:id/trigger` | Manually trigger a job | | `GET` | `/api/cron/:id/logs` | Get execution history (`?limit=N`) | | `PUT` | `/api/cron/:id` | Enable/disable a job (`{ "enabled": true }`) | #### Example: List All Jobs ```bash curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/api/cron ``` ```json { "jobs": [ { "id": "health-check", "name": "System Health Check", "schedule": "*/5 * * * *", "enabled": true, "state": "idle", "totalRuns": 12, "totalFailures": 0, "lastRunAt": "2026-04-24T08:15:00.000Z", "nextRunAt": "2026-04-24T08:20:00.000Z", "lastDurationMs": 3 } ] } ``` #### Example: Trigger a Job Manually ```bash curl -X POST -H "Authorization: Bearer $TOKEN" \ http://localhost:3001/api/cron/health-check/trigger ``` ### Client SDK The Rebase client SDK exposes a `cron` namespace for all operations: ```typescript const client = createRebaseClient({ baseUrl: "http://localhost:3001" }); // List all jobs const { jobs } = await client.cron.listJobs(); // Get a single job const { job } = await client.cron.getJob("health-check"); // Trigger manually const { log, job: updated } = await client.cron.triggerJob("health-check"); // View execution history const { logs } = await client.cron.getJobLogs("health-check", { limit: 10 }); // Enable or disable await client.cron.toggleJob("health-check", false); // pause await client.cron.toggleJob("health-check", true); // resume ``` ### Studio Dashboard When cron jobs are configured, a **Cron Jobs** tool appears in Rebase Studio under the **Automation** section. The dashboard provides: - **Job list** — All registered jobs with live status indicators - **Detail panel** — Schedule, next/last run, duration, and error information - **Execution history** — Expandable log entries with captured output and results - **Manual trigger** — Run any job on demand with one click - **Enable/disable** — Pause and resume jobs without restarting the server The dashboard auto-refreshes every 15 seconds. ### Schedule Validation & AST Parsing At backend initialization, Rebase parses all registered cron schedules using a zero-dependency JS-based cron expander: - **Syntax Check**: Verifies that the string contains exactly 5 whitespace-separated fields (`minute`, `hour`, `day of month`, `month`, `day of week`). - **Range Expansion**: Deconstructs steps (`*/15`), ranges (`9-17`), and comma-separated lists (`0,30`) into explicit arrays of valid integers mapped to their respective bounds (e.g., minutes `0-59`, hours `0-23`, months `1-12`). - If any cron expression fails validation, Rebase rejects the definition, logs a startup error, and refuses to register the job to prevent runtime execution failures. --- ### Under the Hood: Clock-Drift Correction Standard interval-based schedulers (such as `setInterval`) drift over time and cause significant CPU spikes due to OS-level event loop scheduling delays. To guarantee execution accuracy, Rebase implements a **dynamic target-time calculation loop**: 1. **Candidate Calculation**: Upon completing a job or starting the scheduler, Rebase calculates the exact timestamp of the *next* matching candidate minute. 2. **Dynamic Sleep**: It calculates the difference in milliseconds (`nextRun.getTime() - now.getTime()`) and schedules a single `setTimeout`. 3. **Drift Safety Threshold**: A minimum sleep buffer (`MIN_SCHEDULE_INTERVAL_MS`) of **5,000ms** is enforced. If a scheduler tick completes extremely quickly, this threshold prevents near-instant double-firing. 4. **Shutdown Friendliness**: Timer handles are explicitly detached from the Node.js event loop using `timer.unref()`, ensuring background cron schedulers do not block clean process terminations during deployments. --- ### Recovering Missed Slots Because the scheduler computes the next slot from *now* on every boot, a slot only fires if some instance was alive and ticking when it came round. Anything that replaces the process during a slot — a rolling deploy, a crash, a platform recycling the container — drops that run, and the replacement schedules the slot *after* it. Nothing errors; the run simply never happens. This is **not** only a scale-to-zero problem. A service pinned to a warm instance still loses runs, because a platform is free to retire the instance holding the timer and start a fresh one. Set `catchUpWindowSeconds` to a window comfortably wider than a restart, and startup will run a slot it finds unclaimed inside that window: ```typescript export default defineCron({ schedule: "0 6 * * *", // daily at 06:00 name: "Scrape Listings", catchUpWindowSeconds: 3600, // tolerate an hour of downtime around 06:00 handler: async (ctx) => { /* … */ } }); ``` Three things to know: - **Off by default.** Without `catchUpWindowSeconds`, behaviour is unchanged. - **Only the most recent missed slot runs.** Booting after a six-hour outage catches an hourly job up once, not six times. Catch-up stops a run going missing; it does not replay history. - **A claims-capable store is required.** Catch-up claims the slot through the same `(job_id, slot)` key the scheduled path uses, which is the only thing distinguishing "this slot never ran" from "this slot already ran on the instance being replaced". With no store attached, catch-up is skipped and a warning is logged — otherwise an instance recycled every 30 minutes would re-run the same hourly job every time it booted. In the ordinary case — a restart minutes after a slot ran normally — the most recent slot is already claimed, so catch-up costs one claim check per job per boot and does nothing. A recovered run is a normal entry in `cron_logs` (`manual` is `false`), with a first log line recording the slot it recovered and how late it was: ``` ⏰ Catch-up run for missed slot 2026-07-29T06:00:00.000Z (612s late) ``` --- ### Concurrency Guarding To ensure stability when executing resource-heavy operations, Rebase implements a strict **single-concurrency execution lock** per job ID: - **Scheduled Overlaps**: If a job's scheduled tick fires while the previous execution is still running, the scheduler skips the tick, logs a warning, and immediately schedules the next candidate run. - **Manual Trigger Collisions**: If an operator manually triggers a running job via Rebase Studio or the REST API, the request returns immediately with a skipped payload, protecting the active worker: ```json { "jobId": "expire-users", "success": true, "result": { "skipped": true, "reason": "already_executing" }, "logs": ["Skipped: job is already running"] } ``` --- ### Timeouts & Error Isolation - **Forced Timeout Race**: Execution blocks are wrapped in a `Promise.race` against a timeout timer derived from `timeoutSeconds` (default: `300` seconds / 5 minutes). If the handler hangs past this threshold, the promise is rejected, throwing: `Error: Cron job "" timed out after ms` - **Fail-Safe Try/Catch**: Each job handler runs inside an isolated wrapper. Any uncaught exceptions are intercepted, formatting the error traceback into a string, setting the job status to `"error"`, and updating the `rebase.cron_logs` failure counters. A crash inside a single cron task will never crash the scheduler loop or the primary Hono HTTP web server. - **In-Memory Ring Buffer**: The scheduler maintains a ring buffer containing the last **50 runs** per job. This buffer is kept in memory to allow near-instant reads from the Rebase Studio UI. --- ### Database Persistence Schema When database adapters supporting SQL (e.g. PostgreSQL) are active, Rebase provisions the `rebase.cron_logs` table: ```sql CREATE SCHEMA IF NOT EXISTS rebase; CREATE TABLE IF NOT EXISTS rebase.cron_logs ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, job_id TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ NOT NULL, duration_ms INTEGER NOT NULL, success BOOLEAN NOT NULL DEFAULT true, error TEXT, -- Stack trace or error message result JSONB, -- Return value of handler logs JSONB, -- Ring buffer array of ctx.log outputs manual BOOLEAN NOT NULL DEFAULT false -- True if triggered from Studio/REST ); CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON rebase.cron_logs(job_id, started_at DESC); ``` On startup, the scheduler reads stats from this table via aggregate queries (`COUNT(*)`, `SUM(CASE WHEN success = false THEN 1 ELSE 0 END)`) to populate `totalRuns` and `totalFailures` history. Log insertions are executed in a non-blocking asynchronous sweep; if a database flush fails, the scheduler logs the error and continues normal execution using the in-memory ring buffer as a fallback. ### Example: Daily Cleanup Job ```typescript // backend/crons/cleanup-sessions.ts const job: CronJobDefinition = { schedule: "0 3 * * *", // daily at 3 AM name: "Cleanup Expired Sessions", description: "Removes user sessions older than 30 days", async handler(ctx) { ctx.log("Starting session cleanup..."); // Use the rebase singleton for admin-level database access // const { data: expired } = await rebase.data.findMany("sessions", { ... }); const count = Math.floor(Math.random() * 50); // placeholder ctx.log(`Cleaned up ${count} expired sessions`); return { deletedSessions: count }; }, }; export default job; ``` ### Next Steps - **[Backend Overview](/docs/backend)** — Full backend configuration reference - **[Entity Callbacks](/docs/collections/callbacks)** — Run logic on data changes - **[Webhook Integration](/docs/recipes/webhooks)** — Send notifications on events ## Custom Functions ### Overview Custom functions let you add **arbitrary Hono API routes** alongside Rebase's auto-generated CRUD endpoints. They follow the same **file-based discovery** pattern as collections and cron jobs: drop a TypeScript file in your `functions/` directory, and Rebase mounts it automatically. Use custom functions for: - **Business logic endpoints** — approvals, promotions, custom workflows - **Third-party integrations** — Stripe webhooks, Slack commands, external API proxies - **Public endpoints** — contact forms, lead capture, health checks - **Aggregate queries** — dashboard stats, reports, analytics ### Defining a Custom Function Create a file in your `backend/functions/` directory that default-exports a Hono app: ```typescript // backend/functions/hello.ts const app = new Hono(); app.get("/", (c) => { return c.json({ message: "Hello from custom function!" }); }); export default app; ``` This mounts at **`/api/functions/hello`**. The filename (without extension) becomes the route prefix. ### Configuration Enable custom functions by adding `functionsDir` to your backend config: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config functionsDir: path.resolve(__dirname, "../functions"), }); ``` Rebase will: 1. Scan the directory for `.ts` / `.js` files 2. Validate each default export is a Hono app (duck-typed via `.fetch()` + `.routes`) 3. Mount each app at `/api/functions/` 4. Apply the auth middleware (see [Authentication](#authentication) below) ### File Naming and Route Mapping | File | Mount Path | |------|-----------| | `functions/hello.ts` | `/api/functions/hello/*` | | `functions/send-invoice.ts` | `/api/functions/send-invoice/*` | | `functions/webhooks.ts` | `/api/functions/webhooks/*` | Functions are discovered at the **top level of the directory only** — there is no recursion. `functions/admin/users.ts` is compiled by `rebase build` but never mounted; flatten the name instead (`functions/admin-users.ts`). A subdirectory is reported at boot and counted on the listing endpoint rather than ignored silently. Files that are **skipped**: - `index.ts` / `index.js` — reserved - `*.test.ts` / `*.test.js` — test files - `*.d.ts` — type declarations - Subdirectories, and `.mts` / `.cts` / `.tsx` / `.jsx` / `.mjs` / `.cjs` files — reported as problems, since the build compiles more than the runtime loads ### Export Formats The loader accepts two export formats: #### Hono App (recommended) ```typescript const app = new Hono(); app.get("/status", (c) => c.json({ ok: true })); export default app; ``` #### Factory Function ```typescript export default function () { const app = new Hono(); app.get("/status", (c) => c.json({ ok: true })); return app; } ``` --- ### Under the Hood: The Duck-Typing Loader When compiling codebases with multiple nested directories or in monorepos, you may run into **Hono package duplication**. If the Rebase framework depends on one Hono version and your local function directory resolves to another, standard class inheritance checks (`exported instanceof Hono`) will fail because their prototypes exist in separate memory spaces. To prevent false negatives and reject loading functioning routers, Rebase uses a duck-typed validator (`isHonoLike`): - It verifies the exported object is a non-null `object`. - It checks that the object exposes a `.fetch` method (required to route requests). - It verifies that `.routes` is an `array`. ```typescript function isHonoLike(obj: unknown): boolean { if (!obj || typeof obj !== "object") return false; const record = obj as Record; return typeof record.fetch === "function" && Array.isArray(record.routes); } ``` #### ES Module Compiler Escape To import TypeScript and JavaScript files dynamically on both Windows and Posix systems, the loader converts file paths to standard file URIs via `pathToFileURL(filePath).href`. To prevent TypeScript compilation from rewriting native ESM dynamic imports (`import(url)`) into CommonJS `require()` calls (which would throw errors at runtime under ESM runtimes), Rebase executes a runtime compiler escape: ```typescript const dynamicImport = new Function("url", "return import(url)"); const mod = await dynamicImport(fileUrl); ``` --- ### Authentication & Context Propagation Custom functions are mounted with the **same auth middleware** as the data routes, but with `requireAuth: false`. This means: - The user's JWT is **parsed and injected** into the context if present - But requests are **not rejected** if no JWT is provided - You must **explicitly protect** routes that need authentication #### Protecting Routes Use Rebase's built-in auth helpers: ```typescript const app = new Hono(); // Public endpoint — no auth required app.get("/public", (c) => { return c.json({ message: "Anyone can access this" }); }); // Protected endpoint — requires a valid JWT app.post("/protected", async (c) => { // Narrowed: the env types every variable the middleware may set. const user = c.get("user") as { uid: string; roles?: string[] } | undefined; if (!user) { return c.json({ error: "Unauthorized" }, 401); } return c.json({ message: `Hello, ${user.uid}` }); }); // Admin-only endpoint app.post("/admin-only", async (c) => { const user = c.get("user") as { uid: string; roles?: string[] } | undefined; const roles: string[] = user?.roles ?? []; if (!roles.includes("admin")) { return c.json({ error: "Admin access required" }, 403); } return c.json({ message: "Admin operation succeeded" }); }); export default app; ``` :::important Rebase's JWT middleware is scoped to the built-in API routes (`/api/data`, `/api/auth`, etc.). Custom function routes get the **parsed user context** (e.g. `c.get("user")`), but you must enforce access control yourself. ::: #### Service Key Authentication Rebase supports a static `REBASE_SERVICE_KEY` defined in your `.env` for script or server-to-server calls. When an external request passes the service key via the Authorization header (`Authorization: Bearer `), the auth middleware automatically: 1. Validates the key using constant-time comparison to prevent timing attacks. 2. Grants admin-level access, setting `c.get("user")` with: ```json { "uid": "service", "roles": ["admin"] } ``` 3. Injects a `DataDriver` into `c.get("driver")` scoped as that same service identity. Row-Level Security still applies — it is evaluated as `{ uid: "service", roles: ["admin"] }`, not skipped. #### Internal Self-Authentication If you haven't configured a `REBASE_SERVICE_KEY`, Rebase generates a random **internal per-boot key**. The `rebase` singleton uses this key automatically when calling the server's own control-plane APIs (like `rebase.auth` or `rebase.storage`). This means your server-side logic can always perform administrative tasks even without a manually configured service key. ### Accessing the Database & Services Custom functions run alongside Rebase, providing multiple ways to interact with your data depending on your security requirements: #### 1. Via the User-Scoped Data Driver (Recommended for User Requests) Rebase automatically injects the `driver` into the Hono request context (`c.get("driver")`). This driver is **scoped to the authenticated user** and automatically respects all PostgreSQL Row-Level Security (RLS) policies. Using the driver ensures that users can only query or update records they are authorized to access under your database security policies: ```typescript // backend/functions/my-products.ts const app = new Hono(); app.get("/", async (c) => { const driver = c.get("driver")!; // Injected scoped driver const user = c.get("user"); // Authenticated user context if (!user) { return c.json({ error: "Unauthorized" }, 401); } // Queries respect Row-Level Security const myProducts = await driver.fetchCollection({ path: "products", limit: 10 }); return c.json(myProducts); }); export default app; ``` #### 2. Via the Rebase Singleton (Admin-Scoped Access) The `@rebasepro/server` package provides a `rebase` singleton whose `dataAsAdmin` accessor runs as the service identity `{ uid: "service", roles: ["admin"] }`. Use this for background processing, system updates, integrations, or cases where a request needs to read or write to tables that the end-user has no direct permissions for: ```typescript // backend/functions/approve-job.ts const app = new Hono(); app.post("/:id/approve", async (c) => { const id = c.req.param("id"); // Use the admin-level data API (RLS is evaluated as the `admin` role) await rebase.dataAsAdmin.collection>("jobs").update(id, { status: "published", approved_at: new Date().toISOString(), }); return c.json({ success: true }); }); export default app; ``` #### RLS-Scoped Driver vs. Rebase Singleton | | `c.get("driver")` (request-scoped) | `rebase.dataAsAdmin` (service identity) | | ------------------- | ---------------------------------------------- | ---------------------------------------------------------------- | | **Runs as** | The caller (`uid`, their roles) | `{ uid: "service", roles: ["admin"] }` | | **RLS enforcement** | ✅ Yes (evaluated against the caller) | ✅ Yes (evaluated against the service identity) | | **Performance** | Native (direct driver call) | Native (direct driver call) | | **Ideal for...** | General user CRUD, search, and queries | Background jobs, system triggers, webhooks | | **API style** | Driver-level methods (`fetchCollection`, `saveEntity`) | Fluent collection accessors (`rebase.dataAsAdmin.jobs.find`) | ##### What `dataAsAdmin` is, precisely `rebase.dataAsAdmin` is **admin-scoped, not RLS-bypassing**. The driver is scoped once, at boot, with `withAuth({ uid: "service", roles: ["admin"] })`, so every read and write runs inside a transaction that has switched to the restricted `rebase_user` role with `app.uid = 'service'`. Your policies are evaluated — against that identity. For most projects the distinction never surfaces, because the default policies Rebase injects onto every collection admit `serverContext() OR rolesOverlap(['admin'])`, and the service identity clears the second arm. It surfaces the moment you write your own policies: - **`policy.serverContext()` is false for it.** That helper compiles to `auth.uid() IS NULL`, and this accessor's `uid` is `'service'`. A collection with `disableDefaultPolicies: true` whose only write rule is `serverContext()` will refuse a `dataAsAdmin` write with Postgres error `42501`, and a read against such a collection returns **zero rows with HTTP 200** — the silent direction. Write `rolesOverlap(["admin"])` (or add it alongside) when you mean "my backend". - **Its reach equals an `admin` user's reach.** Granting the `admin` role to an application user grants them exactly the rows this accessor sees. It is not a private channel. If you genuinely need an unconditional bypass, `rebase.sql()` is it: raw SQL on the owner connection, no policies, every row. It is the most privileged thing in a function's context — more so than the accessor with "admin" in its name. #### 3. Via Direct Drizzle Access If you need raw SQL or complex custom queries, you can access your Drizzle database instance directly: ```typescript // backend/functions/reports.ts const app = new Hono(); app.get("/stats", async (c) => { const result = await db.execute(sql` SELECT COUNT(*) as total FROM jobs WHERE status = 'published' `); return c.json({ totalJobs: result.rows[0]?.total }); }); export default app; ``` :::tip The Drizzle `db` instance used by Rebase is the same one you pass to `createPostgresBootstrapper`. You can share it freely between custom functions and Rebase. ::: ### Route Registration Order Custom functions are loaded and mounted **after** `initializeRebaseBackend()` completes the core setup. The initialization order is: 1. **Bootstrappers** — Database connections, auth tables, realtime services 2. **Auth routes** — `/api/auth/*`, `/api/admin/*` 3. **Storage routes** — `/api/storage/*` 4. **Data routes** — `/api/data/*` (CRUD for collections) 5. **Custom functions** ← `/api/functions/*` 6. **Cron jobs** — `/api/cron/*` 7. **WebSocket** — Realtime subscriptions This means your custom functions have access to all initialized services. Register any routes that need to run **before** Rebase on the Hono app directly, prior to calling `initializeRebaseBackend()`: ```typescript no-verify const app = new Hono(); // This runs BEFORE Rebase routes app.get("/health", (c) => c.json({ status: "ok" })); // Rebase initialization — registers all /api/* routes const instance = await initializeRebaseBackend({ app, /* ... */ }); ``` ### Example: Webhook Handler ```typescript // backend/functions/stripe-webhook.ts const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const app = new Hono(); app.post("/", async (c) => { const sig = c.req.header("stripe-signature")!; const body = await c.req.text(); const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); if (event.type === "checkout.session.completed") { const session = event.data.object; await instance.driver.data.subscriptions.create({ user_id: session.client_reference_id, stripe_id: session.subscription, status: "active", }); } return c.json({ received: true }); }); export default app; ``` ### Debugging When a function is loaded successfully, you'll see: ``` ⚡ Loaded function route: hello ``` If loading fails, the loader provides diagnostic output: ``` [functions] broken-function.ts: default export is not a Hono app or factory. Skipping. export type: object (SomeClass) prototype methods: constructor, someMethod Hint: ensure the function exports a Hono app created with the same hono version as the server. ``` The router is mounted for the **directory**, not for the functions in it. If every file fails to import — one missing environment variable at module scope is enough to take all of them down — `GET /api/functions` still answers `200` with an empty list plus a `skipped` count, so "nothing loaded" is distinguishable from "this build shipped no functions". The reasons stay in the boot log. ### Timeouts and Rate Limits Two ceilings apply to `/api/functions/*`: - **Request timeout** — 30 seconds by default, answering `504` with code `FUNCTION_TIMEOUT`. Configure with `functionsTimeoutMs` (or `REBASE_FUNCTIONS_TIMEOUT_MS`); `0` disables it. The handler cannot be cancelled from the outside, so give outbound HTTP calls an `AbortSignal` — the timeout frees the client and the socket, not the work. - **Rate limit** — API-key and signed-in callers share the data API's buckets. Anonymous callers get their own, much looser allowance (3000 per window) because this router is public by default for webhook receivers. Override with `rateLimit.anonymousFunctions`; `null` switches it off. Unhandled promise rejections are logged rather than fatal: a fire-and-forget call in one function would otherwise end the whole process. Set `REBASE_EXIT_ON_UNHANDLED_REJECTION=1` for Node's default behaviour. ### Next Steps - **[Backend Overview](/docs/backend)** — Full backend configuration reference - **[Entity Callbacks](/docs/collections/callbacks)** — Run logic on data changes - **[Cron Jobs](/docs/backend/cron-jobs)** — Scheduled background tasks ## Global Backend Hooks ### Overview Rebase provides two levels of entity lifecycle callbacks — both use the same `CollectionCallbacks` type from `@rebasepro/types`: - **[Per-collection callbacks](/docs/collections/callbacks)**: Defined on individual collection configurations. They run only for that collection. - **Global callbacks**: Defined on `initializeRebaseBackend({ callbacks })`. They fire on **every** collection, on every data path (REST API, WebSocket / realtime, server-side `rebase.data`). Use global callbacks for: - **PII masking** — redact sensitive fields for non-admin callers across all collections. - **Unified audit logging** — log every create, update, or delete in one place. - **Cross-cutting validation** — enforce invariants that span multiple collections. :::note **Execution order**: global callbacks → collection callbacks → property callbacks. ::: --- ### Configuration Pass the `callbacks` key to `initializeRebaseBackend`: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { // Runs after every entity read, across all collections return row; }, beforeSave({ values, context }) { // Runs before every entity save return values; } } }); ``` --- ### `CollectionCallbacks` Type ```typescript type CollectionCallbacks = { afterRead?(props): Record; // Transform row before returning to caller beforeSave?(props): Partial; // Modify values before writing to DB afterSave?(props): void; // Side-effects after successful save afterSaveError?(props): void; // Side-effects after a failed save beforeDelete?(props): boolean | void; // Return false or throw to block deletion afterDelete?(props): void; // Side-effects after successful deletion }; ``` All callbacks may return a `Promise` (async) or a plain value (sync). --- ### Callback Props Each callback receives a single props object. Common fields: | Field | Type | Present in | |-------|------|------------| | `collection` | `ResolvedCollection` | All callbacks | | `path` | `string` | All callbacks | | `row` | `Record` | `afterRead`, `beforeDelete`, `afterDelete` | | `id` | `string` | `beforeSave` (optional), `afterSave`, `afterSaveError`, `beforeDelete`, `afterDelete` | | `values` | `EntityValues` | `beforeSave`, `afterSave`, `afterSaveError` | | `previousValues` | `EntityValues` (optional) | `beforeSave`, `afterSave`, `afterSaveError` | | `status` | `"new" \| "existing"` | `beforeSave`, `afterSave`, `afterSaveError` | | `context` | `RebaseCallContext` | All callbacks | `context.user` contains the authenticated user (`uid`, `roles`, etc.), or is `undefined` for public requests. --- ### Execution Pipeline ``` [Client Request] │ ▼ [Hono Router] │ ┌─────┴───────────────────────────────────────────────────────┐ │ 1. Global Callback: beforeSave (Blocking) │ │ 2. Collection Callback: beforeSave (Blocking) │ └─────┬───────────────────────────────────────────────────────┘ │ [Database Driver] ┌─────┴───────────────────────────────────────────────────────┐ │ 3. Start PostgreSQL Transaction │ │ 4. Set Config: app.user_id = '', app.user_roles = ... │ │ 5. Drizzle SQL execution & Postgres RLS evaluation │ │ 6. Commit Transaction │ └─────┬───────────────────────────────────────────────────────┘ │ ┌─────┴───────────────────────────────────────────────────────┐ │ 7. Global Callback: afterSave │ │ 8. Collection Callback: afterSave │ └─────┬───────────────────────────────────────────────────────┘ │ ▼ [Client Response] ``` --- ### Blocking vs. Async Semantics - **`beforeSave`, `beforeDelete`** — blocking. If the callback throws, the operation is rejected with an HTTP 400 error response. The database write never happens. - **`afterRead`** — blocking. The returned row (or transformed row) is what the caller receives. - **`afterSave`, `afterDelete`, `afterSaveError`** — run after the transaction commits. They do not block the HTTP response. --- ### Examples #### PII Masking Redact email addresses for non-admin callers across every collection: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { const isAdmin = context.user?.roles?.includes("admin"); if (!isAdmin && row.email) { return { ...row, email: "********" }; } return row; } } }); ``` #### Global Audit Logging Log all deletions across every collection: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterDelete({ collection, id, context }) { console.log( `[AUDIT] User ${context.user?.uid} deleted ${collection.slug}/${id}` ); } } }); ``` #### Collection-Specific Logic Global callbacks fire for all collections. To scope logic to a single collection, check `collection.slug` or `path`: ```typescript callbacks: { beforeSave({ collection, values, context }) { if (collection.slug === "orders") { if (!values.total || values.total <= 0) { throw new Error("Order total must be positive"); } } return values; } } ``` For callbacks that only apply to a single collection, prefer [per-collection callbacks](/docs/collections/callbacks) instead. ## Entity History ### Overview Entity history records a entity of entity values on every create, update, and delete. This gives you a full audit trail with diffs. ### Enabling History #### Backend Enable history in `initializeRebaseBackend`: ```typescript no-verify await initializeRebaseBackend({ // ... history: true }); ``` Or with custom retention settings: ```typescript history: { maxEntries: 200, // Per entity, oldest pruned first (default: 200) ttlDays: 90 // Entries older than this are pruned (default: 90) } ``` #### Per Collection Mark which collections should track history: ```typescript const ordersCollection = defineCollection({ slug: "orders", name: "Orders", table: "orders", history: true, // Enable for this collection properties: { /* ... */ } }); ``` ### How It Works 1. The backend creates a `rebase.entity_history` table automatically. 2. On every create, update, or delete, a entity is recorded with: - Entity ID, collection slug, and table name - The full entity values (before and after) - Timestamp and user ID - Operation type (`create`, `update`, `delete`) - An array of `changed_fields` showing which columns were modified #### Diff Tracking & Structural Deep Equality To avoid recording redundant logs where fields are saved but no values change, the `HistoryService` performs a structural deep equality comparison on the top-level keys of the old and new values: - It ignores system metadata properties starting with `__`. - If differences are found, the names of the modified properties are saved in the `changed_fields` (`text[]`) column. - If the deep equality check detects zero changes, the history insertion is entirely skipped. #### Non-Blocking Post-Save Pruning Unlike traditional systems that rely entirely on slow periodic batch scripts, Rebase enforces your retention policies continuously: - Right after a entity is saved or deleted, the server schedules an **inline asynchronous sweep** in a non-blocking, fire-and-forget promise. - This sweep immediately checks retention limits for that specific entity ID and prunes older entries exceeding `maxEntries` or `ttlDays`. ### REST Endpoint ``` GET /api/data/:slug/:entityId/history ``` Returns a list of history entries for a specific entity, ordered by most recent first: ```json { "data": [ { "id": 42, "entity_id": "123", "collection_slug": "orders", "operation": "update", "values": { "status": "shipped", "total": 99.99 }, "previous_values": { "status": "pending", "total": 99.99 }, "user_id": "admin-user-id", "created_at": "2025-01-15T10:30:00Z" } ] } ``` ### Retention Configuration | Setting | Default | Description | |---------|---------|-------------| | `maxEntries` | 200 | Maximum entries retained per individual entity ID. Oldest entries are deleted. | | `ttlDays` | 90 | Entries older than this duration (in days) are deleted. | #### Pruning Lifecycle Mechanics 1. **Inline Pruning (Continuous)**: Executed asynchronously immediately after any CRUD operation. It prunes entries for the active entity using a sub-select query with an `OFFSET` equivalent to your `maxEntries` setting, deleting everything beyond that threshold. 2. **Global Pruning (Periodic)**: A background cleanup cron sweep (`pruneExpired`) runs every **6 hours** to evaluate global `ttlDays` thresholds and clean up orphaned logs across all collections. ### Next Steps - **[Entity Callbacks](/docs/collections/callbacks)** — Lifecycle hooks - **[Backend Overview](/docs/backend)** — Full backend configuration ## Database Branching ### Overview Database branching allows you to create **instant, isolated copies** of your entire database (both schema and data) to safely perform development, migration tests, and QA procedures. By leveraging native PostgreSQL templates, Rebase provisions clone databases at the filesystem level. This means you get a full-fidelity replica containing all tables, indexes, custom types, constraints, and Row-Level Security (RLS) policies without any network transfer overhead or schema setup delays. ``` ┌────────────────────────┐ │ Production DB (rebase) │ └───────────┬────────────┘ │ (CREATE DATABASE ... TEMPLATE) │ ┌─────────────────┴─────────────────┐ ▼ ▼ ┌───────────────────────┐ ┌───────────────────────┐ │ rb_feature_auth (Dev) │ │ rb_staging (Staging) │ └───────────────────────┘ └───────────────────────┘ ``` --- ### Under the Hood: PostgreSQL Templating When a database branch is created, the Rebase `BranchService` executes the following SQL: ```sql CREATE DATABASE "rb_feature_auth" TEMPLATE "rebase"; ``` PostgreSQL processes this operation by copying the underlying filesystem directories containing the source database files. This provides: - **Sub-Second Clones**: No SQL generation or data loading is performed. - **Identical Schemas and Data**: Every row, index, and constraint is duplicated instantly. - **Complete Isolation**: Altering the schema or inserting records into the branch has no impact on the source database. #### The Connection Limitation Guard PostgreSQL requires that **no other active connections** exist on the template (source) database when running a `CREATE DATABASE ... TEMPLATE` command. To prevent failures, the Rebase `DatabasePoolManager` executes an active eviction process before cloning or dropping a branch: 1. **Eviction Loop**: It automatically closes and disconnects all idle pools pointing to the targeted database within the Rebase application context. 2. **External Connections Block**: If external clients (such as DBeaver, pgAdmin, or external backend processes) maintain active transactions on the source database, PostgreSQL will reject the template operation with a `"being accessed by other users"` error. In this scenario, those connections must be closed manually. --- ### Metadata Schema Branch configurations are stored in the default database under the `rebase.branches` table, which is provisioned during bootstrapping: ```sql CREATE SCHEMA IF NOT EXISTS rebase; CREATE TABLE IF NOT EXISTS rebase.branches ( name TEXT PRIMARY KEY, -- Sanitize user branch name (alphanumeric & underscores) db_name TEXT NOT NULL UNIQUE, -- Actual PostgreSQL database name (prefixed with 'rb_') parent_db TEXT NOT NULL, -- Source database cloned from created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), metadata JSONB DEFAULT '{}' ); ``` --- ### Programmatic API The branching API is exposed via the backend's `BranchService`. Below is a reference of the core interface: #### Create a Database Branch Generates a new branch database from the default database or an explicit source template. ```typescript no-verify const backend = await initializeRebaseBackend({ /* ... */ }); const admin = backend.driver.admin; // Create a branch from the default database const newBranch = await admin.createBranch("feature_oauth"); // Create a branch from a specific staging database const stagingBranch = await admin.createBranch("pr_review_42", { source: "rb_staging" }); ``` #### List Active Branches Retrieves list of registered branches along with their physical sizes queried via the PostgreSQL `pg_database_size` system function. ```typescript const branches = await admin.listBranches(); /* Output: [ { name: "feature_oauth", parentDatabase: "rebase", createdAt: 2026-06-20T22:00:00.000Z, sizeBytes: 83886080 // 80 MB } ] */ ``` #### Get Branch Information Fetches metadata for a single branch. If the branch exists, the service attempts to query its current physical disk usage: ```typescript const info = await admin.getBranchInfo("feature_oauth"); ``` #### Delete a Branch Drops the target database from the server and cleans up its record in the `rebase.branches` metadata table. ```typescript await admin.deleteBranch("feature_oauth"); ``` > [!CAUTION] > Safety Guard: The main database (default database name configured in connection strings) is protected. If you attempt to delete the parent database, the `BranchService` throws a `"Cannot delete the main database"` error and aborts. --- ### CLI Integration Database branches can be managed directly using the Rebase CLI. ```bash # Create a new branch named 'dev_sandbox' rebase db branch create dev_sandbox # List all branches and disk utilization rebase db branch list # Delete a branch rebase db branch delete dev_sandbox ``` When you create or switch to a branch, the CLI updates your local development configuration. The `DatabasePoolManager` dynamically instantiates a new connection pool for the chosen branch database name (e.g. `rb_dev_sandbox`), letting you test migrations or seed data without manual connection string edits. --- ### Best Practices and Limitations #### Disk Usage Because PostgreSQL duplicates the files on disk, each branch consumes space equal to the source database. If you have a 100GB production database, creating 5 branches will consume an additional 500GB of storage. * *Recommendation*: Use subsetted databases or thin dev-templates as your clone sources instead of full production clones. #### pgBouncer Compatibility When deploying behind pgBouncer or connection poolers, ensure the pooler supports administrative database operations. Creating and dropping databases bypasses standard transaction-level pools and requires direct connections to the Postgres server (using elevated user privileges) via the `adminConnectionString` configuration. #### Cross-Database Queries Because branches are separate PostgreSQL databases, you cannot perform SQL `JOIN` statements across branch boundaries. All relations must be contained within the scope of the single active branch database. ## Custom Server Integration ## Custom Server Integration Rebase was built to be completely modular. While the `initializeRebaseBackend` coordinator provides a full batteries-included backend using Hono, you can completely bypass it and embed the core **Database Adapter** and **Realtime WebSockets** directly into your own custom Node.js application (like Express, Fastify, or plain Node.js HTTP). The `@rebasepro/server-postgres` package is completely framework-agnostic. It depends only on Drizzle ORM and standard Node.js `http.Server`. ### Environment Configuration Rebase provides a centralized `loadEnv()` utility in `@rebasepro/server` that validates your environment variables against a strict Zod schema. Call it **after** loading your `.env` file: ```typescript dotenv.config({ path: "../../.env" }); // Basic — just Rebase env vars: export const env = loadEnv(); // Extended — add your own typed vars: export const env = loadEnv({ extend: z.object({ SMTP_HOST: z.string().optional(), SMTP_PORT: z.string().default("587").transform(Number), STRIPE_SECRET_KEY: z.string(), }) }); // env.SMTP_HOST → string | undefined (fully typed) // env.STRIPE_SECRET_KEY → string (validated, required) ``` **Key behaviors:** - Auto-generates ephemeral `JWT_SECRET` and `REBASE_SERVICE_KEY` in development so you can start without manual setup. - Blocks auto-generated secrets in production — you must set them explicitly. - Validates that `CORS_ORIGINS` or `FRONTEND_URL` is set in production. See `.env.example` in the scaffolded app for the full list of supported variables. ### Using Rebase with Express Here is a complete example of how to initialize the Rebase PostgreSQL adapter and Realtime WebSockets inside a standard Express application, manage read replicas, access Drizzle directly, and implement clean server terminations. #### 1. Installation Install the required core packages along with Express: ```bash npm install @rebasepro/server-postgres @rebasepro/types express pg ``` #### 2. Initialization and Graceful Shutdown Example ```typescript async function startServer() { const app = express(); // 1. WebSocket Upgrade Guard // WebSockets require hijacking the HTTP Upgrade header. You must bind // Rebase to a raw Node.js http.Server instance. const server = createServer(app); // 2. Configure the connection pool const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20, // Max concurrent database connections idleTimeoutMillis: 30000 }); // 3. Initialize the Postgres Bootstrapper const bootstrapper = createPostgresBootstrapper({ connection: pool, connectionString: process.env.DATABASE_URL, adminConnectionString: process.env.ADMIN_CONNECTION_STRING, // Required for branching schema: { tables: {}, // Place your custom Drizzle tables here relations: {} // Place your custom Drizzle relations here } }); // 4. Initialize the Driver and Services // Connects to Postgres, verifies connection, starts cross-instance listeners const { driver, realtimeProvider, internals } = await bootstrapper.initializeDriver({ collections: [] // Pass Rebase CollectionConfigs if using schema-as-code }); // Access the underlying schema-aware Drizzle client if needed. // `internals` is an *opaque handle* on the DatabaseAdapter contract — the shape is // the driver's business, so narrow it to what the Postgres bootstrapper puts there. // (named `driverInternals` rather than `pg`, which is the node-postgres import) const driverInternals = internals as { db: any; // Drizzle NodePgDatabase readDb?: any; // Read replica, when DATABASE_READ_URL is set poolManager?: { destroy(): Promise }; }; const db = driverInternals.db; const readDb = driverInternals.readDb; // 5. Mount Realtime WebSockets await bootstrapper.initializeWebsockets(server, realtimeProvider, driver, { requireAuth: true // Enforces authentication token checks }); app.use(express.json()); app.get("/api/health", (req, res) => { res.json({ status: "healthy" }); }); // Direct Driver CRUD Operation app.post("/api/products", async (req, res) => { try { const result = await driver.save({ path: "products", values: req.body, status: "new" // required: "new" | "existing" | "copy" }); res.status(201).json({ success: true, data: result }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : "Internal Server Error" }); } }); // Raw Drizzle SQL Execution (RLS bypass) app.get("/api/stats", async (req, res) => { try { const countResult = await db.select().from(...); // Perform standard Drizzle operations res.json(countResult); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : "Internal Server Error" }); } }); // Start listening (Using the HTTP Server, NOT app.listen) const port = process.env.PORT || 3000; server.listen(port, () => { console.log(`🚀 Server and WebSocket engine running on port ${port}`); }); // 6. Graceful Shutdown Handler // Terminate listeners and drain connection pools on process termination signals const handleShutdown = async (signal: string) => { console.log(`\nShutdown triggered via ${signal}. Cleaning up resources...`); server.close(async () => { console.log("✔ HTTP Server closed."); try { // Terminate cross-instance pg LISTEN/NOTIFY client if (realtimeProvider && typeof realtimeProvider.stopListening === "function") { await realtimeProvider.stopListening(); console.log("✔ Realtime listeners stopped."); } // Disconnect dynamic branch connection pools if (driverInternals.poolManager) { await driverInternals.poolManager.destroy(); console.log("✔ Branch connection pools evicted."); } // End the main database pool await pool.end(); console.log("✔ Database connection pool drained."); process.exit(0); } catch (err) { console.error("❌ Error during graceful shutdown:", err); process.exit(1); } }); }; process.on("SIGTERM", () => handleShutdown("SIGTERM")); process.on("SIGINT", () => handleShutdown("SIGINT")); } startServer(); ``` > **Standard path:** if your server uses `initializeRebaseBackend` (like the scaffolded template does), don't hand-roll the shutdown handler above — use the built-in helper instead. It drains HTTP, stops the cron scheduler, tears down realtime services, guards against repeated signals, and force-exits if shutdown hangs: > > ```ts > import { installShutdownHandlers } from "@rebasepro/server"; > > const backend = await initializeRebaseBackend({ ... }); > installShutdownHandlers(backend, { onCleanup: () => pool.end() }); > ``` > > Do **not** combine it with your own `server.close()` — `backend.shutdown()` already closes the server, and a second close deadlocks. The manual handler shown in the example above is only for fully custom setups that bypass `initializeRebaseBackend`. --- ### Key Backend Concepts #### Read Replica Connections If you define the `DATABASE_READ_URL` environment variable, Rebase automatically spawns a secondary connection pool targeting your read replica. The bootstrapper registers this under `internals.readDb`. The core `EntityFetchService` routes all SELECT queries to the replica pool to optimize performance, while mutation queries remain on the primary pool. #### Drizzle Integration You do not have to choose between Rebase and Drizzle. The bootstrapper compiles your schemas dynamically. You can access the compiled Drizzle NodePgDatabase client via `internals.db`, allowing you to run raw SQL migrations or invoke type-safe Drizzle builders alongside Rebase's REST services. #### Graceful Connection Draining In serverless environments or orchestrators (like Kubernetes), terminating pods can result in broken connections. Always implement signal handlers that invoke `realtimeProvider.stopListening()` (which terminates the dedicated pg LISTEN client) and `pool.end()` to prevent leaking connection slots in your database server. ## Frontend Overview ### Overview The Rebase frontend is a **React framework** that renders your admin panel. It reads your collection definitions and generates tables, forms, navigation, and routing automatically. In the default scaffold, the admin panel **is** the frontend: it's served at the root of your deployed URL. If you build your own product app instead, you can mount the admin under a prefix like `/admin` in the same deployment — see [Changing the Base URL](/docs/getting-started/deployment#changing-the-base-url). The key components that make up a Rebase frontend: ```tsx {({ loading }) => ( )} ``` ### The Rebase Provider `` is the root provider that makes all Rebase functionality available to child components via context. It accepts: | Prop | Description | |------|-------------| | `client` | `RebaseClient` instance for data, auth, and storage | | `authController` | Authentication state and methods | | `dataSources` | Additional data sources (see [Multiple sources](/docs/backend/multiple-sources)) | | `storageSource` / `storageSources` | File storage operations, and named storage sources | | `userConfigPersistence` | Local UI preferences (column widths, etc.) | | `entityViews` | Global custom entity view tabs | | `entityActions` | Global entity actions | | `plugins` | Plugin instances | | `slots` | Slot contributions declared directly, without a plugin | | `basePath` / `baseCollectionPath` | URL prefixes when the admin is not at the site root | | `components` | Component overrides | The navigation, URL and collection-registry controllers are **not** `` props — they are built by the hooks below and consumed inside the admin tree (`` wires them for you in the default scaffold). ### Two data shapes There are two data layers, and they are **not** interchangeable. Passing one where the other is expected is a type error, so this is worth knowing before you wire a controller by hand. | | Shape | Where you get it | What a row looks like | |---|---|---|---| | **SDK** | `RebaseSdkData` — flat rows | `client.data`, and `context.data` in backend callbacks | `row.title` | | **Admin** | `RebaseData` — `Entity` view-model | `useData()`, inside the `` tree | `entity.values.title` | The SDK layer is the public, symmetric surface: identical on the frontend client and in backend callbacks. The `Entity` layer is the admin's view-model — it adds the `id` / `path` / `values` wrapper that the collection views and forms render against. `CollectionAccessor` and `FindResponse` belong to it and are marked `@internal` for that reason. `` is the boundary between them: it takes your flat `client.data` and wraps it with `wrapAsEntityData()` before providing it as the admin's `RebaseData`. You never call that yourself — you just take the shape you need from the right place: ```tsx // Flat rows — anywhere, including outside React. const { data: posts } = await client.data.posts.find(); posts[0].title; // Entity view-model — inside the tree only. // `data.posts` also works at runtime; `collection()` is the typed accessor. const data = useData(); const { data: entities } = await data.collection("posts").find(); entities[0].values.title; ``` ### Controllers Controllers are React hooks that configure specific aspects of the framework: #### `useBuildNavigationStateController` The main controller that wires everything together: Its `data` is the **Entity-shaped** `RebaseData`, so it comes from `useData()` — not from `rebaseClient.data`, which is the flat-row SDK layer. `` converts one to the other for you (see [Two data shapes](#two-data-shapes) below), so this hook must be called inside the `` tree. ```typescript const data = useData(); const navigationStateController = useBuildNavigationStateController({ collections: () => [...collections], // Collection definitions views: customViews, // Custom navigation views plugins, // Plugin instances authController, data, collectionRegistryController, urlController, adminMode: adminModeController.mode }); ``` #### `useBuildCollectionRegistryController` Manages how collections are resolved from URL paths: ```typescript const collectionRegistryController = useBuildCollectionRegistryController({ userConfigPersistence }); ``` #### `useBuildUrlController` Configures URL generation: ```typescript const urlController = useBuildUrlController({ basePath: "/", baseCollectionPath: "/c", collectionRegistryController }); ``` #### `useBuildModeController` Manages light/dark theme: ```typescript const modeController = useBuildModeController(); // Provides: modeController.mode ("light" | "dark"), modeController.toggleMode() ``` #### `useBuildAdminModeController` Toggles between Studio and Content modes: ```typescript const adminModeController = useBuildAdminModeController(); // Provides: adminModeController.mode ("studio" | "content") ``` ### Scaffold Components | Component | Description | |-----------|-------------| | `` | Main layout container with responsive sidebar | | `` | Top navigation bar with search, mode toggle, user menu | | `` | Side navigation with collection list and view links | | `` | Container for side panel entity editors | | `` | Route container that integrates with React Router | | `` | Handles collection routes (`/c/*`) | | `` | Default home page showing collection cards | | `` | Studio mode home page with developer tools | ### Custom Views Add top-level navigation views for dashboards, tools, or custom pages: ```tsx const views: AppView[] = [ { slug: "dashboard", name: "Dashboard", view: }, { slug: "settings", name: "App Settings", view: , nestedRoutes: true // Support sub-paths, admin: { icon: "dashboard", group: "Analytics", icon: "settings" } } ]; ``` ### Styling Rebase uses **Tailwind CSS v4** and supports light/dark modes. Customize via: - **CSS custom properties** — Override design tokens - **`ModeControllerProvider`** — Control light/dark mode - **Tailwind config** — Standard Tailwind customization ```css /* Override design tokens */ :root { --font-sans: "Instrument Sans", sans-serif; --font-headers: "Instrument Sans", sans-serif; --font-mono: "JetBrains Mono", monospace; } ``` ### Next Steps - **[Custom Fields](/docs/frontend/custom-fields)** — Build custom form fields - **[Entity Views](/docs/frontend/entity-views)** — Add tabs to entity editors - **[View Modes](/docs/frontend/view-modes)** — List, Table, Cards, Kanban - **[Plugins](/docs/plugins)** — Extend the framework ## Extending Rebase ### Overview Rebase offers roughly a dozen extension mechanisms — plugins, slots, component overrides, entity views, actions, custom fields, and more. Each one targets a different scope (app-wide, per-collection, per-entity, per-property) and a different part of the UI. This guide helps you pick the right mechanism for your use case, then links to the detailed reference for each. ### Decision Table | I want to… | Mechanism | Scope | Reference | |---|---|---|---| | Replace the app bar | `components` (`Shell.AppBar`) | app | [Component Overrides](/docs/frontend/component-overrides) | | Replace the login page | `components` (`Auth.LoginView`) | app | [Component Overrides](/docs/frontend/component-overrides) | | Replace the home page | `components` (`HomePage`) | app | [Component Overrides](/docs/frontend/component-overrides) | | Change how one collection's form looks entirely | `formView` | collection | [below](#formview) | | Swap one component inside one collection | `collection.components` | collection | [Component Overrides](/docs/frontend/component-overrides) | | Set default component overrides for all collections | `components` (collection-scoped names) | app | [Component Overrides](/docs/frontend/component-overrides) | | Add a button to the collection toolbar | collection `Actions` | collection | [Entity Actions](/docs/frontend/entity-actions#collection-actions) | | Inject UI at a collection toolbar slot | `collection.actions` slot | app/plugin | [Slots](/docs/frontend/slots) | | Add a computed column to a table | `additionalFields` | collection | [Additional Columns](/docs/frontend/additional-columns) | | Add a custom field widget for a property type | `propertyConfigs` | property type | [Custom Fields](/docs/frontend/custom-fields) | | Add a entity tab | `entityViews` | entity | [Entity Views](/docs/frontend/entity-views) | | Add a row/context action or entity button | `entityActions` | entity | [Entity Actions](/docs/frontend/entity-actions) | | Inject UI at a specific chrome location | `slots` | app/plugin | [Slots](/docs/frontend/slots) | | Ship several extensions as one installable unit | `plugins` | app | [Plugins](/docs/plugins) | ### Mechanisms in Detail #### Plugins **Scope:** app. A plugin bundles collections, views, component overrides, slot contributions, auth, data sources, providers, hooks, and lifecycle callbacks into a single installable unit. All other mechanisms listed here can be contributed through a plugin's interface. → [Plugins reference](/docs/plugins) #### Slots **Scope:** app (contributed per-slot). Slots are named UI extension points scattered throughout the CMS chrome. You register a React component targeting a slot name, and it renders at that location. There are 29 slots covering the home page, navigation, collection views, forms, entity rows, dashboards, and more. → [Slots reference](/docs/frontend/slots) #### Component Overrides (Swizzling) **Scope:** app-level defaults or per-collection. Two modes: **Eject** (full replacement) or **Wrap** (augment the original). 19 overridable component names in two tiers: **App-only (7):** - `Shell.AppBar` - `Shell.Drawer` - `Shell.DrawerNavigationItem` - `Shell.DrawerNavigationGroup` - `HomePage` - `HomePage.CollectionCard` - `Auth.LoginView` **Collection-scoped (12):** - `Collection.View` - `Collection.Table` - `Collection.Card` - `Collection.EmptyState` - `Collection.Actions` - `Collection.FilterField` - `Entity.Form` - `EditView.FormActions` - `DetailView` - `Entity.SidePanel` - `EntityPreview` - `Entity.MissingReference` **Precedence:** Collection-level `components` override app-level defaults for the same component name (simple object spread — collection values overwrite global values). App-only component names (`Shell.*`, `HomePage`, `Auth.*`) can only be overridden at the `` level. → [Component Overrides](/docs/frontend/component-overrides) #### Entity Views **Scope:** entity (adds tabs). Custom views that appear as tabs in the entity detail page. Can be defined globally on `` or per-collection. → [Entity Views](/docs/frontend/entity-views) #### Entity Actions **Scope:** entity. Custom action buttons on individual entities (publish, archive, clone, etc.). Can be defined globally or per-collection. → [Entity Actions](/docs/frontend/entity-actions) #### Collection `Actions` **Scope:** collection. Toolbar-level React components that receive `CollectionActionsProps` (selected entities, table controller, collection context). Rendered in the collection toolbar alongside built-in actions. **Relationship with `collection.actions` slot:** Both are additive — `Actions` components render first in the toolbar, then slot contributions from `collection.actions`. They do not replace each other. → [Entity Actions — Collection Actions](/docs/frontend/entity-actions#collection-actions) #### `formView` {#formview} **Scope:** collection. Replaces the entire default entity form with a custom component. Set on a collection definition: ```typescript const collection = { slug: "products", admin: { formView: { Builder: MyCustomProductForm, includeActions: true // show save/delete bar (default: true) } } }; ``` Use when you need a completely custom layout for one collection's entity editing experience. For smaller tweaks, prefer `collection.components` with `Entity.Form` override instead. #### `additionalFields` **Scope:** collection. Computed/virtual columns displayed in the collection table. These don't correspond to stored properties — they're calculated at render time. → [Additional Columns](/docs/frontend/additional-columns) #### `propertyConfigs` **Scope:** property type. Custom field widgets for specific property types, providing custom form fields and preview components. → [Custom Fields](/docs/frontend/custom-fields) ### Precedence Summary - **`collection.components` beats global `components`** inside that collection (simple spread merge in `DataCollectionView`). - **Collection `Actions` and `collection.actions` slot are additive** — `Actions` render first, then slot contributions. - **Collection-level `entityActions` and `entityViews` extend (not replace) global ones.** - **Plugin contributions are merged in `key` order.** ## Component Overrides (Swizzling) ### Overview Rebase allows you to override default UI components with your own custom implementations. This implements a Docusaurus-style component swizzling model that supports two customization patterns: - **Eject mode** (default): Your component fully replaces the built-in one. - **Wrap mode** (`wrap: true`): Your component wraps the original. The built-in component is passed as the `OriginalComponent` prop so you can render it inside your custom layout/logic. Component overrides can be applied **globally** at the application level (on the `` provider) or **locally** at the collection level (inside individual collection definitions). --- ### Global Component Overrides To override components globally across your entire application, pass a `components` object to the root `` provider. ```tsx function App() { return ( > }) => (
My Custom Brand
)) as unknown as React.ComponentType>, wrap: true } }} > {/* your app */} …
); } ``` --- ### Collection-Level Component Overrides To override components only for a specific collection, add a `components` object to its definition. This is useful for customizing empty states, cards, or detail views for particular models. ```tsx const productsCollection = defineCollection({ name: "Products", slug: "products", table: "products", properties: { /* ... */ }, admin: { components: { // Eject Mode: Replace the default entity form view "Entity.Form": { Component: ProductCustomForm }, // Wrap Mode: Wrap the empty state to add quick links "Collection.EmptyState": { // `OriginalComponent` is injected at runtime when `wrap: true`; the override // slot's type does not model it, hence the annotation. Component: (({ OriginalComponent, ...props }: { OriginalComponent: React.ComponentType> }) => (
)) as unknown as React.ComponentType>, wrap: true } } } }); ``` --- ### Overridable Components Scopes #### App-Scoped Components (`AppComponentName`) These components can only be overridden at the root `` provider level since they represent shell-level structure. | Component Key | Description | |---|---| | `"Shell.AppBar"` | The header bar at the top of the page | | `"Shell.Drawer"` | The collapsible main sidebar navigation drawer | | `"Shell.DrawerNavigationItem"` | Individual links inside the sidebar | | `"Shell.DrawerNavigationGroup"` | Collapsible navigation group headers in the sidebar | | `"HomePage"` | The default content-mode home landing page | | `"HomePage.CollectionCard"` | Individual collection cards on the home page | | `"Auth.LoginView"` | The overlay shown when requesting authentication | #### Collection-Scoped Components (`CollectionComponentName`) These components can be overridden globally (acting as defaults for all collections) or on individual collections. | Component Key | Original Props | Description | |---|---|---| | `"Collection.View"` | `CollectionViewProps` | The entire collection landing page | | `"Collection.Table"` | `CollectionTableProps` | The default spreadsheet tabular view | | `"Collection.Card"` | `CollectionCardProps` | The card view item wrapper | | `"Collection.EmptyState"` | `CollectionEmptyStateProps` | View shown when a collection is empty | | `"Collection.Actions"` | `CollectionActionsProps` | Toolbar buttons above the table/cards | | `"Collection.FilterField"` | `FilterFieldBindingProps` | Custom filter input for a column | | `"Entity.Form"` | `EntityFormProps` | The detail form for creating/updating | | `"EditView.FormActions"` | `EntityFormActionsProps` | Form submission/cancel button bar | | `"DetailView"` | `EntityDetailViewProps` | Read-only detail view | | `"Entity.SidePanel"` | `EntitySidePanelProps` | The side panel container for form/detail | | `"EntityPreview"` | `EntityPreviewProps` | Inline reference/relation chip preview | | `"Entity.MissingReference"` | `MissingReferenceProps` | Rendered when a referenced entity is missing | ## Authentication & Login ### Overview Rebase provides ready-to-use React components and hooks for authentication: - **`useRebaseAuthController`** — Manages auth state, tokens, and session persistence - **`LoginView`** — Pre-built login/signup form with OAuth support - **Role simulation** — Test different roles without logging out ### Auth Controller The `useRebaseAuthController` hook is the core of frontend authentication. It manages the current user, tokens, and session: ```typescript const client = createRebaseClient({ baseUrl: API_URL, websocketUrl: WS_URL }); const authController = useRebaseAuthController({ client, googleClientId: GOOGLE_CLIENT_ID // Optional — enables Google OAuth }); // Available properties: authController.user // Current user object (or null) authController.initialLoading // True while checking stored session authController.signOut() // Log out authController.getAuthToken() // Get current JWT for API calls ``` Pass the `authController` to the Rebase navigation controller to gate the entire admin panel behind authentication. ### Login View The `LoginView` component provides a complete login and registration form: ```tsx function App() { if (!authController.user) { return ( ); } return ; } ``` The login view handles: - Email/password login and registration - Google OAuth sign-in (when configured) - Password reset flow - Form validation and error states ### Roles Model Roles are stored as a `text[]` array column directly on the `rebase.users` table. You define available roles as an enum in your users collection definition: ```typescript title="config/collections/users.ts" no-verify roles: { name: "Roles", type: "array", columnType: "text[]", of: { name: "Role", type: "string", enum: { admin: "Admin", editor: "Editor", viewer: "Viewer" } }, admin: { readOnly: false } } ``` To add or remove role options, update the `enum` map in your users collection and regenerate the schema. ### Role Simulation (Dev Mode) In developer mode, you can simulate different roles without logging out. This is useful for testing RLS policies: ```typescript const effectiveRoleController = useBuildEffectiveRoleController(); // When active, the UI behaves as if the current user has this role effectiveRoleController.setEffectiveRole("editor"); ``` ### Next Steps - **[Backend Authentication](/docs/backend/authentication)** — JWT, OAuth providers, SMTP configuration - **[Security Rules (RLS)](/docs/collections/security-rules)** — Row-level access control per collection - **[Client SDK Authentication](/docs/sdk/authentication)** — Programmatic auth methods ## Storage & File Uploads ### Overview Rebase provides built-in file upload support in collection forms: - **Drag-and-drop** file upload fields - **Image previews** in forms and table cells - **Multiple file uploads** via array properties - **MIME type filtering** and size limits - **Custom filenames** via callback functions ### File Upload Fields To add file uploads to a collection, use the `storage` config on a string property: ```typescript properties: { image: { type: "string", name: "Product Image", storage: { storagePath: "products", // Subdirectory in storage acceptedFiles: ["image/*"], // MIME type filter maxSize: 5 * 1024 * 1024, // 5MB max fileName: (context) => { // Custom filename return context.entityId + "_" + context.file.name; } } } } ``` #### Storage Config Options | Property | Type | Description | |----------|------|-------------| | `storagePath` | `string` | Subdirectory within the storage backend | | `storageSource` | `string` | Named storage source — routes uploads to a specific backend (e.g., `"firebase"`, `"media"`). See [Multi-Backend Storage](#multi-backend-storage). | | `public` | `boolean` | Store files under the `public/` prefix and serve them via stable, token-less, permanent, CDN-cacheable URLs (safe to persist and hotlink). Defaults to `false` (private files use short-lived signed URLs). | | `acceptedFiles` | `string[]` | Allowed MIME types (e.g., `["image/*"]`, `["application/pdf"]`) | | `maxSize` | `number` | Maximum file size in bytes | | `fileName` | `function` | Custom filename generator | | `metadata` | `object` | Additional metadata to store with the file | | `storeUrl` | `boolean` | Store the full URL instead of the relative path | ### Multiple File Uploads Wrap the storage property in an array for multiple file uploads: ```typescript photos: { type: "array", name: "Photos", of: { type: "string", storage: { storagePath: "photos", acceptedFiles: ["image/*"] } } } ``` ### Document Uploads Upload non-image files like PDFs: ```typescript documents: { type: "array", name: "Documents", of: { type: "string", storage: { storagePath: "documents", acceptedFiles: ["application/pdf", "image/*"] } } } ``` ### Multi-Backend Storage When your backend has multiple storage backends configured (e.g., local + S3 + GCS), you can route individual properties to specific backends using `storageSource`: ```typescript image: { type: "string", name: "Product Image", storage: { storageSource: "firebase", // Routes to the "firebase" backend storagePath: "products/{entityId}", acceptedFiles: ["image/*"], } } ``` #### Frontend Direct Sources For **direct** storage backends (e.g., Firebase Storage where the browser uploads straight to the cloud), register them via the `storageSources` prop on ``: ```tsx {/* your app */} … ``` | Property | Type | Description | |----------|------|-------------| | `key` | `string` | Unique identifier — must match `storageSource` in property configs | | `engine` | `string` | Storage engine name (e.g., `"firebase"`, `"gcs"`, `"s3"`) | | `transport` | `"server" \| "direct"` | `"server"` proxies through the backend; `"direct"` uploads from the browser | | `source` | `StorageSource` | Client-side `StorageSource` implementation (required for `"direct"` transport) | The system automatically resolves the correct source per-property — collection properties with `storageSource: "firebase"` will use the matching direct source, while properties without `storageSource` (or with `transport: "server"`) will proxy through the Rebase backend. ### useStorageSource Hook For programmatic file operations outside of collection forms: ```typescript // Returns the default storage source const storageSource = useStorageSource(); // Upload a file — the object is addressed by `key` const result = await storageSource.putObject({ file, key: "documents/my-file.pdf" }); // Get a download URL const { url } = await storageSource.getSignedUrl(result.key); ``` :::tip `useStorageSource()` returns the **default** storage source. For multi-backend setups, the per-property resolution is handled automatically by the form field bindings and the `StorageSourcesContext`. You don't need to manually resolve sources in most cases. ::: ### Next Steps - **[Backend Storage Configuration](/docs/backend/storage)** — S3, GCS, and local storage setup - **[Properties](/docs/collections/properties)** — All property types including storage ## View Modes ### Overview Every collection can be displayed in four view modes: - **List** — Simple, clean list view (the classic CMS default) - **Table** — Spreadsheet-style grid with inline editing, sorting, filtering - **Cards** — Card grid for visual content (images, previews) - **Kanban** — Drag-and-drop board grouped by an enum property ### Configuration ```typescript const productsCollection = defineCollection({ slug: "products", // `orderProperty` and `kanban.columnProperty` are checked against these // keys — with an empty `properties` block they narrow to `never`. properties: { id: { name: "ID", type: "string", isId: "uuid" }, status: { name: "Status", type: "string" }, sort_order: { name: "Order", type: "number" } }, name: "Products", table: "products", admin: { defaultViewMode: "table", // Default view enabledViews: ["list", "table", "kanban"], // Available views orderProperty: "sort_order", // Property for drag-and-drop ordering kanban: { columnProperty: "status" // Enum property for columns } } }); ``` ### List View ![List View screenshot placeholder](/img/features/list-view.png) The list view is the classic, clean CMS default view mode, showing entities in a straightforward list format without the density of a spreadsheet. ### Table View ![Table View screenshot placeholder](/img/features/table-view.png) The default view is a high-performance virtualized spreadsheet with: - **Inline editing** — Click any cell to edit in-place - **Column resizing** — Drag column headers - **Column reordering** — Drag to rearrange - **Sorting** — Click column headers - **Text search** — Full-text search across string fields - **Filtering** — Per-column filters - **Multi-select** — Select entities for bulk actions #### Row Height Control row height with `defaultSize`: | Size | Pixels | Best for | |------|--------|----------| | `"xs"` | 40 | Dense data tables | | `"s"` | 54 | Default | | `"m"` | 80 | With image thumbnails | | `"l"` | 120 | Cards with previews | | `"xl"` | 260 | Rich content previews | ### Kanban View ![Kanban View screenshot placeholder](/img/features/kanban-view.png) Configure a Kanban board by specifying which enum property to use as columns: ```typescript const tasksCollection = defineCollection({ slug: "tasks", name: "Tasks", table: "tasks", properties: { title: { type: "string", name: "Title" }, status: { type: "string", name: "Status", enum: [ { id: "backlog", label: "Backlog", color: "gray" }, { id: "in_progress", label: "In Progress", color: "blue" }, { id: "review", label: "Review", color: "orange" }, { id: "done", label: "Done", color: "green" } ] }, sort_order: { type: "number", name: "Sort Order" } }, admin: { defaultViewMode: "kanban", orderProperty: "sort_order", kanban: { columnProperty: "status" } } }); ``` Drag-and-drop between columns automatically updates the enum field and sort order. ### Cards View ![Cards View screenshot placeholder](/img/features/cards-view.png) Cards display entities as visual cards — useful for image-heavy content: ```typescript const articlesCollection = defineCollection({ slug: "articles", name: "Articles", table: "articles", properties: { title: { type: "string", name: "Title" }, cover: { type: "string", name: "Cover Image", storage: { storagePath: "covers", acceptedFiles: ["image/*"] } } }, admin: { defaultViewMode: "cards" } }); ``` ### Next Steps - **[Entity Views](/docs/frontend/entity-views)** — Custom tabs on entity forms - **[Entity Actions](/docs/frontend/entity-actions)** — Custom entity actions ## Custom Fields ### Overview Rebase generates form fields automatically based on property types. For custom behavior, you can build your own fields. ### Creating a Custom Field A custom field is a React component that receives `FieldProps`: ```tsx function ColorPickerField({ value, setValue, error, showError }: FieldProps) { return (
setValue(e.target.value)} /> {showError && error && {error}}
); } ``` #### FieldProps | Prop | Type | Description | |------|------|-------------| | `value` | `T` | Current field value | | `setValue` | `(value: T) => void` | Update the field value | | `error` | `string` | Validation error message | | `showError` | `boolean` | Whether to display the error | | `isSubmitting` | `boolean` | Form is being saved | | `property` | `Property` | The property configuration | | `context` | `FormContext` | Full form context with all entity values | | `disabled` | `boolean` | Field is readonly | | `minimalistView` | `boolean` | Rendering inside the spreadsheet (compact mode) | ### Registering a Custom Field #### Per-Property Register on a single property: ```typescript properties: { brand_color: { type: "string", name: "Brand Color", admin: { Field: ColorPickerField } } } ``` #### When the collection file is also read by the server In the default scaffold, `config/collections/` is loaded by **both** the admin panel and the backend — the backend reads the same files to derive the schema and the API. A direct component reference is only safe when nothing on the server loads that file, because importing `ColorPickerField` also imports React, your CSS and everything else the component pulls in, into the server's module graph. Point at the component with a lazy import instead. It is type-checked exactly the same way, and the backend never calls it: ```ts no-verify // config/collections/products.ts properties: { brand_color: { type: "string", name: "Brand Color", admin: { Field: () => import("../../frontend/src/ColorPickerField"), Preview: () => import("../../frontend/src/ColorPreview") } } } ``` The module must have a **default export** — the thunk resolves to `default`, and a named-only export renders nothing. The admin wraps it in `React.lazy` on first render, so the component is also a separate chunk rather than part of the initial bundle. #### Global Property Config Register a reusable field type: ```tsx const colorPropertyConfig: PropertyConfig = { key: "color_picker", name: "Color Picker", property: { type: "string", admin: { Field: ColorPickerField } } }; // Register globally — keyed by the config's `key` ``` Then use it in any collection: ```typescript properties: { color: { type: "string", name: "Color", propertyConfig: "color_picker" } } ``` ### Accessing Form Context Custom fields can access the full entity values: ```tsx function PriceWithTaxField({ value, setValue, context }: FieldProps) { const taxRate = Number(context.values.tax_rate ?? 0.1); const priceWithTax = value ? value * (1 + taxRate) : 0; return (
setValue(Number(e.target.value))} />

With tax: ${priceWithTax.toFixed(2)}

); } ``` ### Table Mode When rendering inside the spreadsheet view, fields should be compact. Check `minimalistView`: ```tsx function MyField({ value, setValue, minimalistView }: FieldProps) { if (minimalistView) { return { /* open editor */ }}>{value}; } return (