# Rebase Documentation > Rebase is an open-source TypeScript backend built on Postgres: a REST API, 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 (passwords, magic links, one-time codes, MFA, API keys and twelve OAuth/OIDC providers), 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 runs either way, and your code does not change between them. **Self-host it** — you own the data, the code, and the machine it runs on. Or deploy the same project to **Rebase Cloud** ([app.rebase.pro](https://app.rebase.pro)), which is the same Rebase run for you; it is live with real tenants today and opening in batches while it is in private beta. Think of it as an open-source alternative to Supabase or Retool where *you* decide who operates it. ::: ### Choose your path Rebase is two products that happen to share a server. Start from the one you came for: | I want… | Start here | What you get | |---|---|---| | **A backend and an admin panel** | [Quickstart](/docs/getting-started/quickstart/) | Collections in TypeScript, a generated CRUD UI, a REST API, auth, storage and realtime | | **Just the API, over my own database** | [Backend only](/docs/getting-started/headless/) | A headless REST API and SDK over your existing PostgreSQL — no admin panel, no collection files, no React | Neither is a one-way door: a headless project grows an admin panel by adding collection files, and a full project can serve tables it has no collection for. ### 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 — no editing needed. Then: ```bash pnpm install pnpm run dev ``` That is the whole first run. There is no database to install and no schema step: with no `DATABASE_URL` set, `rebase dev` starts a managed PostgreSQL (PGlite) in the project directory, generates the schema from your collections, and creates the tables at boot. **Read the URLs from the output.** `rebase dev` picks a free port per project rather than fixed ones, so they differ between projects and between machines. The first account you register becomes the admin — on your machine. A production deployment names its admin instead, with `REBASE_ADMIN_EMAIL` and `REBASE_ADMIN_PASSWORD` (see [Configuration](/docs/getting-started/configuration/)). To use your own Postgres instead, uncomment `DATABASE_URL` in `.env` and run `pnpm run dev` again. → 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, twelve OAuth/OIDC providers, magic links, one-time codes, TOTP MFA, scoped API keys, 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 - **[Backend only](/docs/getting-started/headless/)** — The headless API over a database you already have - **[Project Structure](/docs/getting-started/project-structure)** — Understand the generated code, and the five words the rest of these pages assume - **[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. If any of *collection*, *Studio*, *managed runtime*, *bundle* or *resource* is new, the five-word box on [Project Structure](/docs/getting-started/project-structure/) defines them. | Folder | Description | |--------|-------------| | `frontend/` | React SPA — Vite + TypeScript with the Rebase admin UI | | `backend/` | Your custom functions and crons, plus the generated Drizzle schema. There is no server file — the published runtime boots the project | | `config/` | Config files and collection definitions shared by both sides | ### Prerequisites - **Node.js** 22.22+ — every scaffold, headless included, declares `"node": ">=22.22.0"` - **pnpm** (recommended) or npm No database to install, and no Docker. `rebase dev` runs a managed PostgreSQL for the project, with its data under `.rebase/`. See [Variant: use your own PostgreSQL](#variant-use-your-own-postgresql) if you would rather supply one — a local install, Neon, Supabase, or the container this scaffold ships. ### 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. ::: ### Start the Dev Servers ```bash pnpm install pnpm run dev ``` That is the whole first run. There is no database to install and no schema step: with no `DATABASE_URL` set, `rebase dev` starts a **managed PostgreSQL (PGlite)** in the project directory, generates the Drizzle schema from your collections, and creates the tables at boot — including the example `posts`, `authors` and `tags`. It starts both halves together: - **Backend** — REST API, auth, storage, WebSocket - **Frontend** — the Rebase admin panel - **Hot reload** for both 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`. #### Flags worth knowing | Flag | On | What it does | |---|---|---| | `--yes` | `init` | Never prompt. **Required when there is no terminal to answer**, such as CI. It skips git init and dependency install — the interactive defaults say yes to both, so pass `--git` / `--install` if you want them | | `--headless` | `init` | A backend with no collection files and no UI — see [Backend only](/docs/getting-started/headless/) | | `--template ` | `init` | Start from a template other than the default | | `--install` / `--no-install` | `init` | Run the package manager for you, or leave it | | `--docker` | `dev` | Use PostgreSQL in a container instead of the managed one | | `--no-db` | `dev` | Start no database at all — not the container and not the managed one. Set `DATABASE_URL` yourself | ### Variant: use your own PostgreSQL The managed database is a convenience, not a requirement. To point the project at a Postgres you run, uncomment `DATABASE_URL` in `.env`: ```bash DATABASE_URL=postgresql://username:password@localhost:5432/your_database ``` Then start the dev servers as above. A `DATABASE_URL` that is set is never touched, and one pointing anywhere other than this machine is left alone entirely. With your own database you also get the migration commands, which the managed one cannot offer — they plan changes with [Atlas](https://atlasgo.io/), the schema-migration engine Rebase plans with, which needs a second empty database to compare against, and PGlite serves exactly one: ```bash pnpm run db:push ``` Boot already creates missing tables additively, so `db push` is for the two things it deliberately leaves alone: junction-table [RLS](/docs/collections/security-rules/) — PostgreSQL's row-level security, which is how Rebase enforces who may read a row — on many-to-many relations, and any change that is not purely additive — a renamed column, a narrowed type, a removed field. The scaffold also ships a `docker-compose.yml` with a PostgreSQL service, if you want a container rather than an installed Postgres: ```bash docker compose up -d db ``` ### 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. ### 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 `rebase init` also wrote `REBASE_ADMIN_EMAIL` and a generated `REBASE_ADMIN_PASSWORD` into `.env`. Those are not your credentials here: `rebase dev` ignores them and says so at boot. They belong to a production boot — `docker compose up`, or anything with `NODE_ENV=production` — where this bootstrap window is closed, because the server answers on a hostname before you have typed anything. See [Your first admin](/docs/getting-started/deployment#your-first-admin). ### 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. The table name is optional: it defaults to the slug, so set it only when they differ: ```typescript title="config/collections/products.ts" const productsCollection = defineCollection({ slug: "products", name: "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 }, createdAt: { 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 Save the file. That is the whole step: `rebase dev` regenerates `backend/src/schema.generated.ts` from your collections, restarts the backend, and boot creates the new table — so your **Products** collection appears in the navigation. The same is true of a property added to a collection you already have: save, and the column is there. `rebase db push` is for the changes boot deliberately leaves alone — a renamed column, a narrowed type, a removed field, and junction-table RLS on many-to-many relations. It needs your own PostgreSQL: ```bash pnpm run db:push ``` ### Database Commands Reference | Command | Description | |---------|-------------| | `rebase schema generate` | Generate the Drizzle schema from your TypeScript collections. No database needed — `rebase dev` runs it for you | | `rebase schema introspect` | Generate TypeScript collections from an existing database | | `rebase db push` | Push schema changes directly to the database. Needs your own PostgreSQL | | `rebase db generate` | Generate SQL migration files. Needs your own PostgreSQL | | `rebase db migrate` | Run pending migrations. Needs your own PostgreSQL | ### 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 :::note[Five words this page uses] Every one of them means something specific here, and four of them mean something else elsewhere in the industry. - **Collection** — one table, described in TypeScript. The schema, the API and the admin screen all come from the same file. - **Studio** — the developer half of the admin panel: schema editor, SQL console, policy browser. The same app your content team uses, behind a toggle. - **Managed runtime** — the published `rebasepro/server` image boots your project. You write no server file, and you get runtime upgrades without a rebuild. The alternative is `rebase eject`, below. - **Bundle** — what `rebase build` produces: your collections, functions and crons, compiled, with a manifest saying where each one is. It is what the managed runtime boots. - **Resource** — something the project needs from wherever it runs: a database, a bucket, a topic. Declared in `config/resources.ts`, bound by environment variables. ::: A Rebase starter project has three interconnected packages: ``` my-app/ ├── .env # Generated for you: JWT_SECRET, a database password, a free port ├── rebase.json # Which apps this repository contains, and how each is built ├── package.json # Root workspace config ├── docker-compose.yml # Self-hosting: Postgres + the published runtime image │ ├── config/ # Shared by the backend and the admin panel │ ├── index.ts # Re-exports what the runtime reads (collections, storageAuthorize) │ ├── collections/ # Your data model │ │ ├── index.ts # Exports `collections` and the default security rules │ │ ├── posts.ts # Example collections │ │ └── users.ts # The auth collection │ ├── resources.ts # What this project needs from wherever it runs │ ├── storage.ts # Who may read, write and list files │ └── cms.d.ts # One line that makes the `admin` block legal here │ ├── backend/ │ ├── functions/ # Custom API routes, auto-mounted at /api/functions/ │ │ └── hello.ts │ └── src/ │ └── schema.generated.ts # Drizzle schema, regenerated from your collections │ └── frontend/ # The admin panel (React + Vite) ├── src/App.tsx ├── src/main.tsx └── vite.config.ts ``` :::note[There is no `backend/src/index.ts`] And no `Dockerfile`. A scaffolded project declares `runtime: "managed"` in `rebase.json`, which means the **published `rebasepro/server` image boots your project as a bundle** — the same artifact whether you self-host it or deploy it to Rebase Cloud. You configure the server through `rebase.json`, `config/` and environment variables rather than by writing an entry point. If you want to own the process — your own middleware, your own routes, your own auth wiring — `rebase eject` writes the entry point, a Dockerfile and a compose file that builds them. See [Custom Server Integration](/docs/backend/custom-server). ::: ### 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" // `rebase dev` injects VITE_API_URL with the port it actually bound, and that // port is derived from this project's path rather than fixed — so a // `http://localhost:3001` fallback here names a port nothing is listening on. // A deployed build serves the admin from the same origin as the API, where an // empty value is exactly what you want. const API_URL = import.meta.env.VITE_API_URL; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; export function App() { const rebaseClient = React.useMemo(() => createRebaseClient({ baseUrl: API_URL, // Store the refresh token in an httpOnly cookie (XSS-safe) rather than // localStorage. The backend issues it via `auth.cookieAuth`. auth: { authFlowMode: "cookie" } }), []); const authController = useRebaseAuthController({ client: rebaseClient, googleClientId: GOOGLE_CLIENT_ID }); return ( {/* The sign-in screen. On its own this changes nothing — it is where you pass `loginView` to replace it. */} ); } ``` `main.tsx` mounts it under a `react-router` `basename` taken from `import.meta.env.BASE_URL`, which `rebase build` sets from the `path` this app declares in `rebase.json` — so the assets, the router and the server agree on one value without it being written down three times. #### 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 - **`useRebaseAuthController`** — Holds the signed-in user and the token lifecycle, and is what `` distributes to everything below it ### Backend (`backend/`) There is no server file to read, and that is the design: a scaffolded project declares `runtime: "managed"`, so the published `rebasepro/server` image boots your project. What `backend/` holds is the code the runtime picks up: | Path | What it is | |---|---| | `backend/functions/` | Custom routes, auto-mounted at `/api/functions/` | | `backend/crons/` | Scheduled jobs, discovered the same way (create it when you want one) | | `backend/src/schema.generated.ts` | The Drizzle schema, regenerated from your collections on every `rebase dev` and `rebase build` | The runtime sets up: - **REST API** at `/api/data/*` — generated CRUD for every collection - **Auth** at `/api/auth/*` — signup, login, refresh, OAuth - **Storage** at `/api/storage/*` — upload and download - **WebSocket** — realtime sync over Postgres LISTEN/NOTIFY - **Your functions and crons**, from the directories above Configuration comes from `rebase.json`, the `config/` directory and environment variables. See [Environment & Configuration](/docs/getting-started/configuration). `rebase build` turns all of it into a **bundle** — the compiled collections, functions and crons plus a manifest — which the managed runtime boots. Nothing about the bundle is written by hand; if you want to see one, [Runtime & Bundles](/docs/architecture/runtime-and-bundles/) is what is in it. The panel the frontend serves has two halves. **Studio** is the developer one — the schema editor, the SQL console, the RLS policy browser — and it is behind the toggle in the drawer, not a separate deployment. See [Studio](/docs/studio/). To take ownership of the process instead — your own middleware, routes and auth wiring — run `rebase eject`. **Everything below this paragraph is ejected-only**: a scaffolded project has none of those files, and nothing in it calls `initializeRebaseBackend`. It writes an entry point that calls `initializeRebaseBackend` directly, plus a Dockerfile and a compose file that builds it; from then on you maintain the server and platform runtime upgrades no longer reach the project. That surface is documented in [Custom Server Integration](/docs/backend/custom-server). ### 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" const productsCollection = defineCollection({ slug: "products", name: "Products", properties: { name: { type: "string", name: "Name" }, price: { type: "number", name: "Price" } } }); // The default export is what the registry picks up — every collection in the // scaffold is written this way. export default productsCollection; ``` The `slug` becomes the URL path in the admin UI and the REST API endpoint (`/api/data/products`), and the PostgreSQL table name defaults to it. Add `table` only when they differ. ### 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` While `rebase dev` is running, saving a file under `config/collections/` regenerates `backend/src/schema.generated.ts` and restarts the backend, and boot creates the tables and columns that are missing. Outside `rebase dev` the same step is `rebase schema generate`. ### 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 validates environment variables with **Zod** at startup. If > anything required is missing or malformed (a URL that is not a URL, a port that > is not a number), the server refuses to boot and names the variable. > > Where the schema lives depends on how you run the backend. A project booted by > the runtime — `rebase dev`, `rebase start`, the published image — uses the > schema the runtime owns (`loadBootEnv` in `@rebasepro/server`), which is the > union of every table below. A project that has run [`rebase eject`](/docs/cli) > owns a `backend/src/env.ts` calling `loadEnv({ extend })`, and can add its own > typed variables there. #### Required | Variable | Description | Example | |----------|-------------|---------| | `DATABASE_URL` | PostgreSQL connection string. **Optional in development** — unset, `rebase dev` runs a managed PostgreSQL for the project, with its data under `.rebase/`. Required everywhere else. | `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. Read by `rebase start`. `rebase dev` reads it **from the shell environment only** — a `PORT` in `.env` is not read there, because the port is resolved before that file is loaded — and otherwise binds a port derived from the project path, so several projects can run at once. `rebase dev --port` beats both, and the start banner names the rung it used. | `3001` | | `LOG_LEVEL` | Logging verbosity: `error`, `warn`, `info`, `debug` | `info` | | `REBASE_LOG_RAW_QUERIES` | Show the SQL behind a `Failed query: [redacted]` line. Every failing statement is redacted by default, because a failed query carries its bound parameters — an email, a password hash. Set it to `true` while diagnosing a DDL, RLS or change-capture failure. Ignored when `NODE_ENV=production`. | `false` | | `NODE_ENV` | Environment: `development`, `production`, or `test` | `development` | | `CORS_ORIGINS` | Comma-separated list of allowed origins. **Required in production** if different from backend domain. In development it is *added to* localhost — see below. | — | | `FRONTEND_URL` | URL of the frontend app. Used as an alternative to CORS_ORIGINS, in both environments. | — | | `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` | ##### CORS in development Development allows **localhost, plus whatever `CORS_ORIGINS` (or `FRONTEND_URL`) names** — the same list production uses, with localhost added rather than substituted. So the variable works the same way in both environments, and the cases that need it in development are the ordinary ones: ```bash # A phone on the LAN, a colleague's machine, an ngrok tunnel, # a forwarded Codespaces port — all non-localhost origins. CORS_ORIGINS=http://192.168.1.5:5173 ``` An origin that is neither localhost nor listed is refused, and the refusal is logged **once per origin** with the exact line that would allow it. Refusing is not caution for its own sake: the API sends credentials, so reflecting an arbitrary `Origin` would let any site the developer happens to visit make authenticated requests against the dev server with their session and read the answers. #### Authentication | Variable | Description | Default | |----------|-------------|---------| | `JWT_SECRET` | Secret for JWT signing (required in production, auto-generated in development) | — | | `JWT_PRIVATE_KEY` | PEM private key for signing access tokens asymmetrically (RS256), so anything holding the JWKS can verify a session without being able to mint one. Accepts a PEM with real newlines, a PEM with `\n` escapes, or base64 of the whole PEM. Without it tokens stay HS256. | — | | `JWT_KEY_ID` | Names `JWT_PRIVATE_KEY` in the token header and in the JWKS. Change it whenever the key changes — rotation depends on old and new being distinguishable. | `default` | | `JWT_ACCESS_EXPIRES_IN` | Access token lifetime | `1h` | | `JWT_REFRESH_EXPIRES_IN` | Refresh token lifetime. Sliding — every rotation re-ups it, so this governs how long a session survives **inactivity**. | `400d` | | `ALLOW_REGISTRATION` | Allow new users to register (`true`/`false`). Outside production the **first** user can always register, whatever this says — an empty user table has to admit somebody, and that somebody becomes the admin. In production (`NODE_ENV=production`) that window is closed: an empty table refuses the bootstrap registration with `SETUP_REQUIRED`, a first account created through open registration is an ordinary account, and the admin is named with `REBASE_ADMIN_EMAIL` below or assigned with the service key. The scaffold's `.env.example` sets it to `true`; the framework default is off. | `false` | | `DISABLE_SELF_REGISTRATION` | Kill switch. Closes the first-user bootstrap window that `ALLOW_REGISTRATION=false` deliberately leaves open outside production, so registration is shut even against an empty database. Pair it with `REBASE_ADMIN_EMAIL` below, or the deployment has no way to produce its first signed-in caller. Every shipped deployment artifact sets it. | — | | `REBASE_ADMIN_EMAIL` | Email of the first admin account, created at boot **while the user table is still empty** and never afterwards. This is how a production deployment gets its admin: the operator names the first account instead of racing the internet for it. Boot warns when the table is empty in production and this is unset. | — | | `REBASE_ADMIN_PASSWORD` | Password for that account. At least 12 characters, or it is refused and the account is not created. Change it after the first sign-in. | — | | `MFA_ENCRYPTION_KEY` | Encrypts every stored TOTP secret. Unset, the secrets are encrypted with `JWT_SECRET` instead and boot warns once — so rotating `JWT_SECRET` signs everybody out *and* leaves every enrolled authenticator undecryptable. Set a dedicated key (32+ random characters) before anyone enrols. | — | | `MFA_ENCRYPTION_KEY_PREVIOUS` | The key being rotated *away* from. Set both during a rotation: new secrets are written with `MFA_ENCRYPTION_KEY` and existing ones are still readable, so nobody is locked out of their own account mid-rotation. Remove it once every secret has been re-encrypted. | — | | `ALLOW_ANONYMOUS` | Enable anonymous sign-in (`POST /api/auth/anonymous`). Opt-in, and deliberately not gated by `ALLOW_REGISTRATION`. | `false` | | `AUTH_REQUIRE` | Require authentication for the data API. Set `false` for a fully public read surface — RLS still applies. | `true` | | `AUTH_DEFAULT_ROLE` | Role assigned to a newly registered user when none is given. | — | | `AUTH_ALLOW_USER_LOOKUP` | Mount `POST /api/auth/find-user`, which resolves an email to a minimal public profile (`uid`, `displayName`, `photoURL`) for invite-by-email flows. Authenticated callers only, and it never returns the email, roles or metadata of the user it found. Off by default: it is an enumeration surface. | `false` | | `AUTH_COOKIE_SAME_SITE` | `SameSite` on the refresh cookie: `Strict`, `Lax` or `None`. `None` requires HTTPS and is only for a genuinely cross-site frontend. | `Lax` | | `AUTH_COOKIE_SECURE` | `Secure` on the refresh cookie. Secure by default; `AUTH_COOKIE_SECURE=false` for plain http — a deployment on a LAN address where the browser would otherwise drop the cookie and the session would die at the access token's expiry with no error. It warns at boot. `http://localhost` does not need it. | `true` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID (backend validation) | — | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | — | | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | — | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | — | | `MICROSOFT_CLIENT_ID` | Microsoft OAuth client ID | — | | `MICROSOFT_CLIENT_SECRET` | Microsoft OAuth client secret | — | | `LINKEDIN_CLIENT_ID` | LinkedIn OAuth client ID | — | | `LINKEDIN_CLIENT_SECRET` | LinkedIn OAuth client secret | — | | `FACEBOOK_CLIENT_ID` | Facebook OAuth client ID | — | | `FACEBOOK_CLIENT_SECRET` | Facebook OAuth client secret | — | | `TWITTER_CLIENT_ID` | X/Twitter OAuth client ID | — | | `TWITTER_CLIENT_SECRET` | X/Twitter OAuth client secret | — | | `DISCORD_CLIENT_ID` | Discord OAuth client ID | — | | `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | — | | `GITLAB_CLIENT_ID` | GitLab OAuth client ID. A self-hosted instance's `baseUrl` has no environment spelling — configure GitLab in the `auth` block for that. | — | | `GITLAB_CLIENT_SECRET` | GitLab OAuth client secret | — | | `BITBUCKET_CLIENT_ID` | Bitbucket OAuth client ID | — | | `BITBUCKET_CLIENT_SECRET` | Bitbucket OAuth client secret | — | | `SLACK_CLIENT_ID` | Slack OAuth client ID | — | | `SLACK_CLIENT_SECRET` | Slack OAuth client secret | — | | `SPOTIFY_CLIENT_ID` | Spotify OAuth client ID | — | | `SPOTIFY_CLIENT_SECRET` | Spotify OAuth client secret | — | | `APPLE_CLIENT_ID` | Apple Services ID. Apple has no static client secret — Rebase signs a short-lived ES256 JWT per token exchange — so it needs all four `APPLE_*` values, and configures nothing without them. | — | | `APPLE_TEAM_ID` | Apple Developer Team ID, the JWT's issuer. | — | | `APPLE_KEY_ID` | Key ID of the private key registered with Apple. | — | | `APPLE_PRIVATE_KEY` | Contents of the `.p8` private key file, newlines and all (`\n` escapes are accepted). | — | | `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). | — | | `REBASE_RATE_LIMIT_STORE` | Where auth rate-limit counters live: `memory` (per-process) or `sql` (shared across replicas). A process cannot see its own replica count, so a deployment with peers has to say so — three replicas on the default enforce three times the limit. Any other value **refuses to boot** rather than falling back, `postgres` included. | `memory` | | `AUTH_MAGIC_LINK` | Mount the passwordless sign-in-link flow. Needs an email service configured, or the link has nowhere to go. | `false` | | `AUTH_EMAIL_OTP` | Mount passwordless sign-in with a six-digit code sent by email. Same email requirement as above. | `false` | | `CAPTCHA_PROVIDER` | Turn on captcha verification on the auth routes: `turnstile` or `hcaptcha`. Unset means no captcha. | — | | `CAPTCHA_SECRET` | The provider's secret, used server-side to verify the token the browser sends. Required once `CAPTCHA_PROVIDER` is set. | — | | `CAPTCHA_ROUTES` | Comma-separated auth routes to protect (for example `register,login`). Unset protects the provider's default set. | — | #### Storage :::caution[Storage has no row-level security, so it needs an access model] Collections are protected by Postgres RLS. Object storage has no equivalent — keys share one flat namespace — so with a bucket configured and no access model the server **refuses to boot in production**. Satisfy it with exactly one of: a `storageAuthorize` hook exported from `config/index.ts` (what the scaffold ships), `STORAGE_PUBLIC_READ`, or `STORAGE_ALLOW_ANY_AUTHENTICATED`. ::: | 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` | | `GCS_BUCKET` | GCS bucket name (when `STORAGE_TYPE=gcs`) | — | | `GCS_PROJECT_ID` | GCP project. Usually inferred from the credentials. | — | | `GCS_KEY_FILENAME` | Path to a service-account key file. Omit on GCP, where Workload Identity supplies credentials. | — | | `STORAGE_PUBLIC_READ` | Serve every object to anyone, no token. Only for a bucket that genuinely is a public CDN. One of the three ways to satisfy the boot guard below. | `false` | | `STORAGE_ALLOW_ANY_AUTHENTICATED` | Let any signed-in caller read, write, list and delete every object. Named `INSECURE` in the config object for a reason: it is only defensible in a single-tenant app where every account is trusted with every file. | `false` | | `STORAGE_RENDITION_CACHE` | Cache generated image renditions (resizes, format conversions) instead of producing them per request. | `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 | | `SMTP_NAME` | Display name on the sender address | | `APP_NAME` | Product name used in email subjects and bodies (default: `Rebase`) | | `EMAIL_LOGO_URL` | Logo shown atop the default email templates. Absolute `http(s)` PNG or JPG — clients strip SVG and block `data:` URIs. Unset, an app still named `Rebase` gets the Rebase mark and a renamed one gets none | #### Database connection pool | Variable | Description | Default | |----------|-------------|---------| | `DB_POOL_MAX` | Maximum pooled connections | `20` | | `DB_POOL_IDLE_TIMEOUT` | Milliseconds an idle connection is kept | `30000` | | `DB_POOL_CONNECT_TIMEOUT` | Milliseconds to wait for a connection | `10000` | | `DATABASE_DIRECT_URL` | Direct (non-pooled) connection. [Realtime](/docs/backend/realtime) needs one: `LISTEN`/`NOTIFY` does not survive a transaction pooler such as PgBouncer, and without it change notifications are disabled with a warning rather than silently lost. | — | | `DATABASE_READ_URL` | Read replica. Reads go there when it is set and differs from `DATABASE_URL`; if the connection fails, everything falls back to the primary with a warning. | — | | `REBASE_DB_POOL_MAX` | A ceiling on every pool in the process, applied whatever each one asked for. Plain digits only: a malformed value is ignored rather than silently serializing the server. | — | #### Runtime behaviour Read by the runtime — `rebase dev`, `rebase start` and the published server image. A project that has ejected owns these decisions in its own code instead. | Variable | Description | Default | |----------|-------------|---------| | `REBASE_RLS_AUDIT` | Run the row-level-security audit at boot and mount its endpoint, which reports tables that are served without policies. | — | | `REBASE_BASE_PATH` | Base path for every API route. The client must be told the same thing — see [Changing `basePath`](#changing-basepath). | `/api` | | `REBASE_SERVE_STATIC` | Serve the bundle's static/admin assets from this process. Turn it off when a CDN sits in front. | `true` | | `REBASE_HISTORY` | Record [entity change history](/docs/backend/history). | `true` | | `REBASE_COMPRESSION` | gzip/brotli responses. | `true` | | `REBASE_MAX_BODY_SIZE` | Maximum request body, **in bytes** (`10485760`, not `10MB` — a value that is not a number refuses to boot rather than silently removing the limit). | — | | `REBASE_ENABLE_SWAGGER` | The OpenAPI surface. Tri-state: unset means on in development, off in production; `false` turns both off anywhere. Note that `true` in production serves the **spec** at `/api/docs` but not the Swagger **UI** at `/api/swagger` — the UI is gated on `NODE_ENV` separately. | — | | `REBASE_METRICS` | Expose Prometheus metrics at `/metrics`. | `false` | | `REBASE_METRICS_TOKEN` | Bearer token guarding `/metrics`. Unset leaves the endpoint open to anything that can reach the port — fine on a private network, not on a public one, and the boot logs say so. | — | | `REBASE_MIGRATE_ON_BOOT` | What the runtime may do to the schema at boot. `ensure` (the default, everywhere — production included) runs the **additive** pass: create missing tables, columns and enum types, never drop or rewrite one. `none` touches nothing. The published image accepts only those two and **refuses to boot on `push`**. In a [split deployment](/docs/deployment/split-processes) exactly one process may provision, so every other role must set `none` or refuse to boot. | `ensure` | | `REBASE_REQUIRE_SCHEMA_MATCH` | Refuse to boot when the database was last provisioned from a different set of collections than this process was built from. Unset (or anything other than `true`/`1`) warns instead. | warn | | `REALTIME_CDC` | Database-level change capture: `auto` (enable where the connection supports it, silently fall back otherwise), `trigger` (force it, warn if impossible), `wal` (degrades to `trigger` today), `off`. See [Realtime](/docs/backend/realtime#database-level-change-capture-cdc). | `auto` | | `REALTIME_CHANNEL_BUS` | Cross-instance transport for broadcast channels and presence: `memory` or `postgres`. Ignored when `realtime.bus` was given a constructed transport. | `memory` | | `ALLOW_LOCALHOST_IN_PRODUCTION` | Permit `localhost`/loopback values under `NODE_ENV=production`. Off, so a production boot fails loudly rather than connecting to a database that is not there. | `false` | | `REBASE_STRICT_COLLECTION_CONFIG` | What boot does with a key in your collections that this version does not read: `warn`, `error` (refuse to boot — worth turning on in CI), or `off`. Only governs keys it does not *recognise*, which are usually a typo and occasionally deliberate metadata; a key it knows has moved is always fatal, because the feature it configured is silently absent otherwise. | `warn` | | `REBASE_PROVISION_ONLY` | `1`/`true` runs the schema pass and exits without opening a socket — the shape a migration Job wants, from the same image and the same bundle as the server that follows it. An empty value is *unset*, so an unsubstituted `${SOMETHING}` in a compose file cannot turn an ordinary deployment into one that migrates and refuses to serve. | — | | `REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY` | `true` lets a machine — an agent, a CI job — *apply* a schema change through `/api/admin/schema`, not only plan one. Off unless asked for: the credential that would make such a change is the one most likely to be sitting in a CI variable. | `false` | | `REBASE_FUNCTIONS_TIMEOUT_MS` | How long a custom function may run before its request is aborted. Same knob as the `functionsTimeoutMs` option. | — | | `REBASE_EXIT_ON_UNHANDLED_REJECTION` | `true` makes an unhandled promise rejection terminate the process instead of logging it. On under an orchestrator that will restart you; off where a restart is worse than a leak. | `false` | | `REBASE_CRON_ALWAYS_ON` | Keeps the cron scheduler running on a platform the runtime otherwise detects as scale-to-zero, where a timer that fires in an idle instance fires in no instance. | — | | `TRUSTED_PROXY_HOPS` | How many proxies sit in front of this server, so the rate limiter can read the real client address out of `X-Forwarded-For`. Fail-safe default `0`: with no proxy, trusting the header would let any caller forge an identity. | `0` | :::note[Boot provisioning is additive, and is not a migration tool] The boot pass runs unattended with nobody reading a diff, so it will never drop a column, narrow a type or rewrite a table. That is also why the image refuses `REBASE_MIGRATE_ON_BOOT=push`: a full push computes a diff and will happily `DROP COLUMN`, and a container restart must never be able to destroy a production column as a side effect of rescheduling. Destructive or reshaping changes stay where they can be reviewed: `rebase db generate` + `rebase db migrate`, or `rebase db push` from a checkout or CI, which dry-runs the change, refuses destructive ones without confirmation, and can take a backup first. ::: #### Split deployments One image and one bundle can be booted several times over, each serving a different part of the project. One line each here, because this page claims to list every variable; what each combination *mounts and owns* — and which combinations refuse to boot — is on **[Split Processes](/docs/deployment/split-processes)**. | Variable | Description | Default | |----------|-------------|---------| | `REBASE_ROLE` | Which part this process serves: `all`, `api`, `functions` or `worker`. | `all` | | `REBASE_CRON_SCHEDULER` | Override whether *this* process runs the cron timers. Unset follows the role. | — | | `REBASE_JOB_WORKERS` | Override whether this process runs job-queue workers. Unset follows the role. | — | | `REBASE_FUNCTIONS_ONLY` | Serve only the named custom functions in this process. | — | | `REBASE_FUNCTIONS_EXCLUDE` | Serve every custom function except the named ones. | — | | `REBASE_FUNCTIONS_UPSTREAM` | Where the API process forwards a function request it does not serve itself. | — | #### Backups | Variable | Description | Default | |----------|-------------|---------| | `BACKUP_SCHEDULE` | Cron expression for scheduled backups. Unset means scheduled backups are off. | — | | `BACKUP_DESTINATION` | Local path, or an `s3://bucket/prefix` / `gs://bucket/prefix` URL. | `./backups` | | `BACKUP_RETENTION_DAYS` | Delete backups older than N days. Unset or `0` keeps everything. | — | | `BACKUP_KEEP_MINIMUM` | Always retain at least N of the most recent backups, whatever retention says. | — | | `PG_DUMP_PATH` | Override the `pg_dump` binary — it must match the server's major version. | — | | `PG_RESTORE_PATH` | Override the `pg_restore` binary. | — | Backups contain secrets and PII. Use a private destination with encryption-at-rest. | `PG_DUMPALL_PATH` | Where `pg_dumpall` lives, when it is not on `PATH`. Without it — and without the PostgreSQL client tools installed — a globals backup fails with an error naming this variable. | — | #### Bundle delivery A managed deployment does not carry its code in the image: the runtime fetches a bundle at boot. These decide which one and how. | Variable | Description | Default | |----------|-------------|---------| | `REBASE_BUNDLE` | Path to an already-extracted bundle directory. What `rebase start` sets locally. | — | | `REBASE_BUNDLE_URL` | Where to fetch the bundle archive from, when there is no local one. | — | | `REBASE_BUNDLE_TOKEN` | The bearer credential for that fetch. Treat it as a secret: it is what authorises a tenant to download its own code. | — | | `REBASE_BUNDLE_FETCH_DIR` | Where a fetched bundle is extracted. Must be writable and must survive between the fetch and the boot. | — | | `REBASE_RUNTIME_MODULES` | Extra modules the runtime image provides to the bundle, beyond the ones it declares itself. | — | #### Resource bindings Every database, bucket and topic a project declares in `config/resources.ts` is bound by environment variables named after it. The base names are below; a non-default resource appends `__` and its key in upper case, so a bucket called `media` reads `S3_BUCKET__MEDIA`. `rebase status` prints, per resource, the exact variable it is reading and whether it is set. | Variable | Description | Default | |----------|-------------|---------| | `REBASE_DRIVER` | The npm package implementing a data source's driver, when it is not the default Postgres one. Suffixed per source: `REBASE_DRIVER__ANALYTICS`. | — | | `REBASE_TOPIC_URL` | The connection string for a declared topic. Suffixed per topic. | — | #### The CLI's own environment Read by `rebase`, not by the server. Nothing here affects a deployment. | Variable | Description | Default | |----------|-------------|---------| | `REBASE_BASE_URL` | The backend `rebase auth` and `rebase api-keys` talk to, instead of deriving it from the project. | — | | `REBASE_PORT` | The port those commands assume when deriving that URL. | — | | `SERVICE_KEY` | The service key they authenticate with, instead of prompting. | — | | `REBASE_ENV_FILE_PATH` | Which `.env` the CLI reads and writes, when it is not the project's. | — | | `REBASE_CLOUD_URL` | The control plane `rebase cloud` talks to. | — | | `REBASE_CLOUD_EMAIL` | The account `rebase cloud login` signs in as, instead of prompting. | — | | `REBASE_CLOUD_PASSWORD` | Its password, so a secret store can hand it over without it reaching the shell's history. | — | | `REBASE_DEBUG` | `1` prints the underlying error and request detail instead of the short message. The first thing to set when a `rebase cloud` command fails unhelpfully. | — | | `REBASE_DEV_NO_DB` | `rebase dev` starts no database and provisions nothing — you bring your own. Same as `--no-db`. | — | | `REBASE_FRONTEND_PORT` | Pins the frontend dev server's port, which `rebase dev` otherwise derives from the project's path. | — | | `REBASE_DEV_READY_TIMEOUT_MS` | How long `rebase dev` waits for the backend to announce itself before saying it has not started. `0` disables the report. | `30000` | | `DATABASE_PASSWORD` | The password `rebase dev --docker` puts into the connection string it derives from `docker-compose.yml`. | — | | `DO_NOT_TRACK` | The cross-tool convention. Set to anything but `0` and the CLI sends no telemetry. | — | | `REBASE_TELEMETRY_DISABLED` | The same, for Rebase specifically. Needs no file, which is why it is the one to use in CI and in an image. | — | | `REBASE_TELEMETRY_ENDPOINT` | Where telemetry is sent, for a self-hosted collector. | — | ### Secrets in development `JWT_SECRET` and `REBASE_SERVICE_KEY` are required in production and generated for you outside it, so you can start without setting anything up. Those generated values are cached in `.rebase-dev-secrets.json`, beside `.rebase-dev-port` and `.rebase-dev-url` and gitignored with them. Before, they were regenerated on every boot — so restarting the dev server logged you out of your own app and invalidated any API key you had just created. - Set either variable explicitly and yours is used; nothing is cached or read. - Point the cache somewhere else with `REBASE_DEV_SECRETS_FILE` — a path, and the only variable in this section you would ever set deliberately. - Delete the file to roll both secrets. The next boot writes a fresh one. - If the file cannot be written — a read-only container, say — the server starts anyway with an ephemeral secret, exactly as it used to. Nothing is cached in production, or under a test runner. In production a boot that had to generate either secret still fails, naming the variable, and that is unchanged: ``` JWT_SECRET must be explicitly set in production. Do not rely on auto-generated secrets outside development. ``` ### 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/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 ## Troubleshooting The failures that stop a Rebase backend from starting or serving, what each one actually looks like on screen, and what to do about it. Boot fails loudly and completely. If the database is unreachable, the credentials are wrong, or the collection schema cannot be applied, `initializeRebaseBackend` throws, nothing is served, and the process exits `1`. There is no degraded mode: a server that comes up answering sign-in while every `/api/data/*` route fails is harder to diagnose than one that never comes up. So the first place to look is always the last thing in the log before the exit. ### Reading a boot error Every database error you see is a wrapper. Drizzle rethrows query failures as `Failed query: …` with a stack through its own internals, and the sentence that says what is wrong sits underneath, in `.cause` — or inside an `AggregateError` when a dual-stack host tried several addresses. The runtime unwraps it for you. A boot failure logs: - a **boxed diagnosis** naming the host, the port, and the fix, and - `caused by:` lines carrying the chain, ending in the reason the operating system or Postgres gave. If you are reading JSON logs (`NODE_ENV=production`), the same chain is under `error.cause`, with `code`, `address` and `port` on each link. #### `Failed query: [redacted]` That is not a truncated log line. Drizzle builds every query failure as `Failed query: ` followed by the bound values, so the statement and its parameters — an email address, a password hash — ride along in the message and the stack of anything the driver rethrows. The logger strips that span out of every line it writes, and prints `[redacted]` where it was. The statement is rarely the answer anyway: the reason is in the `caused by:` lines underneath. When you do need it, set `REBASE_LOG_RAW_QUERIES=true` in development and the SQL is printed instead. It is ignored outside development, so a variable that leaks into a production environment cannot un-redact anything there. ### The database is not running ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ❌ Cannot connect to PostgreSQL at 127.0.0.1:5432 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ The driver said: connect ECONNREFUSED 127.0.0.1:5432 (ECONNREFUSED) ``` Nothing is listening on that address. Start the database: ```bash docker compose up -d db # the service a Rebase scaffold ships brew services start postgresql@18 ``` Or run `rebase dev` with no `DATABASE_URL` at all, which starts a managed PGlite database for you and needs nothing installed. If the host and port in the box are not the ones you expected, `DATABASE_URL` in `.env` is not the one the process read — check for a second `.env`, a shell variable already exported, or a container that was started before you edited it. ### The password or the database name is wrong ``` ❌ Authentication failed for user "app" at db.internal:5432 The driver said: password authentication failed for user "app" (28P01) ``` `28P01` is a wrong password, `28000` a role that may not connect from here, and `3D000` a database that does not exist. All three are settled facts about the connection string: retrying produces the same answer, so boot fails immediately rather than reporting a pool that "may recover". Check the credentials in `DATABASE_URL`. A password containing `@`, `/`, `?` or `#` must be percent-encoded — an unencoded one silently reshapes the URL and the host you end up connecting to is not the one you wrote. ### `type "vector" does not exist` pgvector is a server extension, so Rebase installs it only where a project says it may. Declare it in `config/resources.ts`: ```ts database({ extensions: ["vector"] }) ``` The database also needs an image that ships the library. The scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not. If the install itself is refused (`extension "vector" is not available`, or a permission error), the config is already right and what is missing is the library on the server or a role allowed to run `CREATE EXTENSION vector;`. ### The database refused the statement ``` DB_PERMISSION_DENIED — Permission denied by the database on "notes" (row-level security). Check the RLS policies for this table. ``` SQLSTATE `42501`. Two different problems arrive under it, and the message distinguishes them: - **A row-level security policy denied the row.** The access-control system is working; the caller asked for something their policies do not permit. Check the collection's `securityRules`, and run `npx @rebasepro/rls-check` for a read-only audit of what the database will actually enforce. - **The role lacks a `GRANT`.** Nothing about the request will help — the connection role cannot touch the table at all. This is a deployment problem. A read that RLS excludes is not an error: the rows are filtered and you get an empty page. If a collection reads as empty for a signed-in user who should see rows, the policy is the place to look, not the query. ### `SCHEMA_DRIFT` — a table or column does not exist ``` SCHEMA_DRIFT — Schema drift: table "posts" does not exist. ``` The code and the database disagree. In development: ```bash rebase db push # apply the collections to the database rebase doctor # the full three-way drift report ``` On a managed Cloud tenant, `db push` cannot reach the database — the runtime applies the schema at boot instead, so redeploy rather than pushing. If a table exists but a column does not, the usual cause is a collection file that was edited without regenerating: run `rebase schema generate` and push again. ### The port is already in use ``` Port 3001 is in use — trying 3002. ``` Dev binds the next free port and says so. The message matters because everything else — your frontend's `VITE_API_URL`, a bookmark, a `curl` — is still pointing at the old one. The usual cause is a previous `rebase dev` still holding the socket. Pass `--port` to pin one, or stop the other process. In production there is no retry: the configured port is the port, and `EADDRINUSE` is fatal. ### The backend crashed and `rebase dev` kept running A backend that throws on boot does not stop the watcher — it prints the stack and waits for a file change. `rebase dev` reports this: ``` ✗ The backend crashed on startup. Fix the error above; the watcher restarts it on the next change. ``` The error above it is the real one. The most common causes are a syntax error in a collection file, an import that does not resolve, and a `DATABASE_URL` that points at nothing. ### A custom function is not being served Functions are loaded from `backend/functions` at boot, and a file that fails to load is **skipped, not fatal** — the server starts without it. So the symptom is a 404 on a route you just wrote, and the explanation is two lines earlier in the boot log: ``` ❌ [functions] Failed to load orders.ts: Cannot find module './util' ⚠️ [functions] 1 function file(s) were skipped and will NOT be served: - orders.ts (threw: Cannot find module './util') ``` The usual causes: a dependency imported but not in `package.json`, a relative import missing its extension (`./util` rather than `./util.js` — the project is ESM, so the extension is required), and a file that exports something other than a Hono app. Author with `defineFunction(...)` from `@rebasepro/server/functions` to get that last one as a compile error instead — that subpath, not the package root, so the function stays portable. A subdirectory is not scanned. `functions/admin/users.ts` is reported as a skipped entry rather than served. Once the server is up, a function that throws at request time answers the JSON error envelope and logs the reason; a function that never returns is cut off at `REBASE_FUNCTIONS_TIMEOUT_MS` and answers `504 FUNCTION_TIMEOUT`. ### Is it up? `/livez` and `/health` | Path | Touches the database | Answers | | --- | --- | --- | | `/livez` | No | `200 {"status":"ok"}` while the process is running. Use it for a liveness probe. | | `/health` | Yes, every data source | `200 {"status":"ok"}` when every configured data source answers; `503 {"status":"degraded"}` when one does not. Use it for a readiness probe. | Do not put a liveness probe on `/health`: a database blip would make the orchestrator kill an otherwise healthy process, turning a short outage into a restart loop. `/health` is unauthenticated, so outside development it publishes the verdict and which data source is degraded, and nothing else. The driver's own error text — which quotes the host, port, database name and role — goes to the logs. ### Errors after boot Every API failure answers the same envelope and carries a `code`. The [error-code reference](/docs/backend/errors/) lists all of them with the status and the fix. ### Where to go next - [Error codes](/docs/backend/errors/) — every `code` the API can answer, with the status and the fix. - [Environment & Configuration](/docs/getting-started/configuration/) — every variable the runtime reads, and the ones production refuses to start without. - [Backend Overview](/docs/backend/) — what boot does, in order, and which probe answers which question. ## Upgrading ## Upgrading an existing app Three hops, newest first. Start at the one above the version you are on and work downwards — or, if you are coming from 0.12, start at the bottom and work up. | You are on | Read | What it does to you | |---|---|---| | **0.17** | [0.17 → 0.18](/docs/upgrading/0-17-to-0-18/) | Eleven breaking changes, most of them compile errors rather than silent behaviour changes. `rebase.data` disappears at runtime, peer ranges narrow to carets, and the Node floor moves to 22.22.0. | | **0.14 – 0.16** | [0.14 → 0.17](/docs/upgrading/0-14-to-0-17/) | The admin packages are renamed, resources are declared rather than configured, and a bundle built before it will not boot on a current runtime. 0.15 and 0.16 break nothing, so this is one hop from 0.14. | | **0.13** | [0.13 → 0.14](/docs/upgrading/0-13-to-0-14/) | Every wire key is camelCase, and anonymous sign-in is opt-in. | | **0.12** | [0.12 → 0.13](/docs/upgrading/0-12-to-0-13/) | The largest hop, and the only one that changes **who can read your data**. It removes the `auth` schema, renames packages, changes what `id` means, drops CJS, moves the admin panel to react-router 8, and fixes three ways access could be granted that you did not ask for. | Each page's sections are in the order you have to make them, and each one names the symptom you would otherwise debug from the wrong end. Then, whichever hop you made: - [The upgrade checklist](#upgrade-checklist) below — the greps and commands worth running afterwards, in one block. ### Upgrade checklist ``` [ ] grep for authenticated() and (auth|rebase).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 --- 0.13 → 0.14 (part 2) --- [ ] re-run rebase generate-sdk, then build — the compiler finds row.author_id [ ] grep for snake_case where/orderBy keys; a stale one is a 400, not a warning [ ] grep for raw fetch consumers reading row._id — these read undefined and raise nothing [ ] if the project was introspected, re-run rebase schema introspect [ ] decide anonymous sign-in: set auth.allowAnonymous: true, or confirm you never used it (grep for signInAnonymously) [ ] drop @rebasepro/client-postgres from package.json --- 0.14 → 0.17 (part 3) --- [ ] rename @rebasepro/admin → @rebasepro/cms and admin-types → cms-types, and RebaseAdmin → RebaseCMS [ ] move dataSources / storageSources out of the backend config into rebase.json and config/, then `rebase build` — an old bundle will not boot [ ] grep collections for admin.titleProperty; it is refused at boot now [ ] grep crons for ctx.client and for `userId` as an identity key [ ] rename --legacy to --workspace wherever build/start is scripted [ ] if you implement EmailService yourself, `send` must return a result ``` `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. ### Related - [Changelog](/docs/changelog/) — the release notes each of these sections summarises - [Compatibility](/docs/compatibility/) — the six contracts, and which ones a version can break - [CLI Commands](/docs/cli/) — `rebase doctor` and the commands the checklists run ## Upgrading 0.17 to 0.18 ## Upgrading 0.17 → 0.18 **0.18.0 is released.** These are the `#### Breaking` entries of its [changelog section](/docs/changelog/), one at a time, with what each one asks you to change. Under 0.x the minor is the breaking position, so this is the wall between 0.17.3 and 0.18.0 — read it before you move, not after. Every one of them is a compile error or a refused boot rather than a silent change of behaviour, except the last, which rewrites a constraint the next `db push` will plan for you. --- ### 20. `defineCollection` is one signature It was three overloads — Postgres, Firestore, MongoDB — and a failing call produced one diagnostic on `defineCollection(`, listing each overload's first failure. There is one signature now, discriminated on `engine` (Postgres when absent), and errors are reported on the key that is wrong, all of them at once. **What to change:** nothing, in a call that already compiled. What changes is where the red squiggle lands. Two type exports moved with it: `UnknownPropertyKey` is now `NoSuchKey` (and carries a `didYouMean` member), and `PropertyTypeNotOnThisEngine` is new. Both are internal machinery of the builder, so only code that imported the old name has an edit to make. ### 21. `defineCollection` keeps checking after the first bad property The builder constrained its property map, and a constraint TypeScript cannot satisfy is one it silently falls back from — so a single bad `defaultValue` widened the inferred entity to `Record` and switched off every check on `display.title`, `listProperties`, `propertiesOrder` and `previewProperties`. **What to change:** nothing to write, but expect *new* errors in collection files that compiled before. They were wrong then too; the compiler had stopped looking. Fix the property the first error names and rebuild — the rest of that file is being checked for the first time. ### 22. A relation's link field has to belong to its `kind` `Relation` has been a closed union since 0.11 — `belongsTo` owns `localKey`, `hasOne`/`hasMany` own `foreignKeyOnTarget`, `manyToMany` owns `through`, `via` owns `joinPath` — but excess-property checking ran against the union as a whole, so `{ kind: "belongsTo", foreignKeyOnTarget: "author_id" }` compiled and then resolved to a different link than the one written. **What to change:** put the link field the kind owns. ```diff lang="ts" author: { type: "relation", - relation: { kind: "belongsTo", foreignKeyOnTarget: "author_id", target: () => authors } + relation: { kind: "belongsTo", localKey: "author_id", target: () => authors } } ``` The boot validator already refused these, so this moves the report from a failed deploy to a compile error. If your project boots today, it compiles today. ### 23. A relation's `validation` moves up to the property `RelationBase.validation` and `ResolvedRelationBase.validation` are deleted. There were two places to write `required` and two generators reading different ones: the DDL asked the property (so the column came out `NOT NULL`) while the SDK type generator asked the relation (so the generated `Insert` type made the field optional), and a `create()` that omitted the relation typechecked and then failed at the database. **What to change:** move it one level up, beside every other field's. ```diff lang="ts" author: { type: "relation", + validation: { required: true }, - relation: { kind: "belongsTo", validation: { required: true }, target: () => authors } + relation: { kind: "belongsTo", target: () => authors } } ``` Boot refuses the old key by name rather than ignoring it. Code that asked a relation whether it was required asks `import { isRelationRequired, relationDeclaringProperty } from "@rebasepro/common"` instead. ### 24. A required `belongsTo` deletes with `RESTRICT`, not `CASCADE` `validation: { required: true }` says the child cannot exist without a parent. It does not say deleting the parent should delete the child — but that is what the generator inferred, so every `DELETE FROM authors` cascaded through posts, their comments, and whatever hung off those. The default is `RESTRICT` now: the delete fails and names the constraint. Optional relations are unchanged (`SET NULL`), and a `manyToMany` junction is unchanged (`CASCADE` — the row it deletes is the link). **This one is a DDL change.** The next `rebase db push` plans a `DROP CONSTRAINT` / `ADD CONSTRAINT` for every required relation that never named an `onDelete`, and after it those parent deletes start failing where they used to cascade. **What to change:** to keep the old behaviour, write it down. ```diff lang="ts" - relation: { kind: "belongsTo", target: () => authors } + relation: { kind: "belongsTo", onDelete: "cascade", target: () => authors } ``` Then read the plan before applying it — `rebase db push` prints the statements. ### 25. The Node floor is `>=22.22.0` Every published package declared `>=20` at 0.17.3 — `@rebasepro/cli` declared no `engines` at all — and every one of them declares `>=22.22.0` on `main`. The single source is the repository's `.nvmrc`. **What to change:** move to Node 22.22.0 before you upgrade. `pnpm install` answers an engines mismatch with `[WARN] Unsupported engine` and installs anyway, so the failure does not arrive at install time; it arrives later, somewhere with no mention of Node in it. ### 26. `rebase.data` is gone at runtime `RebaseServerClient` dropped `data` from its type so that server-side code has to say whose identity a read runs under, and the property was then left on the object as a runtime alias. It is deleted at boot: `rebase.data` is `undefined`. **What to change:** in server code — functions, crons, callbacks — write `rebase.dataAsAdmin` where you mean the admin plane and `context.data` where you mean the caller's. Typed code has been getting the error since the type changed; what breaks now is untyped code, and a scaffold's `ai-instructions.md` that still tells an assistant to reach for `rebase.data.` — `rebase init` rewrites that file, so regenerate it or edit the rule by hand. Browser code is untouched: `rebase.data` on the client SDK is the user-scoped plane and stays. ### 27. `@rebasepro/cli` publishes three exports `src/index.ts` re-exported sixteen modules and put 95 names on npm. It exports `entry`, `manifest` and `bundle` now. **What to change:** nothing, unless you `import` from `@rebasepro/cli` — which is unlikely, since nothing in this repository or the control plane did. If you do, the CLI is a binary: shell out to `rebase ` rather than calling its command functions, whose signatures were never a contract. ### 28. Peer ranges are carets `ui`, `forms`, `firebase`, `plugin-insights` and `cms-types` declared `react >=19.0.0`, a range whose lower half cannot satisfy `app` and `cms` at 19.2.7 — so an installer that picked 19.0.0 produced a tree that resolved cleanly and broke at render. All five say `^19.2.7`. `@rebasepro/app`'s `typescript` peer moves from `>=5.0.0` to `^6.0.0`. **What to change:** be on React 19.2.7 or later, and on TypeScript 6 if you use `@rebasepro/app`'s Vite plugin. If your install previously resolved React 19.0.x you will now get a peer warning until you raise it; that warning is the tree you were already running, said out loud. ### 29. `rebase cloud webhooks create` takes `--endpoint` `--url` names the control plane for every command in this family, so the second `--url` this one declared for the customer's endpoint could never win: the documented example sent the webhook URL to the client as the host to authenticate against. **What to change:** `rebase cloud webhooks create --endpoint https://…` in any script that creates one. The old spelling could not have worked, so there is no behaviour to preserve — only lines to correct. ### 30. `serializeFilter` emits strictly The shared leaf encoder parses liberally, because a short code arrives off the wire, and emits strictly, because a caller handing one to the serializer built the condition by hand. `serializeFilter({ a: ["gt", 5] })` throws rather than round-tripping `gte`. **What to change:** only if you call `@rebasepro/common`'s serializers directly. Use the operator names the types declare (`gt`, `gte`, `lt`, `lte`, …) rather than REST short codes. Query builders and the SDK were already spelling them this way; what changes is that the wrong spelling now says so. --- **Next:** [the upgrade checklist](/docs/upgrading/#upgrade-checklist) · [0.14 → 0.17](/docs/upgrading/0-14-to-0-17/), the hop before this one · [Changelog](/docs/changelog/), the release notes these sections summarise. ## Upgrading 0.14 to 0.17 ## Upgrading 0.14 → 0.17 0.15 and 0.16 add and fix things; neither breaks anything. Everything in this part landed in **0.17.0**. Under 0.x the minor is the breaking position — `^0.16.0` resolves `>=0.16.0 <0.17.0` — so none of it reaches a project until you deliberately move to 0.17. ### 14. `@rebasepro/admin` is `@rebasepro/cms` And `@rebasepro/admin-types` is `@rebasepro/cms-types`. "Admin" named two things at once: the whole panel, and the content-management half of it. ```diff lang="ts" - import { RebaseAdmin } from "@rebasepro/admin"; - import { defineCollection } from "@rebasepro/admin-types"; + import { RebaseCMS } from "@rebasepro/cms"; + import { defineCollection } from "@rebasepro/cms-types"; ``` Change the specifier and `RebaseAdmin` to `RebaseCMS`. There is no alias: a shim would keep both meanings of "admin" alive, which is the thing being fixed. The old packages stop at 0.16.0 on npm and receive nothing after it, so `^0.16.0` keeps resolving rather than breaking — it just stops moving. **Your collection files do not change.** The `admin:` key is deliberately untouched, along with `AdminCollection*`, `ADMIN_COLLECTION_KEYS`, the `admin` auth role and `/api/admin` — those name something other than the CMS product. The panel's mode value moved with the package (`"content"` → `"cms"`). It is persisted per browser and migrates on read, so an existing browser keeps working. ### 15. Resources are declared, not configured `dataSources` and `storageSources` are gone from `RebaseBackendConfig`. Declare them in `rebase.json` and the config package instead. **A bundle built before this will not boot on a current runtime.** Rebuild it: ```bash rebase build ``` `rebase eject infra` is gone too, along with `rebase.infra.json` and the `{"$env": "..."}` indirection. Resources bind from the environment on the `__` convention, which is what every deployment already used. ### 16. `admin.titleProperty` is rejected at boot Use `admin.display.title` — the same string works there. ```diff lang="ts" admin: { - titleProperty: "name" + display: { title: "name" } } ``` This can stop a project that starts today, which is the point: silence would mean the title quietly reverting to the derived one with nothing to explain why. ### 17. `ctx.client` in a cron is `ctx.rebase` And `userId` is no longer accepted as an identity spelling anywhere — it is `uid` throughout. ```diff lang="ts" - export default defineCron({ schedule: "0 3 * * *", async handler({ client }) { - await client.dataAsAdmin.collection("orders").find(); + export default defineCron({ schedule: "0 3 * * *", async handler({ rebase }) { + await rebase.dataAsAdmin.collection("orders").find(); } }); ``` ### 18. CLI flag rename The `--legacy` flag on `build` and `start` is now `--workspace`. The mode is supported, not retired, and the old name said otherwise. ```diff - rebase build --legacy + rebase build --workspace ``` Worth grepping your scripts and CI for: `arg` runs permissively on these two commands, so the old spelling is **ignored rather than rejected** — the build succeeds and silently uses the other mode. ### 19. `EmailService.send` returns a result Breaking only for code that *implements* `EmailService`: a `send` returning `Promise` no longer satisfies it. Callers are unaffected — they may ignore the result — and the `auth.email.sendEmail` hook stays permissive, so an existing `async () => {}` provider still works and simply reports nothing. ### Next - [The upgrade checklist](/docs/upgrading/#upgrade-checklist) — what to run afterwards - [Upgrading 0.13 → 0.14](/docs/upgrading/0-13-to-0-14/) — the hop before this one - [Changelog](/docs/changelog/) — the release notes these sections summarise ## Upgrading 0.13 to 0.14 ## Upgrading 0.13 → 0.14 Sections 0–10 above are the 0.12 → 0.13 hop. The three below are 0.13 → 0.14. If you are already on 0.13, start here. Two of them are breaking. The first stops code compiling, which is the good case; the second changes who can obtain an account, and announces itself only as a 403 your users hit and you do not. --- ### 11. The API is camelCase throughout — `author_id` is now `authorId` #### What changed A field's wire name is its property key, and `columnName` renames only the *column*. That rule did not change — but two of the four sources of keys never had a property key to use, and both fell back to the column name: - a **foreign key derived from a relation** had no property of its own, so `belongsTo` on `author` served the `author_id` column under its own name; - **introspection** wrote the raw column name as the property key. So `GET /api/data/users` answered `displayName` while `GET /api/data/posts` beside it answered `author_id`, and nothing visible from outside said which a field would land in. Both now derive a camelCase key. ```diff - GET /api/data/posts → { "id": 1, "title": "Hello", "author_id": 3 } + GET /api/data/posts → { "id": 1, "title": "Hello", "authorId": 3 } - ?where={"author_id":["==",3]} 400 UNKNOWN_FILTER_FIELD + ?where={"authorId":["==",3]} ``` **The database does not change.** Columns stay snake_case, `\d posts` still shows `author_id`, no migration runs, and `rebase doctor` reports no drift. #### What you have to do **Re-run `rebase generate-sdk`.** `row.author_id` stops compiling and `row.authorId` starts — the compiler names every call site for you. This is the half you do not have to search for. **Then find the half the compiler cannot see.** Hand-written `where` and `orderBy` keys, raw `fetch` consumers, and anything reading a row by key: ```bash grep -rn "_id\"\|_id'\|\._id\b" src/ config/ grep -rnE '(where|orderBy)[^)]*"[a-z]+_[a-z]+"' src/ config/ ``` A filter key that no longer resolves is a **400 with `UNKNOWN_FILTER_FIELD`**, and the error lists the valid names. It fails closed on purpose — a dropped condition widens a result set, which is the one failure you do not want to be silent. A row read by the old key, though, is just `undefined`, and nothing raises. #### If your project was introspected rather than authored This is the largest single change for you. `rebase schema introspect` no longer echoes column names on the wire: a `customer_id` column is generated as a `customerId` property carrying `columnName: "customer_id"`, and is served, filtered and sorted as `customerId`. **Re-running introspection is what produces the new collections.** The column, the constraints and the policies are untouched. > A property key *you* wrote is still your key, whatever its shape. Nothing > camel-cases a name someone already chose — only the two sources that never had > a name to use. There is no dual-key emission and no compatibility flag, because > serving both spellings would leave both conventions in place permanently, which > was the defect. --- ### 12. Anonymous sign-in is opt-in #### What changed `POST /auth/anonymous` answers **403** until you set `auth.allowAnonymous: true`. Anonymous sign-in is registration that never asked: it inserts a `users` row and assigns `defaultRole` exactly as `POST /auth/register` does. But both anonymous routes were mounted unconditionally and consulted none of the registration gates, so a backend that had closed the door still handed out permanent accounts — `POST /auth/anonymous` for the row and the session, then `POST /auth/anonymous/link` to put credentials on it, the second authenticated only by the token the first had just issued. #### What you have to do If you use anonymous sessions — guest carts, trials, unauthenticated drafts — opt in explicitly: ```diff ts auth: { + allowAnonymous: true } ``` If you do not, there is nothing to do, and the 403 is the point. **Check which one you are before you deploy.** The symptom of guessing wrong is sign-in failing for users who never had credentials to re-enter: ```bash grep -rn "signInAnonymously\|/auth/anonymous" src/ config/ frontend/ ``` --- ### 13. `@rebasepro/client-postgres` is gone Remove it from `package.json`. If you imported from it, the SDK reaches Postgres through the ordinary client — there is no separate package to install. --- ### Next - [Upgrading 0.14 → 0.17](/docs/upgrading/0-14-to-0-17/) — the hop after this one - [The upgrade checklist](/docs/upgrading/#upgrade-checklist) — what to run afterwards - [Changelog](/docs/changelog/) — the release notes these sections summarise ## Upgrading 0.12 to 0.13 ## Upgrading 0.12 → 0.13 The largest hop, and the only one that changes **who can read your data**. Sections 0, 1 and 2 do that; read them before anything else. Section 0 changes SQL you may have written by hand, and sections 1 and 2 alter who can read your data. None of them announces itself. ### 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 rebase.uid() IS NOT NULL AND rebase.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 the bare `IS NOT NULL` tautology — spelled `auth.uid()` if it was pushed before section 0, `rebase.uid()` after. **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 `IS NOT NULL` tautology, in > either schema spelling (`rebase.uid()` or the pre-1.0 `auth.uid()`). 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 -rnE "(auth|rebase)\.uid\(\) IS NOT NULL" config/collections/ ``` Both spellings, because section 0 moved the helpers: a rule written before it says `auth.uid()`, one written after says `rebase.uid()`, and the compiler accepts either. **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 `rebase.uid() IS NOT NULL` — or `auth.uid()` on a database not yet re-pushed — **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/cms`, `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 `RebaseCMS`.** `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. --- ### Next - [Upgrading 0.13 → 0.14](/docs/upgrading/0-13-to-0-14/) — the hop after this one - [The upgrade checklist](/docs/upgrading/#upgrade-checklist) — what to run afterwards - [Security Rules (RLS)](/docs/collections/security-rules/) — the rule vocabulary sections 0–2 are about ## Compatibility What Rebase promises across versions, and what it does not. This is the document to read before changing anything a deployed project or a running Rebase Cloud tenant already depends on. It is also the honest answer to "if I build on Rebase today, what breaks under me later?" ### What "beta" means here Rebase is in public beta. Most projects use that word to mean "anything may break", which tells a reader nothing they can plan around, so here is the line this project actually draws: > **The API you write against can change in a minor, with a changelog entry. > Your data cannot break quietly.** The first half is ordinary `0.x` behaviour and is described below. The second half is the part worth checking, because it is a claim about mechanisms rather than intentions: the versioned contracts in the next section are each stamped into an artifact or a database, each is checked at boot or at intake, and each **fails loudly and specifically** rather than degrading. A schema push that would drop a column is refused by a destructive gate (`packages/server-postgres/test/e2e/db-push-safety.test.ts`), and the upgrade path itself is a test: `upgrade-e2e.test.ts` restores databases as older releases left them, runs the current migration path over each one, and asserts the rows survive — not merely that the boot did. What beta does mean: features are still missing, some subsystems are newer than others, and the shape of a rough edge is that something is absent or awkward, not that it silently corrupts something. Which subsystems are which is published and dated rather than left to be discovered — the table below is that publication. ### Readiness by subsystem **Dated 2 September 2026, against 0.17.3.** Re-read it at each minor; a rating that has not moved in three releases is either settled or forgotten, and this note is here so the difference gets checked. The three ratings mean: - **Stable** — the shape is settled and covered by a gate in CI. It can still gain features; it will not be redesigned under you inside 0.x, and a change to it that would break you is announced in the changelog. - **Beta** — it works and is used in production, and something about it is known to be rough: a limit you can reach, an edge that is awkward, a design decision not yet made. The rough part is named in the notes, because "beta" on its own tells you nothing you can plan around. - **Experimental** — shipped so it can be used and reported on. Expect to hit the parts nobody has. | Subsystem | Rating | What the rating rests on | |---|---|---| | REST API + generated SDK | Stable | The wire contract is versioned and gated; `client-sdk-e2e` drives register → sign-in → RLS-scoped reads → refresh → storage → realtime end to end | | Auth — email/password, OAuth, OIDC, magic link, one-time code | Stable | Twelve OAuth providers ship. The auth schema is a versioned contract, stamped and checked at boot | | Auth — MFA (TOTP) | Beta | Enrolment, verification and recovery work and are tested. Key rotation is implemented for the encryption key; there is no admin surface for resetting a locked-out user's factor | | Row-level security | Stable | The wedge of the product. `pnpm rls:check` audits a live database against fifteen checks, and the RLS e2e suite runs on every push | | Storage | Stable | Local, S3 and GCS. Default-deny in production since 0.17.0, and the scaffold ships an authorize hook | | Realtime | **Beta** | Subscriptions are matched by collection path only, so N subscribers on one collection cost N RLS-scoped refetches per write. That caps a deployment at low hundreds of concurrent subscribers. Correct at any scale; expensive past that one | | Vector search (pgvector) | Beta | Exact search is stable. ANN indexes are not yet declarable, so large collections scan | | Offline sync | Beta | Mutations carry idempotency keys the server honours, and the data-loss defects found in the July audit are fixed. The conflict model is last-write-wins with no per-field merge | | Entity history | Stable | Snapshot-based, gated by its own suite | | Functions and crons | Stable | The portable entry point (`@rebasepro/server/functions`) is a versioned contract with its own API-surface section | | MCP server + agent skills | Beta | Thirty-odd tools, bearer auth per project, destructive tools refuse non-local targets unless opted in. stdio transport only — there is no remote/HTTP transport yet | | Studio (SQL, schema, RLS, API explorer) | Beta | Used daily against real projects. Branching is present in the OSS package and deliberately not exposed in Rebase Cloud, because moving a running deployment onto a branch has no story yet | | CMS + admin panel | Beta | Complete for CRUD, relations, storage fields and roles. **The data table has no grid semantics** — no `role`, no `aria-rowindex`, `tabIndex` stripped — so keyboard and screen-reader users cannot operate the main view. No drafts, no per-locale content, no block rich-text | | PGlite managed dev database | Beta | Zero-setup `rebase dev` with no Docker. One session at a time, so requests serialize and concurrency cannot be reproduced against it; Atlas-backed commands (`db push`, `generate`, `migrate`) do not work there and say so | | Helm chart | Beta | Renders the split-process topology and is published to the OCI registry at each release. The default remains a single container | | `@rebasepro/server-mongo` | **Experimental** | A working driver with change-stream realtime and snapshot history. **No row-level security** — the whole isolation model above does not apply to it — and no relations | | `@rebasepro/firebase` | Experimental | Runs the admin panel and SDK against Firestore. No RLS, no SQL surface; the Postgres feature set does not carry over | | Rebase Cloud | **Private beta** | Live, running real tenants, opened in batches. Not self-serve | Two entries above are the honest cost of publishing this table at all: the realtime refetch and the data table's accessibility are open defects, not roadmap items, and both are listed rather than left for a reader to discover. This table is what exists. What does not yet is on the [roadmap](https://rebase.pro/roadmap), one entry per GitHub issue, with the subset required for 1.0 marked. ### The 0.x promise Rebase is `0.x` — 0.17 at the time of writing. This section is written to hold for every 0.x release rather than for one of them, so it does not go stale on each cut. **Breaking changes to the authored TypeScript API are still allowed in a minor**, and the changelog is where they are announced. What is *not* allowed to break silently is the set of versioned contracts below: each one is stamped into an artifact or a database, each is checked at boot or at intake, and each fails **loudly and specifically** rather than degrading. That distinction is the whole promise. A renamed export costs you a compile error and five minutes. A bundle that boots against the wrong runtime and serves subtly wrong data costs you an incident, and the contracts exist so that the second category cannot happen quietly. Rebase Cloud consumes exactly these contracts and nothing else. Anything not listed here is an implementation detail the platform does not depend on. ### The versioned contracts The values below are read from source; treat the file references as the truth and this table as the map. ```bash grep -rn "BUNDLE_FORMAT_VERSION =\|RUNTIME_CONTRACT_VERSION =" packages/types/src/types/project_manifest.ts grep -n "AUTH_SCHEMA_VERSION =" packages/server-postgres/src/auth/schema-version.ts ``` | ## | Contract | Declared in | Checked in | Compatibility direction | |---|---|---|---|---| | 1 | `rebase` range in `rebase.json` | the user's project | CLI at build | project states which runtimes it accepts | | 2 | `BUNDLE_FORMAT_VERSION` | `packages/types/src/types/project_manifest.ts` | `packages/server/src/boot/bundle.ts` | **backward compatible** — new runtime reads old bundles | | 3 | `RUNTIME_CONTRACT_VERSION` | same file | same file | **exact match, both directions** | | 4 | `AUTH_SCHEMA_VERSION` | `packages/server-postgres/src/auth/schema-version.ts` | at boot, against `rebase.schema_meta` | **forward only** — new runtime migrates old databases | | 5 | `manifest.schemaVersion` | emitted by `rebase build` | sent by the SDK as `x-rebase-schema` when configured | advisory — identifies which schema a client was built against | | 6 | Derived database identifiers | `contracts/derived-names.txt` | `pnpm check:derived-names` | **frozen** — a name a release emitted is never re-derived | #### 1 — `rebase` in `rebase.json` A semver range, read like `engines` in a `package.json`: which runtime versions this project accepts. Named `rebase` rather than `runtime` deliberately, because `runtime` already means *who owns the process* (`managed` | `custom`) on an app. #### 2 — `BUNDLE_FORMAT_VERSION` (currently 2) The on-disk layout of a built bundle. A runtime accepts any bundle whose format is **less than or equal to** its own, which is what lets the managed tier move a tenant onto a new image without anyone rebuilding their project. - **1** — `mode: "cms" | "baas" | "static"`, `entry.static` a single directory, `entry.admin` for a bundled admin. - **2** — `kind: "backend" | "static"`, `entry.static` a list, `entry.admin` removed. Format 1 is still read, via `upgradeLegacyManifest`. **Bump it when** the layout changes such that an older runtime would misread a newer bundle. The bump is what converts "boots and serves nothing" into a refusal to start. #### 3 — `RUNTIME_CONTRACT_VERSION` (currently 1) The bundle↔runtime contract major. Distinct from the `@rebasepro/server` package version, which may release any number of minors and patches while this stays put. **Read this before touching it.** The check is `!==`, not `>`: > a bundle targeting contract *N* runs **only** on a runtime implementing *N* so bumping it invalidates **every bundle ever built**, all at once, until each is rebuilt. That is the intended severity — it is the "nothing old can run here" lever — but it means a bump is a fleet-wide migration, not a release note. For the managed tier it must be sequenced with a rebuild of every tenant's bundle. If a change is *additive* and old bundles would still be correct, it wants `BUNDLE_FORMAT_VERSION` (or nothing at all), not this. #### 4 — `AUTH_SCHEMA_VERSION` (currently 2) Stamped into `rebase.schema_meta` and compared at boot. A runtime **refuses to start** against a database migrated by a newer framework version, rather than operating on a shape it does not understand — during a rolling deploy that is the difference between half the fleet erroring and half the fleet corrupting. Forward migration is automatic: `ensureAuthTablesExist` brings an older database up. Note that this migration block is deliberately wrapped in `try/catch` and logs rather than throwing — a limping boot beats a crash loop — so **"it booted" proves nothing**. Every assertion in the upgrade suite reads the catalogue or the data instead. **Bump it when** a migration must not be skipped by an older runtime. Do not bump for an additive, back-compatible column; there is a worked example of that judgement in `packages/server-postgres/src/auth/ensure-tables.ts`. #### 5 — `manifest.schemaVersion` A hash of the compiled collection definitions, emitted into the bundle manifest and echoed by a generated SDK in the `x-rebase-schema` header (`SCHEMA_VERSION_HEADER`). It exists so the platform can say "this app was built against an older schema" instead of failing mysteriously at the first request. `rebase generate-sdk` writes the value into `schema.meta.ts`; pass it to the client to send it: ```typescript const rebase = createRebaseClient({ baseUrl: "http://localhost:3001", collections: collectionsDictionary, schemaVersion: SCHEMA_VERSION, }); ``` The backend reads that header on every data request. Drift never refuses a call — an SDK a schema behind is usually still compatible, and shipping the backend before the frontend is the normal deploy order — but when a request fails with a 400 or a 404, the error carries the drift as its cause: ```json { "error": { "code": "BAD_REQUEST", "message": "Unknown field \"authorName\" on collection \"posts\"", "cause": { "code": "SCHEMA_DRIFT", "clientSchema": "v1:0e1c…", "serverSchema": "v1:9ab4…", "message": "This client was generated against schema v1:0e1c…; this backend serves v1:9ab4…" } } } ``` So a renamed column reads as "your SDK is stale, regenerate it" rather than as a field your own types insist exists. A request that succeeds is never told. It covers **collections only**. A hook or function edit does not change a client's contract and must not invalidate every generated SDK. #### Who is calling Two more identifying signals, neither of which gates anything on its own: - **`GET /api/meta/schema-version`** is unauthenticated and answers with the project's `schemaVersion` *and* its `runtime` (`version` and `contract`). A CI job comparing its generated SDK against a live project needs no credentials, and neither does a client asking which runtime it is talking to. - **`User-Agent: rebase-cli/`** goes on every `rebase cloud` request. The control plane's wire format runs ahead of what is published to npm, so it needs to be able to answer an old client with `CLI_TOO_OLD` and the minimum version — which it can only do for a caller that says who it is. #### 6 — Derived database identifiers Every name this framework works out for itself rather than being told: a foreign key column, a foreign key constraint, a junction table and its two key columns, an enum type, a policy name, a `camelCase` property's `snake_case` column. > **A derived identifier is frozen the moment a release emits it.** Not "frozen until the next major" — frozen. The reasoning is different from the other five contracts, and stronger. Those are versioned, so a mismatch can be *detected* and refused. This one cannot: the name is written into a customer's database on the day they deploy, and there is no version stamp on a column. Every database provisioned by every release that ever shipped carries whatever it derived, and no code in this repository can reach in and rename them all. 0.13 is the worked example. `generateForeignKeyName` learned to singularize properly — `categorie_id` → `category_id`, `addres_id` → `address_id` — which is unambiguously the better derivation, and it broke every aged database that had an irregular plural. Boot-ensure migrated the column, so the data survived; the project's checked-in `schema.generated.ts` did not, and the boot died on a column that existed. Three commits, a new seam test, and a permanent entry in the upgrade notes, in exchange for a nicer-looking column name nobody had asked about. **If a derivation is genuinely wrong**, it changes for collections created *afterwards*, behind a naming strategy recorded in the project — never retroactively, and never as a side effect of improving the function underneath. **The one legitimate override** is a change that makes the code agree with a name the database *already has*. The worked example is identifier truncation: Postgres silently cuts an identifier to 63 bytes, so a longer derived constraint name was never the name in the catalogue — the derivation was describing an object that did not exist under that spelling, and boot-ensure re-issued `ADD CONSTRAINT` on every single boot because its comparison could never match. Truncating at construction changes what this repo *derives* and changes nothing about what any deployed database *contains*. That is the test to apply: not "is the new name better", but "does any existing database have to change". The one thing that is always safe is to *recognise* an old name in order to migrate it: `legacyForeignKeyName` exists to be detected, never to be generated, and the baseline pins those detections too. Dropping one silently un-migrates every database still carrying that spelling. **The gate.** `tooling/scripts/derived-names.mts` runs a naming-stress fixture — irregular plurals, an `ss` ending, an acronym, a junction off a plural slug, explicit overrides, a slug long enough to truncate — through both producers of schema DDL, and renders every identifier either one names: ```bash pnpm check:derived-names ``` A changed or removed line fails as a contract break, with the old and new spelling side by side. A purely additive change also fails, but with "regenerate" — so the baseline cannot drift underneath anyone. It also pins that `rebase db push` and the managed runtime's boot-ensure derive the *same* names, which is a second contract hiding inside the first: they compile the same collections through different code, and a project pushed once and booted later must not end up with two schemas. ### What is *not* frozen Said plainly, so nobody infers a promise that was never made: - The authored TypeScript API — collection config, `initializeRebaseBackend` options, admin props, SDK method names. Breaking changes land in minors and are announced in the changelog. - `@rebasepro/studio`, `@rebasepro/mcp`, `@rebasepro/inference`, `@rebasepro/plugin-*` — these move fastest and have the fewest consumers. - Anything under a package's `src/` that is not re-exported from its barrel. `packages/client/src/index.ts` carries a note explaining that its export list is curated precisely so an internal export cannot become public by accident. - The database schema of *your* collections. That is yours; Rebase only owns the `rebase` and `auth` schemas. ### The gates that hold these None of the above is a convention — each has a test that fails when it breaks: | Gate | What it pins | |---|---| | `pnpm verify:corpus` | every bundle shape ever shipped, booted on today's runtime. Fixtures in `fixtures/bundles/` are **hand-authored and frozen** — a fixture the builder regenerates moves whenever the builder moves | | `pnpm verify:selfhost` | a real bundle built, folded, booted and fetched as a browser would | | `upgrade-e2e.test.ts` | old database schemas (`schema-snapshots/`) met by the current runtime | | `e2e/tests/cli-init-e2e.ts` | a scaffolded project installed from **real tarballs**, not workspace links | | `e2e/tests/client-sdk-e2e.ts` | the end-user path: register → sign in → RLS-scoped reads → refresh → storage → realtime | | `pnpm check:derived-names` | every column, constraint, junction, enum and policy name the framework derives — and that boot and `db push` derive them identically | | `pnpm rls:check` | the generated schema's policies | | `pnpm check:api-surface` | every export, and its members, of the five packages the image supplies — `@rebasepro/server`, `types`, `client`, `common`, `utils` — plus the `@rebasepro/server/functions` entry point, against the six sections of `contracts/server.api.txt`. These are the packages `infra/docker/entrypoint.mjs` symlinks over a deployed bundle's own copies, so removing an export from one is not a compile error for anyone — it is a boot failure across the fleet, during a rollout nobody asked for | | `pnpm test:gates` | the two gates above, over fixtures. `check:api-surface` spent its whole life unable to see a member disappear from `const rebase` | | `node tooling/scripts/check-release-bump.mjs` | that the bump level a release ships under matches what the release did to the baselines above — run by `publish.yml` before the changelog is stamped | | saas CI | the control plane built against this repo's `main`, on its own pushes and nightly | **Record a bundle fixture and a schema snapshot once per release.** The value of both corpora is entirely in how far back the oldest one goes, and neither can be backfilled after the fact. ### Changing a contract 1. Decide which of the six it is. Most changes are none of them — but "none of the six" does not mean "uncontroversial". Removing or renaming an export of `@rebasepro/server`, or a member of one, is none of the six and is the single most dangerous change in the repository, because the code it breaks is already built and will not be recompiled. `pnpm check:api-surface` is what holds that line; whether it becomes a seventh numbered contract is an open decision (`docs/audits/81-compat-policy.md`). 2. Add a fixture or snapshot for the **old** shape first, and watch it pass. 3. Make the change and bump the constant. 4. Confirm the old fixture still passes, or that it now fails *with the message a user would need*. Both are valid outcomes; silence is not. 5. For contract 3, plan the rebuild of every deployed bundle before merging. 6. Contract 6 is the exception to steps 3 and 4: there is no constant to bump and no version to refuse on, because a column carries no version stamp. The step that replaces them is deciding not to make the change — see the section above for what the alternative looks like. ### Related - [Upgrading](/docs/upgrading/) — what actually broke, release by release - [Changelog](/docs/changelog/) — every change, including the ones that broke nothing - [Runtime & Bundles](/docs/architecture/runtime-and-bundles/) — contract 3 — the bundle format a deployed project is already built against ## Changelog ## Changelog ### [Unreleased] ### [0.19.1] - 2026-09-07 #### Fixed - **A project that installed its own zod booted, reported healthy, and loaded none of its functions.** The whole error was [functions] Failed to load ops.js: {"code":"invalid_type","expected":"nonoptional","path":["GEMINI_MODEL"]} naming fields the project sets a `.default()` for, and never mentioning zod. `loadEnv({ extend })` combined the two schemas with `rebaseEnvSchema.merge()`, which reaches into the other schema's internals and therefore only holds when both were built by the same copy of zod. This package inlined the zod it built against while re-exporting `z`, so an app's `import { z } from "zod"` was a different set of classes — and, under our own `^4.4.3` range, a different version, since we build against 4.4.3 and apps install 4.5.x. Merging across the two dropped every `ZodDefault` wrapper, so each defaulted field came back required. Fixed at both levels. zod is externalised, so an app gets one instance whether it imports `z` from `zod` or from `@rebasepro/server`; and `loadEnv` now parses the base schema and the extension each with its own `.parse()` and merges the results, so it no longer depends on shared class identity at all. The production-refinement pass still runs over the merged object, so a `.default()` in an extension is still checked for a loopback host. The guard added for this in 0.18 never fired once. It probed `extend instanceof z.ZodType`, on the stated theory that `instanceof` is false across two copies — true of zod 3, and false of zod 4, which installs a structural `Symbol.hasInstance`. Its test passed because the fixture built its "foreign" schema from `zod/v3`, which is a different implementation rather than a second copy of the one that ships. That test now pins zod 4.5.4 as a separate devDependency and asserts the pairing works; it fails against the old code. A build gate refuses any package that inlines a library it re-exports. Five defects an end-to-end pass over a freshly installed project turned up. - **`rebase db push` could not finish against a database that had never booted.** It died at "Step 3/3: Applying RLS policies" with `relation "rebase.users" does not exist` — which is the documented bring-your-own-Postgres first run: set `DATABASE_URL`, then push. The step before it, `ensureAuthTables`, exists precisely to create that table and had failed one line earlier with a warning nobody would connect to the error: it loads the project's collections to find the one flagged `auth`, and could not resolve `./authors` onto `authors.ts`. Third copy of the same rule. Two spawns of the driver CLI were fixed to take tsx; this one was missed, because a guard that asks "does a tsx lookup appear earlier in this file" is satisfied by an unrelated one. There is now a single `spawnDriverCli`, and the gate counts spawn sites rather than inspecting them — one copy is the number that makes the question unnecessary. - **`.env.example` named a port the project does not publish, with its `DATABASE_URL` uncommented.** `rebase init` derives a per-project database port and rewrites `docker-compose.yml` and `.env` with it. It left `.env.example` at the template's `localhost:5432` — and left its `DATABASE_URL` *set*, which contradicts the rule the rest of the scaffold states in three places: unset means the managed database. Copying `.env.example` to `.env` is a near-universal habit, and on a machine already running Postgres on 5432 — most developer machines — the result is not a connection error. It is a successful connection to a different database, which boot then provisions. Found exactly that way: a test harness read the example file and spent three runs talking to the wrong server. `init` now comments the line out and writes this project's port into it, and into the `sslmode` illustration further down, so the file names one port. - **A dev database that fails to start now says why.** The daemon is detached, so its output goes to `pglite.log` rather than to the terminal, and both failure messages named that path instead of carrying its contents. That is a fair trade on a laptop and none at all in CI, where the runner discards the workspace: a canary run failed with "The development database failed to start (exit 1) — see pglite.log", and by the time anyone read it the file was gone. Both messages carry the tail of the log now, bounded, and say so distinctly when the daemon wrote nothing or the log cannot be read. - **`GET /api/data/:slug/aggregate` answered 501 on every deployment.** `FetchService.aggregate` has existed for as long as the route has, and the route reads it off `restFetchService` — an adapter object that listed `fetchCollectionForRest` and `fetchOneForRest` and nothing else. Both getters, so both the service-key path and the authenticated one. The 501's own comment says it describes "every non-Postgres driver", which is what its author believed; the endpoint index documents `count()`, `sum()`, `avg()`, `min()`, `max()` and `groupBy`, none of which had ever run over HTTP. Forwarded from both. The authenticated one goes through the same read-only `withTransaction` as the other two, which is what sets the RLS GUCs and drops to the restricted role — not an implementation detail here, because an aggregate is an efficient way to learn about rows you cannot select. Verified: an admin counts 2 users and a second account counts 1, matching what each can list, and an unauthenticated aggregate is 401. - **`rebase eject` produced a project that compiled and could not boot.** The template's `env.ts` took `z` from `"zod"` rather than from the runtime, under a comment explaining that the version an ejected project pinned did not export it and ending *"Switch both lines back when the version bumps."* 0.18.0 bumped and published `z`; the workaround stayed, and became the thing it was written to avoid. `loadEnv({ extend })` recognises a `.default()` by class identity, so a schema built with a second copy of zod is rejected field by field: every ejected project on 0.18.0, 0.18.1 and 0.19.0 died at boot on a raw `ZodError` naming `SMTP_PORT`, `SMTP_SECURE` and `APP_NAME` — three variables with defaults, none of which the operator had set. `check:templates` now refuses a template that imports `zod` directly *while the runtime exports `z`*, which is the narrow condition that turns this workaround into a defect rather than a necessity. - **A path naming a relation the collection does not declare was a 500.** `GET /api/data/authors/1/posts` answered `INTERNAL_ERROR` — telling the caller to retry or report an outage over a request that will never succeed, and burying a message that already said exactly what was wrong. It is a `404 UNKNOWN_RELATION` now, like an unknown collection: the URL names nothing. Three throw sites, one in `nested-path.ts` and two in `RelationService`. - **`rebase schema stale` checked nothing on the stock scaffold.** It loads the project's collections in process, and the two places that spawn the driver CLI each chose the interpreter from the driver's own file extension — tsx for a `.ts` entry, `node` otherwise. Correct while the driver shipped `src/`; wrong the moment it shipped `dist/cli.js`, because the files being loaded are the *project's* TypeScript. Node cannot resolve `./authors` onto `authors.ts`, so the command reported "⏭ Not checked" and exited 0 — on a scaffold whose own collections import each other. Both spawns take tsx whenever it is installed, and a gate refuses an interpreter chosen from the artifact's extension. - **`defineCron` required a `name` the runtime does not.** The loader has always read `definition.name ?? loaded.id`, where the id is the file's own name — so a cron without one registered happily under `rebase dev` and then failed `rebase build` with "Property 'name' is missing". `name` is optional, and documented as defaulting to the filename. ### [0.19.0] - 2026-09-07 #### Changed - **The scaffold's imports lose the `.js` extension.** A new project's collections imported each other as `./authors.js` — an output extension in a `.ts` file, on every relative import — and nothing in the scaffold asked for it. Both its tsconfigs are `moduleResolution: "bundler"`, where extensionless is correct; `normalizeEsmSpecifiers`' own docblock calls extensionless "the extremely common style". The template was the thing out of step. What the extension was really for is one step further on. TypeScript emits module specifiers verbatim, by long-stated policy, and Node's ESM loader resolves no extensions — so `./authors` compiles and then throws `ERR_MODULE_NOT_FOUND` under `node dist/…`. `rebase build` has always closed that gap for the bundle it writes. Nothing closed it for the plain `tsc` output that `rebase eject` runs, or that a self-hoster runs after `pnpm build`, so the templates paid the tax in source instead. **`rebase normalize-imports `** is that step, made available to the builds that need it: it rewrites each relative specifier in emitted JavaScript to the file it actually resolves to. Only relative specifiers, only when the target exists, idempotent, and skipping comments — the stock `config/resources.ts` documents an import inside a docblock. Both scaffold build scripts run it after `tsc`, so an ejected project and a hand-built one get what `rebase build` already produced. The scaffold's `backend/tsconfig.json` also gains the `rootDir: ".."` its layout has always implied — the program includes `../config/**`, so the emit already lands at `dist/backend/src/…`, which is what `scripts.start` runs. TypeScript 6 refuses to infer that (TS5011). #### Fixed - **`check:eject` never ran what it checked.** It typechecked the ejected project with `noEmit`, which is blind to the only failure that path has: Node resolving `./authors` to nothing at start-up. It now compiles each package with the project's own tsconfig, runs the same normalization the build runs, and asserts every relative specifier in the emitted output names a file that exists. Mutation-tested by skipping the normalization: 25 dangling imports across both flavours, where the old gate stayed green. ### [0.18.1] - 2026-09-07 #### Fixed - **The published driver shipped no CLI, so a new project's first data read returned 500.** `rebase init` → `pnpm install` → `pnpm run dev` → register → `GET /api/data/posts` answered `500 Table not found for collection 'posts'` on 0.18.0, and the boot log said `Applied 30 additive schema change(s)` — the table was there. The driver looks its columns up in `backend/src/schema.generated.ts`, which the scaffold ships as a stub, and `rebase dev` regenerates that file at startup for exactly this reason. It could not: the regeneration shells into the driver's own CLI, and the published tarball did not contain one. `@rebasepro/server-postgres` runs four things as child processes — the Drizzle generator, the DDL planner, the introspector, the schema doctor — located by path relative to its CLI. Those paths named `.ts` files, and resolved only because `files` listed `src`. `dff34e8688` removed `src` from sixteen tarballs to stop shipping the same bytes twice; it was right about the bytes, and it deleted this package's entire command surface, because no `dist/cli.js` had ever been built. Every `rebase db …`, `rebase schema …` and `rebase introspect` was broken for every npm consumer of 0.18.0. The parent CLI reports a driver CLI it cannot find as *"Dependencies are not installed"*, so the message blamed the user's install — at a project that had just installed cleanly. The build emits `dist/cli.js` and the four child scripts. They still run under `tsx`, which is not incidental: they load the *project's* collection files, and those import each other as `./authors.js`, the extension TypeScript tells you to write. Node resolves it literally and fails on the user's code rather than on ours. The two generators are built from `src/schema/bin/*`, because their own `import.meta.url.endsWith(process.argv[1])` guard is false once the module is a shared chunk rather than the process entry — the built script otherwise exits 0 having generated nothing, and the caller reports success over an unwritten schema. `tooling/scripts/test/driver-cli-surface.test.mjs` reads the spawn sites out of `cli.ts` and requires each one in `dist`, so a fifth spawned script added without a build entry fails on the commit that adds it. Verified end to end against a project scaffolded by the published 0.18.0 CLI: `GET /api/data/posts` returns 200. The release pipeline's new smoke step is what caught this, on its first ever run. 0.17.3 failed the same way and shipped, because nothing installed what had been published. - **The driver's build banner made the runtime image's copy unparseable.** Adding entries reshuffled the chunks, and the banner — prepended to *every* output chunk — declared `import process from "process"` in one that also contained chalk's `import process from "node:process"`. Two declarations in one module: `SyntaxError: Identifier 'process' has already been declared`, and the driver would not load. It appeared only in the image, from source identical to the local build, because which modules share a chunk is a decision the bundler remakes each time — the local dist parsed fine. `process` is a Node global and the import bought nothing; it is gone. `check:runtime-image:boots` caught it, and a new assertion in `driver-cli-surface.test.mjs` now refuses a banner that binds any plain identifier, so the next one is caught on the host without building an image. ### [0.18.0] - 2026-09-07 #### Breaking - **`defineCollection` is one signature, and its errors land on the field.** It was three overloads — Postgres, Firestore, MongoDB — and when a call failed TypeScript emitted exactly one diagnostic, on `defineCollection(`, listing each overload's first failure. So a Postgres collection's typo reported `FirebaseCollectionConfig` and `MongoDBCollectionConfig` at an author who had named neither engine, and a collection with two mistakes reported one of them, revealing the second only on the next compile. There is now one signature discriminated on `engine` (Postgres when absent). Errors are reported where they are, all of them at once: a misspelled key on the key, a bad `admin.display.title` on the path, a link field that belongs to another relation kind on that field. Two type exports move with it: `UnknownPropertyKey` is now **`NoSuchKey`**, which carries a `didYouMean` member naming the shape whose keys were valid, and `PropertyTypeNotOnThisEngine` is new — it is what a `relation` on a Firestore collection resolves to. Both are internal machinery of the builder; nothing should be importing them, and neither has a runtime representation, so no contract bump. - **`defineCollection` no longer stops checking the `admin` block when a property is wrong.** The builder declared `const P extends PostgresProperties`, and a constraint TypeScript cannot satisfy is one it silently falls back from: a single property with a bad `defaultValue` made `P` collapse to `PostgresProperties`, the inferred entity shape collapse to `Record`, and `display.title`, `listProperties`, `propertiesOrder` and `previewProperties` all widen to `string`. Every check the builder exists to provide switched itself off, quietly, at the first mistake. `P` is unconstrained now; exactness and the engine gate are expressed inside `StrictProperties`, where a violation is one error on one property. - **`defineCollection` now checks a relation against the member its `kind` selects.** `Relation` has been a closed union since 0.11 — `belongsTo` owns `localKey`, `hasOne`/`hasMany` own `foreignKeyOnTarget`, `manyToMany` owns `through`, `via` owns `joinPath` — but TypeScript's excess-property check runs against a *union* as a whole, so `{ kind: "belongsTo", foreignKeyOnTarget: "author_id" }` compiled. It also meant something else: the generator reads `localKey`, defaults it to `_id`, and never looks at the column the author named, so the relation resolved to a different link than the one written. The boot validator already refused these; the type now refuses them too, which moves the report from a failed deploy to a red squiggle. Code that carried a link field belonging to another kind stops compiling — it was already failing at boot. - **A relation no longer carries its own `validation`. `required` lives on the property.** There were two places to write it and they were read by different generators: the Postgres DDL asked the *property* (so the foreign-key column came out `NOT NULL`) while the SDK type generator asked the *relation* (so the generated `Insert` type made the field optional). A `create()` that omitted the relation therefore typechecked and then failed at the database with a not-null violation — and getting both right meant writing `required` twice, identically. `RelationBase.validation` and `ResolvedRelationBase.validation` are deleted. Move it up one level, beside every other field's: ```ts author: { type: "relation", validation: { required: true }, // ← here relation: { kind: "belongsTo", target: () => authors } } ``` The boot validator refuses the old key by name rather than ignoring it: silence would have left the surviving `required` unset, quietly turning a required relation optional in the generated types and nullable in the column. How every reader now asks is `import { isRelationRequired, relationDeclaringProperty } from "@rebasepro/common"`: `isRelationRequired(collection, relation)` for the answer, `relationDeclaringProperty(collection, relation)` for the property that carries it. The runtime contract major is **not** bumped. What was removed is an optional member of a TypeScript interface — it has no runtime representation, so no already-built bundle can fail to boot on it. - **A required `belongsTo` no longer defaults to `ON DELETE CASCADE`. It is `RESTRICT`.** `validation: { required: true }` on a relation says the child cannot exist without a parent. It does not say that deleting the parent should delete the child — but that is what the generator inferred, so `onDelete`, a field nobody has to write, quietly turned every `DELETE FROM authors` into a cascade through posts, their comments, and anything hanging off those. The default is now `RESTRICT`: the delete fails and names the constraint, and `onDelete: "cascade"` is something an author asks for on purpose. Optional relations are unchanged (`SET NULL`), and a `manyToMany` junction is unchanged (`CASCADE` — the row it deletes is the link, not the target). **This is a DDL change for existing projects.** The next `db push` will plan a constraint rewrite (`DROP CONSTRAINT` / `ADD CONSTRAINT`) for every required relation that never named an `onDelete`, and after it those parent deletes start failing where they used to cascade. To keep the old behaviour, write it down: `onDelete: "cascade"` on the relation. Review the plan before applying it — `db push` prints the statements. All three generators moved together (the `CREATE TABLE` DDL, the desired state boot-ensure diffs the live database against, and the generated Drizzle schema), so the default cannot differ between them and make every boot plan the same rewrite forever. - **The Node floor is `>=22.22.0`, on every published package.** It was `>=20` (and `@rebasepro/cli@0.17.3` declared no `engines` at all), so a project on Node 20 installed and ran. The single source is `.nvmrc`, and `check:floors` holds every manifest to it — one floor, in one file, rather than a number repeated in twenty-two. Move to 22.22.0 before upgrading: `pnpm install` answers an engines mismatch with `[WARN] Unsupported engine` and carries on, so the failure arrives later, somewhere with no mention of Node in it. `check:release-bump` now refuses a release that moves an `engines` field without saying so here. - **`rebase.data` is gone at runtime, not only from the type.** `RebaseServerClient` dropped `data` so the admin-scoped plane would have exactly one name and the privilege would be visible at the call site — and the property was then left on the object as a runtime alias, which defeats the point: untyped code could still reach the privileged plane by the name that means *user-scoped* everywhere else. It is deleted at boot; `rebase.data` is `undefined`. Server-side code says `rebase.dataAsAdmin` when it means the admin plane and `context.data` when it means the caller's. Eleven places in the prose across six locales taught the old name, and the scaffold's always-on agent rule was `always use the SDK (rebase.data.)` — the one accessor the server omits, in the file an assistant reads before doing anything else. - **`@rebasepro/cli` publishes three exports, not ninety-five.** `src/index.ts` re-exported sixteen modules — `initCommand`, `dbCommand`, the whole of `commands/cloud`, `detectPackageManager`, `findProjectRoot` — and became an API because it was published, not because anyone decided it should be one. Nothing imported it: not this repository, not the control plane, which does not depend on `@rebasepro/cli` at all. Three now, each with a reason: `entry`, because `bin/rebase.js` cannot run without it, and the `manifest` and `bundle` contracts, which are deliberately shared so a control plane can validate a bundle with the code that produced it. - **Peer ranges are carets, and an open range is not a promise.** `app` and `cms` moved to React 19.2.7 when react-router 8 made it mandatory; the five packages they pull in — `ui`, `forms`, `firebase`, `plugin-insights`, `cms-types` — stayed at `>=19.0.0`, a range whose lower half cannot satisfy the app depending on them, so an installer picking 19.0.0 produced a tree that resolved cleanly and broke at render. They are `^19.2.7` now. `>=19.0.0` also claims React 20 compatibility on behalf of a component library, which is precisely the claim that turns out to be false. `@rebasepro/app`'s own `typescript` peer moves from `>=5.0.0` to `^6.0.0`: `src/vitePlugin.ts` imports the compiler API to find the callbacks block, which is a runtime import in a published package and not a build-time convenience, and the plugin is written against TypeScript 6's compiler API — `>=5` understates it in one direction and promises TypeScript 7 in the other, where that API no longer exists. - **`rebase cloud webhooks create` takes `--endpoint`.** `--url` names the control plane for every command in this family — `resolveCloudUrl` reads it straight off the raw line, ahead of the environment variable and the link file — and `webhooks create` declared a second `--url` for the customer's endpoint. The two parses are independent, so the documented example sent `https://example.com/hook` to `requireClient` as the host to authenticate against: the one command whose whole argument is somebody else's URL could not run. A test now reads every `spec:` handed to `parseCloudArgs` and refuses any key the global spec already covers, because that spec is spread over the globals and silently replaces one. - **`serializeFilter` emits strictly.** The shared leaf encoder parses liberally, because a short code arrives off the wire, and emits strictly, because a caller handing one to the serializer built the condition by hand and should use the spelling the types name. `serializeFilter({ a: ["gt", 5] })` throws rather than round-tripping `gte`. This is the rule `serializeTuple` has always followed; what changed is that `serializeLogicalCondition` no longer carries a second, drifted copy of it. #### Added - **`rebase db migrate --baseline `** records a version as applied on a database that already carries the schema — every database Rebase has booted against does, because boot provisions the tables — so the first `db migrate` no longer dies on `type "posts_status" already exists`. That error now names the version on disk and the command that records it. - **Every kind in one graph: crons, functions and queues join databases, buckets and topics.** A cron file is now also a declaration — the loader records it under the id the scheduler runs it as, with its schedule and zone — and a function is recorded from the bundler's analysis by filename. `rebase resources` lists them; a host reads a project's schedules before running it. `import { queue } from "@rebasepro/types"` joins `database`, `bucket` and `topic`: `queue("thumbnails")` is the work-list shape of background work, one handler per queue, on the same durable job queue topics ride on — and unlike `jobs.tasks`, which needed an entrypoint a managed project does not have, a declared queue is picked up by every boot path. - **Handles are the API.** `defineCollection({ dataSource: analytics })` and `storage: { storageSource: media }` take the handle a constructor returned — the same name, spelled once — and record its key. `rebase.bucket(media)` reaches a declared bucket's storage source; `rebase.sql(q, { database: analytics })` takes the handle too. The derive step records who uses what (`usedBy` on each graph entry: `collection:events`, `property:posts.cover`, `function:report`), which is the map a console needs for "what breaks if I remove this" and a future split needs to be derived from. - **`rebase dev` serves every declared database.** `database("analytics")` in `config/resources.ts` is the request: the development daemon starts a second PGlite instance for it on demand — without a restart, so `rebase studio` in another terminal keeps its connection — and exports `DATABASE_URL__ANALYTICS`. A variable you set by hand is never overridden. `--reset` removes them all. - **A declared object store nothing binds is a local directory in development.** `bucket("media", { engine: "s3" })` with no `S3_BUCKET__MEDIA` used to answer 501 on the first upload, which made MinIO a prerequisite for uploading one file. In a development process it now stands in as `uploads__media`, boot says which engine it stands in for, and `rebase status` shows it in yellow beside the tick. Production never does this: an unbound bucket stays unbound. - **Kinds own their binding.** `@rebasepro/server` registers a resolver per kind (`registerResourceResolver`), and boot, `rebase status` and the gate that holds a kind's `envBases` to what the runtime reads all consult the registry rather than a switch. A kind this runtime has no resolver for is refused at boot by name instead of dropped. Adding a `cache` is a spec, a resolver and a driver. - **Cron `timezone`.** `defineCron({ schedule: "0 3 * * *", timezone: "Europe/Madrid" })` reads the schedule in that zone; without it the schedule is the host's own zone, which is UTC in nearly every container and yours on a laptop. An unknown zone is refused when the job loads. - **`rebase cloud resources`** — what the code declares against what the platform holds, per database and bucket — and **`rebase cloud resources prune database `**, the one removal a deploy is not allowed to make. What used to be `rebase cloud resources` (CPU, memory, replicas, cost) is **`rebase cloud compute`**: two commands named "resources" that showed different things was a support ticket. - **The bundle-manifest contract.** `tooling/contracts/bundle-manifest.json` is written from the CLI's manifest composer with fixed inputs, and the control plane's tests read that same file to prove intake accepts it and the deploy finds every resource in it. Both suites were green for two weeks while every bucket a current CLI declared reached the platform as nothing; this is the test across that seam. - **`rebase db branch prune` — branching shipped with no cleanup story at all.** No TTL, no prune, no `delete --all`, and every branch is a full-size copy: `CREATE DATABASE ... TEMPLATE` duplicates the files on disk, so five branches of a 100 GB database cost 500 GB. The only way to reclaim any of it was to remember every name you had ever typed. ```bash rebase db branch prune # orphans only — always safe rebase db branch prune --older-than 2w # and anything past two weeks ``` It also finds the two ways branches drift from their metadata, which drift in opposite directions: an entry whose database was dropped outside Rebase, which `list` would keep reporting forever while `switch` and `info` fail against a database nothing can find; and a branch database whose entry was never written, because `create` makes the database first and records it second. Nothing expires unless asked, ages are floored so a cutoff never catches something younger than it says, and `--older-than 7h` is refused rather than read as seven days. Atlas's `_dev_diff` scratch databases are reported alongside but removed only with `--include-dev-diff`. - **`rebase db branch` reported branches the managed database had not made.** PGlite serves exactly one database, so `CREATE DATABASE ... TEMPLATE` wrote a `pg_database` catalog entry and copied nothing. Every step then agreed: `create` answered `✓ Branch "feature_x" created successfully.`, and `list` showed it at 7.1 MB because the catalog entry makes its `JOIN pg_database` succeed and `pg_database_size` answer for the one real database. Connecting to `rb_feature_x` reported `current_database()` = `postgres`, and a table created "in the branch" appeared in the parent — so every write made in the belief that it was sandboxed landed in the developer's own database. Measured on a fresh `rebase init` scaffold, which is the default path. The whole `branch` domain is now refused there, before the database is started, naming `rebase dev --docker` and `DATABASE_URL` as the two things that work. - **`rebase db pull` handed back a database the application could not read.** `pg_dump --no-privileges` strips every GRANT, so the copy arrived with the source's RLS policies and its `FORCE ROW LEVEL SECURITY` intact and nothing behind them. Measured on a 30-table project: 68 policies and 60 grants in, 68 policies and **0** grants out, and the first read as the role Rebase serves every request through failing with `permission denied for table leads` — after a green `✓ Local database now holds a copy of …`. Anyone who pulled and then opened `psql`, ran `rls-check`, or pointed a test suite at the copy hit a wall with no hint of the cause. The pull now re-provisions the app role through the same `ensureAppRole` boot and `db push` call, so internal tables stay revoked as well. The backup path already guarded this hazard; the newer command people are told to use did not. - **`rebase db pull --database-url` was accepted and ignored.** The flag never reached the resolver, so the pull went ahead against the `.env` database anyway: `rebase db pull --from prod --database-url scratch --yes` destroyed the working database while naming a different one. It is now refused rather than honoured — the target is the local development database by construction, since a command that can copy in both directions eventually copies the wrong way, and the wrong way here is a laptop over production. The refusal points at `--from`. - **`rebase db branch switch` — branching stopped one step short of being a feature.** `create` copied a 12 MB database in 1.2s and then printed `Database: rb_feature_auth` and nothing else: there was no `switch`, no `--branch` on `rebase dev`, no `REBASE_BRANCH`, and not even a connection string to paste. The only way to work on a branch was to hand-edit `DATABASE_URL`, while the documentation said the CLI updated your local development configuration — it did not, and the `.env` was byte-identical afterwards. ```bash rebase db branch switch feature_auth # every later command follows rebase db branch switch # which branch am I on? rebase db branch switch --off # back to the main database ``` The branch is recorded in `.rebase/branch.json` as a name, never a connection string, so credentials stay in `.env` alone. It outranks `DATABASE_URL` in `.env` — any lower and switching would do nothing on a project that sets one — and loses to `--database-url` and a `DATABASE_URL` in the shell, so a flag on the command line still beats a switch made yesterday. Deleting the branch you are on returns the checkout to the main database instead of leaving it aimed at a database that no longer exists. - **`policy.registered()`.** `POST /auth/anonymous` mints a real user row with a real uid, so a guest satisfies `policy.authenticated()` — which is the point of the feature, and also means "signed in" was true for anyone who pressed *Continue as guest*. `registered()` is `authenticated()` plus "not a guest"; reach for it wherever a rule is about somebody who could be held responsible for something. The flag travels in the access token and reaches the database as `rebase.is_anonymous()`, so a policy can ask without a lookup. - **`rls-check` gained `policy-authenticated-tautology`.** Correcting an anonymous tautology by excluding the sentinel is where people stop, and what remains — "every account may read every row" — is the shape that made a customer's `users` table readable by anyone who could sign up. It is a separate id from the anonymous finding on purpose: different severity, different fix, and `--skip` should be able to silence one without the other. - **`get(id)` reads a row that is expected to exist.** `findById` returns `M | undefined`, and it was the only way to read one row, so every caller had to prove the row was there before touching a field — or reach for the `!` that everyone reaches for instead. A row fetched by an id that came from a link, a route parameter or another row is expected to exist: its absence is the error case, not a value to thread through the rest of the function. `get(id)` returns the row and throws `RebaseApiError` `NOT_FOUND` (404) when it is not there; `findById` stays as the explicit maybe-form. Added in all three implementations of the collection surface — the browser SDK, the offline wrapper, and the server-side accessor behind `rebase.dataAsAdmin` and `context.data` — so the shape of "it is not there" does not depend on who is asking. - **Anonymous sessions and MFA in the SDK.** `signInAnonymously()` mints a real account — an id, roles, a session, and row-level security scoping its rows — and `linkAnonymous(email, password)` promotes it in place, keeping everything written as a guest. The MFA routes have been on the server since 0.16 with no client method to reach them; enrolment, verification and the challenge step are on `auth` now. Both pair with `policy.registered()` below: a guest satisfies `policy.authenticated()`, which is the point of the feature and also the reason a rule about somebody who can be held responsible needs its own predicate. - **`channel.onError`, so a refused broadcast is not a delivered one.** A channel frame is fire-and-forget, so a refusal about one matched no pending request, no subscription and no channel: `CHANNEL_FORBIDDEN`, `RATE_LIMITED`, `CHANNEL_HISTORY_WRITE_FAILED` and `CHANNEL_BUS_PAYLOAD_TOO_LARGE` all fell through into a console warning while `await channel.broadcast(…)` resolved as though the message had been sent. The missing piece was an address: the server names the channel on a refusal about one, and the client routes it to the channel's own handler. - **`rebase doctor` checks the things that break a first run.** It compared three descriptions of a schema, which is the right check for a project that works and the wrong one for a project that has never worked — everything that stops a first run happens before a table can be compared. Seven checks. Five need no database and run first: the running Node against the range the CLI declares, two lockfiles in one project, two collections claiming one slug, a `JWT_SECRET` production will refuse to boot on, and the same `@rebasepro/*` package pinned to different versions across a project's manifests. Two join the database phase: pgvector missing where a `{ type: "vector" }` property needs it, and a schema stamp that says this database was provisioned from different collections. An environment error fails the command — a doctor that exits 0 over one is a doctor nobody can gate on — and `.env` values are never echoed, because doctor's output goes wherever a terminal goes. - **Studio's Logs Explorer can show you an error.** Its ring buffer was filled by one request middleware and nothing else, so the panel rendered a wall of `GET /api/data/posts 200 4ms` — every entry at `info`, whatever the request answered — while every error, warning and boot diagnosis the server wrote went to a terminal the person looking at the panel does not have. The request entry now carries the status as its level, the collection, and the code and message the error handler answered with; and `logger` gained a sink, teed into the ring at warn and above with `source` read off the message's own prefix. Sinks receive the message after redaction, never before. - **Studio speaks the seven locales the panel ships.** Every tool name, group heading and description was an English literal in a panel that translates 900 other keys, so a German reader got a German drawer with an English half. 43 `studio_*` keys cover the eleven tools, the five groups and the refused and empty states. **`devViews` adds a tool** rather than being discarded: `RebaseStudioConfig` declared it and `RebaseStudio` destructured only `tools`, then built its own list and registered that — so `` typechecked, registered nothing, and left no trace explaining why. - **A cron that will never fire is visible, and a timeout stops the work.** A schedule the scheduler refused — six fields, copied out of a tool that supports seconds, is the common one — was logged once at boot and then gone, and the job is absent from `listJobs()`, so "my cron is missing" and "my cron will never fire" were the same picture. `GET /api/admin/cron` now carries a `rejected` array with the id, the schedule and the reason, counted into `skipped`. A job may also declare a timeout, after which the run is abandoned and recorded as such rather than holding its slot forever. - **Boot says where each backend option goes, and warns when it goes nowhere.** Every backend page shows `initializeRebaseBackend({ … })`, and a managed deployment has no such call — the runtime makes it, reading exactly four names out of `config/index.ts`. Anything else was dropped in silence: `export const storagePolicies` compiled, deployed, and did nothing. Boot now warns when the config index exports an option the managed runtime cannot take, and names where that option does belong. - **A successful `rebase cloud deploy` prints the URL it deployed to.** The one thing the command was for was left to a second command. Resolved exactly as `status` resolves it, so the two cannot disagree about the host, and best-effort throughout: a control plane that does not report a base domain prints no URL rather than a fabricated one. - **Fourteen `rebase cloud` groups gained the `--help` page the docs promised them.** `login`, `logout`, `whoami`, `link`, `unlink`, `use`, `open`, `rollback`, `cancel`, `start`, `stop`, `restart`, `metrics` and `resources` all fell through to the index — a list of groups and not one flag — so `login --password`, `link`'s positional URL, the `-y` that `stop` requires and every resource dial had no discoverable spelling anywhere in the CLI. The pages carry the units and ranges the dials take (`--db-mode shared|dedicated`, autoscale 1–16, CPU target 10–95) and one sentence that keeps them honest: the prices are the control plane's quote, not a number written here. Two tests hold it, and `resources` joins the index it was missing from. - **A headless first run ends in a box.** `printSummary` waited for a frontend URL before drawing anything, so a headless project — the shape whose whole first run is `rebase dev` — got no summary at all, and the managed database's port is derived from the project path, so nothing on disk said how to reach it. The box is sized to its contents, names Swagger only when the backend says it mounted it, and says why when it did not. **`rebase db url`** is the same answer outside the banner: the resolved connection string on stdout and nothing else, so `psql "$(rebase db url)"` works. - **The version handshake the compatibility matrix already described.** Three documented signals had a receiver and no sender. `x-rebase-schema` has been listed as "sent by the SDK" since the matrix was written and no client ever put it on a request; the transport takes `schemaVersion` and sends it now. `runtime.version` was published only on the admin-gated `/contract`, so the two callers that need it — a CLI deciding whether it is too old, an SDK reporting what it built against — could not ask; `/api/meta/schema-version`, which is unauthenticated by design, carries it. And `rebase cloud` requests now send `User-Agent: rebase-cli/`, from a single `cliVersion()` replacing two divergent copies that read the manifest by counting directories. - **`rebase skills install` has a target for every pointer file the scaffold writes.** `rebase init` writes five instruction pointers and `skills.ts` knew four agents, neither of which was Codex or Copilot — so a Codex user opened a file their own scaffold had written, followed it to `rebase skills install`, and got a prompt that did not list them. `codex`, `kiro` and `copilot` join the list. **The MCP server can show the SQL before running it**: an agent asked to change a schema had no way to say what the change would do, since `rebase db push` printed the planned SQL only while refusing. `rebase_schema_plan` answers that question on its own, and hands a destructive plan to a human rather than approving it. - **`rebase cloud login` warns where the password just went.** A password written as an argument is in the shell's history file and in the process table for as long as the command runs, and neither is something this CLI can redact afterwards. The flag stays — there is no machine token, so a non-interactive login genuinely needs the password from somewhere — but it warns before the request, not after, and names `REBASE_CLOUD_EMAIL` / `REBASE_CLOUD_PASSWORD` as the route that does not touch the command line. #### Changed - **A bucket can be marked as the default, and the promotion that stands in for that says so.** `bucket("media", { default: true })` names the bucket that serves uploads which name no `storageSource`. A project of named buckets with none marked still gets the first one declared promoted at boot — refusing would have stopped every project written before the option existed at the next runtime rollout — but the warning now names the one line that ends the guessing, and why it matters: the local bucket development stands in with is dropped in production and the promotion is not, so the answer differs either side of a deploy until one is marked. - **`rebase cloud deploy --force` is now `--eject`, with no alias.** `--force` means four different things across this CLI — overwrite a file (`schema introspect`, `apps`, `eject`), disconnect other Postgres sessions (`db branch`), set a build-time key anyway (`cloud env set`) — and three of them are recoverable in a minute. The fourth moved a live project off the managed runtime onto a container image it now owns, which is the least reversible thing the CLI can be asked to do, under the same word as "overwrite this file". It has its own name now, and `--force` on `cloud deploy` is an unknown option rather than an alias: a script carrying the old spelling stops instead of ejecting a project on a word it no longer means. What the flag does is unchanged — it is still the only way to build a container image for a managed project, for the bare form and for `--source` alike. - **A collection's `callbacks` runs on the server, and only there. The panel's own callbacks are `admin.browserCallbacks`.** The Vite plugin has always stripped that block's bodies out of the admin bundle, so a `beforeSave` calling a vendor with a key from `process.env` does not ship to every visitor — but two keys were exempt, on the grounds that the panel ran them. Both exemptions were wrong, in different directions. `afterSave` had no client-side call site at all: the body shipped and never ran. `afterRead` did run, unconditionally, on top of the server having already applied it — so every server-backed collection transformed its rows twice, and anything not idempotent compounded. Meanwhile a collection on a `direct` transport, where the panel talks to the store itself and no server sees the operation, got read callbacks while its write callbacks were stripped out from under it, silently. The strip is total now, with no allowlist to fall out of date, and callbacks the panel runs live under `admin.browserCallbacks`: a separate key, so which runtime a callback belongs to is a fact about the collection file rather than about a `dataSources` declaration in another one — which is the thing a build-time transform cannot see. Move a browser-side `afterRead` into the new block; a server-side one already worked and needs no change. - **`saveEntityWithCallbacks` and `deleteEntityWithCallbacks` run callbacks.** Both have been named for callbacks they never ran, since they were written, and `deleteEntityWithCallbacks` went as far as accepting a `callbacks` prop and dropping it on the floor. They run the collection's `admin.browserCallbacks` around the write: `beforeSave` can block a save the way the server's does, `beforeDelete` can block a delete, and `afterSave` receives the row *as saved* rather than the values submitted. That prop is gone, as is the `callbacks` prop on `DeleteEntityDialog` that fed it — both were being passed the server's block, in the browser, where it does nothing. - **The ERD has one layout, and it reads top to bottom.** The LR/TB toggle offered a choice the canvas cannot honour: the visualizer's pane is tall and narrow, so the left-to-right default pushed the graph off both sides on open. The machinery went with the buttons rather than being left behind. - **Saving a collection file is the first edit, and now that is what works.** Adding a property while `rebase dev` was running produced the worst shape a failure can have: everything looked right. tsx restarted the backend, boot's additive ensure added the column, the panel showed the field — and the first save of a row carrying it answered `400 VALIDATION_UNKNOWN_FIELDS: 'posts' has no column 'subtitle'`, because the driver looks its columns up in `backend/src/schema.generated.ts` and nothing had regenerated it. Four voices told the reader four different things to do about it, and the one on screen named `rebase db push` — which cannot run at all on the database a scaffolded project uses, since Atlas diffs against a second empty database and PGlite serves exactly one. The watcher does it now: the same idempotent `ensureGeneratedSchema` call `dev` already makes at startup runs on a change under `config/collections/`, and the box says what happened rather than what to type. - **`--no-db` starts no database, including the managed one.** The flag gated the branch that starts a docker-compose container and nothing else; the managed PGlite starts on the other branch, in `prepareDatabaseEnv`, which was called unconditionally. So on a scaffolded project — the one project shape where the managed database is what you get — `rebase dev --no-db` wrote `.rebase/pglite/`, started a daemon and served against it. Two reads of one flag are two things that have to agree; there is one now. With no database prepared the backend is left to fail on `DATABASE_URL: is required`, which is the failure the flag exists to produce. The README and the Quickstart said `--no-db` meant "bring your own", which described neither the old behaviour nor the new one: bringing your own is setting `DATABASE_URL`. - **`rebase dev --docker` starts the container and reaches it.** The flag changed a banner line and nothing else: the backend was spawned with no `DATABASE_URL` at all, and the preflight that starts the container decided "local, and not running" from a DSN it could not find in `.env`, so it returned before it ever looked at `docker-compose.yml`. The URL is derived now — `composeDatabaseUrl` reads the compose `db` service and interpolates `${DATABASE_PASSWORD:-changeme}` the same way compose does, producing byte for byte the string `rebase init` already writes into `.env` as the commented-out `DATABASE_URL`. A compose file that cannot yield one is a `--docker` that cannot be honoured, and it says so rather than falling back to `localhost:5432` and pointing the project at whatever Postgres happens to be on the default port. - **Sixteen packages stop shipping their sources twice.** `files: ["dist", "src"]` was justified by making stack traces and go-to-definition work for anyone who installs the package. They already did: every `.map` under `dist` carries full `sourcesContent`. So `src` was a second copy of the same bytes — `server-postgres` 7.30 → 5.39 MB unpacked, `types` 1.35 → 0.84, `client` 1.28 → 0.87, `common` 1.09 → 0.76. The `"source": "src/index.ts"` field goes with it, because it named a file the tarball no longer contains — from all twenty that carried it, including the four (`cli`, `server`, `codegen`, `rls-check`) whose `files` never listed `src` in the first place, so the pointer had been dangling for as long as it had existed. `check:package-contents` refuses `src/` in a tarball, so this cannot come back one manifest at a time. - **Every package is ESM-only, and now says so.** All 21 are `"type": "module"` with no CommonJS build, which the README never stated — `require("@rebasepro/client")` fails with `ERR_REQUIRE_ESM`, a message naming the loader rather than the decision, and no configuration recovers it. Stated in the README, and `@rebasepro/mcp` and `@rebasepro/rls-check` gain the `exports` map their eighteen siblings have, so every file in them stopped being API by default. - **One always-on rule for flat-layout agents, not 84,000 characters.** Cursor, Windsurf, Kiro and Copilot load their whole rules directory into every request, so installing 21 skills there put about 84,000 characters of Rebase reference in front of every question a person asked those tools — whether or not the question was about Rebase. An instruction an assistant skims is an instruction it does not follow, so the effect of installing more was to make each skill count for less. Those targets get one rule that points at the rest. `rebase-basics`, the skill loaded for every Rebase task, was 929 lines, two thirds of them reference material read once a month; it leads with the recipes now — the sequence for adding a collection, a function, a rule — and keeps the tables behind them. - **`rebase init` asks npm once for a version it already knew.** Every `@rebasepro/*` package in this repository ships at one version, and `check:publishable-set` fails the build on the first PR after a bump if any of them drifts — so the version to pin was decided before `init` touched the network: it is the CLI's own. Asking the registry once per package bought nothing but eleven chances to hang, and offline it was eleven timeouts ending in a fallback to `"latest"`, the one answer that scaffolds a project mixing framework eras. One call remains, to `@rebasepro/server`, because lockstep is enforced in the repository and not on the registry — a release can still leave a package behind, which is what happened to `@rebasepro/agent-skills` across three of them. A registry it cannot reach is not a release gap: it prints what it pinned and why and carries on. - **The scaffold's toolchain catches up with the runtime it targets.** `@types/node` was `^20` in all four scaffold manifests while `engines.node` says 22.22, so a new project typechecked against the standard library of a runtime it refuses to run on. `backend/tsconfig.json` shipped `moduleResolution: "node"` — TypeScript's node10 algorithm, which does not read `exports` maps — while its `config/` sibling, whose sources the same program includes, has always used `bundler`. And dotenv was pinned a major behind every package in this repository, so a scaffolded project loaded `.env` through a different major than the CLI and the runtime do. `check:templates` now asserts the tsconfig it compiles with is the one the template ships, instead of proving the templates work under a setting they do not carry. #### Removed - **`loadDeclaredStorageSources`** (`@rebasepro/server`), **`normalizeStorageSources`** and **`DeclaredStorageSources`** (`@rebasepro/types`). All three served the `storage` block of `rebase.json`, which the manifest validator now refuses: buckets are declared with `bucket()` and the graph is generated into `rebase.resources.json`. The loader parsed a block that can no longer exist and the merge resolved a conflict between two homes there is now one of. Pre-release, a breaking change is just a change — the runtime contract stays at 1, as it did for the declaration change itself. - **`basePath` and `baseCollectionPath` on ``.** Declared on `RebaseProps`, documented as "URL prefixes when the admin is not at the site root", and never destructured in `Rebase.tsx`. Setting either did nothing at all, and the symptom of getting the prefix wrong is a collection view that hangs on a spinner — so the reader who reached for the documented prop got exactly the failure the prop claimed to prevent, with no way to tell it had been ignored. The sub-path recipe is `apps.admin.path` in `rebase.json`, which `rebase build` already supplies. - **`UIReferenceView`, `UIStyleGuide` and `CrmDashboardDemo` leave `@rebasepro/app`'s barrel.** A file that renders every component in the kit, a token sheet, and a fake CRM with its sample data, in the bundle of every consumer who never opens them — and `RebaseRouteDefs` hardcoded `/debug/ui`, so the whole reference was a static dependency of the admin's route table. - **Four agent-launcher manifests that nothing could install.** `tooling/rebase-agent-skills/` carried a Claude plugin, a Cursor plugin, a Gemini extension and a Kiro power; every one of those installers reads its manifest from a *repository root*, and these sat five directories down in a monorepo. `files: ["skills/"]` kept them out of the npm tarball as well, and they were pinned at `version: 1.0.0` against a package at 0.17.3. - **`pnpm.onlyBuiltDependencies` from `packages/cli`.** It is honoured at the workspace root only, so it did nothing there — and it was published to npm as part of the manifest. Worse than inert: it listed `esbuild`, which the workspace root deliberately sets to `false` under a docblock explaining that esbuild's postinstall swaps its JS shim for a native binary and leaves every `pnpm exec esbuild` dying on `SyntaxError: … ELF`. The one setting that would have mattered if pnpm ever started reading it was the one that would have reintroduced a build failure the repository had already diagnosed. #### Fixed - **The `database` resource kind could not load beside a driver published before 0.17.2.** `optionKeys` gained `"extensions"` in the literal itself, and a kind literal that has shipped is a wire contract: every published driver inlines this package and compares the shared registry's entry against its own copy at load. So a runtime from 0.17.2 onward met a 0.17.0/0.17.1 driver, found two differing specs at the same revision, threw `Resource kind "database" is already registered with a different definition`, and refused to boot. The freeze that was later added to prevent exactly this froze the *newer* literal, which does not make the older one go away — there were already two in the field, and no single literal can equal both. `database` now carries `revision: 1`, so a copy holding either published literal loses to the current definition with a warning instead of throwing. `shipped-kinds.test.ts` holds every spec this package has published and asserts each one can still meet the current one; removing the revision fails it with the 0.17.0 literal, which is the crash. - **A tenant whose pods could not boot reported healthy for six and a half days.** Tenant Deployments run `maxUnavailable: 0`, so a failed move surges a new pod and leaves the previous ReplicaSet serving. `Available` stays `True` and `readyReplicas` stays at the desired count — both sum over every ReplicaSet, and both were describing the pod that was *not* being rolled out. The rollout controller does read the Deployment when a health scrape comes back empty, but it only ever looks at projects named in an active `rollouts` row, so a tenant moved by hand or by a fleet script got no supervision at all. Two of them crash-looped 222 times while every check called them fine. A new `fleet-health-guard` cron reads every tenant Deployment on every cluster each ten minutes, regardless of how it got into the state it is in, and judges it by exactly the reading a rollout uses — `Progressing: False` plus the per-template counters, never the sums. A tenant Kubernetes has given up on is named with the image it could not move onto and returned to the ReplicaSet it is actually serving, which ends the crash loop without changing what is served. It stands down while a supervised rollout is in flight, so it cannot race the controller for a tenant that is legitimately moving. - **The alert for tenant application errors could not describe one.** Its filter matched every container in a tenant namespace and its label extractors read `jsonPayload.msg`, `.error` and `.logger` — CloudNativePG's field names. The application logger emits `{severity, message, timestamp}` and has never had a `msg`, so every notification about an application error rendered `Error: (null) Detail: (null) Component: (null)`. It is now scoped to the app container and reads `jsonPayload.message`; the database half it was silently covering became its own policy, keeping CloudNativePG's names. The policies lived only in the Cloud console, where no test could see them and no review could read them. They are checked in under `saas/infra/monitoring/` with an idempotent `apply.sh`, and a gate asserts that every extractor names a field the containers it matches actually emit, and that every `${log.extracted_label.…}` in a notification is a label some condition extracts. - **A collection routed to a second database got its table in the first.** Provisioning filtered collections by *engine*, so `dataSource: "analytics"` on a Postgres collection handed it to the default Postgres database: the table was created where nothing read it, the analytics database never got one, and every query there failed on a missing relation behind a boot that reported the schema up to date. Tables and RLS policies are now provisioned per source, and the helper functions the policies call — which arrive with the auth tables on the default only — are created on every other source first. - **Two local buckets shared one directory.** Every local source without its own `STORAGE_PATH__` resolved to the same base path, so a file uploaded to `media` was readable through the default source. A named source now gets `uploads__`; the default keeps the plain path, so nothing an existing deployment wrote moves. - **`REBASE_TOPIC_URL` was a variable nothing read.** The topic kind advertised it as a binding and `rebase status` listed it. The gate now covers every registered kind, so a phantom name fails a build. - **Blank variables are unset.** A value of three spaces was a bucket name. - **The scaffold and the docs named variables nothing reads** — `STORAGE_BUCKET` in both `resources.ts` templates, `STORAGE_REGION__MEDIA` and `REBASE_DB_POOL_MAX__ANALYTICS` in the multiple-sources page. Fixed to the names the resolver reads (`S3_REGION`, `DB_POOL_MAX`). - **`rebase status` — what this project declares, and whether it is configured.** The model a developer has to hold is three files: `rebase.json` says where the code is and who runs the server, `config/resources.ts` says what the project needs, and the environment says how to reach each thing. Everything else — `rebase.resources.json`, the bundle manifest — is generated from the middle one for readers that cannot run your code. None of that was visible in one place. `rebase resources` listed declarations, the variables lived in a `.env`, and the rule joining them was a suffix convention you had to know. So the question people actually arrive with — *why does uploading to `media` answer 501* — could only be answered by deriving the variable name by hand. It is now printed, per resource, with the consequence spelled out: ``` buckets ✓ media s3 · account:minio ✓ S3_BUCKET__MEDIA ✓ S3_ACCESS_KEY_ID__MINIO (shared, for S3_ACCESS_KEY_ID__MEDIA) ○ exports s3 · S3_BUCKET__EXPORTS not set └ declared, not configured — uploads here answer 501 ``` It shows three things nothing showed before: which shared-account variable a bucket is *actually* reading, a source that is declared but not configured (before a 501 in production rather than after), and a `local` bucket, which resolves happily and is dropped in production because a container's filesystem is erased on restart. The verdicts come from `resolveDataSources` and `resolveStorageBackend` — the functions that run at boot — rather than from a second implementation of what "configured" means, which would eventually reassure someone about a deployment that is about to refuse to start. - **A second collection claiming the same `slug` or table was dropped without a word.** `CollectionRegistry` registers by slug and by table name, and returns early when the table is already taken — so the second file's routes never existed, its relations resolved to the *other* collection's rows, and the file sat in `config/collections` looking loaded. Boot now fails, naming both files: ``` • posts 2 collections declare `slug: "posts"`: posts.ts, blog_posts.ts. … ``` The file names come from the loader, which is the only thing that has them; passed through `ValidateCollectionConfigOptions.sources`, and falling back to the collection's index for callers that validate an array they built themselves. The table is resolved with `getTableName` — the same function the registry and both generators use — so two slugs that *derive* the same table are caught as well as two that declare it, and a duplicate slug says it once rather than twice. - **A typo inside an `admin` block was the one config mistake with no signal at all.** The boot validator checked the collection's keys, each property's keys and each relation's keys — and then stopped at the edge of `admin`, on the reasoning that the block belongs to `@rebasepro/cms-types` and the panel adds to it. But the lists to check against, `ADMIN_COLLECTION_KEYS` and `ADMIN_PROPERTY_KEYS`, live in core *so that core can read them*, and `@rebasepro/cms-types` type-checks both against the option types. So `admin: { multilne: true }` booted clean, rendered a single-line input, and left nothing to find. Both blocks are checked now, as warnings under the existing `REBASE_STRICT_COLLECTION_CONFIG` policy, and every unknown-key message — at any level — carries the near-miss when there is one: *"`multilne` is not a known property `admin` key and is being ignored. Did you mean `multiline`?"* The suggester is the CLI's, moved into `@rebasepro/utils` as `isNearMiss` and `suggestNearMiss`. Both key lists now assert completeness against the option types in *both* directions, at compile time. The reverse direction was believed to have no type-level expression and had never been checked; on its first run it found two real options that had never been listed — `format` on a number property and `renderInForm` on a relation. Left unlisted, they would have made this new warning fire on correct config, which is how a check earns its way into being switched off. - **Two enum entries with the same `id` silently removed the enum.** The ids are the labels of a Postgres enum type, so a duplicate makes `CREATE TYPE "posts_status" AS ENUM ('draft', 'draft')` — which Postgres refuses with `23505` on `pg_enum_typid_label_index`. Boot treated *every* unique violation on a `pg_catalog` index as a lost race with a peer pod, skipped the statement, and carried on: the type was never created and the column became plain `TEXT`. The config said "one of these three" and the database accepted any string, with nothing in the log. The config validator now rejects a duplicate or blank enum `id`, and a blank `label`, naming the property and the index of the entry; a repeated *label* is a warning, since two options that read the same is a panel problem rather than a database one. And `pg_enum_typid_label_index` is off the duplicate-object-race allowlist, so a config that reaches the database with one fails loudly. The concurrent-boot case that allowlist exists for is untouched: two pods adding the same label race on `ALTER TYPE … ADD VALUE`, which raises `42710`, and the generator writes `IF NOT EXISTS` anyway. - **`rebase resources --check` failed every project that declares nothing.** A backend has a default database and a default bucket whether or not anyone says so, and a project with no declarations has nothing to record — but the check demanded a `rebase.resources.json` saying exactly that, and reported its absence as stale. This surfaced the moment the check was put in a gate: it failed this repository's own reference app, which declares nothing, and would have failed every scaffolded project until someone declared a second bucket. - **Nothing ran `rebase resources --check`.** Not CI, not a package script, not another gate — while the comment introducing it said it "is what keeps it honest". `rebase build` rewrites the file, so a project that builds is honest by construction; a `runtime: "custom"` project never runs `rebase build`, because it builds its own image. So for exactly the projects where the committed graph is the only record of what they need, it could drift in silence. `pnpm check:resource-graphs` now runs it over every installed project in the repository, in CI. - **A bucket's shared credentials worked on the managed runtime and silently did nothing in an ejected one.** Turning a declaration into a source definition was two field-by-field maps — `graphToStorageSources` in `@rebasepro/server`, which the managed boot path uses, and `declaredStorageSources()` in `@rebasepro/types`, which an ejected project's entrypoint and the frontend use. The server's copy carried a bucket's `account`; the types copy dropped it. So `bucket("media", { account: "minio" })` found `S3_ACCESS_KEY_ID__MINIO` on the managed runtime and found nothing after `rebase eject` — the source was skipped as unconfigured and every upload to it answered `501 STORAGE_SOURCE_NOT_CONFIGURED`, having never asked for the credentials the project had set. There is now one mapper, and a test that the two readers return identical definitions. - **An ejected backend ignored every database but the first.** The emitted entrypoint was one hardcoded `createPostgresDatabaseConnection(env.DATABASE_URL)`, so a project declaring `database("analytics")` ejected into a server that never opened a second connection: collections routed there fell back to the default driver and their rows landed in the wrong database, behind a boot that logged a warning and a health check that stayed green. It now uses `resolveDataSources` + `initializeDataSources` — the same resolvers the managed runtime uses — binds one bootstrapper per declared database, and closes every pool on shutdown rather than only the first. - **`bucket({ engine: "s3" })` threw "a bucket needs a non-empty key".** The options-only form `database()` has had since it shipped was missing here, so configuring the *default* bucket meant writing the internal sentinel `bucket("(default)", { … })`, and passing options where a key belongs failed with a message naming neither the mistake nor the fix. - **The bucket and database kinds advertised environment variables nothing reads.** `ResourceKindSpec.envBases` is what a generator or control plane binds from, and the bucket kind named `STORAGE_BUCKET`, `STORAGE_ENDPOINT`, `STORAGE_REGION` and `STORAGE_PUBLIC_URL` — none of which the runtime has ever read — while omitting `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`, without which a bucket cannot be reached at all. The database kind named `REBASE_DB_POOL_MAX` for a resolver that reads `DB_POOL_MAX`. Both lists now match `boot/sources.ts`, and a new test holds them to it by reading the resolver's own source. - **A test's registry reset was a silent no-op.** `storage-account-scope.test.ts` called `resetResourceRegistry?.()`, which is not an export — so the optional call did nothing and the test ran against whatever an earlier test had declared. - **Every `rebase db` failure exited 1 without saying anything.** The driver's entry point ended in `.catch(() => process.exit(1))`, which discarded the error. So a branch that could not be created, a name Postgres would silently truncate, a duplicate, a branch that was not there — each printed its header and then nothing, with an empty stderr: ``` $ rebase db branch create feature_auth 🌿 Creating database branch... Name: feature_auth ← nothing. exit 1. ``` This was the whole `db` namespace, not only `branch`. The messages existed and were carefully worded; none of them had ever reached a terminal. A child process that already wrote its own diagnosis through inherited stdio still stays quiet, and a bare Drizzle `Failed query:` wrapper now carries the PostgreSQL error it hides in `cause`. - **`rebase db branch info` exited 0 for a branch that does not exist.** It printed `✗ Branch "x" not found.` in red and reported success, while `delete` — answering the same question — exited 1. So `rebase db branch info "$b" && deploy_against "$b"` ran the deploy against a branch that is not there, with the reason already on the terminal. - **`rebase db branch create alpha beta` created a branch called `alpha`.** The extra word was discarded silently, so an unquoted name (`create my feature`), a flag written without its dashes (`create feat from main`), or a shell splitting a token you thought was one all succeeded and made a branch you did not ask for — under a name you then type to switch, delete, or point a deploy at. Words the command cannot account for are now refused before anything connects, with a suggestion of the hyphenated name you probably meant. - **A branch could not be created while the dev server was running, and would not say why.** `CREATE DATABASE ... TEMPLATE` needs the source quiescent, and `DatabasePoolManager` only disconnects pools inside the process doing the work — `rebase db branch` is its own process, so a `rebase dev` in another terminal was never touched by it. Wanting a branch and running the app are the same moment, so this was the common path, and the advice on failure was "close other clients and try again" for the one case where you do not know what is connected. The failure now lists the sessions by `application_name`, and `--force` disconnects them for you, on create and delete alike. - **Creating a user in the panel never showed the temporary password.** The server mints one, returns it beside the columns on the create response, and will not repeat it; the dialog that shows it to the administrator was installed as an `afterSave` on the collection's `callbacks` — the server's block, which nothing in the browser runs and whose bodies the bundler strips on the way in. So the callback was never called, by anything, and the credential was returned to a panel that dropped it. It is injected onto `admin.browserCallbacks` now, written to both the block and its flattened form so re-resolving a collection cannot undo it. Resetting a password was never affected: that dialog fetches and renders the result itself. - **Four of the seven showcase cards on the homepage rendered blank.** A key sweep on 2026-09-02 deleted the strings for the presupuestos, Prospector, Unfeigned and Edith cards because the carousel builds its keys dynamically and the sweep only saw literal `t("…")` calls. The four locales get their copy back, and the built page no longer carries `alt="undefined …"`. - **`rls-check` reported clean on a table the whole internet could read.** The `policy-anonymous-tautology` check bailed out whenever it saw the literal `<> 'anonymous'`, so a policy guarding against `'anon'` instead fell past the bail — and then no longer matched the bare null-test shape either, so the check returned nothing at all. `rebase.uid() IS NOT NULL AND rebase.uid() <> 'anon'` reads as "signed in", is true for every anonymous caller, and was scanned and passed. The guard is now parsed rather than string-matched: the expression is split on `AND` counting parens, `<> ALL (ARRAY[…])` and `NOT IN (…)` are read as the exclusions they are, and a policy only clears when it excludes an id a signed-out caller can actually arrive with. Excluding some other literal is now the loudest finding of the three, because it is the one that survives review. Severity also rises a step for `ALL`, `UPDATE` and `DELETE` policies, where the same predicate decides who may write. - **A bundle that vendors its own `node_modules` booted two copies of the framework.** New pods crash-looped with `Could not load the database driver "@rebasepro/server-postgres": Resource kind "database" is already registered with a different definition` — while the driver was installed the whole time. The process held the image's `@rebasepro/types` and the bundle's vendored copy; `registerResourceKind` keys off `globalThis` deliberately, so both registered into one registry, and the two specs for kind `database` had diverged by a single `optionKeys` entry. It threw during the driver's import, so the driver got the blame. The dedupe that exists to prevent this was unreachable on three of the four paths into a running pod. (#38) The shipped kind literals are frozen at their 0.17.3 bytes now, because the copy that compares them is inlined in every published driver and is in the field. What a kind *binds* from is a per-copy overlay instead — `import { amendResourceKind } from "@rebasepro/types"` — which an older inlined copy never sees and therefore never compares. - **`zod` was missing from the runtime-provided list, so a managed app ran no crons and reported success.** A bundle shipped its own zod beside the image's; `loadEnv({ extend })` then parsed a schema built by a different module instance, and every field carrying a `.default()` was rejected. Nothing in that failure mentions zod. Added to all three lists, and the agreement test that should have caught it no longer filters to `@rebasepro/*` — which is why a non-scoped entry could diverge for four releases unseen. The runtime also re-exports its own copy — `import { z } from "@rebasepro/server"` — so an `extend` schema cannot be built from the wrong one; `loadEnv` refuses a foreign schema by name rather than validating half of it. - **Every failure on a first cloud deploy said what it was, or said nothing.** The boot ensure built its message from `err.message`, which for drizzle is `Failed query: ` and not one word of Postgres's answer — the SQLSTATE, message, detail and hint all sat in `.cause` and were discarded. Four unrelated boot failures read identically. Fixed here and in the RLS-policy sibling, where the swallowed reason explains a table that is now denying every request. Also: `storage create` 404'd because `invoke()` percent-encoded a function name the CLI had folded a project id into, so `storage-provision%2F` matched a route that had been live for six weeks. - **A restored, empty scroll entry asked the API for `limit=0`.** `initialItemCount` fell back with `??`, which catches `null` and `undefined` but not `0` — and `0` is exactly the value that means nothing was restored. Any filter combination matching no rows saved `data: []`, so returning to that view rendered `Invalid limit: 0` instead of a table, and a client bug wore an API failure's clothes. (#37) - **The default scaffold's first data read returned 500.** `rebase init` writes no `DATABASE_URL`, so `rebase dev` takes the managed PGlite path — and nothing on that path replaced the template's stub `schema.generated.ts` (`export const tables = {}`). The database was fine: boot created every table, `/health` answered 200 and auth worked, while every `GET /api/data/*` returned `Table not found`. `rebase dev` now generates the Drizzle schema before starting anything, whichever database is behind it. - **`rebase db push` against the managed database failed twice over.** First `pq: SSL is not enabled on the server`, because PGlite's socket server speaks no TLS — its remedy box then told the reader to append `sslmode=disable` to `DATABASE_URL`, the variable that is unset precisely because the managed database is in use. Past that, Atlas needs a second empty database to diff against and PGlite serves exactly one. The managed DSN now disables SSL, and `push`, `generate` and `migrate` stop up front on that database with the two things that do work. - **A `--headless` project read as misconfigured on a clean boot.** The source bundle's manifest declared `entry.collections` and `entry.schema` from the conventional layout whether or not those paths existed, so a correct project warned that a file "does not exist" and that no tables would be created. The manifest now states what the project has. - **`/api/data/*` answered a plain-text 404 when a project served no collections.** The surface was not mounted at all, so Hono's default replied — which reads as a wrong URL when the truth is that there is nothing to serve yet. It now returns a JSON `NO_COLLECTIONS` 404 saying tables must exist first. - **The welcome email was in Spanish, and linked the wrong port.** Subject and body, on an English project, while every other template is English. And `rebase dev` left the frontend port to Vite while the backend was started with the scaffold's fixed `FRONTEND_URL=http://localhost:5173`, so the link named a port the app was not on. The frontend port is now derived per project, like the backend's, and handed to the server that builds the link. - **Twenty lines of ERESOLVE before the CLI printed anything.** `@electric-sql/pglite-socket` pins its peers to exact versions per release, so `^0.2.9` floated to 0.2.11, which demands `pglite-pgvector` 0.0.9 while the CLI asked for 0.0.7. The family is pinned exactly. - **`rebase --debug db branch switch feature` reported success and left the checkout on the main database.** `switch` writes a per-checkout pointer the CLI owns, and the dispatch found the word at `rawArgs.slice(2)[2]` — which assumes nothing precedes the command words. `--debug` is what `bin/rebase.js` prints after every failure as the thing to re-run with, so it is the likeliest token to precede them. The command missed the CLI's branch, went to the driver, and came back reporting success; every later `dev`, `push` and backup then ran against the wrong database believing it was the branch — the exact failure branching exists to prevent, announced as a success. Same class, swept: `refuseAtlasOnManagedDatabase` and `refuseBranchOnManagedDatabase` read the domain at a fixed index and stopped refusing, and the driver reads its domain out of `args[0]`, so `rebase --debug db push` answered "Unknown domain command: --debug". A leading flag is moved after the command words rather than dropped — dropping is the same bug facing the other way, and it would lose `--database-url` in silence. - **`rebase db backup list` created a backup.** `list` was a positional the local command parsed permissively and never read, so the wrong guess wrote a dump instead of reading one and left a file retention then has to reason about — while `rebase cloud db backup list` did what it says. Both spellings list now. In the same shape: `--output` is canonical and `--out` is the alias `build` and `cloud env pull` already carry, but `db backup` took only `--out` and `generate-sdk` only `--output`, so the wrong one was silently discarded and the default path used. - **A misspelled flag is an error, not a silently discarded value.** `arg(…, { permissive: true })` does not relax parsing — it moves an undeclared flag into the positionals, and nothing on these command lines reads a positional: ``` rebase db push --alow-destructive pushed with the destructive gate shut rebase schema generate --ouput x wrote the default path, in silence rebase generate-sdk --ouput ./sdk same, and the next build imported it ``` One spec per command in `cli-flags.ts`. The driver keeps its check at its own entry point rather than in those parsers, which is load-bearing: `db push` and `db generate` re-enter the schema and DDL generators with the *db* line, so a strict parser inside either would reject `--allow-destructive` on a line where it is correct. - **`rebase schema generate --help` regenerated the schema.** `cli.ts` rewrote the subcommand to `--help` only when the user named none, so the flag travelled into the driver, whose `schemaCommand` has no `--help` case, and it ran the generator — overwriting `src/schema.generated.ts`. `introspect --help` rewrote the collection files. It also hit `requireProjectRoot` first, so `rebase schema introspect --help` in an empty directory exited 1 with "Could not find a Rebase project root": help you cannot read until you already have a project. Answered before the root lookup and before the spawn now, with a page per subcommand, and the driver keeps its own second line of defence because it is spawnable on its own. - **A mistyped command answers in one line, with the correction.** Six families each answered a mistyped subcommand differently, and five of them dumped the whole help afterwards — which pushes the line that says what happened off the top of a CI log. `telemetry` printed only the help, so a typo was indistinguishable from `rebase telemetry --help`. None offered the correction, which for a typo is the entire answer. One helper now: the near miss when there is one, where to read the rest, on stderr, exit 1 — Damerau-Levenshtein because a transposition is the commonest typing error and plain edit distance scores it 2, a tight budget because `push` and `pull` are two edits apart and one is the destructive neighbour of the other, and an abbreviation completes only when it names exactly one command, so `res` never picks between `reset` and `restore`. - **The top-level help lists the commands that were only in the source.** `db pull`, `db stop`, `db reset`, `db branch` and `schema stale` all worked and appeared in no help page anywhere — and those are the recovery commands, the ones you go looking for at the moment you are least able to go source-diving for them. The guard reads the commands out of the dispatch — the `case` labels, the subcommand comparisons, and the driver's own `VALID_ACTIONS` — so a command added without a help line is a test failure. - **`bin/rebase.js` stopped colouring pipes, and a typo stopped offering a stack trace.** Its three stderr writes happen before the bundle is imported, so they never went through chalk and hard-coded `\x1b[31m`: the escapes landed in every redirect, every CI log and every agent reading a failed command. Now `NO_COLOR` / `FORCE_COLOR` / `isTTY`, the same three chalk honours. And a usage error no longer ends with "Re-run with `--debug` for the stack trace" — the stack points at the argument parser, and the suggestion is to add another flag to the command line we have just established is the problem. - **A child process that never started said nothing at all.** `schema.ts` and `doctor.ts` ended their spawn in `catch { process.exit(1); }` — the error not even bound — so `rebase schema generate` against a broken tsx exited 1 with no output, and so did `rebase doctor`, whose entire job is to say what is wrong. `db.ts` was supposed to be the model and had the same hole for a subtler reason: it filtered execa's message with `/Command failed|exited with code/i`, and execa prefixes every failure that way — including the spawn failures the filter was written to let through. A child that ran has a numeric `exitCode` or a signal; a child that never started has neither. One helper, that rule. - **Six commands had three wordings for "dependencies are not installed", and none of them said what to run.** "Could not find CLI entry point for @rebasepro/server-postgres", "Could not find tsx binary", "Could not find tsx binary for backend" — all three mean `node_modules` is missing, and all three read as Rebase being broken. Now: "Dependencies are not installed — run `pnpm install` in ", with the package manager taken from the project's lock file and the directory named, because the working directory is usually `backend/` or `frontend/` where the install would create a second, wrong `node_modules`. - **Six things a first run showed you, and none of them were true.** `--yes` says no to git init and to installing, which is the right behaviour for CI and the opposite of the interactive defaults, and `--help` now says so rather than leaving the reader to discover it from an empty `node_modules`. Offline, every version lookup failed and the release-gap refusal fired — "Rebase X is not fully published to npm … not a problem with your machine, your network, or your package manager" — when every package being unreachable is exactly a connectivity failure. `index.html` linked a `/favicon.ico` that the template does not ship and declared it `image/svg+xml`, so every first page load logged a 404. `generated/` is written by `rebase generate-sdk` and is now ignored, since a committed copy is stale the moment a collection changes. The summary box named the admin URL and not the API, which is what every SDK client, curl and Swagger link needs, and the two ports are derived separately so one is not derivable from the other. And `rebase.json` spelled `npm run build --workspace frontend` in a pnpm project; `init` rewrites it from the detected manager. - **A warning that was always there.** Every first `rebase dev` on a fresh project warned that `.env`'s `PORT` and `VITE_API_URL` were being ignored — about a file the developer had not opened yet, and both halves were wrong. `PORT=3001` is copied verbatim out of the scaffold's own `.env.example`, so a value equal to the scaffold's default was written by the scaffold, not chosen by anyone. `VITE_API_URL=` ships empty and should never have been named at all: the reader was `^\s*KEY\s*=\s*(.+?)\s*$` with `/m`, and `\s` matches a newline while `$` matches at every line end, so the leading `\s*` ate the line break and the capture returned the *next* line. An empty variable read as whatever was written under it. `readEnvValue` matches horizontal whitespace only. - **`--no-install` was in both the README and the Quickstart, and was not a flag.** `arg` has no negation, so the second half of `--install` / `--no-install` was not "the default, redundantly stated" — it was an unknown option, and `rebase init app --yes --no-install` exited 1 on a line copied straight out of the docs. It is an explicit "no" now: it suppresses the interactive install question too, and wins over `--install` when both are passed. And **the example script the scaffold ships is a script that runs** — `scripts/example.ts` is in the base template while its runner and every one of its imports were in the baas overlay only, so in the default scaffold `pnpm example` was a script that did not exist. - **A boot failure names the database, the port and the reason.** `loadBootEnv` restates a `ZodError` as a list of variables to fix, and its "this one is simply missing" branch tested `issue.message === "Invalid input"` — zod 3's wording. Under zod 4 the message carries the types and the `received` field is gone, so nothing had matched since the upgrade and the friendliest line in the boot output was the validator's own, verbatim. Alongside it: a failed connection now says which database, on which port, and why, and `rebase dev` says which port was already in use and which one the server moved to — and says when the backend died, instead of leaving "Press Ctrl+C" on screen against a process that is gone. - **A 404 on a row says which row, and names row-level security.** "Entity not found" was the entire message on five routes. It does not say which collection, which id, or — the part that costs the most time — that a row can be present and invisible: authenticated requests run as a restricted role, so a `SELECT` policy that excludes this caller produces exactly this 404. Somebody checks whether the row exists, finds it in psql, concludes the API is broken, and spends the afternoon in the wrong file. The collection and the id are both in the URL the caller just sent, so naming them back reveals nothing. - **Five more messages that named nothing.** "Parent table not found" appeared four times in `RelationService` naming neither the collection nor the table, and the absence has two causes worth telling apart — the collection is not exported from `config/collections/index.ts`, or the generated schema predates it. "Junction table not found: " had the name and not the diagnosis. "Default driver not initialized by bootstrappers" left the reader's next question — which one — unanswered, and in a multi-source configuration that is not obvious. `serve-spa.ts` keeps its bare `Not found` bodies: two of the three are a forbidden path answered as a missing one on purpose. - **One log line per failed request, carrying the user and the collection.** A failure produced two lines, each holding half of it: the error handler had the code and the diagnosis and nothing about who asked, and `requestLogger` had the user, the status and the latency and nothing about what went wrong. Correlating them meant matching on the request id, for twice the volume and less than one line's worth of meaning. The handler leaves its half on the context and stays quiet wherever a request line is coming; where nothing claimed one — a project mounting routes onto its own Hono app — it still speaks. - **`LOG_LEVEL=warn` silenced every `console.log` in the process.** `utils/logging.ts` reassigned `console.debug`, `console.log` and `console.warn` to no-ops, irreversibly, because the originals were discarded rather than saved — so a line that ships in the scaffold's own `.env.example` muted a dependency's output, the project's own debugging, and any CLI report running in the same process. Deleted. The structured logger is the only level authority now: it filters its own lines and touches nothing else, and the level is read per line rather than captured at construction, since the singleton exists from the first import, long before any project configuration has been read. - **The root app answers the JSON envelope when anything throws**, so a failure before the API router — a body too large, a malformed URL, a middleware fault — stops arriving as Hono's own HTML. And **a failed write keeps the SQLSTATE that says why it failed**: drizzle's `err.message` is `Failed query: ` and not one word of Postgres's answer, which sat in `.cause` and was discarded, so four unrelated write failures read identically. - **`rebase doctor` no longer ends when it cannot reach the database.** `checkCollectionsVsDatabase` threw, the error escaped `runDoctor`, and the CLI's last-resort catch printed "Doctor failed" over a drizzle stack — so the two phases that need no database at all, the generated schema and the SDK types, never ran. The database phase is a skipped phase with the boxed diagnosis above it now, and the run continues to the verdict. `blocked` separates the two ways a phase can be skipped, because "you did not set `DATABASE_URL`" is a situation somebody may be content with and "the `DATABASE_URL` you set refuses connections" is not — the report renders them identically and the exit code does not, or `rebase doctor` goes green in CI against a database it never reached. - **The dev secrets were called ephemeral when they were not.** A generated `JWT_SECRET` is written to `.env` and survives every restart; boot announced it as regenerated each time, which is the one property that decides whether yesterday's tokens still work. - **`CORS_ORIGINS` is read in development too.** `resolveCorsOrigin` returned early outside production and never looked at the allow-list, so the variable did nothing in the environment where it is most often needed: a phone on the LAN, a colleague's machine, an ngrok tunnel, a forwarded Codespaces port — every one is a non-localhost origin, every one was refused, and the variable that names the fix had no effect on it. Development allows localhost plus the list now. - **Nine places the SDK envelope and its unwrapping disagreed with themselves.** `RebaseApiError` now carries `requestId` and `retryAfterSeconds` — both were on the wire and dropped, so a bug report could not quote the one string that finds the server-side line and the offline queue's backoff ignored a server that had said exactly how long to wait. The auth middlewares answer through `errorHandler` instead of seven near-copies of the envelope written by hand, and the handler is *called* rather than thrown to, because `defineFunction` hands users a Hono app and a throw with no `onError` at the mount point is a 500 where a 401 was meant. `client.call()` returns the body verbatim like `functions.invoke()`, instead of unwrapping a top-level `data` key and making the documented shorthand return a different value. A second `.where(or(…))` now ANDs with the first instead of replacing it — it was the one call on the builder that made a query match *more* rows. `update()` takes `WriteOptions`, on the verb where a lost response is most likely to be retried into a second applied edit. And the dead `collection_patch` handler is gone, with its own drifting copy of the cache-merge rules. - **`page` and `offset` are validated, like `limit`.** A negative or non-numeric value reached the query builder, where it became either a Postgres error about a bound parameter or a silently wrong window. - **`count()` de-duplication is per client, not per process.** The in-flight map was module-level and keyed on a path plus a query string, which says nothing about *who* is asking — so two clients in one process counting the same collection shared a single request and the second was answered with the first one's total. That is not a stale number, it is somebody else's: row-level security makes an admin panel's count and a signed-in user's count of the same collection genuinely different answers. - **The server being down is a `RebaseApiError` too.** The class's own docblock says a `catch` block only ever needs to check for this one class, and it did not cover the failure every app meets first — a refused connection, a DNS failure, CORS, an abort — which came out as whatever the runtime's `fetch` felt like rejecting with. `e.status` and `e.code` were undefined on the error most likely to have a branch written for it. - **The fluent builder takes what `find(params)` takes.** Two spellings of one API, and the chainable one was a subset, so a caller who started with `.where()` discovered it could not express the query the object form could. Every gap was documented, worked over HTTP, and was a compile error on a generated SDK — which is to say on every project that took the trouble to be typed. **`listen`, `listenById` and `count` are part of the contract**, not optional members: optionality was answering two questions at once, the SDK landing page's own Quick Example did not compile under `strictNullChecks`, and the realtime page had to write `.listenById!(`. - **One leaf encoder for filters, so a group means what it says.** `serializeLogicalCondition` carried its own copy and the copy had drifted from `serializeTuple` on every rule the top-level codec had already been fixed for. Each divergence is a filter that runs, returns rows, and answers a different question than the one asked, with no error anywhere: `null` went out as the four-character string, so `deleted_at.eq.null` was a 500 on a timestamp column and silently the wrong rows on a text one. - **An `afterRead` that returns nothing no longer erases the row.** The collection tier fell back with `?? fetched` and the global and property tiers did not — so the same callback, one that mutates `row` and returns nothing, worked when registered on a collection and replaced every row with `undefined` when registered globally. Three call sites, one per read path. - **A `beforeDelete` that returns `false` answers 403, not 204.** It is typed `boolean | void` and documented as "return false or throw to block deletion". Returning `false` did stop the delete — and the route then answered `204 No Content`, which means the row is gone: the panel removed it from the list, clients dropped it from their caches, and the next reload brought it back. A veto that reports success is worse than no veto. - **A write from an `afterRead` is a 409 that names the callback.** Request-scoped reads open their transaction `READ ONLY`, so a write from an `afterRead` — or from anything it calls — is refused by Postgres with SQLSTATE 25006, and that arrived as `500 Internal Server Error`, which reads as the database being down rather than as the reader's own code being refused. 25006 maps to `409 READ_ONLY_TRANSACTION` now. - **`afterSaveError` gets the error.** `AfterSaveErrorProps` never declared it and neither driver passed it, so the handler the guide has always shown compiled and logged `undefined`. The caller's real `id` comes with it (it was the string `"unknown"`) and so do the `previousValues` the pre-write read already had (they were hardcoded `undefined`, hiding an update's before-state). - **`rebase.email` is there even when nothing configured mail.** `RebaseServerClient` declares `email: EmailService`, not optional, and the property was simply absent whenever the backend booted without `auth.email` — so a cron or a custom function written against the type compiled, deployed, and died on "Cannot read properties of undefined (reading 'send')", a stack trace about a language feature rather than about the thing nobody set up. A stand-in is always attached; its `send()` throws a sentence naming `SMTP_HOST`, `auth.email` and the dev sink, and `isConfigured()` is false. - **`rebase dev` watches the directories the runtime scans, not only what it imports.** tsx restarts on a change to something the entrypoint imported; functions and crons are not imported, because the runtime scans their directories at boot. So a new `backend/functions/new.ts` written while `rebase dev` ran stayed 404 until somebody restarted by hand, and a new cron never registered at all. - **`loadEnv({ extend })` refuses a schema built by another copy of zod.** A managed bundle that installed its own zod ran with two copies loaded, `.merge()` accepts the schema because the shapes are identical, and `.parse()` then rejects every field carrying a `.default()`, because a default is recognised by class identity. The deploy came up, reported success, and ran zero crons — and nothing in the failure mentioned zod. It is refused by name now, with the remedy. - **The RLS editor saves through plan/apply, like everything else.** Two doors onto one file, behaving differently: editing a policy in the collection editor planned the change, showed the SQL, asked, and applied it, while editing the same policy in the RLS editor POSTed the rules to `/schema-editor/collection/save` and stopped — the source changed, nothing said what the change implied, and the database was left to a later `db push`. A policy declared and not enforced is the worst thing this editor can produce, and it was what it produced by default. - **A refused listing is not an empty one.** Backups, Cron Jobs, API Keys and Branches each caught a failed list, opened a snackbar, and left their list empty. Four seconds later the toast was gone and the screen read "No backups found yet", "No Cron Jobs Registered", "No API keys yet", "No branches yet" — statements about the project, made on the strength of a request the caller was refused. Anyone arriving after the toast was told a database with backups has none. In the same pass: **the schema visualizer stops waiting for collections it has** — `undefined` is the Studio bridge before the admin's registry has registered itself and waiting is right, `[]` is that registry saying there is nothing to draw, and a project that declares no collections sat on "Loading schema…" for as long as anyone left it open — and **the empty states name things the reader can actually find**, rather than a `docs/backups.md` path that exists in this repository and in nobody's project. - **Core hooks say which provider is missing.** Five contexts defaulted to `{} as Whatever`, a lie the type system agreed with: `useData()` called outside `` returned an empty object and the failure surfaced one call later as "data.collection is not a function" inside some component's render — no hook named, no provider named, and a stack pointing at the wrong line. They default to `null` now and each hook throws with both names. - **The demo's `useAuth` flashed signed-out on every reload.** `getSession()` is synchronous, and in cookie mode a restore is always a round trip — the refresh token is in an `HttpOnly` cookie the page cannot read — so reading it on the first render answers `null` whether there is a session or not. The hook awaits `client.auth.isInitialized()` now, which is the pattern the docs show. - **`rls-check` stopped hiding what a scan did not cover.** A `--role` that is not in `pg_roles` was dropped, so a typo did not widen the scan and get noticed — it narrowed it, and every check gates on a grant to an exposed role, so the run printed a clean report of a database nobody looked at. It is exit 2 now, naming the role. And **a keyword connection string reaches the host it names**: `pg` cannot read the libpq keyword form, so `host=127.0.0.1 port=1 …` connected to the default host while the error named `127.0.0.1:1` — a failure reported against a host the scan never went near. The keywords are translated into explicit `Client` options, and the ones that cannot be expressed are refused rather than dropped. - **Six MCP errors an agent could not act on.** `fetch failed` is what Node says when nothing is listening, and on its own it names no host, no port and no next step — nine times out of ten the situation is that `rebase dev` is not running, and the agent holds the tool that starts it. Connection errors now carry the URL that was tried and the command that fixes it. **The environment block outranks the persisted registry**: `REBASE_PROJECT_DIR` / `REBASE_BASE_URL` / `REBASE_API_TOKEN` seeded the `default` project exactly once, on the first start that found no `~/.rebase/projects.json`, so the block a person had just written into `.mcp.json` was dead — pointing it at a second project kept talking to the first, and nothing anywhere said so. And the package's npm landing page told its reader to run `npx rebase-mcp` — the unscoped name on npm belongs to somebody else, so the documented first command fetched and executed a stranger's code. `check-doc-commands.mjs` has caught this class since it was written; `packages/*/README.md` was simply never in its globs. - **Eleven claims in the agent skills that the code contradicts.** A doc a person reads wrong costs them a minute; a skill an agent reads wrong becomes code. `rebase-api` gave the list default as 20 and the ceiling as 100 — they are 50 and 1000, and asking for more is a 400 rather than a clamp, so an agent paging by 100 got 50 rows and reported a short table as complete. The user tools take `uid`, not `userId`, which is what the input schema declares and what the handler reads. `rebase-basics` opened with two prerequisites that do not exist — sign in to Rebase Cloud, then pick a project with `list_projects` — and there is no `list_projects` tool, the MCP server has never read `tokens.json`, and it does not talk to Rebase Cloud at all, so an agent following the first section was stopped at step two by a login that cannot succeed. A gate now checks the class. - **`rebase cloud rollback` refused every managed project.** `isRollbackable` required an `imageUrl`, and a managed deploy publishes none — the platform image is the platform's half of the runtime and the customer's half is the bundle. So the refusal fired locally, before the server was ever asked, and told the owner of a managed project that no image was recorded when one never could have been. The rule is the backend's `rollbackTargetOf` now: successful AND (image OR bundle). - **A mistyped `rebase cloud` action ran the group's default.** Four groups are written as a chain of `if (action === "x")` with the listing at the bottom, so a word matching nothing fell through to it: `storage creat` listed the buckets and exited 0, `clusters verifyy` listed the clusters, `resources et --cpu 500m` printed the dials it was asked to change. Reporting a typo as a successful run of a different command is worse than an error. Each group declares the words it dispatches now, and the rest are refused before the client is built. Relatedly, four groups answered a mistyped action with `{"error":{"code":"error"}}` — an envelope whose only machine-readable field is the word "error" — and all of them answer `unknown_command` now; and **`billing --help` names the actions billing dispatches**, having documented `portal` and `usage` against a dispatch that answers `setup` and `checkout`, so both documented words fell through to the default and printed the billing account instead. - **A piped `rebase cloud --help` said something different from the terminal page.** Eight group pages existed twice — a hand-formatted template literal for a terminal, and a bare list of action words for everything else — so piped, `env --help` answered `{"command":"env","actions":["list","set","unset","reveal","pull"]}`: no descriptions, no flags, and not the paragraph about build-time variables that is the reason the page exists. This family latches JSON mode off a TTY, so that was every scripted and every agent-driven read of it. One description, rendered twice. `REBASE_JSON=0` is honoured, having been tested against the literal "1" so that "0" fell through to the TTY test and set the mode anyway. And **the index lists each group once, from one list** — `clusters` appeared twice under two different descriptions, because the page was a hand-formatted literal sitting beside the array that fed the JSON form with nothing relating the two. - **`rebase cloud deploy` asks about the card before the build, not after the upload.** The first deploy of a project needs a payment method on the organization, and nothing said so until the control plane answered 402 — which on the managed path arrives last, after a type-check, a build, a pack and an upload whose only product was a discarded tarball. The check refuses in one direction only, on a positive "no card on file", because two of the server's skips are invisible from here; every answer this client cannot get proceeds and lets the server decide. - **The "you are not linked" hints named a command that does not exist.** Three messages ended with `rebase link `, and `cli.ts` dispatches `cloud` — the link lives inside that family. So `rebase apps config backend` on an unlinked checkout, and both of `generate-sdk`'s unlinked paths, answered "what do I run now?" with something that exits 1, at exactly the moment somebody is stuck. A test now sweeps every `rebase ` the CLI quotes at a reader and holds the first word to the dispatch list — `check-doc-commands.mjs` does this for the markdown, and nothing did it for the strings the CLI prints, which are read more often because they arrive when something has gone wrong. #### Security An external audit of the framework and Rebase Cloud on 2 September 2026. Every item below was reproduced before it was fixed. Read the first one if you read nothing else: it is the only one that can have been silently true on a deployment you already run. - **A server's first boot on an empty database served every request with RLS effectively off.** The driver decides whether to drop to the restricted `rebase_user` role by asking whether its connection role is a superuser, has `BYPASSRLS`, or owns any tables. On a fresh database with an ordinary owner the answer to all three is no, so no role switch was configured — and the same process then created and owned every table, which makes it exempt from every non-`FORCE` policy for the rest of its life. It logged "subject to RLS natively; no role switch needed" while it did so. The next restart answered differently and quietly fixed it, which is why it survived: the window is one process lifetime, and it is the first one, when a deployment has its first users and nobody is looking yet. Reproduced end to end — a second user listed, counted and rewrote the first user's rows under an `ownerField` rule. The posture is now decided after the schema is provisioned, from ownership Postgres would actually recognise (`pg_has_role`), and a connection that just created tables and still reports "no switch needed" fails the boot instead of logging past it. - **`excludeFromApi` was a read-side rule that stopped at the top-level row.** It was stripped from a row and from an inline relation target, but the "ref" rendering used by WebSocket fetches and by every realtime subscription frame copied the target's columns unfiltered — so a `posts.author` relation to `users`, which is what the scaffold ships, delivered password hashes to anyone subscribed. REST was clean the whole time, which is why it was not noticed. It was also read-side only in the other direction: any caller who could write a row could write the columns it hid, and the OpenAPI generator documented that as intended. Both directions now hold, in every rendering. - **`storagePublicRead` satisfied the production access-control gate and left write, delete and list open.** The boot check treated it as "access control is configured", after which no authorize hook was required — but public read only relaxes reads. Writes fell back to the global `requireAuth`, which is off in exactly the public-site configuration the docs recommend alongside it. Proven anonymous: upload over another user's key, list, read, delete. Public read without a hook now installs one that denies write, delete and list to anyone who is not an admin. - **Resumable uploads were not bound to their owner.** `HEAD`, `PATCH` and `DELETE` looked an upload up by id and never compared the recorded owner, so a leaked upload id let somebody else finish or cancel it under the owner's authorized key. The per-upload ceiling was 5 GB regardless of the controller's `maxFileSize`, which was checked only after finalize had read the whole temp file into memory. Ownership is now checked, the declared length is refused up front, and the file is streamed. - **Revoked and demoted admin tokens kept working on the most valuable routes.** The user-management routers re-read roles and the revocation watermark on every request; the gate in front of backups, cron, logs, the schema editors, the RLS audit and dev mail did not, and neither did the API-key router or the auth router's self-service routes. So a revoked token still downloaded a full database dump and read captured reset emails for up to an hour; a demoted admin could still mint an `admin: true` API key that never expires; and a stolen access token could still link an attacker's GitHub identity to the account *after* the victim had reset their password and signed out everywhere. All of them re-read now. - **The DDL builders interpolated schema and table names straight into `CREATE TABLE` and `ALTER TABLE`.** Identifier validation ran on the introspection reads and not on the statements built from them, and the live-schema router is mounted regardless of `NODE_ENV`, so a crafted table name executed on the owner connection — which bypasses RLS entirely. The AST schema editor had the matching hole on the TypeScript side: top-level keys were emitted unquoted into a collection file and a relation target containing an arrow was emitted verbatim, and `rebase dev` re-imports on change, so the payload ran as soon as it was written. Names that become SQL identifiers or source are now validated where they are used, not only where they are read. Database introspection had the same shape — a table name from `pg_class` went into both a query and a file path — so a hostile table in a database you onboard could run SQL as your role and write outside the collections directory. - **A fresh deployment gave admin to whoever registered first.** No shipped artifact set `DISABLE_SELF_REGISTRATION` — not the compose file, the Helm chart, the platform blueprints, or the Hetzner module whose README brings DNS and TLS up before the operator has had a chance to register. The first administrator is now seeded from `REBASE_ADMIN_EMAIL` / `REBASE_ADMIN_PASSWORD` and self-registration ships off. The address is checked against the same rule the login route parses its body with, and a boot refuses one that rule would reject — an admin nobody can sign in to is worse than the race it replaced, because the account existing is also what removes the first-run path. - **The same window, closed in the framework and not only in the artifacts.** An empty user table admitted the first registration and promoted it to admin — the right rule for a laptop, and an open window on every host with a public hostname, since the shipped artifacts bring DNS and TLS up before the operator has typed anything. `GET /auth/config` advertised `needsSetup: true`, so the unclaimed hosts were also easy to find. `POST /admin/bootstrap` offered the same prize one request later, to the earliest-registered user. The window now exists only outside `NODE_ENV=production`. In production an empty table refuses the bootstrap registration with `SETUP_REQUIRED` and says what to do instead, a first account created through open registration is an ordinary account, `needsSetup` is never advertised, `/admin/bootstrap` refuses, and boot warns when the table is empty and `REBASE_ADMIN_EMAIL` is unset. The two ways in — the named admin seed (`REBASE_ADMIN_EMAIL` / `REBASE_ADMIN_PASSWORD`, which every shipped blueprint already sets) and the service key — are the ones nobody can race for. Development, `rebase dev` and the test suites keep first-registration-is-admin. - **`GET /api/functions` handed anyone the inventory of custom endpoints.** Functions themselves stay anonymous-callable by default (a webhook receiver has to be), but the listing now requires a resolved identity — a signed-in user, an API key or the service key. `rebase cloud debug` already read a 401 there as "mounted". - **Two audit reports named internal infrastructure.** A private database address and a cluster-internal service hostname in `docs/audits/` are replaced with placeholders, and the webhook audit carries a dated status line, since the SSRF guard it says does not exist has existed since 2026-08-08. - **Smaller, and all reproduced:** preview URLs were sanitized by a blocklist that tab and newline variants walked past, and the rich-text editor assigned AI completions to `innerHTML`; a download token could mint itself a fresh one for the same path, which on a trailing-slash key is a perpetual folder-wide grant; static SPA serving returned dotfiles and followed symlinks out of the build directory, so `/.env` and `/.git/config` answered 200; 4xx responses echoed Postgres `DETAIL` in production, an existence oracle for rows RLS hides; image transforms decoded at sharp's defaults with no pixel ceiling and trusted the uploader's declared content type; the email-OTP send route reintroduced timing enumeration and had no per-recipient limit; and `customizeAccessToken` could overwrite `uid` and `roles`, because custom claims were spread over them. - **`rebase auth reset-password` with no password set a constant.** Both reset paths used the literal `NewPassword123!` and `--help` printed it as the default — a string that is in a public repository and in a published package. Reset is the documented way back into an account nobody can sign in to, which in practice means an admin. A password is now generated and printed, and one you supply is masked rather than echoed back. - **Published tarballs carried their own tests.** Sixteen packages ship `files: ["dist", "src"]` on purpose; seven of them keep tests beside the code in `src/`, so `src` swept them in — `@rebasepro/client` published 27 test files. `check:package-contents` now asks npm what it would pack and fails on anything test-shaped. - **Dependency advisories.** `fast-uri` (four highs, all of them the parser's idea of a host disagreeing with the fetcher's — malformed IPv6, a percent-encoded scheme, a repeated hostname, skipped IDN canonicalization) and `qs` both reach `@rebasepro/mcp` as runtime dependencies, not only build tooling. Both raised, with `browserslist` alongside them. #### Documentation - **The branching page promised three things the feature does not do.** It said the CLI updates your local development configuration when you create or switch to a branch — there is no `switch`, and `create` leaves `.env` byte-identical. It presented `DatabasePoolManager`'s pool eviction as the guard against `is being accessed by other users`, when that only reaches pools inside the process doing the work and `rebase db branch` is its own process — so a running `rebase dev` blocks branching and always did. And nothing said branching needs a real PostgreSQL server: on the managed PGlite database `CREATE DATABASE ... TEMPLATE` writes a catalog entry and copies nothing, so the "branch" resolves to the database it was cloned from. `rebase db branch info` and `--from` were missing from the CLI reference as well. - **`docs/compatibility.md` publishes the readiness table it promised** — one row per subsystem, dated, rated stable / beta / experimental, each with what the rating rests on. Realtime is beta because subscriptions are matched by collection path, the data table's missing grid semantics are listed as the defect they are, and `@rebasepro/server-mongo` is marked experimental with no row-level security. - **One first run.** The README, the docs index and the quickstart page described three different sequences, none of which matched what happens. Converged, in six locales, with the derived ports and the `init` flags documented and "use your own Postgres" as a named variant. - **The self-hosting page stops publishing a compose file that cannot work.** Its inline YAML mounted `/var/lib/postgresql/data` (which the pg18 image refuses) and set `POSTGRES_USER: rebase_app` against a `postgres://rebase:` connection string. It now points at the compose file in the repository, the one the acceptance gate boots on every push. - **Rebase Cloud has a documentation page**, and `rebase cloud`'s twenty-eight command groups are in the CLI reference. So are `resources`, `apps init`/`config`, six `db` subcommands, and every `init` flag. - **`@rebasepro/server-mongo` and `@rebasepro/firebase` have pages**, each leading with what it does not do. - **Six deployment guides built a Dockerfile that does not exist.** AWS, Azure, GCP, Scaleway, Railway and Fly.io all said `docker build -f backend/Dockerfile .`, with a careful note about the build context because *that* had been wrong once. There is no `backend/Dockerfile`: a scaffolded project's compose stack mounts a bundle into the published runtime image, and the only Dockerfile the CLI writes comes from `rebase eject`, at the project root. Every one of the six stopped at step 2, for everyone, on every platform. - **The production checklist described a window that is shut.** "On the first visit, Rebase shows a bootstrap screen … claim it right after deploying" is development. In production an empty user table refuses the bootstrap registration with `SETUP_REQUIRED`, `needsSetup` is never advertised, and an account created through open registration is an ordinary one — so a reader following the page deployed, waited for a screen that does not appear, and had no admin. The Kubernetes guide's own headline `helm install` was refused by the chart for the same reason (`config.adminEmail` has been required since self-registration was turned off), the self-host recipe is six values rather than four, and the VPS recipe ran the process in development mode — localhost origins reflected, the OpenAPI spec served, the first-admin window left open on a box with a public name. The unit file now carries `NODE_ENV=production` and the two `REBASE_ADMIN_*` lines, and says why each is there. - **Two pages recommended two different compose files.** Deployment said the generated `docker-compose.yml` "is the source of truth; use it as-is"; Self-Hosting said to use `infra/docker/docker-compose.selfhost.yml` "rather than copying a snippet out of this page". The two do not take the same environment, so a reader with a scaffolded project who met both produced a stack that would not interpolate. The two storage refusals that stop a production boot — a configured bucket with no access-control model, and `STORAGE_TYPE=local` on a host with no persistent volume — are named on the checklist now. - **An error-code reference and a troubleshooting page**, both gated, and the error envelope the server actually sends: both samples in `backend/index.md` showed `{ message, code, status }` with kebab-case codes, where the server sends `{ message, code, details?, requestId? }` with SCREAMING_SNAKE codes and no `status` in the body at all — so a reader who followed either wrote a branch that never fires, in six locales. - **After-hooks run inside the write's transaction, and the docs now say so.** `afterSave` and `afterDelete` have always run there, awaited, so a throw in one rolls the row back. Three docs and a JSDoc promised the opposite — "run after the transaction commits", "do not block the HTTP response" — which invited exactly the code that breaks under the real behaviour: an HTTP call in `afterSave` that turns a slow remote into a rolled-back save. - **"Two-Phase Meta" is deleted, because it never happened.** The realtime page taught an emission model the SDK does not have: a first callback with heuristic metadata carrying `estimated: true`, then an optional second one with the real numbers. There is one emission per push, it waits for `count()`, and there is no `estimated` flag anywhere in the codebase — so an app written to the page checked a field that is always undefined and rendered its first paint as authoritative. - **Four documented routes answered a `text/plain` 404.** `GET /api/collections` had its own section in `api.md` and no router serves it, so the request fell through to Hono's own 404 — not the error envelope, not even JSON. Its neighbours in the route table were the same. The OpenAPI paths are `/api/docs` and `/api/swagger`, named wrongly in every locale, and `/api/meta/contract` is what actually serves schema metadata. - **The five `RebaseBackendConfig` keys nobody could find, and the seven nobody should set.** `compression`, `maxBodySize`, `csrf`, `cronPersistence` and `schemaEditor` all change behaviour you can otherwise only observe, and none appeared in the configuration reference — so the way to learn that responses are gzipped by default, or that CSRF is deliberately off, was to read `init.ts`. Seven others are internal and now say so. The configuration page also lists the variables the code actually reads, and `DATABASE_URL` stops being called required: unset means the managed database, set means yours, said once. - **The AI plugin's Autofill posts field values to `app.rebase.pro`.** That is a reasonable default — the service is free, the requests are anonymous, no JWT travels with them — but the person deciding whether the content of those fields may leave the machine had no way to learn it from the docs. The plugins page names the address, says what is and is not sent, and shows the `endpoint` option for running it yourself. The same page never said that plugins are an admin-panel concept. - **The information architecture, checked rather than asserted.** Every link resolves and every page leads somewhere; the two longest pages are split; a glossary covers the five words the first fifteen minutes assume; Studio has a home; `rebase cloud` has a subsection per group; there is one index of every route the server mounts; a feature that is not released yet says so; a BaaS reader has a path that never mentions React; and a translation that has gone stale is now something the verifier says out loud rather than something a reader discovers. "a entity" was a global search-and-replace, thirty times over. - **Docs that quote a file now are that file.** Page three of Project Structure showed an `App.tsx` with no `App` function, no export, a `createRebaseClient` at module scope and two hooks no package exports — a newcomer met that on page three and concluded the scaffold was broken. The block is the template's file byte for byte, and `check:templates` fails when the two differ. The plugins, custom-views and frontend-overview samples compile and lead with the composition `rebase init` actually writes; the `defineFunction` hover example imports from the portable `@rebasepro/server/functions` and stops recommending a `use()` that covers only the routes below it; the functions guide's first example is `app.post("/")`, because `functions.invoke` sends POST and following the page start to finish produced a 404; and `rls-check`'s README samples are output the tool actually produced, with every flag it accepts in the table and a gate that reads the flags out of `parseArgs` to keep them there. - **The `rebase cloud` recipes are the ones the CLI accepts.** The deploy recipe is one command — `deploy` has read `rebase.json` since it learned to, so `rebase build && rebase cloud deploy --bundle` was three things to remember for a path that needs none of them — and the link sequence starts with `billing setup` and creates the project with flags, because `rebase cloud projects create my-app` is parsed with `maxPositionals: 0` and exits before it reaches the control plane. Rollback says what is restored for each way of deploying, and `--provider` / `--region` are explained as the registered deploy target they record rather than the region choice the beta does not offer. All mirrored into the five locale copies. ### [0.17.3] - 2026-08-31 #### Fixed - **A driver published before 0.18 could not load beside the 0.18 runtime, and `revision` could not have fixed it.** `346df48e2` gave the `database` kind a `revision` so that an older copy of `@rebasepro/types` would lose a disagreement instead of throwing. The bundle corpus refused the 0.18.0 release anyway, on the 0.17.3 row, with v0.17.3's message verbatim — *"Two packages cannot define the same kind."*, no revision clause — which is the tell: the throw came from the driver's inlined copy, not from the runtime. The registry is process-global on purpose, but each copy calls **its own** `registerResourceKind`, and the one that runs the comparison is whichever registers *second*. The runtime registers at import and a driver is imported after it, so the judge is always the bundle's copy, frozen at its release. 0.17.0–0.17.3 deep-equal the spec and throw; they have never heard of `revision`, so no value this package writes can change what they do. The previous fix was only ever reachable in the load order that does not happen. Kinds now live under `Symbol.for("@rebasepro/types.resourceKinds.v2")`, which no released copy looks at. An older copy registers into the legacy map alone, finds nothing to contest, and cannot throw — in either order, which is what the last fix only claimed. Declarations stay on the shared symbol, because that sharing is real and load-bearing. `revision` keeps its job among copies that understand it, and two current specs at one revision still throw. The duplication itself is the disease: `@rebasepro/server-postgres` inlined `@rebasepro/types` into its `dist`, so every pod held two registries. It is externalized now, along with `@rebasepro/common` and `@rebasepro/utils` — all three are `RUNTIME_PROVIDED`, so an npm consumer installs them, a linked consumer resolves them through the workspace, and a managed bundle gets the image's. One copy per process; nothing to reconcile. Two gates hold it. `packages/types/test/kind-registration-protocol.test.ts` reproduces v0.17.3's function verbatim rather than importing this build's — the hole that let `shipped-kinds.test.ts` pass green on the commit the corpus failed — and asserts both load orders. `tooling/scripts/test/driver-single-copy.test.mjs` reads the built `dist` and refuses a driver that defines the registry instead of importing it. - **Three releases published without `@rebasepro/agent-skills`, and nothing failed.** On 2026-08-24 `rebase-agent-skills/` moved under `tooling/`. Both release paths named their publishable packages as literal paths; the shell loops were updated and four `pnpm --filter './rebase-agent-skills'` were not. pnpm treats a filter that matches nothing as a **warning and exits 0** — it prints `No projects matched the filters "…"`, then does the work for the filters that did match. So the bump ran for `packages/*`, silently skipped the skills package, and every job stayed green. The package was last published on 2026-08-23. 0.17.0, 0.17.1 and 0.17.2 each shipped without it. Worse, and far less visible: `packages/cli` depends on it as `workspace:*`, which pnpm resolves at publish time against *that package's own manifest* — so all three published CLIs carry a hard `"@rebasepro/agent-skills": "0.16.0"`, a pin nobody wrote, four versions behind. `rebase skills install` has been writing the 0.16.0 set ever since, which means every agent skill authored or edited in that window — including the whole `rebase-cloud` skill — reached no user at all. Nothing in the pipeline could have caught it: every check asked whether the packages it *found* were correct, and none asked whether it had found them all. **A release no longer enumerates its own contents.** `publishable-packages.mjs` derives the set from `pnpm-workspace.yaml` — every member that is not `private` — and it is the single derivation used by the workflow, by `release.sh`, and by the workspace-protocol validator, all three of which held their own copy of the list. Publishing takes no `--filter` at all, since `pnpm -r publish` already publishes exactly the non-private members wherever they live, so there is nothing left for a directory move to invalidate. `pnpm check:publishable-set` is the guard, and it runs on **every PR** rather than at release time, because a release-time check is discovered during a release. It fails when publishable packages fall out of version lockstep (the symptom), when any release file enumerates packages by hand (the cause), when a publishable `@rebasepro/*` package sits outside the workspace globs where nothing would see it, when a package declares no `files`, or when its `repository.directory` no longer matches where it lives — which the same 2026-08-24 move had also left stale. This does not repair the published 0.17.2: `workspace:*` was resolved at publish time and cannot be rewritten after the fact. The next release is what puts the skills package back on npm and points the CLI at it. A fourth copy of the list surfaced when CI ran against this fix, and it is the one that could not be repaired by correcting a path: the registry-install e2e built its set with `readdirSync("packages")`, so it structurally could not see the package under `tooling/`. `@rebasepro/agent-skills` was therefore never packed, the CLI's dependency on it was never rewritten to a local tarball, and the install fetched it **from the public registry** — which worked only because 0.16.0 is the version this very bug had stranded there. It packs the derived set now, and a first-party package that is not in it is a thrown error rather than a warning: an e2e that reaches the real registry for our own package is not testing the tree it was given. - **The API key dialog described the widest grant in the product as a narrower one.** A permission row's collection field addresses three namespaces, not one: `*` matches every collection *and* every custom function *and* storage, while `storage` and `functions`/`functions/` reach the other two. The dialog labelled that field "Collection slug or *" and the detail panel rendered the wildcard as "* (all collections)" — so a key granting everything read as a key granting only the collections, and two namespaces were undiscoverable from the UI that creates them. `permissions.ts` is now the single place that knows the mapping; the picker labels, row descriptions, grant summary and detail panel all read from it, so they cannot drift from each other or from the server guard. The free-text box became a grouped picker (Everything / registered collections / Functions / Storage / a free-text escape for anything unregistered), and a live read-back under the rows spells the grant out in English as it is built. A row granting nothing used to be dropped silently at submit and now says so, and operation toggles are neutral when off — `delete` rendered red whether or not it was checked, so a read-only key looked destructive. - **`Select` announced every option list as "Select an option".** Its trigger hardcoded that string whenever `label` was not a string, so every such select in the API key panel was identical to a screen reader. It takes an `aria-label` passthrough now. - **The demo has been un-deployable, not merely stale.** `scripts/` moved under `tooling/`, and `app/backend/Dockerfile`'s `COPY scripts ./scripts` kept naming the old path while the `RUN` two lines below it already used the new one — the two halves of one rename disagreeing, so every `pnpm deploy:demo` since died at that layer with "file not found in build context". It copies `tooling/scripts` rather than all of `tooling/`, since only the scripts are needed in the image. Same move, same shape as the entry above. #### Added - **API keys can be created as admin keys, and are labelled as such.** The wire has carried `admin` all along and this view could neither set nor show it: the local `ApiKeyMasked`/`ApiKeyPermission` copies had drifted from `@rebasepro/types` and never gained the field, so an admin key was indistinguishable from a scoped read-only one. The types come from the package now, and admin keys are badged in the list, the detail panel and the created-key confirmation. ### [0.17.2] - 2026-08-31 #### Fixed - **`rebase db push` was impossible for any collection declaring a `{ type: "vector" }` property.** The column compiled to `VECTOR(n)` in `drizzle/schema.sql`, and Atlas computes its desired state by materialising that file in a scratch database it creates empty and empties again at the start of every run — so the type was resolved against a database that structurally cannot have pgvector, and every push died with `pq: type "vector" does not exist`. Not intermittently: permanently, for the framework's own embedding property. Nothing in userland got past it. Seeding the extension does not survive Atlas's clean (measured: present before the run, gone after), a `CREATE EXTENSION` in the desired state is refused as a paid feature, an extension in a non-`public` schema makes the scratch database "not clean", and `--exclude` filters the diff only after the file has been parsed and applied. Vector now takes the carve-out full-text search already had. The column, its ANN indexes and the extension are generated into `drizzle/vector.sql`, Atlas is told to exclude them, and Rebase applies the file itself after `schema apply` and appends it to migrations. Excluding the column turns out to be enough on its own — a `NOT NULL` or `UNIQUE` on it is a property *of* the column and goes with it. - **`rebase db generate` and `rebase db migrate` were both down for any project with a `search` block.** `--exclude` is accepted by `atlas schema apply` and by nothing else, and the guard that added it read as a subcommand test without being one: `migrate apply` matches `args.includes("apply")` exactly as `schema apply` does. Atlas rejects an unknown flag before doing any work, so both commands exited with `unknown flag: --exclude`. Present since the same commit that started appending search DDL to migrations, which means that append had never once run. - **A migration could carry a `DROP COLUMN` for a search or vector column nobody asked to lose.** `migrate diff` computes the current state by replaying the migration directory — which builds those columns, because that DDL is appended to migrations — and diffs it against a `schema.sql` that deliberately omits them, so Atlas plans a drop. Nothing caught it: the destructive gate reads the *push* plan, and this is a file applied days later. Those statements are now removed from the file Atlas writes, clause by clause because Atlas folds the phantom drop into whatever real change shares the table. A drop the CLI cannot rewrite stops `db generate` rather than being guessed at. - **A no-op `rebase db generate` grew the last migration every time it ran.** The `CREATE SCHEMA` rewrite and the RLS policy append were not gated on Atlas having written a new file, so a run that found nothing to diff appended another copy of the policies to a migration that had already been applied in production — changing a hash Atlas had recorded, while the appended SQL ran nowhere. - **Live schema editing left behind a `schema.sql` that `db push` chokes on.** The commit generated it whole, so it carried the RLS policies, the search helpers Atlas will not parse, and the vector column. It now writes the same split `rebase db generate` does. - **`rebase dev` could not host a vector column at all.** The managed development database derived every extension bundle's module path as `@electric-sql/pglite/contrib/`, which does not exist for pgvector — it is a package of its own, and it was not declared. `CREATE EXTENSION vector` failed there with `extension "vector" is not available`, which reads like a broken database rather than a missing import. - **`rebase db push --help` applied the schema.** The flag printed usage and then ran the command it was documenting, against whatever database the project was pointed at. Asking what a destructive command does is the one moment you are most certain not to want it to happen. - **A first deploy deadlocked on a step nothing named.** A project created with `rebase cloud projects create` had no database, was written `status: "provisioning"`, and stayed there: nothing was in progress, the platform was waiting for `rebase cloud db create`, and no output, help page or skill named that command. "Provisioning" reads as work underway, and the correct response to work underway is to wait — so the correct response to this state was the one thing guaranteed never to resolve it. Measured at 43 minutes of polling on a real first deploy; an unattended agent would still be polling. - **`cloud deploy` meant two different things depending on the runtime.** The managed-bundle path stopped at "deploy started" and told you to run `cloud logs`, while the source path waited and made its exit code the verdict — so the same command returned 0 for builds that went on to fail. It now follows on both paths unless `--no-follow` says otherwise, and takes `--wait` and `--timeout`. - **A cluster's refusal was printed as if it were yours.** Failures arrived as a whole Kubernetes `Status` object with request headers, an audit id and a flowschema uid. Worse than the noise: a `403` naming a `system:serviceaccount:` is the control plane's OWN credentials being refused, which nothing in a user's project can grant — and printed raw in a failed deploy it reads exactly like a project fault. Someone acting on that reading deletes working code. Failures are now classified before they are summarised, carry `platform: true` in the JSON, and say in words when retrying and changing the project will not help. The untouched body stays behind `--debug`. - **`clusters verify` never saw the id it was given.** It selected one from `rawArgs`, which is the whole of `process.argv`, so the first match was the node binary path: every invocation asked about a cluster called `/usr/bin/node`, got a 404, and read as "this diagnostic is not deployed yet". It is the one command that reports `permissions.allowed` / `permissions.denied` for a cluster, so the diagnostic for a missing RBAC grant was itself unavailable. #### Added - **`rebase cloud projects create --db managed|byodb|none`**, defaulting to `managed`, so the sequence every project needs is one command rather than two. `--db none` is the deliberate opt-out and still prints the command that finishes the job. - **`blockedOn` and `nextAction` on `cloud status`.** `blockedOn: null` is the load-bearing value — it is the CLI saying that waiting is correct, and the only condition under which polling `status` makes sense. Every other value names a command. - **`db create --wait`**, which polls a bring-your-own database until it answers. For a managed one it reports that there is nothing to wait for and returns, since a loop there would be the same non-terminating wait in a new place. - **A `rebase-cloud` agent skill**, and `rebase-deployment` now points at it. - **`database({ extensions: ["vector"] })`.** Declared in `config/resources.ts`, it lets `rebase db push` and the boot schema-ensure run `CREATE EXTENSION IF NOT EXISTS vector` for you. A permission rather than a request: the statement is issued only where something in the schema needs it, so naming an extension nothing uses installs nothing. It is opt-in because everything that decides whether the install can succeed — the image shipping the library, the role's grant, a managed provider's allow-list — is invisible from inside the connection. Saying nothing withholds the install, never the column, so a database where pgvector was installed by hand keeps working with no configuration. `database()` also accepts options in place of a key, since the default database has no name to pass. #### Changed - A changed `dimensions` on a vector property is no longer silent. Atlas used to own the column and plan the type change; now that it cannot see it, `ADD COLUMN IF NOT EXISTS` would have done nothing and left the old width behind a config that says otherwise. The generated DDL widens the column when it holds no values and refuses — naming the statement to run — when it does. #### Removed - `seedDevDatabaseSearchHelpers`, which never did anything. Its docstring claimed an excluded column is still materialised in Atlas's scratch database; it is not. Measured with a real generated `tsvector` column: a seeded scratch database and an empty one produce byte-identical output in every configuration, and Atlas wipes that database before it plans. The `--exclude` patterns were always the whole protection. ### [0.17.1] - 2026-08-30 #### Fixed - **A managed pod could not unpack its own bundle.** 0.17.0 shipped a fix for a `tar` failure and the fix did not work; every managed pod rolled onto that image crashlooped on the bug it was meant to close. An archive rooted at `.` carries the mode of the directory it was packed from, and GNU tar applies that to the extraction root as its last act — refused where the process does not own the directory, and refused *after* every file has been written, so a complete bundle is reported as a corrupt one. A Kubernetes emptyDir is exactly that case: `root:node` 0775 setgid against a runtime running as uid 1000. No flag avoids it. `--no-overwrite-dir`, `--no-same-permissions`, `--delay-directory-restore` and `--exclude=./` were each measured against GNU tar 1.34 extracting into a directory owned by another uid, and all four still fail on the root: it is not an entry the archive can be told to skip, and it fails even when the mode being set is the mode the directory already has, because the refusal is about ownership rather than change. The runtime now unpacks into a directory it creates and moves the entries up, so the root `tar` chmods is one it owns. Staging sits inside the destination, which keeps the moves renames within a single filesystem rather than a second copy of a tree that is already the largest thing in a pod's ephemeral-storage grant. - **A failed fetch no longer discards a bundle that works.** The runtime threw away the bundle already on disk when a download or unpack failed and then exited, so a pod holding something serviceable died anyway. It now falls back to it. Had this been in 0.17.0 the unpack bug above would have degraded the fleet rather than crashlooping it. - **A collection whose name collides with an internal table keeps its grants.** Such a collection had them revoked. #### Changed - `check:runtime-image:boots` boots the image a fourth way: `mode=url` into a directory the runtime does not own, which is the shape every managed pod actually has. The gate previously only ever unpacked into `/bundle` as the image ships it — the one arrangement in which the failure above cannot occur, which is why it stayed green through 0.17.0. ### [0.17.0] - 2026-08-29 #### Breaking Under 0.x the minor is the breaking position: `^0.16.0` resolves `>=0.16.0 <0.17.0`, so nothing here reaches a project until it deliberately moves to 0.17. The entries below say what stops working and what to do; the reasoning for each is in the detailed section it links to. - **`@rebasepro/admin` is now `@rebasepro/cms`, and `@rebasepro/admin-types` is `@rebasepro/cms-types`.** "Admin" named two things at once — the whole panel, and the content-management half of it — and the ambiguity had already cost something: spreadsheet views, entity history, users & roles and CSV import were being sold as Studio features because there was no other name for the half they actually belong to. The structure is now three peers under Rebase — Backend, CMS, Studio — rather than a parent with two children. "Admin panel" survives only as a lowercase phrase for CMS and Studio rendered together. ```diff ts - import { RebaseAdmin } from "@rebasepro/admin"; - import { defineCollection } from "@rebasepro/admin-types"; + import { RebaseCMS } from "@rebasepro/cms"; + import { defineCollection } from "@rebasepro/cms-types"; ``` **Who this breaks, and what to do.** Anyone importing either package: change the specifier, and `RebaseAdmin` to `RebaseCMS`. There is no alias and no deprecation period — a shim would keep both meanings of "admin" alive, which is the defect being fixed. `@rebasepro/admin` and `@rebasepro/admin-types` stop at 0.16.0 on npm and receive nothing after it, so a range like `^0.16.0` keeps resolving to the last release rather than breaking; it simply stops moving. **Your collection files do not change.** The `admin:` config key is deliberately untouched, along with every identifier named after it (`AdminCollection*`, `Admin*Options`, `ADMIN_COLLECTION_KEYS`), `DatabaseAdmin`/`databaseAdmin`, `wsAdmin`, the `admin` auth role, and `/api/admin`. Those name something other than the CMS product: the `admin:` block feeds a nav drawer Studio shares, and `/api/admin` serves the RLS audit and API keys, both of which are Studio's. Renaming them would have doubled the churn to no one's benefit. The panel's mode value moved with the package, `"content"` → `"cms"`. It is persisted per browser and migrates on read, so a browser that used the panel before this keeps working instead of holding a mode nothing matches and rendering neither half of the drawer. - **Resources are declared, not configured.** `RebaseBackendConfig`'s `dataSources` and `storageSources` are gone; declare them in `rebase.json` and the config package instead. **A bundle built before this will not boot on a current runtime — rebuild it with `rebase build`.** The runtime contract stays at 1 deliberately; see the note under *Removed*. - **A collection still carrying `admin.titleProperty` is rejected at boot.** Use `admin.display.title` — the same string works there. This can stop a project that starts today, which is the point: silence would mean a title quietly reverting to the derived one with nothing to explain why. Details under *Removed*. - **`ctx.client` in a cron handler is now `ctx.rebase`**, and `userId` is no longer an accepted identity spelling anywhere — `uid` everywhere. Both under *Removed*, with the reason each alias was more dangerous than the rename. - **`rebase eject infra` is gone**, along with `rebase.infra.json` and the `{"$env": "..."}` indirection. Resources bind from the environment on the `__` convention, which is the path every deployment already used. - **`rebase build --legacy` and `rebase start --legacy` are now `--workspace`.** The mode is supported, not retired, and the old name said otherwise. - **Every deprecated API alias is deleted rather than warned about**, including `WhereValue` (use `WhereValueFor`) and `RENAMED_SLOTS`. The full list is under *Removed*. - **An incoherent Kanban board now fails at boot.** A board is two declarations that have to agree, and every way of getting it wrong used to parse, boot, serve rows and render — the only symptom being that dragging did not stick. `checkBoardConfig` now runs wherever collections load, so the runtime, `rebase schema generate`, the policy generator and `rebase doctor` all say it. An `orderProperty` naming a property that does not exist, **or one that is not a string, is fatal**; `kanban` with no `orderProperty` only warns, and the board still boots without reordering. **This can stop a project that boots today, and the docs are why.** An order key is a `fractional-indexing` key in base36 (`"i0"`, `"i1"`, `"i0i"`), so a `number` can never hold one — but the documentation said `sortOrder: { type: "number" }` in every locale, and five translated copies additionally nested `orderProperty` inside `kanban`, where nothing reads it. All of that is corrected. If you followed it, change the property to a string: ```diff ts - sortOrder: { type: "number" } + sortOrder: { type: "string" } ``` - **A static app can no longer claim a path the backend serves.** One process serves the API and however many static apps a project declares, and mounting is longest-path-first — so an app declaring `path: "/api"` outranked the API itself, and every request to it was answered with that app's `index.html`: a 200 carrying HTML where the caller wanted JSON, from a project that looked deployed and healthy. `rebase.json` validation, the control plane at deploy intake, and the router's own mount ordering now enforce the same reserved list from `@rebasepro/types`. Matching is at segment boundaries, exactly as the router matches: `/apidocs` is still fine, `/api/v2` is not. `PUT` on the data API is **not** in this list: it was removed during this cycle and put back before release, because every published SDK still sends it. See *`PATCH` is the update verb* under Changed. #### Removed - **`rebase eject infra` and `rebase.infra.json`.** The command wrote a file documented as being "read *before* the environment", and nothing read it: `loadInfraConfig` and `bindResources` had no caller outside their own tests, in either repository. The three-tier binder they implemented — file, then environment, then a local provisioner — never ran, and the header claiming the control plane injected such a file was contradicted by the control plane's own comment saying it deliberately does not. Resources bind from the environment on the `__` convention, which is the path every deployment has always used. Running the command now names the removal rather than failing as an unknown app. `packages/server/src/boot/local-provisioner.ts` went with it — it returned `STORAGE_BUCKET` and `REBASE_STORAGE_ENGINE`, names the resolver has never read. Removing this drops the `{"$env": "..."}` indirection with it. A self-hoster wiring secrets from Vault or SOPS renders them into the environment, which is what everyone was already doing — the alternative was maintaining a second binding path no deployment has ever exercised. > **Breaking: resources are declared, not configured.** `RebaseBackendConfig`'s > `dataSources` and `storageSources` are gone; declare them in `rebase.json` and > the config package. A bundle built before this will not boot on a current > runtime — rebuild it with `rebase build`. > > The runtime contract stays at **1**. Pre-release, a breaking change is just a > change: there is no population of old bundles to protect, so a major would buy > nothing and invalidate the `rebase` range in every manifest and template. #### Added - **`pnpm check:portable-core` — what the request path depends on Node for.** A request this server can answer without touching the database pool is a request an isolate could answer, and that set is larger than it looks: token verification, rate limiting, idempotency, storage URL signing, and every custom function. Eight modules on that path needed a Node process, for nine separate reasons. Five of those modules needed it for no reason anyone had chosen: `randomUUID` from `node:crypto` where the `crypto` global would do, `node:path` to fold `.` out of a storage key that never touches a filesystem, SHA-256 and a constant-time compare that WebCrypto does just as well. Those five are gone, and the gate records what is left in `contracts/portable-core.txt`. It is a ratchet rather than a wall: the file may shrink and may never grow, so a branch that puts a fresh dependency on Node in front of every request has to say so in review instead of a year later. Nothing has to reach zero for that to be worth having — `drizzle-orm` and `pg` need a TCP socket, and that is a driver decision. What it buys is that a later port is a scoping exercise against a list, not an excavation. Three lines remain, each with its reasoning in the file: the JWT library, PEM key parsing, and the client's socket address — a per-adapter capability rather than something a portable module can reach, since Hono has no runtime-agnostic `getConnInfo`. The SSRF guard joined the same list and needed two changes to clear it. `net.isIP` became `utils/ip-address.ts`, a transcription of Node's own grammar held to it by a property test comparing the two directly — a validator that is stricter than `net.isIP` sends a literal down the resolution path, and one that is looser judges bytes nobody else agrees with. And its default resolver is loaded on use rather than imported, so a runtime with no `node:dns` can be handed one instead of being unable to load the module at all. A host with neither fails closed and says which of the two it is missing: the alternative to resolving a name is not "allow it", it is "do not send". - **Custom functions have their own entry point: `@rebasepro/server/functions`.** `import { defineFunction } from "@rebasepro/server"` reaches the whole framework — the boot sequence, the collection loader, the backup routes, the SPA server, `@hono/node-server`, `ws`, `jsonwebtoken`, Drizzle. On Node that costs a little start-up time and nothing else, which is why it stood. It also meant a function file could only ever resolve inside a Node process, however portable the function's own code was — and since that import line is in every function file, every template and every documentation page, it is not a thing that can be changed later without breaking everyone who wrote one. The new entry point carries the authoring surface and nothing else: `defineFunction`, the `rebase` singleton, route guards, typed context accessors, configuration readers, `waitUntil`, `ApiError`, `HonoEnv`. Its published bundle imports exactly two things, `hono` and `hono/adapter`, and the build refuses to ship it otherwise — a test walks the import graph from source and names the chain that broke the rule, and a second check evaluates the emitted file in a context holding web globals and no `process`, `Buffer` or `require` at all. Importing from the package root still works and still behaves identically; it is now the second-best way to write a function rather than the only one. - **Typed accessors for the request context.** `getUser(c)` returns `{ uid, roles, …claims }` or `undefined`, with `roles` always an array. Every documented example used to open with `const user = c.get("user") as { uid: string; roles?: string[] } | undefined` — an assertion in a security-relevant position, copied once and never re-examined, and wrong for at least one auth path that reaches it. `getUserId`, `getRoles`, `hasRole`, `isAdmin`, `isAuthenticated`, `getDriver`, `requireDriver`, `getApiKey` and `getRequestId` come with it. `requireDriver(c)` replaces `c.get("driver")!` and, when there genuinely is no driver, says that the app was mounted outside the functions router instead of failing twenty lines later on `undefined`. `requireRole("editor", "admin")` joins `requireAuth` and `requireAdmin`. All three read the identity the platform already resolved rather than parsing a token, which is what makes them portable — and is a distinction with no behavioural difference inside a function, where both auth middlewares have already run. Outside one, where nothing has, they answer 500 naming the wiring rather than 401 blaming the caller's token. - **`waitUntil(c, promise)` for work that outlives the response.** An un-awaited promise looked equivalent and was not, in both directions. At `SIGTERM` a floating promise is dropped mid-flight, so a rolling deploy has always been able to lose the webhook a request had already answered 200 for; shutdown now waits for tracked work, bounded, and says how much it had to drop. And on any host where the process does not outlive the request, an un-awaited promise is not slow but cancelled — silently, behind a clean 200. `waitUntil` is the one construct both cases honour. - **Configuration is read from the request: `getEnv`, `env`, `requireEnv`, `lazyResource`.** `const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)` at the top of a function file is a live defect today, not merely an unportable one: it is evaluated while the file is being imported, so an unset variable throws before any request exists and the loader reports the whole file as a *skipped function*. The route 404s, and the reason is one line in a boot log. `lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!))` builds the same client once, on first use, from that request's configuration. `rebase doctor` and `rebase build` now report module-scope `process.env` reads in the functions directory. - **`rebase build` records what each function needs from its host.** The bundle manifest gains a `functions` array — name, file, and whether the function's own source reaches a Node built-in or a package that needs one. Purely descriptive: nothing fails, and a function that opens a file or runs raw SQL is a fine function. It is recorded because the name is already the function's identity everywhere (`/api/functions/`, the `functions/` API-key permission, `REBASE_FUNCTIONS_ONLY`), and a host that wants to know what is in a bundle should not have to boot it to find out. - **Live schema editing, from the collection editor to the database.** A running backend can now plan a schema change, show what it would do, and apply it only once somebody agrees. `planSchemaChange` reads the live catalogue before it plans, because whether a `NOT NULL` can be added is a question about rows and whether an enum value will land is a question about the type — neither is answerable from the collections alone. The editor's save path shows the verdict in a sentence, then each change with its remedy, and does nothing until confirmed. Applying is a **second** privilege, not the same one that opens the editor: it alters the database and it writes a commit into the project's repository under somebody's name, and an admin credential is not an author. For deployments with no working tree — a Cloud tenant runs a built bundle and its repository lives elsewhere — the commit goes through GitHub's Git Data API instead of `git`. - **A managed development database, so `rebase dev` needs no Postgres.** Getting a project running was `docker compose up -d db`, then `db push`, then `dev` — three steps, each a place to bounce, plus a compose file the developer then maintains. `rebase dev` now starts the database and pushes the schema itself. The managed database is PGlite behind a multiplexing socket server, and `db pull`, the schema flows and the rest of the CLI were wired through to meet it. Realtime was the one thing it could not do, and it failed in the worst way available: every query succeeded, `LISTEN` returned cleanly, and change events simply never arrived. It now works through a notification proxy. - **`REBASE_DB_POOL_MAX`, a ceiling every pool honours.** The managed database is a single session, where two pooled clients holding overlapping transactions deadlock rather than error. - **The RLS audit runs on a schedule, and the backend serves what it found.** Also `rls-check --html`: the text report is written for a terminal, and the person who has to act on it is usually not the person who ran the scan. A `--fail-on` exit code stops a pipeline; it does not survive being forwarded to whoever owns the database. - **`rls-check --role`, because a check can only gate on a role it knows about.** Every check reports a table as exposed only when a role an untrusted caller can arrive as holds privileges on it, and that set was hardcoded to `PUBLIC`, `anon`, `authenticated`, `web_anon` and `rebase_user`. A stack whose app role is called `app_user` gave every check nothing to gate on, so the scan printed a clean report for a database it had not cleared. The report now also lists `unrecognizedGrantees` — write-holding roles it can neither recognise as exposed nor explain as trusted — so it says "clean as far as I could tell" rather than "clean". - **Storage: byte-range requests and per-object access control.** Media can be seeked, and who may read an object is declared rather than coded. - **Bot protection on the auth endpoints that cost something to hit**, development secrets that survive a restart, and auth email captured in development instead of refused. - **An ANN index for every vector column**, with pgvector shipped in the scaffolded database image. - **Collections declare their indexes, and a hand-written index stops disappearing.** The collection model had no `indexes` key: the DDL generator emitted index statements for exactly two things, both structures a *feature* owns rather than queries anyone wrote — the GIN index behind a `search` block and the ANN index behind a `vector` property. The plain case, the btree behind a `where` clause, had no declaration site at all. ```ts indexes: [ { on: ["status", { prop: "publishDate", direction: "desc" }], reason: "admin list: filter by status, newest first" }, { on: ["publishDate"], where: { prop: "status", op: "=", value: "published" }, reason: "public feed is published-only" }, { on: ["author"], reason: "an author's posts, and the ON DELETE cascade" } ] ``` So the only way to have one was to write it by hand — which is the other half of this. `rebase db push` is declarative, so an index on a managed table that is absent from `schema.sql` is drift and Atlas plans `DROP INDEX` for it. `DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the auto-approved apply took it with no prompt. Measured against atlas v1.2.3 and Postgres 18, not inferred: create an index by hand, re-run an unchanged push, and the plan is a bare drop. Every hand-written index in the field has been living on borrowed time, and since a hand-written index was the *only* kind there was, that was the only outcome. Adding `DROP INDEX` to the destructive list would have been the wrong fix — once indexes are declarable, removing one from your config *should* remove it without a scare. Ownership is decided by the name instead, the arrangement policies already use. An index is named `__ix_<7 hex>` (`_ux_` when unique), which no other namer here can produce, so a declaration you delete drops as intended and an index Rebase did not create is excluded from the diff and never touched. That also settles the introspection round trip: the existing indexes of a database you point Rebase at are foreign until somebody declares them. The hash is over the index's *semantics*, not its rendered SQL, so reformatting the generator never renames a live object — and it is what makes a redefinition take effect at all, since `CREATE INDEX IF NOT EXISTS` matches on the name. (That bug is shipped today one layer over: `vector-index.ts` leaves `WITH (m, ef_construction, lists)` out of its name, so retuning an HNSW index is a permanent silent no-op.) `prop` takes a **property key, never a column name**, because the two differ in exactly the case people index most: a `belongsTo` resolves to its `localKey`, so `author` becomes `author_id`. `where` is structured rather than a SQL string — a string could not be checked against the collection's properties and could not be fingerprinted without putting its own text in the index name. And `reason` is required, and deliberately not hashed: an index is the only thing a config can declare that costs money forever and whose benefit is invisible from the config, so rewording the justification must not rebuild it. Both producers emit them — `db push` on the ordinary Atlas path, and boot-time schema ensure with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`. The first cut only did the former, which a managed-runtime tenant never runs; the derived-names contract caught it, with the whole suite green and the round trip through real Atlas clean. Not included, each its own subsystem: the deferred `CONCURRENTLY` builder for a redefinition (today a DROP + CREATE holding a lock), a size-based push gate, `doctor`'s index categories, introspection adoption, and the drizzle-schema side. See [Indexes](/docs/backend/indexes). - **A pod contract the chart and the control plane both answer to.** Probe paths, shutdown budgets, the bundle mount and the set of topology variables a deployer owns now live in one place that both pod builders read, instead of two hand-written lists that had already disagreed. - **`rebase cloud resources` is priced, with no plan left to name**, and `rebase cloud projects info` prints a Storage line — plus a warning or a lockout notice when the project is near or past its limit. The shared pools already enforced a per-tenant disk ceiling by setting `CONNECTION LIMIT 0`; the tenant's first signal used to be their database refusing connections, with no number anywhere that would have warned them. - **One-click deploy blueprints, an MCP registry manifest, and the security post.** - **The eight documentation pages every locale was missing are translated**, with validation of what the model returns, and the landing page has a translation script of its own — the marketing pages read no markdown, so nothing had ever translated them. - **Resources are declared, not configured — and there is one way to do it.** A database, a bucket and a topic are all spelled the same way, in the project's own config: ```ts // config/resources.ts export const main = database(); export const media = bucket("media", { engine: "s3" }); export const signups = topic<{ userId: string }>("signups"); ``` Before this, storage topology was hand-written into `rebase.json` while database topology lived in TypeScript, and the boundary between them was a fact about what the control plane could read before a build — a platform implementation detail a developer had no way to derive. Worse, a bucket could be declared in *both*, and the runtime merged them: one engine was kept and the other silently discarded. A declaration accepted and then ignored, which is the class this release removed everywhere it appeared. Kinds are **registered**, not hardcoded, because the cost of adding one is exactly why the last two ended up in different homes — a new kind needed a manifest schema edit, a validator edit and a switch statement, so the cheapest thing was always to bolt it onto whichever home was nearest. `cache`, `queue` or `search` now need none of that. Each kind owns its engine list and `custom:` is always accepted, which fixes `engine` having been a free string: `"s2"` used to pass every check and fail far from the typo. - **`rebase resources`** lists what a project declares; `--write` regenerates `rebase.resources.json` and `--check` fails on drift. That file is generated and committed, and it is what a host reads to decide what to provision *before* running anything — which is how a console can say "wants a `media` bucket, has none" on a first deploy, and how a `custom` runtime (which emits no bundle manifest) is visible to the platform at all. - **Binding is separate from declaration, and identical everywhere.** A declaration says a resource exists; the environment says where it lives, on the `__` convention. Baking the address into the repository is how a project ends up with its staging credentials in git, and it is why staging and production can run the same commit against different infrastructure. The cloud is not a second mechanism: the control plane binds the same variables a self-hoster sets, so a managed tenant runs exactly the code path a self-hoster runs. - **Several buckets can share one account.** `bucket("media", { account: "minio" })` reads its own `S3_BUCKET__MEDIA` while the provider-level variables — credentials, endpoint, region — fall back to `S3_ACCESS_KEY_ID__MINIO` and so on. Fifteen buckets on one install go from ninety variables to eighteen, and rotating a key is one edit rather than fifteen paired ones. The bucket name itself never falls back, and neither form falls through to the unsuffixed variable: that one belongs to the default source, and letting a named bucket inherit it would mean a mistyped key silently signs with another source's credentials. - **Topics, delivered through the durable job queue.** Publishing writes one row *per subscription*, so each subscriber retries on its own schedule and a broken one neither blocks the others nor makes them run again. Delivery is at-least-once and says so — `at-most-once` is refused at declaration rather than quietly given the other guarantee. A publish inside a transaction that rolls back never happened. Declaring a topic turns the job queue on by itself, and a driver that cannot carry the queue refuses to boot rather than starting a backend where every publish throws. - **The managed tier provisions what a project declares, and charges for it.** A second database is created on the project's own pool, owned by the same role, and billed as a second shared-database line. The disk quota moved from per-database to per-project for it: the ceiling used to be keyed on `datname`, so five declared databases would have held five full quotas against a volume sized to budget one each — the pool would have run out of space with nothing naming the cause. Sizes and allowances are both summed per project now, so a second database brings its own space rather than splitting the first one's. - **Six-digit sign-in codes by email.** A magic link opens the session on whichever device holds the mailbox, which is the wrong device on a television, a terminal, a kiosk or a second browser — the flow simply cannot be completed there. `auth.emailOtp` (or `AUTH_EMAIL_OTP=true`) adds `POST /auth/otp` and `POST /auth/otp/verify`, and `rebase.auth.sendEmailOtp` / `verifyEmailOtp` in the client. Six digits is a million possibilities, so what is stored is a hash of the address *and* the code together: a guess is a guess against one named account rather than against every account in the table, which is what a code-only lookup would have made of it. Five verification attempts per address per window, keyed on the address rather than the caller's IP because an IP is the attacker's to rotate and the account under attack is not. Ten minutes, single use, uniform digits. `POST /auth/otp` answers identically for an address with no account, so it cannot be used to ask whether somebody is a customer. `AUTH_MAGIC_LINK` arrives with it: both flows were code-level flags only, so a bundle deployment — the shape every self-hosted and managed project runs — could not turn either on without rebuilding. - **Storage triggers: run something when an object lands.** A row has `beforeSave` and `afterSave`, a schedule has a cron job, and an upload had nothing — so everything an upload implied had to be a second call from the client, which means it does not happen when the client goes away between the two. `storageTriggers` fires on `finalize` and `delete`, matched with the same pattern language `storagePolicies` uses, for the multipart and resumable paths alike. Handlers are awaited before the response, because a floating promise is one a serverless runtime may freeze mid-flight; a handler that throws is logged and does not fail the request, because the object is already stored and an error would tell the client to repeat a write that succeeded. - **Image renditions can live in the storage source instead of one process's memory.** The transform cache was per-instance and did not survive a restart, so every replica computed every variant and every deploy threw the lot away. `storageRenditionCache: { enabled: true }` writes each rendition back to the source's own bucket under `_rebase/renditions/`, keyed by the source object's version so a replaced image serves the new one. Off by default, because turning it on makes a `GET` write to somebody's bucket — and when that write fails the request still succeeds from memory, with the reason logged once. - **The development mailbox is readable over HTTP.** Auth mail with no SMTP configured is captured and its links printed, which completes the flow for somebody watching a terminal and leaves it incomplete for a server in Docker, in another window, or one line above where the log has scrolled to. `GET /api/admin/dev/emails` serves the same capture, `DELETE` empties it. What it hands out is a working login, so it is gated three times over: admin-only, a sink must be registered, and the handler re-reads `NODE_ENV` per request — there is no configuration that makes it readable in production. - **A pooled Postgres port for the callers that cannot hold one.** `docker compose --profile pooler up -d` adds pgbouncer on 6432, for the serverless functions, scheduled scripts and BI tools that would otherwise exhaust `max_connections` long before the database is busy. Documented with what transaction pooling takes away — `LISTEN`/`NOTIFY`, session-level `SET`, cross-statement advisory locks, prepared statements — which is why the runtime keeps its direct connection. `SET LOCAL` survives, so RLS behaves identically through it. - **The runtime keeps a little history of itself, and `rebase cloud metrics` prints it.** Drawing "CPU over the last hour" from Cloud Monitoring would have made the panel unportable the day the platform moves, for a feature every self-hoster also wants; metrics-server cannot help either, since it stores only the latest sample by design. So the process samples *itself* — `process.cpuUsage()` and `process.memoryUsage()`, no cluster and no vendor — into its own database, and anything that can read the database can draw the chart. A laptop, a Hetzner box and a Cloud tenant keep the same history from the same code. One row per series per minute, five series, swept to a fourteen-day window at boot, beside the job and cron stores and for their reason: it is the moment the schema is reachable and nobody is mid-request. - **`rebase cloud resources set --replicas` and `--autoscale-max`.** Autoscaling had columns and no flags, so the console form was the only way to reach it. Two flags on the command that already writes every other dial, rather than a `rebase scale` verb — a second CLI surface writing the same row, whose `--size medium` form would have had to carry a t-shirt→cpu/memory mapping client-side, which is exactly what substrate differences (Autopilot's 250m/512Mi floor and 1:1–6.5:1 band do not exist on Hetzner or EKS) make wrong. `--replicas` is the floor and the spend a project is guaranteed to incur; `--autoscale-max` is the ceiling and the worst case it may be billed. There is deliberately no `--autoscale on|off`, which would admit the incoherent state where autoscaling is on and the range is a single point. - **A Terraform module for Hetzner**, and a Hetzner page that is true. The old page described a Rebase that no longer exists — Docker building a Node.js backend from a local Dockerfile, and boot creating only auth tables so collections 404 until someone runs `db push`. Both were wrong, in all six locales, and that page is where a reader lands from `/docs/deployment`. It is rewritten against the contract the self-host compose file implements, and points at that file rather than carrying a copy that can drift again. The module provisions the host — server, firewall, a primary IP that survives a rebuild, and a volume holding Postgres data, Caddy's certificates and the bundle cache. The volume is the reason it exists: replacing the host must not destroy the database, which the shell recipe cannot promise. - **Live schema editing works on MongoDB.** `isSchemaEditingAdmin` is a structural check — a driver either offers `planSchemaChange` or it does not — and the Mongo driver did not, so a Mongo project fell back to the source-only editor, which is off in production. A schemaless database is the one place where changing a collection against a running backend cannot fail, and it was the one place it did not work. `planMongoSchemaChange` is short by the whole of its difficulty: no table to alter, so every change is applicable, nothing is refused, and there are no statements. What each change still carries is what happens to the **data**, because that is where a reader imports the wrong intuition — removing a property on Postgres is refused because it would drop a column, while on MongoDB the field stays in every document that has it and the API stops serving it. Saying so is the difference between knowing the data is there and assuming it is gone. #### Changed - **JWT verification and signing are asynchronous.** `verifyAccessToken`, `generateAccessToken`, `verifyDownloadToken`, `generateDownloadToken`, `hashRefreshToken` and `extractUserFromToken` return promises. Nothing about their behaviour moved; the signatures did, and on purpose, before anything forced it. Every portable JWT implementation is asynchronous, because `crypto.subtle` is. So a later swap of `jsonwebtoken` — for `jose`, or for WebCrypto directly — is not the expensive part: the expensive part is going from synchronous to asynchronous verification, which touches every caller of every function that reads a token. That was 22 call sites in `src` and about 190 in the suite. Paying it now, with the tests green and nothing else moving, costs a day; paying it as a line item inside a runtime port, on top of everything else changing at once, is how a port stalls. `jsonwebtoken` is now confined to one module, `auth/jwt-crypto.ts`, which is what makes the eventual swap a one-file change with no caller affected. The one trap in that swap is written down where the swap will happen: `jsonwebtoken` stamps `iat` on every token it signs and `jose` does not, and `iat` is what the revocation watermark is compared against — tokens minted without it would verify perfectly and simply stop being revocable. `RateLimiterOptions.keyGenerator` and `resolveLimit` accept a promise as well as a value, since a limiter that buckets by user has to verify a token to find one. Passing a synchronous function is unchanged. - **`EmailService.send()` reports what the provider said, and carries headers.** It returned `Promise`, which meant an application that sent a message could not learn the id the server assigned it — so threading a reply back to the message that prompted it was impossible through this interface, and any app that needed it had to bypass the service and hold its own transport. It now resolves with `{ messageId, accepted, rejected }`, every field optional because not every backend reports them: an absent `messageId` means "not reported", never "not sent", which is still signalled by a throw. `messageId` comes back **without** angle brackets, since it is a value to store and compare against a reply's `In-Reply-To`, and one that sometimes carries brackets is a bug waiting in every comparison. `EmailSendOptions` gains `headers`. Several things a real sender must do are only expressible as headers and had no route through this interface at all: `List-Unsubscribe` and `List-Unsubscribe-Post`, which give a mail client its own one-click opt-out and which the large providers weigh when deciding whether bulk mail reaches an inbox; `In-Reply-To` and `References`, without which a reply starts a new thread. Values are **validated, not escaped** — a value containing CR or LF is rejected, because a newline ends the header and begins another one, so any field built from data the sender did not write is a way to add a `Bcc:`. Stripping the newline instead would deliver a message the caller did not write and tell nobody. Header names are checked against RFC 5322 too, and both checks run before a custom `sendEmail` provider is reached, so the custom path is not a way around them. Breaking only for code that *implements* `EmailService`: a `send` returning `Promise` no longer satisfies it. Callers are unaffected — they may ignore the result — and the `auth.email.sendEmail` hook stays permissive (`Promise`), so an existing `async () => {}` provider still works and simply reports nothing. The development mail sink now reports a synthetic id, so a flow that stores one and later matches a reply against it takes the same path in development as in production. - **A vendored tree too large to upload is not vendored.** The control plane refuses a bundle over 100 MB, and vendoring is the one thing that can push a bundle near it — so a build that crossed the line shipped a bundle whose deploy would be rejected, with the remedy (`--no-vendor`) only discoverable by knowing that had happened. Past 200 MB on disk the tree is now thrown away and the bundle ships unvendored: 40–60s of cold start, and a deploy that works. `--vendor` keeps it regardless, for a deploy that builds from source and never uploads the tree at all. The ceiling assumes a pessimistic 2× floor on compression, because the limit is on the *compressed* upload while this measures the tree on disk. The warning below it now says which quantity is which — "201 MB, close to the 100 MB upload limit" was two different numbers described as one, and read as nonsense. - **`GET /api/auth/config` answers one question once, from one handler.** Two handlers claimed that path: `init.ts` registers it directly and only afterwards mounts the auth router, so the router's copy never ran — and the two returned different payloads, one reporting `emailServiceEnabled` and `magicLinkEnabled` where the live one reported `passwordReset` and `magicLink`. A fix aimed at the wrong copy therefore changed nothing, which had already happened once. The router's copy is gone, the payload is assembled in one function, and the surviving route is rate-limited like the rest of the unauthenticated auth surface — it counts users on every call. In the payload itself, `registration` and `registrationEnabled` were the same boolean under two names, both advertised. `registrationEnabled` is the only one now, and it is required rather than optional: it says whether self-registration is open *right now*, first-user bootstrap window included. `anonymousLogin` is required for the same reason. `AuthConfig` in `@rebasepro/client` and `AuthConfigResponse` in `@rebasepro/app` are aliases of `AuthAdapterCapabilities` instead of near-copies of it — the SDK's copy listed an `emailServiceEnabled` flag no backend has ever sent, and marked as optional fields every backend always sends. A test pins the exact key set, because the drift that started this was a field *name*, and no per-field assertion can see one. - **`PATCH` is the update verb for the data API.** `PUT` was mounted on the same handler and the generated OpenAPI spec described the operation twice — once as `patch`, once as `put` marked `deprecated` — so a client generated from the spec had to choose, and the verb it chose meant "replace" for a handler that merges. The SDK's `update()` now sends `PATCH`, which is what the spec has advertised since 0.14; `updateMany` was already there. `PUT` still answers, on the same handler, carrying `Deprecation: true` (RFC 8594). It was removed during this cycle and put back: every published SDK up to and including 0.16.0 sends `PUT`, so removing it broke clients that had no fixed version to upgrade *to* — see *`PUT` on a collection answers again* under Fixed. There is no `Sunset` date, because the removal is gated on which SDKs are in the field rather than on a calendar. #### Removed Nothing below was deprecated in the usual sense of "still works, please stop". Each was a second name for something that already had one, and every one of them is gone. There is no compatibility mode. - **`admin.titleProperty`** → `admin.display.title`. The same string works there, and `display.title` also takes a resolver. The old key had grown seven readers that disagreed about the fallback; a collection still carrying it is now rejected at boot, by name, with the replacement in the message — silence here would mean a title that reverts to the derived one with nothing to explain why. - **`ctx.client` in a cron handler** → `ctx.rebase`. Its type re-exposed `client.data`, the alias `RebaseServerClient` omits on purpose so that the privileged plane is spelled `dataAsAdmin` on every server surface. A reader who learned `client.data` in a cron carried it into a collection callback, where `context.data` is the *user-scoped* plane: same spelling, opposite privilege. - **`userId` as an identity spelling.** `AuthResult` advertised `uid` or `userId` from a custom validator, and the middleware had already half-removed it — the normalisation read `("uid" in r ? r.uid : undefined) || ("uid" in r ? r.uid : undefined)`, the same clause twice — so the documented `userId` had stopped working there while `getUser()` and the JWT verifier still honoured it. `uid` everywhere. - **`RENAMED_SLOTS`**, the rewrite that quietly redirected the retired `collection.insights` and `home.card.insight` slot names, and the console warning beside it. - **`WhereValue`**, superseded by the operator-correlated `WhereValueFor`. - **`tooltipsOpen` and `adminMenuOpen`** on both drawer components, **`error` and `padding`** on `RelationSelector`, and the ignored second parameter of `getEntityTitlePropertyKey` — all declared, all documented, none of them read. - **`isBootstrapCompleted` / `setBootstrapCompleted`** on the auth route module and the admin users route. No caller ever supplied them; the bootstrap gate is "does this backend already have an admin", asked of the rows. - **The websocket client's `subscriptions` map** and the "legacy subscription handling" branch that read it. Nothing ever wrote to it. - **Three modules that only forwarded exports**: `@rebasepro/types/controllers/database_admin` (already exported from `types/backend`), `server-postgres/utils/table-classification` (from `@rebasepro/common`), and the `unflattenObject` re-export in the admin's `file_to_json`. - **`rebase build --legacy` and `rebase start --legacy`** are now `--workspace`. The mode is supported, not retired, and the name said otherwise. - **`UploadFileResult.storageUrl` is required.** Every controller returns one — S3, GCS and local alike — so the `??` fallback behind it was dead code. - **`dataSources` and `storageSources` on `RebaseBackendConfig`**, and the `storage` block in `rebase.json`. All three were ways to declare a resource somewhere other than a declaration. Each is refused at boot, by name, with the replacement in the message — not ignored, because a key that still parses and no longer does anything is the failure this replaced. `` and `` are unaffected: those are props on the React provider, a different surface. Hand them `declaredDataSources()` and `declaredStorageSources()` so the list is not written twice. #### Fixed - **`PUT` on a collection answers again, because every published SDK still sends it.** PATCH became the update verb and the PUT alias went with it. That reached a control plane before it reached any client: `collection.update()` sends PUT in every release up to and including 0.16.0, which was tagged three days before the change landed, so upgrading to `latest` did not help either. Three CLI commands are one `update()` — `rebase cloud stop`, `start` and `restart`, all through `setStatus` — and all three answered `404 No PUT route on collection 'projects' at this path`, which reads as a fault in your own data model rather than a verb that was withdrawn. Worst on `restart`, the thing you reach for when a deploy has gone wrong. PUT is mounted on the same handler and carries `Deprecation: true` (RFC 8594). It is deliberately **not** in the OpenAPI document: PATCH remains the single update operation, so anything generated from the spec still sends the verb the server means, and a spec-validating gateway still sees one operation. There is no `Sunset` date, because the removal is gated on which SDKs are in the field rather than on a calendar — it goes one release after the first published client whose `update()` sends PATCH. - **`executeSql({ role })` answered a refused role switch with owner rows.** The option exists so a statement can run *as* a database role, which is the only way to see what a table looks like with RLS binding. When `SET LOCAL ROLE` came back `42501` — the connection user not being a member of the role — the driver logged a warning and ran the statement on the unswitched connection anyway, then latched a process-wide flag so every later call skipped the switch too, in silence. Owner output is not a degraded answer to that question, it is a confident wrong one: a policy spot-check reads a protected table as exposed. The WebSocket audit line recorded `role` as the role that had been *asked for*, so the trail agreed with the mistake rather than catching it. The same subsystem already fails closed twice over — `applyAuthContext` aborts the transaction when the switch errors, `scopeDataDriver` refuses the request rather than proceed unscoped — so this was the one door left open, and the only one whose fallback changed which rows came back. It now throws `RoleSwitchUnavailableError`, naming the role and both ways out. `DISABLE_DB_ROLE_SWITCHING=true` is unchanged and remains the sanctioned way to run SQL Editor queries as the connection owner: that is an operator's decision, not a failure. `effectiveSqlRole` reports which of the two actually applied, so the audit line no longer restates the request as the outcome. Asking for the role the session already holds needs no switch and still runs — that is the Studio role picker's default, and it never went near the failing path. Not an escalation, and worth saying so: every caller that can pass `role` already holds owner — `rebase.sql` is trusted server code whose default is the owner connection, and the `EXECUTE_SQL` WebSocket verb is admin-gated. This is a correctness and assurance fix, not a patched hole. - **`rebase cloud deploy` read its own command word as the app name.** The command parsed `process.argv.slice(2)` permissively and took the first positional as the app to deploy — but that slice removes only `node` and `rebase.js`, so the first positional is always the string `cloud`. Every documented invocation therefore refused itself: `rebase cloud deploy --bundle` answered *This repository declares no app named "cloud". It declares: backend, web.* — on any project that did not happen to declare an app called `cloud`, which is all of them. `rebase cloud deploy ` was unreachable for the same reason: the app argument landed at `_[2]` and was never read. The failure pointed away from itself, which is what made it expensive. The refusal comes from `selectDeployApp` and names the apps the manifest really declares, so it reads as a fault in the user's `rebase.json` — and `rebase apps list` calls the same manifest valid and eligible. The only route through was `--bundle-dir`, which skips app selection by skipping the build and the static fold with it, so it uploads whatever is already on disk: correct immediately after a `rebase build` and a stale site at any other moment. `deploy` now parses through `parseCloudArgs` like the rest of the family, with `commandWords: 2`, so the command words are dropped from the *parsed* positionals — a flag written before the group no longer shifts the app either. Being a strict parse, it also refuses a flag nobody declared (`--bundel` no longer deploys) and a second positional, rather than treating either as the app name. `--url` joins `GLOBAL_CLOUD_FLAGS`: `resolveCloudUrl` honours it on every line in this family, so a strict parse had to accept it. The tests assert the resolved app name directly rather than through a fixture manifest — a fixture that happened to declare an app named `cloud` would have passed against the broken parse. - **The published types were `any` for anyone using modern Node module resolution.** Every package here is `"type": "module"`, and `tsc` writes relative specifiers into `.d.ts` exactly as the source wrote them — extensionless, because the source is compiled by a bundler. Under `moduleResolution: "nodenext"` (or `"node16"`) an extensionless relative specifier inside an ESM declaration file is an error, and TypeScript's response is the part that matters: it does not fail at the consumer's import. It resolves the package, discards every declaration it could not follow, and types the whole import `any`. So there was no diagnostic anywhere near the cause. The first thing a consumer saw was an implicit-any error in **their own file**, pointing at their code, in a project that had done nothing wrong. Measured on `@rebasepro/server`: `bundler` resolution saw 170 value exports, `nodenext` saw **zero**. It had been that way for the entire life of the packages and was never reported, which is what a silent failure looks like from the outside. Fixed by appending the extension the declarations always needed — `./init` → `./init.js`, and `./auth` → `./auth/index.js` where the target is a directory, resolved against the filesystem rather than guessed. This is not a trade: TypeScript maps a `./x.js` specifier onto `./x.d.ts` under `node10`, `bundler` and `nodenext` alike, so nothing that worked before stops working. The rewrite runs as a build step in all twenty-one published packages. Nothing in this repository could have caught it, and that is the more interesting half. `pnpm typecheck`, the docs verifier and the template checks all map `@rebasepro/*` onto **source**; the API-surface gate reads a single `.d.ts` in isolation. Every gate looked at something other than the artifact a stranger installs. `pnpm check:dts` now looks at that: it installs each built package into a throwaway directory by symlink, imports it, and asks the type checker whether the result is `any` — a question that needs no knowledge of any package's API, and so keeps working as they change. It runs in CI after the build. - **`bundle.mode: url` had never worked, and three independent things blocked it.** The runtime's fetch looked for a `rebase-bundle.json` that nothing has ever written — the CLI writes `manifest.json` — so no unpacked directory was ever recognised as a bundle; the entrypoint exited 1 before `@rebasepro/server` was imported; and the chart rendered a pod missing what the working path expects. Removing any one of them changed nothing, which is how the mode stayed dead while being documented, validated by the gate, and offered in the values file. - **The runtime image stripped four packages it never supplied.** `packages/cli/src/bundle.ts` removes five `@rebasepro/*` packages from a bundle's declared dependencies on the grounds that the image supplies them; `docker/entrypoint.mjs` supplied one. Custom functions and cron jobs therefore failed to load with `Cannot find package`, the routes 404'd, and the container reported itself healthy — only a boot-log warning separated a deployment whose code ran from one where none of it did. The entrypoint's dedupe step also only *repaired* a duplicate and never *provided* a missing copy, which is the common case. The same gap was then live on the fetch path, which does its own stitch after the download and carried a one-package list of its own. All three lists are now checked against each other. - **The published image could not load its own Postgres driver.** The driver's barrel eagerly imported a file watcher used by exactly one `--watch` branch of a CLI, and the image's hand-maintained dependency list does not include it — so `@rebasepro/server-postgres` failed to load entirely and every `/api/data/*` route 500'd behind a green container. Found by a new acceptance run that builds the image from source, brings the documented compose file up, and asserts from outside the container. - **A static app dropped requests on every rollout**, and a killed bundle install left a tree the next boot mistook for a finished one — at a 128Mi limit npm is OOMKilled holding 124 of 156 packages, which is indistinguishable from success unless something records completion. - **The chart's probes contradicted the runtime, and the api counted every caller as one caller.** `TRUSTED_PROXY_HOPS` was set on the functions unit and never on the api, so a default install ignored `X-Forwarded-For` and keyed every rate limit to the ingress. The chart also stopped offering `migrationJob.mode: push`, which the image refuses outright. - **`REBASE_RLS_AUDIT` was a topology variable the pod contract did not claim.** The runtime reads it to decide which process owns the RLS audit scan, beside `REBASE_CRON_SCHEDULER` and `REBASE_JOB_WORKERS`, but it was never added to the list a deployer owns — so a tenant could set it to `false` and stop their own audit with no error anywhere. - **Two auth gaps on the WebSocket path.** `ADMIN_ONLY_TYPES` held nine strings while the handler answers ten privileged verbs; the tenth ran `SELECT DISTINCT unnest(roles)` over the users table ungated. - **A storage key containing `#`, `%` or an encoded slash addressed the wrong object.** Every storage URL interpolated the key raw and the server decodes what it receives. - **Three ways a legal database name generated a file that will not parse.** A hyphenated collection slug, a search column with a hyphen, and a table name legal in Postgres each produced a JavaScript identifier that is not one. The same file already defined `quote`, `propKey` and `member` with docblocks explaining exactly this; they were applied in some positions and not others. - **Seven presentation keys were accepted at boot and then ignored.** `fixedFilter`, `includeId`, `includeEntityLink`, `widget`, `sortable`, `canAddElements` and `previewProperties` were still listed as top-level keys on the four property types they used to live on, so on exactly those types the key was accepted, the migration hint was never reached, and nothing read the value — while the identical key on any other type failed with a helpful message. - **The history prune could delete below `maxEntries`.** It decided how many rows to drop and which rows to drop in two separate reads, and the prune runs unawaited once per write — so two in flight both counted three rows, both decided to drop one, and the second re-read and took a row that was never surplus. Silent data loss, worst exactly where history matters: a record being written concurrently. - **Reading a UI preference could crash the whole render.** Four call sites guarded `localStorage` with `typeof window !== "undefined"` and then used the bare global, which answers "am I in a browser" rather than "can I read storage". Safari in private mode, a blocked cookie policy and a sandboxed iframe all throw on the property *access*, so a user in that state got a blank admin panel instead of the default theme. - **`rebase cloud billing` and `resources` never printed a price.** Both called `invoke("pricing/quote", …)`, and `invoke` URL-encodes the function name, so the slash became `%2F` and the route 404'd — every time, since the commands shipped. - **`pg` was imported at runtime and declared dev-only**, so `rebase db pull --anonymize` would fail in a published CLI under pnpm's isolated layout while resolving fine in this workspace. - **The realtime `vectorSearch` refusal existed and could not fire**, `clearFilter` reset to `defaultFilter` so a collection defining one could never clear its filters, and the admin decided from whether an answer had arrived rather than from the answer — a save in the first round trip after mount silently took the unconfirmed branch. - **A dead local proxy is not a TLS problem.** `rls-check` translated every `ECONNRESET` into advice about `sslmode=require`, which is right for a managed provider and actively misleading for a loopback proxy that has died. - **The agent-skills subpath could never reach a skill** — `exports` declared a trailing-slash directory export that Node has deprecated and cannot resolve a file through — and a scaffolded project had no schema resource, because two helpers assumed this monorepo's `app/` layout. - **"Cancelled deployment null" was the fix reported as a bug**, the auth bootstrap probe swallowed its own failure and answered "already set up" in silence, and `rebase dev` announced the database twice during start-up. - **Storage delivery**: a replaced image no longer serves its old rendition, private objects are no longer marked public, `Content-Length` is declared so a player can work out what to seek to, and cacheable responses say so. - **The snapshot recorder produced snapshots that could not restore**, which is why the upgrade gate had decayed to two hand-written files while 0.14, 0.15 and 0.16 shipped without one. - **`frameworkVersion` meant two different things** — the framework the runtime image ships, and the framework a bundle installed — so `cloud status` and `cloud deployments` read as contradicting each other. - **The schema dialog is no longer downloaded before login**, 14 kB of eager JavaScript for a dialog that only opens when somebody edits a collection. - **Two documentation routes only non-English readers reach were dead**, 124 landing strings whose English had moved on are resynced, and `--refresh-stale` stopped reporting ten keys that were already correct. ### [0.16.0] - 2026-08-20 #### Added - **A relation picker can create the row it is looking for.** The list ends in an *Add …* action that opens the target collection's form in the side panel, over the form you are already filling in; saving it closes the panel and leaves the new row selected, with no second trip through the picker. Until now a relation could only point at something that already existed, so a company that was not in the list meant abandoning the form, going to that collection, creating the row and starting again — and on a record being created, everything typed so far was lost. A search that matched nothing is the name you were looking for, so it seeds the new row: typing `EDU.MX` into the picker and choosing *Add "EDU.MX"* opens the form with that already in the title field. Only when the collection's title lands on a plain string property — putting free text into an enum or a relation would be worse than not prefilling — and the action itself appears only when the user could actually insert into the target, the same permission the selection dialog's own *Add* button checks. The create form does **not** take the URL. It is a detour inside the form you are in, and a record that does not exist yet has no address to restore; pushing one made closing it a *pathname* change, which is exactly what the unsaved-changes blocker watches — so a successful save raced the panel clearing its own dirty flag and often answered with "There are unsaved changes", with the URL stranded on the target collection. The dialog widget already worked this way — except for the URL, which it took too. Its *Add* button is fixed with it, so both create-in-place paths now leave the address bar where the form is. - **A unit of a split deployment can be released on its own.** `functions.image.tag` in the Helm chart (and the `api` / `worker` equivalents, or `bundleUrl` under `bundle.mode: url`) holds one unit at a build of its own, so a fix to a custom function no longer restarts the API. Empty by default: every unit renders one image and one bundle, which is still the shape to prefer. Pinning only the tag inherits the repository, because the common case is one project and one image with one unit held back. Two units on different builds are two sets of collections against **one** database, and only one unit provisions it. So the rule, stated in the values file and in the docs: **the unit that owns the schema rolls first, and a unit may lag but must never lead.** A unit running ahead queries columns that do not exist yet and relies on RLS policies nobody applied — the first is a SQL error on one route, the second is an empty result with a 200. A unit running behind is the ordinary state of any rollout in progress. The migration Job renders the release-wide image, so it always leads the pinned units by construction. - **The Helm chart is published.** `helm install rebase oci://registry-1.docker.io/rebasepro/rebase` — an OCI artifact beside the runtime image, pushed by the same release job under the same credentials and verified pullable from outside with no credentials, exactly as the image is. Until now the chart existed only inside the repository, so installing it meant cloning first: the same defect the runtime image had in 0.13.0, when the first command a new self-hoster ran answered `pull access denied`. A guard asserts an automated workflow publishes it, so it cannot regress quietly the way its predecessor did. The chart carries the **same version as the runtime** rather than its own. It ships with the runtime and its default image tag was already held to the runtime's version; two numbers would mean working out which pairs with which, and there is no useful answer to that question. Both are gated against `@rebasepro/server`. - **The runtime records the collections schema version it applied, and every other process checks itself against it.** The process that provisions writes a version into `rebase.schema_meta`; every other process computes its own from the collections it loaded and compares. On a disagreement it names both versions, says which way is safe, and serves anyway — during a rollout that disagreement is *correct*, because the units that have not rolled yet are supposed to be behind. `REBASE_REQUIRE_SCHEMA_MATCH=true` (or `sharedState.requireSchemaMatch` in the chart) refuses the boot instead, for a deployment that would rather not serve at all than serve wrong. The stamp lives in the database rather than behind an HTTP call to the api, and the difference is not stylistic. Asking the api needs its address configured on every other process — a variable whose absence disables the check silently — and makes booting depend on another process already being up. It also asks the wrong question: two processes can agree with each other while both disagree with the database, and the database is what they are all about to query. It is additionally the only form that works for a `worker`, which has no reason to know any URL, and for a single `all` deployment scaled to three, where there is no api to ask. Both sides of the comparison are **computed** from the collections in hand, never read from a bundle manifest. A version a build declares about itself is not evidence that the database agrees with it — `/api/meta/schema-version` returns exactly that declared value, which is why comparing that endpoint to that manifest is a check that passes on a bundle whose declared version is nonsense. What it cannot do is tell you which side is ahead: a schema version is a hash, so it reports disagreement and never direction. That is why the rollout order is a documented rule rather than something the runtime enforces. A driver older than the runtime has neither hook — the image supplies `@rebasepro/server` while the driver comes from the bundle — and that is treated as "this driver does not record a version" rather than as a boot failure. The check starts working when the project's driver is next updated. - **An entity view can sit in front of the record's own tab.** `position: "start"` on an `EntityCustomView` meant "first among the custom views", which placed it after a tab it was already after — the record's own tab is drawn unconditionally first, so `start` and `end` only ever ordered the custom views against each other and no collection could open on anything but its form. That contradicted `defaultSelectedView`, which has always been able to name a custom view as the landing tab. A cover — a read-only summary of a record, the thing an operator opens a row to see — now renders before the form that edits it. A view that says nothing still lands after the record, so nothing moves for a collection that never asked. - **The record count sits in the collection toolbar.** It lived in the breadcrumb trail, which the app bar owns, so a collection rendered without an app bar simply had no count and no way to see how many rows a filter resolved to. It now ends the toolbar's leading group, after the filter, sort and preset controls — the count is what those resolve to, and it reads as one sentence with them. Hidden wherever the toolbar goes icon-only, because a passive readout is the first thing that should give up its room on a strip that scrolls. - **`rebase cloud resources` shows what a project is given, and changes it.** The dials print with "plan default" where one is unset, so *chosen* and *inherited* are visible rather than inferred, and `resources set` sends only the dials named — a patch carrying every field at its default would overwrite dials set from another client. Nothing here validates a value on purpose: the rules belong to the target cluster (Autopilot bills a 250m/512Mi floor and rewrites anything outside a 1:1–6.5:1 memory:CPU band; a Hetzner or EKS node has neither), so a CLI carrying those numbers would be wrong for two of three providers the day it shipped. - **A cluster can be registered and verified from the command line.** `rebase cloud clusters` was list-only, so registering one meant inserting a row by hand and finding out whether it worked when a customer's first deploy failed inside provisioning. `clusters add` registers from a kubeconfig and points straight at `clusters verify`, which reports what the control plane found — reachable, what the identity may do, what is installed, and a verdict — and exits non-zero on `unusable` so it works as a gate in a runbook. Registration stays admin-only, matching the collection's RLS: a cluster record carries a credential that can create namespaces and read every secret in them. - **`rebase doctor` reports a connection string libpq cannot parse.** Fixing the generator does nothing for the projects it already generated, and this defect is invisible day to day — node-postgres accepts the string, so `rebase dev` and `rebase db push` work while `rebase db backup` has never once succeeded. Doctor now scans `.env`, `.env.local` and the compose files for `DATABASE_URL` and `ADMIN_CONNECTION_STRING` and prints the corrected string to paste back. The compose files are checked on their own account: a deployed stack's scheduled backup cron reads its connection string from there, so it stays broken after `.env` is repaired. - **The default auth emails carry your logo.** All five built-in templates — password reset, verification, invitation, welcome and magic link — render `email.logoUrl` above the card, and the six auth call sites that each carried their own `appName || "Rebase"` line now resolve branding in one place. The fallback is asymmetric on purpose: `appName` falls back to "Rebase" because an unconfigured app has no better name to show, and the logo does not follow it, because the alternative is mailing Acme's users a Rebase mark from Acme's domain. It must be a PNG on an http(s) URL — mail clients do not render SVG and block `data:` URIs, so a non-http(s) `logoUrl` renders no logo rather than a broken image. #### Changed - **Discarding an edit is undoable.** *Discard* and *Clear* sit beside *Save*, throw away everything typed since the record was opened, and until now did it permanently: the reset replaced the form's undo history with a single entry, so the ⌘Z that would have brought the edit back had nothing to step into. The identity bar's version does not even stop to ask — one click, one lost form. Both now go *through* the history rather than around it, and both raise a confirmation carrying an **Undo**, which is the only place the way back can be offered: the form has no undo button, only a shortcut nobody has a reason to guess at. What is stepped back into is the edit, not just its values. The entry the reset leaves behind carries the touched map as well, because the draft backup is extracted *through* that map — restore the values alone and the record comes back looking pristine to everything that asks, including the backup that is supposed to survive a reload. The step also re-publishes the form's version, so a field holding state of its own — a markdown editor, which re-seeds only when that moves — comes back with the rest instead of staying cleared over a value that has already returned. Ordinary undo is untouched: stepping back over a keystroke deliberately does *not* re-seed every field, which is why the two are distinguished at all. A reset the form performs on its own — after a save, on a new record — still clears the history, since there is nothing behind it worth returning to. - **The form's metadata rail widens a little where there is room for it.** 304px to 336px past `@7xl` of *form* width — the same container signal the content column already widens on, so a side panel inside a large window keeps the narrow rail rather than taking a viewport breakpoint's word for it. 304 was picked against the narrow end, where the extra 32px went to the gap beside a chip; on a full-screen form it comes out of the gutters instead, and the status select and date picker in there are the same controls as in the column. - **Realtime is a runtime surface now, and the roles that serve no websockets no longer pay for it.** It was neither a surface nor role-aware, so every role ran it — including `functions` and `worker`, whose entire claim is that they touch nothing. Both mounted a websocket server no client could reach, both held a dedicated `LISTEN` connection outside the pool for the life of the process, and both installed the change-capture machinery at boot: a schema, a trigger function, and a `DROP`/`CREATE TRIGGER` pair per collection table. That last part contradicted the invariant the runtime otherwise refuses to boot without. `REBASE_ROLE=functions` and `REBASE_ROLE=worker` are rejected unless `REBASE_MIGRATE_ON_BOOT=none`, on the grounds that exactly one process owns schema DDL — and then the driver ran schema DDL from all of them anyway, from a code path that never asked the role. Nothing was corrupted (each statement is idempotent, and the multi-statement string is atomic), but every rollout took an `ACCESS EXCLUSIVE` lock per table per pod for no reason. Writes made by those processes are still heard. Capture is database triggers, so a change is published by the database rather than by whichever process made it: a function that writes a row still wakes every subscriber on the `api`. The driver is told two things separately — whether this process consumes change events, and whether it owns the DDL — because they genuinely come apart. An `api` behind an external migration Job subscribes without provisioning. - **A bundle's dependencies are installed once, by `rebase build`, not on every pod start.** A managed pod's bundle lives on an emptyDir, so it was re-fetched and re-installed on every start — an eviction, a node failure, an OOM, a runtime rollout — and that install is 35–55 seconds of a 40–60 second cold start. It is therefore the price of every unplanned restart a tenant suffers, not a startup detail. The pod side needed no change: the init container already skips installing when `node_modules` is present. Native code is never vendored (a compiled binary is only valid for the platform it was built for), and a failed install is never fatal — an unvendored bundle is what every project shipped before this existed. Nor is an *incomplete* one accepted: if the installed tree does not contain the database driver — which happens when the project declares it at a version no registry can serve, a `workspace:` range in a monorepo — the tree is thrown away and nothing is vendored, because the init container skips installing when `node_modules` is present, so a partial tree does not start slowly, it does not start at all. `--os=linux --cpu=x64` is the load-bearing flag, and not because of native modules: the dangerous case is a pure-JS package whose real work lives in a platform-specific *optional* dependency, esbuild being the one everybody meets. - **A new logo and mark, everywhere the panel draws one.** `RebaseLogo`, the favicon it sets, the docs header, the site's own icons and the example apps'. - **Semibold is the ceiling of the type ladder — no weight above 600.** `h1`/`h2` had already been walked back to `font-medium` when the site and the panel were reconciled, leaving the stat variant as the last `font-bold` and a comment announcing a "display tier" rule nothing implemented any more. The display end separates itself by size and tracking, not by weight: a 30px 700 beside a 30px 500 elsewhere in the same product reads as two type systems rather than one ladder, which is exactly what shipped — the marketing site at 500, the panel's stat tiles at 700. Swept, because a ceiling nothing enforces is a preference: every hand-written `font-bold` is now `font-semibold`, and the `font-black` / `font-extrabold` above it came down with it. - **A driver *ahead* of the runtime is reported too.** Version skew was one-directional: a driver behind the runtime was named at boot, a driver ahead by a minor was silent — and that is the pairing a floating runtime range produces when the image lags the packages a project builds against, with half a feature present in the bundle and the other half missing from the harness, in a process reporting itself healthy. Patch leads stay silent on purpose: pinning one fix forward is deliberate, and warning about it trains people to ignore the line. #### Fixed - **A select in a side panel opens where you can see it.** The panel hands its descendants a portal host — itself — so their popups open inside the modal, where the focus and scroll locks let them be used at all. It also carried `will-change: transform` for the slide-in, and that (like `transform`, `filter` or `perspective`) makes an element a *containing block* for its `position: fixed` descendants. A select dropdown is fixed and positioned in viewport coordinates, so inside the panel those coordinates resolved against the panel instead and every list came out displaced by the panel's own left offset — far enough, on a right-hand panel, to open past the edge of the screen. Open, correctly stacked, and nowhere anyone could see it, which is indistinguishable from a select that ignores clicks. Popovers, menus and date pickers in the same panel were unaffected: their positioning measures the offset parent and subtracts it. Only the select's item-aligned placement does the arithmetic against the viewport itself, which is why one control looked broken while its neighbours did not. - **A collection's default sort survives its own mount.** `admin.sort` had no effect on any collection view: rows arrived in the table's natural order however the sort was written, while REST and the realtime socket both ordered correctly when asked directly — which is what made it look like a transport bug. It was not. The table controller subscribed twice, once with the sort read off the collection and then immediately again with no `orderBy` at all, and the second answer replaced the first. The URL-sync effect mirrors the sort with `history.replaceState`, which react-router does not observe, so `useLocation()` kept reporting the search string the view mounted with — the empty one — and a re-render caused by nothing more exotic than a caller passing `fixedFilter` as an object literal parsed it and cleared a default no user had touched. An explicit `?__sort=` in the URL still outranks the collection's default, and back/forward still syncs. - **A card no longer prints the row id above its own title.** Every card in the grid led with a truncated uuid sitting over the product name; `isId: "uuid"` is the default for a Rebase collection, so that line was noise on most of them, and at a card's width it is too short to copy — which is the only thing an id on screen is good for. The card was the odd one out: a list row and a board card show an id only when nothing else names the record. That fallback is untouched, so a record with no readable name still gets its id. `hideIdFromCollection` is not the lever for this and stays exactly what it was: the table reads the same flag for its ID column, where an id is genuinely useful. - **One signed-URL request per file, not one per thumbnail.** A collection view draws one thumbnail per row and rows share images far more than not — 200 blog posts illustrated by 20 hero files. Each thumbnail minted its own download token on mount, and the URL cache is only written when a response *lands*, so it deduped nothing during the burst: 100+ requests for 20 distinct files, which spent the whole rate-limit budget on one page view and made every image on the screen fail together with a 429. The in-flight promise is now shared per cache key — 20 requests, all 200. Deliberately not a longer-lived cache: a signed URL is temporal, so a later mount refetches exactly as before and only the concurrent duplicates are removed. - **The storage limiter counts a signed-in caller as signed in.** A request to `/api/storage/*` carrying a valid admin JWT came back `x-ratelimit-limit: 300` and shared an `ip:` bucket with unauthenticated traffic — everyone behind one NAT together — where a signed-in caller should have had 1000 keyed by uid. The same token on `/api/data/*` reported 1000 correctly, which is what made it look like a quirk of the demo. The limiter reads the user off the context; on the storage router it is registered before the routes, and the JWT middlewares live inside them, so both the key and the limit fell through to their anonymous arms. It now derives the uid from the bearer token itself when the context has none, and uses it **for bucketing only** — pre-resolving the user into the context instead would change authorization, not just accounting, letting a Rebase-signed JWT satisfy a deployment that delegates auth to Firebase or Clerk. An unverifiable token buckets by IP exactly as before. - **`rebase db backup` works on a generated scaffold.** `rebase init` wrote a `DATABASE_URL` whose `options` value carried a literal `=` (`?options=-c%20search_path=public&sslmode=disable`). libpq splits a URI query parameter on the first `=` and rejects any further one, so every libpq caller failed on a fresh project — `pg_dump`/`pg_restore` behind `db backup|restore`, and a plain `psql "$DATABASE_URL"` copied out of the generated `.env`. It shipped because node-postgres parses URLs itself and accepts the literal form, so `rebase dev` and `rebase db push` worked and nothing exercised the URL; the `--database-url` branch had always encoded it, and no test compared the two. Fixed in `init`, `.env.example`, both compose templates and the deployment skill — the compose files on their own account, since a self-hosted stack's backup cron failed the same way. A failed `pg_dump` also no longer leaves a 0-byte artifact behind, which `backups list` showed as an ordinary backup and pruning ranked by timestamp alone, so the corpse held a protected slot while a real backup aged out under it. #### Testing & CI - **Type names claimed in prose are checked, not just the ones in code fences.** Every doc verifier so far read *fenced code* — `check-api-names` greps imports, `typecheck-snippets` compiles the fences outright. A markdown **table** is neither, and a reference table is the shape nobody runs: it is where the agent skills had drifted furthest. `check-prose-types.mjs` reads backticked `*Props` / `*Config` / `*Options` / `*Hooks` / `*Context` / `*Callbacks` names *outside* fences and requires that something in `packages/*/src` declares them. It found, and the sweep removed: `BackendHooks`, `UserHooks`, `DataHooks` and `BackendHookContext`, taught across two skills together with a `hooks.data` config block — none of the four types exists and `RebaseBackendConfig` has no `hooks` key at all, so an agent following it wrote configuration that type-errored or, in plain JavaScript, was silently ignored; `AdminCollectionConfig`, deleted on purpose and still the annotation one skill told agents to write; `EntityOverrides`, for a collection option no config type has; and six `*Props` names in the component-override table, in all six locales, for an override map that is not typed per key at all. The suffix filter is the whole design. A bare capitalised word in backticks is as likely to be a product name, an HTTP verb or a column type as an identifier; `SomethingConfig` is a claim about this repository's types nearly every time. That is what makes it precise enough to be blocking rather than a backlog. - **The documented CLI is checked against the CLI.** `check-doc-commands` had globs for the agent skills, the example READMEs and the repository's own agent instructions — and never for `website/src/content/docs/`, the published documentation. Two commands lived in that gap for as long as the pages have existed: `rebase db studio` had a section of its own in both the CLI reference and the schema page, and `rebase auth create-user` was the first line of the auth example. Six locales each, because the translations are generated from English and inherit whatever it says. Neither command has ever existed; both exit 1. Pointing the existing check at the docs needed no new parser, only the glob nobody had added. `CHANGELOG.md` is exempt — a changelog records what *was* true. - **A first-party GitHub URL must name the repository the package declares.** `rebase-agent-skills/README.md` offered six ways to install and five routed through `github.com/rebaseco/agent-skills`, a standalone mirror that does not exist: `npx skills add`, `gemini extensions install`, `claude plugin marketplace add` and a `git clone` all answered 404, and both plugin manifests advertised the same address as their `homepage` and `repository`. The one path that worked, `rebase skills install`, was Option 1 and the only one needing no repository at all. Checking that a URL *resolves* would need the network, which a gate must not; checking that it names the repository `package.json` declares needs nothing, and is what actually went wrong. Only first-party-looking URLs are checked — an owner within an edit or two of ours, or a repo named after this bundle — so a skill linking `nvm-sh/nvm` is untouched. - **The release stamps the Helm chart along with the packages.** Neither `scripts/release.sh` nor the stable publish workflow touched `charts/rebase/Chart.yaml`, so every release moved `@rebasepro/server` and left the chart on the previous number — and `appVersion` *is* the default image tag, so `helm install` with no `image.tag` rendered a version behind the one just published. `check:runtime-image` caught it, but only after the fact, on the next run. Both paths bump it now, beside the package bump, and refuse the release if neither field matched. - **The Helm chart is checked.** It shipped with no coverage of any kind: no lint, no `helm template`, nothing in CI — and its failure mode is a cluster that comes up looking right. `pnpm run check:chart` lints it, renders the five topologies it documents, and reads the decisions back out of the manifests: the roles, who provisions, that the worker gets no Service, that `/api/functions` reaches the functions unit in one hop through the ingress rather than two through the api's proxy, that a static app takes its own image and carries no Secret. It then extracts every `fail` from `_validate.tpl` and requires a case that reaches it, so a refusal added later fails the check until it is covered. - **The chart's default image tag is held to the runtime's version.** `appVersion` *is* the default tag — `helm install` with no `image.tag` renders it — so the chart's own documented minimum viable install is an image reference made to a user. It had drifted to `0.15.0` against a `0.14.1` runtime, which renders a tag nothing has built and lands in `ImagePullBackOff`. `check:runtime-image` now treats the chart as the user-facing reference it is, hermetically against `@rebasepro/server`'s version and, under `--live`, against the registry. - **A third adapter wrapper is held to the capability list.** `createPostgresAdapter` rebuilds the bootstrapper field by field, exactly as the two wrappers in the runtime do, and nothing was holding it to anything. It silently dropped both new schema-stamp hooks: every layer type-checked, nothing threw, and the runtime did what it does with any missing optional capability — skipped — so the stamp was never written on any real boot. A check that never runs is indistinguishable from a check that passes. `packages/server-postgres/test/adapter-forwarding.test.ts` compares the adapter against the bootstrapper's own key set, so the next capability is covered without anyone remembering to list it. ### [0.15.0] - 2026-08-17 #### Added - **A filter can reach through a relation to a column of the related row.** `where: { "applications.status": ["in", ["applied", "reviewing"]] }` — "has a related row whose column satisfies this", which is the form every queue screen is written in and which previously could not be said at all. Relation filters compared the related row's *id* and nothing else, so the only way to ask the question was to fetch every row and filter in the browser: a filter the client applies after paging is not a filter, because the page was already chosen without it. Compiled to the correlated `EXISTS` the question already was, with the predicate moved off the target's id and onto one of its columns. A many-to-many reaches one table further than the id filter does — that one stops at the junction, which already holds the value it compares — so its subquery joins the target to the junction *inside* the `EXISTS`, where it cannot multiply the outer rows. `belongsTo` is included: `author.name` is a column of another table either way. Every operator works, because the compared value is an ordinary column: `>=` on a date and `ilike` on a name mean here what they mean anywhere else. The negative operators keep the rule the id filter already had, for the same reason — `!=` is `NOT EXISTS` of the **positive** predicate, never `EXISTS` of a negated one. `EXISTS (… AND status != 'hired')` asks "does some application differ from hired", which is true of nearly every candidate with more than one application and answers nothing anybody asked; `NOT EXISTS (… AND status = 'hired')` asks "is there no hired application", and makes `==` and `!=` partition the rows the way a filter implies they do. `is-null` and `is-not-null` are deliberately not a complementary pair on a relation column. They mean "has a related row whose column is unset" and "has one where it is set" — both true of a candidate with two applications, one of each. Making the second the negation of the first would make it "no application has an unset status", which is true of a candidate with no applications at all: the very rows a queue exists to exclude. A relation that does not exist, or a column the target does not have, is a 400 naming the *target's* real columns — never a dropped condition, which would widen the read to every row. - **A sort key can be an aggregate over a to-many relation.** `orderBy: [[{ relation: "applications", field: "created_at", agg: "min" }, "asc"]]` — candidates, longest-waiting first. `count` alone answers the other half of the queue family: clients, busiest first. `min`, `max`, `count`, `sum`, `avg`. This is the half that could not be worked around. A relation filter can be approximated by denormalising a flag onto the row — a trigger, a backfill, and a promise to keep it correct on every write to the related table. An *ordering* cannot be approximated at all once the result set is paged, because the client only ever holds one page and the page was chosen by the wrong order. It is why a project ends up with a 600-line custom view beside the collection it is about: not because the rendering needed customising, but because the query could not be expressed. Compiled to a correlated scalar subquery in `ORDER BY` rather than a `LEFT JOIN LATERAL`, because the same expression has to serve the keyset comparison behind cursor paging — and if the two are not the same expression, paging and ordering disagree and rows are skipped. Cursor paging works: there is no aggregate stored on the cursor row to compare against, so the driver recomputes the cursor row's value in SQL from the id it does have, as a subquery pinned to that id. Pinned rather than correlated, so Postgres evaluates it once for the statement rather than per row. Rows the relation reaches nothing from land at a defined end — `NULLS LAST` ascending, `NULLS FIRST` descending. That was already Postgres's default and is now written out in the `ORDER BY`, because `buildKeysetComparison` encodes the same placement and an invariant two functions depend on should be stated in both rather than assumed in one. `count` of nothing is `0`, not null, so those rows sort as zero. The id stays the last key, so the order is total and paging over it neither repeats nor skips. The object form is the authoring surface; on the wire the key is a single string, `min(applications.created_at)`. `OrderByTuple` is `[string, direction]`, the REST parameter is `?orderBy=key:direction`, the driver contract takes `orderBy?: string | OrderByTuple[]`, and a cursor names its keys by string — `_score` established the same pattern, and this reuses it rather than widening five signatures to carry an object that would be flattened at the end anyway. `normalizeOrderBy` is where the two spellings collapse into one. Both features are declared as capabilities — `supportsRelationFieldFilters` and `relationAggregateSorts` — and both default to **false** for an unclaimed driver. Firestore and MongoDB declare neither. A wrongly assumed filter capability widens a read to every row; a wrongly assumed sort capability answers 200 with rows in whatever order the database pleased, which reads as a sorted list. The offline overlay refuses both rather than answering them wrongly. A dotted filter key resolves to `undefined` on every cached row, which would exclude all of them — a 200 with an empty list, indistinguishable from "nothing matched". An aggregate is not a field on the row either, so every cached row reads `undefined` for it, which the sortability check would have read as a column of nulls and called reproducible before handing back rows in id order. #### Changed - **The `collection.insights` slot is now `collection.widgets`, and `home.card.insight` is `home.card.widget`.** The old names described one plugin's use of the slot rather than the slot, which is any widget strip above the table or on a home card. The prop types follow: `CollectionInsightsSlotProps` → `CollectionWidgetsSlotProps`, `HomeCardInsightSlotProps` → `HomeCardWidgetSlotProps`. A contribution registered under an old name is **redirected to the new one and still renders**, with a one-time console warning naming the replacement. Slot names are matched by string equality, so a plain rename would have left every plugin still on the old name compiling, registering, and rendering nothing — the same silent nothing `UNRENDERED_SLOTS` exists to warn about. The old names are retired, not removed, and will go in a future major version. - **`AdditionalFieldDelegate` says that it is display-only.** `value()` is async and receives the whole `RebaseContext`, so it *can* read another collection and its result is cached per record — which makes it read like a computed column when it is not. It runs in the browser, once per row, after the page has already been fetched and ordered, so its result can never take part in choosing which rows came back or in what order. The doc comment now says so, and points at the two things that can: an aggregate sort or a relation filter for a value derived from a relation, and a real column for anything else. - **The type ladder spans three weights, and a card's edge is a hairline.** Two visual changes to `@rebasepro/ui` that every app built on it inherits. `h1` and `h2` go to **700**. The ladder capped at 600, so a page title and the section heading inside it were the same voice at two sizes and a screen had no clear first thing to read. UI chrome — nav, labels, buttons, table headers — stays at 600, and `h4` steps up to semibold because at 20px medium sits close enough to body copy that a long page reads as one undifferentiated column. This costs nothing on the wire: both faces already load as **variable** fonts, so the whole weight axis ships whether or not it is used. The rule it replaces was written when static weights meant every step was another download. `cardMixin` draws `surface-700/60` instead of a solid edge, and rounds to `rounded-xl`. The softer border is not a new opinion — it is the one `defaultBorderMixin` has always carried, and the SaaS console alone was overriding the card's own border to reach it at **53 call sites**. The component was wrong and every caller knew. `paperMixin` is deliberately unchanged: a menu, dialog or popover sits *over* unknown content and needs a definite edge, where a card in the document flow does not. Page surfaces get a hairline; floating surfaces keep theirs. - **Three type tiers the product kept improvising, and an inset surface for code.** `typography-lead` is the sentence under a page title, which had been borrowing 12px `body2` — so the one line explaining what a page is *for* was smaller than that page's own table rows. `typography-micro` is the uppercase field label above a value: the single sanctioned tier below `text-xs`, and it earns the exception by never carrying a sentence. `typography-mono` carries `tabular-nums`, because proportional digits make a column of measurements ragged and a live counter jitter as its glyphs change width. All three are `Typography` variants, not new components. `codeSurfaceMixin` fixes a surface that had been inverted: code blocks sat on `surface-800` (#111) inside `surface-900` (#0a0a0a) cards — *lighter* than the thing containing them, so every inset well read as raised. It is `surface-950` now, which is what "recessed into the card" actually looks like. #### Fixed - **Chip ink was written down instead of measured, and 63 of 120 hue/tone/mode pairs were below WCAG AA.** The worst was white on `teal.solid` at **1.76:1**, which is not a near miss — it is unreadable. Two causes, and the larger hid behind the smaller: `"#fff"` was hardcoded as the ink on every `solid` background, and this palette is Airtable-shaped, so its bright mid stops want *dark* ink — 14 of 15 hues were wrong. The other 8 came from `onDeep` (defaulting to `pale`) on every `deep` background. The ink is derived now. It walks a hue-tinted starting colour toward black or white only as far as it must to clear the floor on the background it will actually sit on, and takes whichever direction has more headroom. **No palette stop moved**, so chips keep their colours; only the ink did. Starting from each hue's own tints rather than flat `#000`/`#fff` keeps the family looking related — `blue.solid` gets a dark navy, not black. The part that made this more than a colour tweak: an `outlined` chip drops its fill and sits on the **page**, but the component reused the filled ink for it. One value was being asked to be legible against two different surfaces, and for most hues it cannot be — so flipping the filled ink to dark would have made every outlined chip in dark mode invisible. `outlineText`/`darkOutlineText` are separate now, measured against the real page backgrounds. Because it is derived rather than tabulated, a hue added later cannot land below AA: there is no per-tone ink left to forget to check. Asserted for every scheme, filled and outlined, in both modes. - **The accent was below AA as text on a dark card.** `#0070F4` is tuned as a *fill* — white on it, it on white. Read as type on a `surface-900` card it measures **4.36:1**, and every accent link in the product sits on exactly that surface. Dark mode uses `primary-light` for accent *text* now (7.34:1), which is the same hue lifted in lightness and indistinguishable as "the blue". Fills are untouched; there the contrast question runs the other way and `#0070F4` was already right. - **A refused Google sign-in left the login screen silent.** Every provider button failed into nothing: a popup the visitor closed, a redirect whose `state` did not match, a Google script that never loaded — none of that reaches the auth controller, which can only record what it is handed, so the screen rendered no error and the button simply appeared dead. `LoginView` keeps its own error for the half of the flow that happens before a code reaches the controller, and renders the controller's for the half after — cleared once a user is present, so a stale failure cannot sit over a screen that has since succeeded. Backing out is not a failure. `access_denied`, a closed popup and `immediate_failed` are answers, and showing them in red reads as a broken login, so they are swallowed rather than reported. Server side, "Registration is disabled" was a non-sequitur on this path — nobody pressed Create account, they pressed Sign in with Google, and there is no account behind that identity. All three rejection points now say both halves, since the visitor can see neither. The public demo was doing exactly this to every visitor: `--set-env-vars` replaces the whole env block on each deploy and the server defaults `ALLOW_REGISTRATION` to false when it is absent, so the demo advertised "Sign in with Google" and then 403'd the account behind it. - **Every relation in a project whose collections import each other was reported as broken, by the two commands that load collections from source.** `rebase generate-sdk` and `rebase build` read `config/collections` through jiti, which transpiles ES modules to CommonJS. A CommonJS cycle hands the module entered *second* the namespace object — `{ __esModule: true, default: … }` — and never replaces it with a live binding, so a `target: () => otherCollection` thunk returned the namespace rather than the collection. Resolution saw an object with no `slug` and refused it. The measured cost, on a 63-collection project introspected from an existing database: 58 relations rejected, one warning each, and a generated SDK in which **every relation field and every derived foreign-key column was silently missing**. `customerId` and `customer` were simply absent from the row type. Nothing failed — the command exited 0 and wrote a file that looked complete, which is the failure mode you find out about from the compiler months later. The value was never lost. The thunk is lazy, so by the time it runs the exporting module has finished and the collection is sitting one level down in `default`. Resolution now takes it — and only when the inner value is itself a collection, because a `default` that is not one is a genuinely wrong thunk and has to keep reaching the error. `ResolvedRelation.target` is normalised rather than passed through as written, which is the half that decides whether this is a fix or a patch over one symptom. Resolution reads the target once, to derive `targetSlug` and a join table; the forty-odd callers that matter read it *later* — `PostgresBackendDriver` building a join, the Drizzle and DDL generators, `RelationWriteService`, the doctor, the admin's relation fields and table cells. Handing those the thunk as authored would have left every one of them holding the namespace, so the generated SDK would have come out right while the server that serves it stayed broken. Measured on the same project: 134 resolved relations, 0 still answering with a namespace. What made this hard to place is that the warning blamed the author — *"make sure the target is `() => otherCollection` and not evaluated at module load"* — for something the rejected code already did. Cycles between collection files are not an authoring mistake to be designed out: two collections that point at each other **must** import each other, and the lazy thunk is this framework's own answer to that. Native ESM resolves those thunks correctly, which is why the same collections load, relate and serve perfectly under the dev server while the CLI called them broken. A thunk that returns a promise — `target: () => import("./other")`, one keystroke away and the mistake the namespace shape resembles — now says so, rather than reporting "not a collection". - **A write refused by a row-level-security policy answered 500, not 403.** The client could not tell "you may not do this" from "the server is broken" — and a 500's message is sanitized on the way out, so the reason went with it. An operator got paged for access control working correctly. Only `INSERT` was affected, and for a mechanical reason: a refused `UPDATE` or `DELETE` simply matches no rows, which was already classified as `403 WRITE_DENIED`, while a refused `INSERT` raises `42501` from a failed `WITH CHECK` and fell through to the unclassified path. All four spellings of the denial now answer the same status and the same code. `42501` carries two opposite problems and only the message separates them, so the driver now does too: a policy refusing the caller is a 403, while the connecting role lacking a `GRANT` stays a 500 — telling an operator "forbidden" for a missing privilege would send them hunting for a policy bug that does not exist. The message used to name both causes because it could not tell them apart; it now names whichever happened. #### Changed - **The admin panel's Logs view streams, instead of polling every three seconds.** The old view re-fetched the whole window on a timer, which was wrong in three ways at once: an entry could sit up to three seconds before appearing, each client cost a request every three seconds to be told nothing had happened, and — because that request passed through the same middleware that fills the log buffer — the view's own polling became the loudest thing in its own output. On a quiet server it was also what evicted real entries out of the ring. `GET /api/logs/stream` is server-sent events, admin-only like the query beside it. The backlog and the live entries arrive on **one** connection: a client that fetched its history separately would race its own subscription, and entries logged between the two calls would belong to neither. Appends are batched over a 250ms window rather than sent per line, because a busy server logs faster than a browser can render and one frame per entry would cost a re-render per request served — worse than the poll it replaces, precisely when the logs are worth watching. The view says which it is doing. "Live" and "Polling" are not cosmetic: an empty log is ambiguous — quiet server, or a tail that died — and the studio and the server are versioned separately, so a frontend that knows this route will meet servers that do not. A 404 there is an older backend, not an error, and it degrades to the three-second poll rather than showing an empty view. A connection holds a bounded number of entries between flushes, so a burst past roughly eight thousand a second leaves a gap — and says so, with a count, rather than presenting a tail with a hole in it as complete. Fixed in the same work: a client that disconnected *during* the opening write — a fast navigation, or a reconnect storm against a restarting server — leaked its subscriber and a repeating timer, per attempt, for the life of the process. A listener added to an already-aborted `AbortSignal` is never called, so the handler had no way to learn the reader had gone. ### [0.14.1] - 2026-08-16 #### Added - **Access tokens can be signed asymmetrically, and the public keys are published.** A shared secret cannot do the one thing verification most needs to be: cheap to delegate. Handing a gateway, an edge worker or a neighbouring service the means to *check* a session also hands it the means to *mint* one, so in practice the check moves back to the server that owns the secret — and because rotating that secret invalidates every token at once, it is never rotated. `auth.signingKeys` takes PEM private keys. Access tokens are signed by the active one, carry its `kid`, and verify against the matching public half, which is served unauthenticated at `/.well-known/jwks.json` — the URL every verifier already looks for. Additive by construction: without keys nothing changes, tokens stay HS256, and the JWKS answers an empty key set rather than a 404, because "this issuer publishes no public keys" is a fact a verifier can act on where a 404 is indistinguishable from a wrong URL. Turning it on signs nobody out, and neither does rotation — list the new key first, keep the old one until the tokens it signed expire, then drop it. Only private keys are configured, so a mismatched pair cannot be expressed; malformed keys, a duplicate `kid`, an algorithm the key cannot sign and an EC curve other than P-256 all fail at boot rather than at the first login. `JWT_SECRET` stays required regardless: download, MFA-pending and password-reset tokens are read only by the server that minted them. - **A durable job queue, and webhook deliveries that survive a restart.** `webhook-service.ts` said it plainly in its own docblock — the queue was in-process and in-memory, so a crash or a deploy between the enqueue and the delivery dropped the event. Nothing recorded that the event had existed, which makes the failure mode silence: the receiver simply never hears about a row that was definitely written. A job is now a row in `rebase.jobs`. Workers claim with `SELECT … FOR UPDATE SKIP LOCKED`, so each job goes to exactly one worker and N instances divide the work with nothing elected leader. `rebase.jobs.enqueue(task, payload)` is the public door; `jobs: { enabled: true, tasks: { … } }` registers the handlers. The decisions worth knowing: `attempts` increments on *claim*, not on failure, so a job that kills the process cannot retry forever, once per restart. A worker that dies cannot release its own claim, so jobs held past `visibilityTimeoutMs` return to `pending` or dead-letter with an error that says which. An unknown task is returned to the queue rather than failed, because during a rolling deploy the old instance is handed jobs belonging to the new one. Failed jobs are kept for 30 days — a queue that silently drops what it could not deliver looks exactly like one with nothing to do. `idempotencyKey` is unique over *unfinished* work only, or "the nightly digest for user 7" would be sendable once, ever. - **Aggregates: `count`, `sum`, `avg`, `min`, `max`, optionally grouped.** The query API could return rows and a total and nothing else, so every dashboard question — revenue by status, orders per day — meant hand-written SQL in a custom function, or fetching the rows and reducing them in the client. The second is wrong at any size that matters and *silently* wrong under a `limit`: the numbers look plausible and describe the first page. `GET /data/:slug/aggregate?select=count(),sum(total)&groupBy=status`, taking the same filters, `or`/`and` groups and `searchString` as the listing beside it. **RLS applies to the rows being aggregated** — an aggregate is an efficient way to learn about rows you cannot select, so it runs through the request-scoped driver like every other read, and a caller whose policies return nothing counts nothing. Aliases are derived rather than accepted (`sum(total)` is `sum_total`), because a caller-chosen alias would have to be checked against the `groupBy` fields. `count`/`sum`/`avg` are parsed to numbers here, since Postgres returns bigint and numeric as strings. A driver without aggregate support answers 501, not an empty list: "no matches" is the wrong thing for a dashboard to conclude from "not supported". - **Filter inside a jsonb column by path.** Rebase has had jsonb columns for as long as it has had columns and no way to ask a question about what is in one — a filter could compare the whole document and nothing else, so "orders whose metadata says the country is US" meant a custom function or reading the table into the application. `metadata->>country` and `metadata->address->>city` now compile to the extraction they look like. The syntax is Postgres's own and PostgREST's, so the filter reads the same as the SQL it becomes. The path is bound, never interpolated — it arrives from a query string, and `->>` takes a text parameter perfectly well. The filter *value* picks the comparison, because both obvious readings are wrong on their own: comparing as text puts `"9"` above `"100"`, while casting unconditionally turns any row holding a string into `invalid input syntax for type numeric` — a 500 caused by one row's data on a request that is not wrong. An ordering operator given a number casts, guarded so non-numeric rows are excluded rather than fatal; everything else compares as text. A path into a column that is not json is a 400 rather than SQL Postgres rejects at execution time. - **One bundle can run as several cooperating processes.** `REBASE_ROLE=api|functions|worker|all` decides what a runtime process serves and what it owns, so a custom function that pins the event loop can be given its own replica count, restarts and blast radius without its code moving anywhere. Same image, same bundle, same database — only the environment differs. `all` is the default and is byte-identical to the process this server has always booted, so no existing deployment changes. `REBASE_FUNCTIONS_UPSTREAM` lets the `api` role forward `/api/functions/*` to the functions process, so a split deployment presents the identical URL surface and no client, SDK or API key notices. `REBASE_FUNCTIONS_ONLY` / `REBASE_FUNCTIONS_EXCLUDE` narrow a process to named functions; a name the bundle does not contain fails the boot and the error lists the names it does have. Two combinations refuse to start rather than misbehave quietly: a non-`api` role left on the default `REBASE_MIGRATE_ON_BOOT` (several processes would race to provision one schema), and a variable set on a process that does not read it (it would do nothing at all, leaving a deployment that looks configured and is not). See [Split processes](/docs/deployment/split-processes/) for the compose topology, and for what splitting does *not* give you — shared rate limits, cross-instance channels and scale-to-zero are each called out. - **A sort is a list of keys, not one key.** `orderBy` accepts `[["category", "asc"], ["created_at", "desc"]]` wherever it accepted `["created_at", "desc"]`, over the SDK, the REST parameter (`?orderBy=[{"field":"category"},{"field":"created_at","direction":"desc"}]`), a WebSocket subscription, and every driver. The second key decides between rows the first calls equal. On the fluent builder, `.orderBy()` called twice now **adds a tie-breaker instead of replacing the first key** — the previous behaviour discarded the earlier call, which made a multi-column sort unexpressible. If you were relying on the second call to win, pass the one key you want. A bare field name with no direction reads as ascending everywhere. It used to mean DESC on Postgres and ASC on Mongo, so one call described two different queries depending on the database underneath. - **The admin panel orders by more than one column.** Shift-click a table header to add a column under the sort already there; the header shows each key's rank so a two-arrow header says which one wins. The toolbar's sort menu — now in the table view as well as list and cards — is where a key is re-ranked or removed without rebuilding the sort, and a multi-key sort survives a reload and a shared link. #### Changed - **Collection tables are created on every boot path, not only the managed one.** `ensureCollectionSchema` and `ensureCollectionPolicies` were called from exactly one place — the managed bundle boot. An app shipping its own image boots by calling `initializeRebaseBackend` directly, never entered that path, and so had its collection tables created by nothing. It came up serving sign-in — auth bootstraps its own tables, which is what made this read as a data bug rather than a boot bug — and 500'd every `/api/data` route, with a green deploy and a healthy `/health`. Found on a tenant that had been in that state for weeks. Provisioning now lives in `initializeRebaseBackend`, the one function both paths go through. **If you run a custom image against a database whose tables you manage yourself, set `REBASE_MIGRATE_ON_BOOT=none`** — the additive ensure will otherwise create anything the collections declare and the database lacks. It never drops, narrows or rewrites. - **A write over the WebSocket now meets the same validation as a write over HTTP.** `assertKnownWriteFields` and `assertWriteValuesValid` were called from the REST generator and nowhere else, so the socket `SAVE` handler took the client's payload straight to `driver.save`: ``` PATCH /api/data/users/1 { age: 999 } → 400, naming the rule ws SAVE { path: "users", values: { age: 999 } } → written ``` Everything else on the socket path was enforced — it authenticates, it scopes the delegate so RLS binds, and the driver still refuses a column the table does not have. What it skipped is the collection's own `validation` block: `min`, `max`, `matches`, `required`, and the unknown-field check behind `strictWrites`. A realtime write that has been storing values your rules reject will now be refused. #### Fixed - **A policy compiler that quoted values and no identifiers.** `policyToPostgres` quoted the value side of every comparison and the identifier side of nothing, so a column whose name Postgres does not read back unchanged reached `CREATE POLICY` as a bare word — and there are three ways that goes. `"createdAt"` folds to `createdat`, the statement errors, and the collection keeps RLS on with no policy, which denies every row; `columnName` is used verbatim and `rebase schema introspect` populates it from the live database, so this is what any camelCase table adopted from an existing project did. `order`, `default` and `end` are syntax errors mid-clause. Worst, `user`, `current_user`, `session_user` and `current_date` are *valid bare expressions*, so the policy compiled, applied, and was logged as applied — while comparing against the connected role or the wall clock instead of the column. - **A backslash in a policy clause was eaten before Postgres saw it.** The Drizzle generator writes each compiled clause into a `.ts` file inside `` sql`…` `` with no escaping, and Drizzle's `sql` tag reads the *cooked* template strings rather than `.raw` — so JavaScript consumed the escapes first. A rule written ``using: "email ~ '^admin\\.user@corp\\.com$'"`` reached the database as `^admin.user@corp.com$`, where each `\.` matches any character. The DDL generator writes the same rule into a `.sql` file, where a backslash is just a backslash, so the two generators produced different policies from one rule — and the difference was always in the permissive direction. `\d`, `\s` and `\w` went the same way. - **A vector search on a subcollection route was served as a plain listing.** The subcollection routes parse `?vector_search=`/`?vector=` through the same `parseQuery` the root list uses and then built their options without it, so `GET /authors/1/posts?vector_search=embedding&vector=[…]` came back 200 with rows ordered by `id DESC`, no `_distance`, and the threshold ignored — a silent downgrade the caller reads as "these are the nearest neighbours". - **A vector-search threshold narrowed the rows but not the count.** `countRawEntities` forwarded `filter`, `logical` and `searchString` to `driver.count` and dropped `vectorSearch`. A `threshold` is a WHERE clause, not a hint, so a similarity-filtered listing was served narrowed rows beside the count of the *unfiltered* set — three rows with `meta.total: 25` — and paging forward then handed back empty pages while `hasMore` stayed true, until the offset walked past the inflated total. The standalone `/count` route answered the same inflated number. - **`?offset=` became a cursor value on every non-Postgres driver.** Two paths serve `GET /api/data/`: `restFetchService` when the driver has one, and `fetchRawCollection` for everything else — mongo, firebase, anything a developer registers. The second passed `String(offset)` as `startAfter`, which is a cursor *row*, and never passed `offset` at all. The caller got page one every time, with a `meta` block reporting the offset it had asked for. - **A subscription is a query, and two of its fields never arrived.** `logical` and `offset` were accepted at every type-checked boundary and then dropped, because `CollectionSubscriptionConfig` did not declare them — the client sends both, the type has no slot for either, and the subscription re-fetches a different query than the one that was asked for. An `or(...)` subscription ran with the group gone and was pushed every row the caller's policies allow, rather than the rows it asked for. - **A subscription's re-fetches raced, and the loser was delivered last.** Every update a subscription delivers is a full re-fetch, and several things start one for the same subscription without coordinating: the initial fetch at subscribe time, the change stream or `NOTIFY`, and the debounced refetch after a mutation. A fetch that started earlier could finish later, and the delivery replaces the subscriber's whole list — so the subscriber went back to the state before the change and stayed there, silently, until something else touched the collection. Fixed on both the Postgres and the MongoDB paths. - **A LISTEN connection that failed after connecting was never closed.** Both LISTEN clients build a client, connect it, issue `LISTEN`, and only then assign the field the rest of the class cleans up. Anything before that assignment can throw, and when it did nothing knew the connection existed — `stop()` closed the field, the reconnect timer closed the field, and the field was still undefined. A persistent failure (a revoked LISTEN privilege, a pooler that refuses session state) leaked a backend every three seconds. - **A declared-but-empty `REBASE_FUNCTIONS_TIMEOUT_MS` read as "no timeout".** `Number("")` is `0`, and zero is meaningful on this setting — it disables the ceiling on purpose. Both ways of producing an empty value are ordinary: a compose file with `REBASE_FUNCTIONS_TIMEOUT_MS=${SOMETHING}` and no `SOMETHING` in the environment, and a `.env` line carrying the name and no value. Neither reads like a configuration change, nothing logged, and what it switched off is the only bound on how long code the framework did not write can hold a socket. - **`PORT=0` announced `http://localhost:0`.** The listen helper resolved with the port it *asked for* rather than the one it bound, so the ordinary "any free port" request wrote `0` into the boot banner, the dev port file and `.rebase/state.json` — pointing the CLI, MCP discovery and any health check at a port nothing listens on. - **Simultaneous boots abandoned their schema plan.** `CREATE … IF NOT EXISTS` reads the catalog and then writes to it as two steps, so instances starting together do collide — measured at 8 losses in 10 with five peers. The losing statement threw, and the throw abandoned every remaining action in the plan, so a replica that lost one race came up missing tables it had never attempted, with the boot log blaming the one statement that was harmless. The channel-presence and channel-history bootstraps had the same shape with a `REVOKE` as their tail: losing a create race there left the presence roster and every retained broadcast readable by any signed-in user. - **A double-clicked signup answered 500 instead of "email already registered".** `POST /auth/register` reads before it inserts, and both engines back that check with a unique index, so no deployment ever ends up with two accounts on one address. What was missing is what the loser is told: neither `createUser` mapped its driver's duplicate-key error, so the second request raised a bare `23505` or `E11000`, reached the central handler as an unclassified failure, and came back as a sanitized 500. - **Enabling MFA on a Mongo backend answered a sanitized 500.** The auth router mounts the MFA routes for every backend, so `POST /auth/mfa/enroll` is live on MongoDB and landed in the repository's stubs — six of which threw a bare `Error`, which the central handler classifies as unhandled. The person turning on two-factor authentication was told "Internal Server Error" while the actual reason sat in the server log, and the operator got a support ticket about a fault that does not exist. - **`POST /storage/folder` documented a `bucket` field and never read it.** The handler read `path` and `storageId` and derived the bucket from the path prefix, so `POST /storage/folder { path: "reports", bucket: "media" }` answered 201 and created the folder in the default bucket — the parameter accepted, ignored, and the call reported as success. - **The newsletter opt-in was offered only to people who typed a password.** It sat on the credentials form, under the password field — the screen you reach *after* choosing email — so a visitor who signed in with Google was never shown the checkbox at all. It moves to the provider screen, beside the buttons that choose how to sign in and directly under the consent block a host passes as `topComponent`; the two ticks are now spaced as one block of conditions rather than two unrelated asks. - **The OpenAPI spec never mentioned the count endpoint.** `GET /data/{slug}/count` is registered for every collection and appeared in no generated document — the word "count" was not in the generator at all. The spec is what a client generator can see, so an endpoint missing from it is an endpoint that client does not have, and this is the one a paginating UI needs to know how many pages there are. - **A local-first read disagreed with the server about tied rows.** Every server-side sort ends on `id DESC`, which is what makes the ordering total; the offline overlay re-sorted with an ascending id tiebreak, so two rows sharing a sort value came back from the cache in the opposite order to the network — and `isLocallySortable` reported that page as exactly reproducible while it was not. - **The Mongo driver's sort was not a total order, and could not name the id at all.** It emitted only the keys the caller gave, so two rows sharing a value were returned in whatever order the engine pleased — free to differ between two runs of the same query, which is what makes `offset` paging repeat and skip rows. It now closes on `_id` descending, as the Postgres driver has always done. `orderBy: ["id", …]` also named a field no document carries — rows leave the driver with `_id` renamed to `id`, so `id` is the only name a caller has — and Mongo answered by ignoring the sort. It maps to `_id` now. - **Six labels in the admin's sort menu rendered as their own key names.** `sort_then_by`, `sort_move_up`, `sort_move_down`, `sort_remove_key`, `sort_ascending` and `sort_descending` were referenced by the control and declared by none of the seven locale files, and i18next answers an unknown key with the key. All seven locales carry them now, along with `save_entity_before_subcollections`, which had the same defect behind a `?? "…"` fallback that could never fire. A test now checks every literal key the panel renders against the catalogue. - **A list-view column header said "Sort by " whichever state it was in**, so on a descending column it promised a sort where the click removed one — and it said it in English regardless of the panel's language. It now names the next action, the key's rank, and the shift-click that adds a column rather than replacing the sort. ### [0.14.0] - 2026-08-12 #### Breaking - **A `validation.matches` pattern that will not compile is now fatal at boot, instead of silently deleting the rule.** `toPattern` rebuilds the `RegExp` per request and answers `undefined` when the pattern is malformed, and its caller reads `if (pattern && !pattern.test(value))` — so an unclosed bracket did not reject writes, it removed the constraint. Every value passed, for the lifetime of the deployment, while the author went on believing something guarded that column. The lenient runtime branch stays: refusing every write over a config typo blames the wrong party. What was missing was anyone telling the author. `validate-config.ts` now compiles every `validation.matches` at boot and refuses to start on one that does not, naming the pattern, the engine's reason, and what it would have cost — the same way this repo already refuses to start on a relation that cannot resolve. **This can stop a project that boots today.** If you are carrying a malformed pattern, it has not been validating anything, and the error names it. A `RegExp` literal is unaffected — the engine compiled it where it was written. - **BREAKING: the API is camelCase throughout. `author_id` is now `authorId`.** The wire carried two naming conventions at once, and which one a field landed in was not inferable from outside. `GET /api/data/users` answered `displayName`, `photoURL`, `createdAt`; `GET /api/data/posts`, next to it, answered `author_id`. Both were "the wire names". These are also the `where` and `orderBy` keys and the keys the generated SDK types, so a developer moving between two collections had to know, per collection, which convention it had happened to land in. The rule was never stated because there wasn't one. A field's wire name is its property key, and `columnName` renames only the *column* — that part is right and does not change. But two of the four sources of keys never had a property key to use, and both fell back to the column name: - a **foreign key derived from a relation** had no property of its own, so `belongsTo` on `author` served the `author_id` column under its own name; - **introspection** wrote the raw column name as the property key, with `columnName` restating it beside it. Both now derive a camelCase name and keep the column exactly as it was. **The database does not change.** Columns stay snake_case, because an unquoted Postgres identifier folds to lower case and a camelCase column is reachable only as `"authorId"` forever — in hand-written SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool that touches the database. `\d posts` still shows `author_id`, no migration runs, and `rebase doctor` reports no drift. ```diff - GET /api/data/posts → { "id": 1, "title": "Hello", "author_id": 3 } + GET /api/data/posts → { "id": 1, "title": "Hello", "authorId": 3 } - ?where={"author_id":["==",3]} 400 UNKNOWN_FILTER_FIELD + ?where={"authorId":["==",3]} ``` **Who this breaks, and what to do:** - **Everyone using the generated SDK: re-run `rebase generate-sdk`.** `row.author_id` stops compiling and `row.authorId` starts. This is the good case — the compiler names every call site for you. - **Hand-written `where` and `orderBy` keys.** A filter key that no longer resolves is a 400 with `UNKNOWN_FILTER_FIELD`, and the error lists the valid names. It fails closed on purpose: a dropped condition widens a result set, which is the one failure you do not want to be silent. - **Raw `fetch` consumers, and anything reading a row by key.** `row.author_id` is now `undefined`. There is no compiler to find these; grep for the column names your relations derive. - **`rebase schema introspect` over an existing database no longer echoes column names on the wire.** A `customer_id` column is generated as a `customerId` property carrying `columnName: "customer_id"`, and is served, filtered and sorted as `customerId`. This is the largest single change for a project that was introspected rather than authored, and re-running introspection is what produces the new collections. The column, the constraints and the policies are untouched. No dual-key emission and no compatibility flag: serving both spellings would leave the two conventions in place permanently, which is the defect. The one thing that is *not* camel-cased is a name someone already chose — a property key you wrote is your key, whatever its shape, and a `columnName` you set still names the column. - **BREAKING: anonymous sign-in is opt-in. `POST /auth/anonymous` answers 403 until you set `auth.allowAnonymous: true`.** Anonymous sign-in is registration that never asked: it inserts a `users` row and assigns `defaultRole` exactly as `POST /auth/register` does. But both anonymous routes were mounted unconditionally and consulted none of the registration gates, and no config key existed to turn them off. So a backend that had closed the door still handed out permanent accounts. With `allowRegistration: false` and `disableSelfRegistration: true` — whose own docstring calls it a *"hard kill switch: block self-registration outright"* — `POST /auth/register` correctly answered 403, and two unauthenticated requests produced an email/password account anyway: `POST /auth/anonymous` for the row and the session, then `POST /auth/anonymous/link` to put credentials on it. The second was authenticated only by the token the first had just issued, and carried no rate limiter at all. ```diff ts auth: { allowRegistration: false, disableSelfRegistration: true, + // Anonymous sessions are now a thing you ask for. + allowAnonymous: true } ``` Opt-in rather than opt-out, and this is the part that will cost an upgrade some downtime: **a project relying on anonymous sign-in today stops working until it sets the key.** Defaulting it to `true` would have preserved that at the cost of leaving the hole open for everyone who never learns the key exists, and the key did not exist before, so no deployment had yet made a choice. The 403 names the key it needs (`ANONYMOUS_AUTH_DISABLED`); `ALLOW_ANONYMOUS` is the env spelling. `disableSelfRegistration` overrides it — an account created without credentials is still an account created by the public. `allowRegistration` deliberately does not gate it: a public read-mostly app that wants anonymous sessions and no sign-up form is a real deployment, and `allowAnonymous: true` says exactly that. `/auth/anonymous/link` is gated on the same predicate, so a session minted before the switch cannot finish the upgrade, and it gains the limiter it never had. `GET /auth/config` and `getCapabilities()` now report `anonymousLogin`, so a client can discover the state instead of finding out by calling. Still open, and not addressed here: nothing downstream reads `isAnonymous`, so an anonymous user holds the same `defaultRole` as a registered one and no policy can say otherwise. That needs `is_anonymous` in the RLS-visible identity. #### Added - **`admin.display` — one block for how a record presents itself.** A record shows up as a heading, a card, a row, a board tile and a reference chip, and each of those needs to know which property is the title, which is the image, which is the status. That was `admin.titleProperty` and a great deal of per-surface guessing: the detail view had grown its own copy of the title logic and the two had already drifted, so the same record could be headed one way in the list and another way when you opened it. `display` names the roles instead — `title`, `subtitle`, `image`, `status`, `date`, `tags` — and one resolver (`entity-display.ts`, `useEntityDisplay`, cached) answers for every surface: the table, list, board and card bindings, the preview slots, the form, the entity views and `useColumnsIds`. The property paths are checked against your own properties the way the rest of the `admin` block is, so a renamed field is a compile error rather than a column that quietly stops appearing. ```diff ts import { defineCollection } from "@rebasepro/cms-types"; export default defineCollection({ name: "Posts", slug: "posts", table: "posts", properties: { title: { name: "Title", type: "string" } }, - admin: { titleProperty: "title" } + admin: { display: { title: "title" } } }); ``` **`admin.titleProperty` still works.** It is deprecated, not removed: it shipped in 0.13.0 and is still read at runtime, with `display.title` winning when both are set. Postgres introspection codegen emits the new block, and the collections docs and skill are updated in all six locales. - **The self-host runtime image is published by the release, not by remembering to.** The scaffolded `docker-compose.yml` presents `rebase build` + `docker compose up` as the way to self-host and pins `REBASE_VERSION` to the released version, but nothing published `rebasepro/server` on a release — `cloudbuild-runtime.yaml` has had a Docker Hub push for months and runs only when someone types `gcloud builds submit`. So the first command in the file a new project is handed ended at `pull access denied for rebasepro/server, repository does not exist`. The release workflow now builds and pushes it (amd64 + arm64) after npm and the tag, then verifies the tag is pullable from outside with no credentials. `scripts/check-runtime-image.mjs` keeps it honest: every image reference in a shipped compose file must have an automatically-triggered publisher, and a build config only a human can run does not count. `verify-selfhost.mts` could never have caught this — its own header says what it leaves out, "a container and an image tag". - **`rebase skills install --agent all`**, for scripted and CI use. Without a TTY the command has to be told which agents to install for, because a scaffolded project ships a marker file for every one of them (`.cursorrules`, `CLAUDE.md`, `.windsurfrules`, `AGENTS.md`) and detection therefore has no signal — guessing would install four agents' skills unasked. - **`updateMany` and `deleteMany`, the counterparts `createMany` never had.** An ETL job could insert 1000 rows in one transaction and then had to delete them one HTTP request at a time. The asymmetry was not a gap in one layer but in all of them — contract, driver, REST, SDK, offline queue and generated spec. Both shapes are the conservative reading rather than the inherited one. `updateMany` takes `{ id, data }` entries, not flat rows carrying their own key: on a table keyed on a `sku` or a composite key a flat row cannot say whether a column is the address or a value to write, so naming the address separately mirrors single-row `update(id, data)` and leaves nothing to infer. `deleteMany` takes ids, not a filter — a filter-shaped bulk delete is a different and far more dangerous operation, whose failure mode is an omitted or mistyped condition emptying a table, and unlike an explicit list it cannot be reviewed at the call site. Read first, pass the ids you meant. The delete is served at `POST //bulk/delete` rather than `DELETE //bulk`. A DELETE body is the honest verb and the one request shape the HTTP ecosystem handles unreliably: bodies on DELETE are permitted but widely dropped by proxies, and several OpenAPI generators ignore `requestBody` on a DELETE operation, so a generated client would send the request with no ids at all. "Deletes nothing" is the good outcome of that bet. ```typescript await client.data.products.updateMany([ { id: "sku-1", data: { price: 1200 } }, { id: "sku-2", data: { price: 900 } } ]); const stale = await client.data.sessions.findAll({ where: { expires_at: ["<", cutoff] } }); await client.data.sessions.deleteMany(stale.map(s => s.id as string)); ``` #### Removed - **`@rebasepro/client-postgres` is gone.** It was published on every release since the `client-postgresql` rename — 137 versions, `latest` on npm — and imported by nothing: no workspace package depended on it, no example, template, doc page or skill used it, and its own README's Quick Start did not compile (``, a prop that does not exist). Its description was wrong too: not a direct PostgreSQL client and not PostgREST, but a WebSocket passthrough to the Rebase backend, which `@rebasepro/client` already is. It was also quietly broken. `fetchCollection` re-listed seven of `FetchCollectionProps`' twelve fields by hand and dropped `offset` and `logical`, so `find()` returned page one beside a correctly-narrowed total, `hasMore` never went false, and `findAll()`/`iterate()` returned page one N times, terminated cleanly, and reported a plausible row count — silent duplicate data. Four sibling methods had the same shape. Use `@rebasepro/client` with a `dataSources` entry; that is what the admin panel does and what `docs/data-sources.md` has always described. The published versions stay on npm and will be deprecated there — nothing is unpublished, so an existing lockfile keeps resolving. #### Fixed - **A 200 the SDK could not parse was returned as an empty object.** `request()` kept `body = {}` when `JSON.parse` threw. On an error status that is harmless, because the status is the answer; on a *success* it was the whole answer — `find()` resolved to `{}` rather than an array and `getOne()` to an empty object, with nothing thrown. To a caller that reads as "no data", not as "you are not talking to the API". The case is ordinary: point `VITE_API_URL` at the frontend's own host and `/api/data/posts` lands on the single-page-app fallback, which answers 200 with `index.html`. So the misconfiguration the 404 branch spends four lines explaining reached callers in its most common form as an empty success, because an SPA fallback returns 200, not 404. A proxy error page does the same. A success whose body this client cannot read is now a `RebaseApiError` with `INVALID_JSON_RESPONSE`, quoting the first 120 characters — `` identifies the sender faster than any wording could. A 200 with no body at all still resolves to `{}`: some endpoints legitimately answer that, and it is a different thing from an unreadable one. - **Introspected collections opted out of key checking.** `rebase schema introspect` opened every generated file with `const ordersCollection: PostgresCollectionConfig = {`, and that annotation widens `properties` to `Record`. Every key-shaped field in the `admin` block is derived from those keys — `propertiesOrder`, `listProperties`, `sort`, `display.title`, `fixedFilter` — so annotated, they accept any string. Introspection was emitting a `propertiesOrder` array that nothing checked: rename a column, re-introspect, and the stale key compiled silently and reordered nothing. Introspected keys are precisely the ones nobody typed and nobody remembers, so this was backwards. Generated collections now use `defineCollection`, which `rebase init` has always written, and which keeps the keys literal. A `propertiesOrder` entry naming no property is a compile error, and the compiler names the column the rename left behind. Which `defineCollection` is detected per run, from the package manifests above the output directory — the same path Node resolves a bare specifier along: | Your project declares | Generated collections use | `admin` block | |------|------|------| | `@rebasepro/cms-types` | `defineCollection` from `@rebasepro/cms-types` | yes | | `@rebasepro/common` (a `--headless` project) | `defineCollection` from `@rebasepro/common` | no | | neither | a `PostgresCollectionConfig` annotation, with a warning | no | **The headless flavours emit no `admin` block, on the collection or on any property.** That is a fix rather than a downgrade: `@rebasepro/types` declares no `admin` field at all — the augmentation in `@rebasepro/cms-types` is what adds it — so the block introspection used to emit was a type error in every headless project it was ever written into. What is dropped is presentation (`icon`, `propertiesOrder`, `multiline`, `readOnly`) for a project with no panel to present it; nothing about the schema, the API or your data depends on it. `@rebasepro/common` is a dependency of the headless config package now, which is what makes that branch reachable. A project scaffolded before this keeps the old annotation and is told what it is missing. One thing changed in the generated relations to make inference survive a real schema: the `target` thunk's return type is written out, `target: (): AnyCollectionConfig => authorsCollection`. Without an explicit type on the const, a relation cycle — `posts` belongs to `authors`, `authors` has many `posts`, or a self-referencing `employees.reports_to` — makes the inference circular and every file in the cycle fails to compile. - **Storage was the one router with no rate limiter, and the one where a request costs money.** `createDataRateLimiter` was mounted on the data router and the functions router and nowhere else. Upload, download and the whole tus sequence were unbounded — and storage is the surface where a single HTTP request buys a metered third-party operation: `PutObject`, `GetObject` and its egress bytes, `ListObjectsV2`. The download path was the worst of it. With `storagePublicRead: true` — a documented, ordinary setting — `readAuthMiddleware` resolves to a no-op, so `GET /file/*` was anonymous, unauthenticated and unlimited. One machine looping over a large public object is a full `GetObject` and a full egress charge per request, with no ceiling and no per-caller accounting; the bill arrives a month later. Storage now shares the same limiter *and the same store* as data and functions, so a caller has one budget across the product rather than one per router. It is registered after the API-key guard, so a key's identity is on the context and requests bucket by caller rather than by IP. The request limiter is the floor, not the whole answer: storage's cost profile is bytes rather than requests, and a bytes-per-window bound per bucket needs accounting this layer does not have yet. - **`rebase doctor --policies` reported a clean database with row level security switched off.** `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in `pg_policies` untouched, and `pg_policies` was all the drift checker read. So every expected policy still matched on name, roles, command and clause presence, and doctor printed `✓ RLS policies match your collections` for a table Postgres was applying no filter to at all. Requests run as `rebase_user`, which holds full DML — the table was wide open while the check certified it. Nothing else on the declared-collections path covered this either: the only reader of `relrowsecurity` in the driver serves the *introspection* branch, i.e. only when there are no declared collections, and the re-enable runs only on the managed-runtime boot path. A self-hosted project's next `db push` would have fixed it; until then, doctor said it was fine. Drift now reports `rlsDisabled` first, because it subsumes every other finding on the same table — if RLS is off, the policies listed under it are not being applied. The same pass closes a second blind spot: `mode: "restrictive"` is a public `SecurityRule` field and the generator emits `AS RESTRICTIVE`, but the DDL parser captured that group into a discarded slot and `pg_policies.permissive` was never selected, so a restrictive rule stored as PERMISSIVE read clean — with its gate being ORed in rather than ANDed, which is the maximally permissive way for it to be wrong. Both are exact catalogue values, so neither can cry wolf; an unreadable value on either side is skipped rather than guessed. - **A client with generated types could not be passed to ``.** `RebaseProps` was generic over `USER` and not over the database, so its `client` prop was pinned to `RebaseClient` — and `RebaseClient` is not a supertype of `RebaseClient`. The untyped branch of `RebaseSdkData` is an index signature (`[slug: string]: SDKCollectionClient`), and no concrete instantiation satisfies it, because `RebaseSdkData`'s own `collection` method is not an `SDKCollectionClient`. So the typed SDK path — run codegen, get a `Database`, build a typed client — ended at the provider that every panel is mounted inside, and reaching `data.products` through the prop handed back `Record` rather than the generated row. `RebaseProps` and `Rebase` now take `DB`, inferred from the client and defaulting to `unknown`, so existing untyped callers are unaffected. `wrapAsEntityData` asks for `Pick`, which is all it ever used. Pinned by `packages/app/test/rebase_client_prop_types.type-test.ts` — compile-time assertions, written as assignments rather than conditional types, because the first draft used `extends` and went on compiling with the bug restored. - **Retiring the pre-1.0 `auth` schema could drop a helper still in use.** The cleanup refuses to run while a *policy* calls `auth.uid()`, and that half is safe by construction — Postgres records a dependency for a policy that references a function, so `DROP FUNCTION` refuses on its own. The function half has none: a `LANGUAGE sql` body written as a string literal is never parsed at creation, so nothing is recorded, `RESTRICT` has nothing to refuse on, and the drop succeeds while callers still exist. They fail when a query reaches them rather than at boot. If you defined your own helper in the `auth` schema — anything calling `auth.uid()`, `auth.roles()` or `auth.jwt()` from its body — the schema is now kept, and the boot names the functions holding it so you can repoint them at `rebase.uid()`. Our own control plane is the case that found this: two org-membership helpers there, with eleven policies going through them, had nothing protecting them. - **A date in the future was described in the past tense.** Seven hand-rolled relative-time formatters computed `now - then` and then tested only the positive side, so a timestamp ahead of now fell through to whichever branch came first: a post scheduled for next month read "Just now", and one due this afternoon read "-1d ago" — a negative quantity, printed. These are dates a CMS holds constantly, and the two admin formatters render whatever property the collection points its date slot or date column at. `formatRelativeTime` in `@rebasepro/utils` is now the one implementation: the distance is `Math.abs`, so no branch can see a negative number, and the tense comes from the sign rather than being assumed. It returns `null` past a horizon the caller sets, so each site keeps its own absolute format and locale. The cloud CLI and studio's cron and API-key views were already correct and are unchanged. - **Persisted UI state written by an older release bricked the view that read it.** `JSON.parse(localStorage.getItem(key)!)` has four ways to throw and, in a `useState` initializer, no way to recover from any of them: it throws during the first render, and the value that threw is still in storage on reload. The SQL editor read its open tabs and column widths exactly that way. The failure that matters is not corruption but *age*: storage holds whatever version last ran, nothing migrates it, and an older release that wrote an object where this one calls `.map` produces valid JSON that survives `JSON.parse` and fails one line later. `readStoredJson`/`writeStoredJson` cover all four cases — storage that throws on access (Safari private browsing, SSR), text that is not JSON, JSON of a rejected shape, and a `setItem` over quota. Also fixed alongside: an empty stored tab list left no active tab, a stored tab with no `id` could never be closed, and a non-numeric stored pane size laid the editor out at `NaN`. - **An `orderBy` whose shape was wrong returned unsorted rows and a 200.** The sort *field* has been schema-checked for a while, on the grounds that answering 200 with unsorted rows leaves the caller believing in an order that is not there. The parameter's *shape* was not, and failed the same silent way one layer earlier: whatever `JSON.parse` returned was assigned to an option the REST layer reads as `orderBy[0].field`. So `?orderBy={"field":"name"}` — an object rather than an array, and the most natural thing to reach for — dropped the ORDER BY and answered 200, as did `5`, `true`, `null` and `["name"]`. A direction of `sideways` was silently coerced to ascending. These are now a 400 with `INVALID_ORDER_BY`, matching what the published OpenAPI parameter already documented and what `?where=` has always done with a malformed filter. Every shape that worked before still works. - **`PORT` was parsed but never checked, so `PORT=oops` started the dev server on `NaN`.** `resolveStartPort` range-checked the port *file* it writes itself, with a test naming every value it should refuse — and the environment variable one line above, the source a human or a platform actually sets, had neither the check nor a test. One `parsePort` now serves both; an unusable `PORT` warns rather than being ignored in silence. - **A blank cell imported as the number zero.** The importer mapped a string column to a number with a bare `Number(value)`. `Number("")` is `0`, so every empty cell in a number column arrived as a real zero — a price of nothing rather than a price nobody filled in — and anything unreadable arrived as `NaN`. Both are absent values and both are now `null`, which is what the importer's own validation exists to catch. - **An offline read ignored `orderBy` unless a locally-created row was in it.** The overlay sorted only when it had just injected a local row; every other read handed back cache order, which is insertion order. So a caller that asked for `orderBy` got whatever the store happened to hold — in the panel, a collection's `sort` silently dropped on every list served from the offline overlay, while the query carried it and the server honoured it. Order is part of the query, not a detail of how the rows were obtained. - **A group icon took the icons off every row beneath it.** Declaring `icon` on a `NavigationGroupMapping` did not just label the group header — it switched the whole group to a categorised treatment, stripping the entries of their own icons and indenting them, and stepping the header's own size and contrast up to match. That made a per-app styling choice into framework behaviour: any project that labelled a group lost the icons on its rows, with no way to turn it off. The icon decorates the header and stops there. `indented` survives on `DrawerNavigationItem` for an app that wants the categorised look, and nothing in the framework sets it. `DrawerNavigationItem` and `DrawerNavigationGroup` are exported now, with their props types, since overriding `Shell.DrawerNavigation*` is how an app opts in — wrapping the stock row beats reimplementing one and drifting from the framework's hover, active and tooltip behaviour on the next release. - **The add button kept its English verb in every locale.** The label was built as `Add {name}` in JSX, so only the collection name came from config: a Spanish panel read "Add Mensaje de contacto". An `add_specific: "Add {{name}}"` key already existed in all seven locales and nothing used it; both add buttons go through it now. The Hindi entry had translated the key rather than the label. - **A save raised two panel navigations, and the second one decided where you ended up.** `SidePanelBinding.onUpdate` reached three navigation-capable calls for one save — the opener's `props.onUpdate`, then a `replace` onto the saved record's address or `closeEditView()`, then `closeAfterSave()`. Against a data router the last call wins, so which of them the user got was settled by statement order across two files that were not written as a pair. "Save and close" on an existing record worked, because the close happened to be last. The reference picker's "add new" is the same three in the other order — the picker's own `onUpdate` closes the panel and the `status !== "existing"` branch replaced it afterwards — so the close lost, the new entity's panel stayed open, and the `replace` landed in the picker's slot and destroyed it. It raises exactly one panel navigation now. `closeOnSave` is honoured (declared, documented, passed as `true` by the picker, and read by nothing until now, which is the behaviour that flow was failing to get by hand); closing beats replacing, because moving a panel to an address it is about to leave only fights the close; and `props.onUpdate` runs last so the opener's intent is the final word. Reordering alone would have left the other half standing — `close()` pops the top panel and a `replace()` after it writes into the slot below — so the fix removes the pairing rather than sequencing it. Written up as class 28 in `docs/bug-classes.md`. - **One column the table could not describe took the whole table with it.** `propertiesToColumns` runs inside the memo that builds a collection table's columns, so anything it threw took the header, the rows and the empty state together: a blank pane with no error, no empty state and no data request to attribute it to. Three ways in, all reached by walking a property map with a hole in it — `getColumnKeysForProperty` read `.type` off an undefined map child, `getResolvedPropertyInPath` read `.type` off a missing path root, and `propertiesToColumns` threw outright when a key resolved to no property. A column that cannot be resolved is one column the table cannot offer, not a dead table, so it is named in a warning and the rest carry on — which is the choice `getSortablePropertyOptions` had already made twelve lines below, with a comment saying so. - **One live subscriber counted for every other subscriber sharing its query.** A live subscription re-counts on every push so its reported total stays honest, and `listenCollection` deliberately collapses identical queries onto a single socket subscription while keeping one callback per subscriber — each of which ran its own count. One `collection_update` fanned out into one identical HTTP count per subscriber. Not hypothetical volume: every relation cell in a table mounts a selector that subscribes to the target collection before its dropdown is ever opened, so one message produced one count per visible cell — measured at 11 requests to `/api/data/customers/count` for an 11-row page of orders, and 66 on a wider table, all asking the same question. A count is a property of the query, not of the caller, so concurrent callers share the request; the entry is dropped as soon as it settles, which merges concurrent calls and never serves a cached total. Measured after: 11 requests to 1. - **A relation column arrived as an id and the preview called it the wrong type.** The REST layer returns a relation column as the foreign key it is — a flat scalar — and only some fetch paths hydrate it into an object, so which form a preview saw depended on how the row was loaded. The preview accepted only one: `normalizeToEntityRelation` returned null for anything that was not an object and `PropertyPreview` read that null as a type error rather than as "not fetched yet", giving a red "Unexpected value" box per row wherever a relation sat in `previewProperties`, and a silently blank column elsewhere. `ArrayOfRelationsPreview` dropped such elements without a word. The property already knows the answer — it declares the target it points at — so the id is resolved against it. `getRelationTargetPath` reads the target from either form that carries one, the stamped `resolvedRelation` or the inline `relation`, which is all a preview can reach while holding a property and a value and no collection. The only lever an app had here was to keep relations out of `previewProperties`, which also decides the row title in list view: a choice between an error box and rows that cannot say what they are about. - **Changing a record's layout met the edit as a stale draft.** The split's "hide list", full screen's "show list" and the side panel's "open full screen" all replace one mounted form with another showing the same record. Only the side panel handed its edit over; the other two left it to the local-changes backup — the channel for a draft left behind by a *closed tab* — so the record reopened clean under an "unsaved local changes" banner offering to apply changes the user had made a second earlier and never walked away from. Every control that changes layout calls one `carryEdit` now, and it carries only what was touched here; the side panel used to carry the whole record, which marked every field touched in the receiving form. The stale draft that banner exists to ask about was meanwhile being applied *without* it: the handoff map was hydrated from `sessionStorage` at startup, so after a reload every persisted draft looked like a handoff and the first visit to the record opened silently dirty carrying it. The handoff is in-session only now, and consumed on pickup. - **The list row's responsive columns measured a ref attached to nothing.** The trimming logic existed, but `containerRef` was never put on an element — the component returned `` directly — so the ResizeObserver never fired and `containerWidth` stayed at its 1200 seed forever. That seed resolves to exactly three extra columns at every width, which is why a split panel's list showed the same row as a full-window one and truncated the title to make room for them. The ref sits on a wrapper and the width is seeded from `getBoundingClientRect` on mount, so unmeasured means "title only" rather than "assume 1200". The title takes a comfortable 320px before columns bid at all, and each column is charged its own rendered width rather than a flat 160 — a relation no longer costs what a date costs. The first column that cannot be afforded ends the row, so columns drop right to left and never reorder while dragging the splitter. - **The split view's record panel had no close button.** The only way out of an open record was Escape, or noticing that the collection name ahead of the title was a link. The bar ends in a ✕ now, behind a rule: a ✕ flush against Save reads as the next item in the action row, so the two adjacent controls are "commit this edit" and "abandon it". It needs no confirmation logic of its own — the split closes by navigating, and `useNavigationBlocker` already stands between a navigation and an unsaved edit. "Save and close" reaches the split too, as a `▾` welded to Save rather than the separate filled button the overlays carry: there the list is beside you and j/k walk from record to record, so saving and *staying* is the common case. - **PATCH is served for updates, and the spec stopped describing a partial write as a replace.** The update handler merges — it writes the columns in the body and leaves the rest, which is what the SDK's `update(id, data: Partial)` means — and PUT was the only route it was mounted on. The generated OpenAPI spec inherited that and made it worse by reusing the *create* input schema for the update body, so every `validation.required` property was marked required on a partial update: a published contract nobody implements, where a generated client sends more than it needs and a spec-validating gateway would reject partial updates the server happily accepts. PATCH is mounted on the same handler at both the collection and nested paths, and the spec describes it with a new `Update` schema derived from the input schema with `required` dropped — derived rather than rebuilt so the two cannot come to disagree about which columns exist. PUT stays, on the same handler, deprecated in the spec: changing its semantics to a true replace would silently start nulling columns callers have omitted for years, which is a data-loss change wearing the costume of a standards fix. The SDK also stays on PUT for now, deliberately and commented at the call site, because a 0.14 client sending PATCH to a 0.13 server gets a 404. - **An offline `createMany` whose ACK was lost duplicated the whole batch.** `WriteOptions.idempotencyKey` was accepted on `create` and nowhere else, and the bulk route had no idempotency handling at all — so the one path where a replay costs the most was the one with no defence. A client that never sees a response cannot know whether the write committed, so it retries; without a key the server cannot tell that retry from a second genuine import. Through `create` that duplicates one row, through `createMany` every row in the batch up to `maxBulkRows` (1000 by default) — and the offline queue replays `createMany` on exactly this path. `upsert: true` hid it for callers who set it, and upsert is documented for re-runnable imports rather than for crash recovery. `POST //bulk` claims the key before the write and replays the stored response, the same claim-before-write shape the single create uses and for the same reason: recall-then-write lets two concurrent replays of one key both through. A failed write releases the key, so one dropped connection does not leave a batch that can never be sent. `createMany` accepts `WriteOptions` across the client and the contract, and the offline replay passes `op.mutationId`, which closes the loop. - **A dead public type, a closed error-code union, and docstrings that had drifted.** `RebaseBrowserClient` was exported and documented as "the shape produced by `createRebaseClient()`", and produced by nothing — that factory returns `CreateRebaseClientResult`. It also hand-duplicated ~16 members of `RebaseClient` rather than deriving from them, so it was a standing drift source as well as a false claim. Deleted. `RebaseApiError.code` was a bare `string` while the server emits a known set, so `e.code === "NOTFOUND"` type-checked exactly as well as the spelling that works. It is `RebaseErrorCode` now: the nine codes any route can answer with, unioned with `string & {}` to stay open, since routes define their own (`EMAIL_EXISTS`, `TOKEN_EXPIRED`, a couple of dozen in auth) and closing it would be a lie that broke on the next one. Re-exported from `@rebasepro/client`, where callers catch. Also corrected: `slug` — required, and what the REST path, the SDK accessor, the admin URL and every reference property key on — was documented as "an alias that will be used internally", describing an optional field that no longer exists. - **`rebase cloud --help` ran the command instead of printing a page.** Two bugs stacked: `cli.ts` rewrote the subcommand to the literal `"--help"` whenever the flag appeared anywhere, so the group never reached the dispatcher, and the dispatcher short-circuited on that value before dispatch. Seven cloud modules carry their own `"--help"` flag that could not run, leaving ~44 flags with no way to be listed. Routing `--help` through as the *action* would have fixed only the groups that switch on it; the rest take `rawArgs` and ignore the action, so the flag did nothing and the command ran anyway — `cloud env --help` failed on "No project specified", `cloud deploy --help` began resolving a project, and `cloud link --help` opened an interactive project picker, a prompt from a flag whose whole job is to print text and exit. `--help` is answered centrally now, before dispatch, from a group→page map, so a handler cannot be reached and prompting is structurally impossible rather than merely fixed. `cloud-help.test.ts` asserts that no handler ran, since a regression here does not look like wrong text — it looks like CI hanging on a prompt. - **One name per privilege level on the server, and the RLS claim the callbacks guide made was wrong.** `RebaseServerClient` omits `data` so the RLS-bypassing plane has exactly one name, `dataAsAdmin` — and cron undid it, typing the same singleton as `RebaseClient` and handing it back as `client`. Its own docstring admitted it ("it is only named `client` here"), and a reader who learned `client.data` there carried it to a collection callback, where `context.data` is a different trust level entirely. Cron exposes `rebase: RebaseServerClient` now, matching the singleton import and `defineFunction`, with `client` kept as a deprecated alias typed to keep its `data` member so existing cron files compile and run unchanged. The larger find is the documentation. The callbacks guide stated, in all six locales, that `context.data` bypasses RLS and has "full database access regardless of the triggering user's permissions". It does not: authenticated writes run through `withTransaction`, which builds a fresh base driver bound to the RLS-scoped transaction after the role has been downgraded, so a callback on a user request is user-scoped for reads *and* writes; only server-context work (`dataAsAdmin`, cron) bypasses. Wrong in the unsafe direction, and nothing tested it either way, which is how it stayed wrong. - **A `hasMany` was declared once and rendered twice.** A many-cardinality relation becomes an entity tab, which is the whole treatment for a list of child rows. It was *also* still a member of `properties` — the only place a relation can be declared — so the form rendered it a second time as a relation picker: a dropdown offering to select a collection's own children, one card per child row. Nothing marked the relation as already consumed, so every `hasMany` and `manyToMany` property in every project grew a stray field. `getChildViewRelationPropertyKeys` is the missing half of `getEntityChildViews`: given a collection, it names the property keys the entity view has already taken, and the form's field list (`getFormFieldKeys`, which both the editable form and the read-only entity view build from) drops them. The match is on the resolved `relationName` rather than on the key, so a relation declared in `relations` and pointed at by a differently-named property is recognised too. A relation nested inside a `map` gets no tab, so it keeps its picker; a `belongsTo`/`hasOne` is a foreign key the author edits, and never had a tab to be redundant with. The collection table had the same duplication with the halves reversed. Every child view gets a 200px button column that opens its tab, and for a relation declared in `relations` that button is its only presence in the table — but for one declared as a property it was the *second*: the property's own column was already there, hydrated by the list fetch's `include: ["*"]` and showing the child rows themselves, and the button carried the same heading, because a tab takes its name from the declaring property. Two columns called "Vacantes a las que postuló", one of them a button. The property column wins there — it shows what the children are, and each chip in it opens one — so the button is dropped. Unless the author hid that column: `hideFromCollection`, or a `propertiesOrder` that omits it, is a statement about the column and not about the relation, so the button comes back rather than the relation dropping out of the table altogether. `getRedundantChildViewColumnIds` answers this for both the column ids and the delegates that build them, so an id is never displayed without something to render it, and a column order saved before this change stops naming the button. Declaring a many-relation as a property is still worth doing — it names the tab and gives the relation a key that a table column and an `include` can address. If you want the inline picker anyway, `admin: { renderInForm: true }` asks for it back. ```diff ts "talent-applications": { name: "Vacantes a las que postuló", type: "relation", relation: { kind: "hasMany", target: () => talentApplications, foreignKeyOnTarget: "talent_id" } - // …and a second, unusable copy of this relation at the bottom of the form } ``` - **`conditions: { hidden: true }` did not typecheck.** `hidden`, `readOnly` and `disabled` took a `JsonLogicRule`, which is `Record`, so the unconditional case — a field that is simply never shown, never editable — had to be spelled `{ "==": [1, 1] }`. That reads as a puzzle at the call site and as a mistake to the next person. They take a plain boolean now (`ConditionRule`), and because a literal needs no context to evaluate, `isHidden`/`isReadOnly`/`isDisabled` answer it directly — so it is honoured everywhere a field is laid out, not only where a condition context happens to exist. A *rule* is still not evaluated in production; that gap is unchanged and is about needing an entity to evaluate against. - **A relation preview rendered the target's whole document.** An author card printed the entire Markdown biography — headings, bullet list and all — inside a box built for two lines of text, because three layers each assumed one of the others was keeping the value to a line. Selection was positional. `getEntityPreviewKeys` took the first few properties from `propertiesOrder` and read that as a claim that they summarise a record; `propertiesOrder` states the *column* order of a collection table, which is how the third column of an author ended up on the card. Properties are ranked now by whether the value has a one-line form at all (`rankSummaryProperty`): a map renders as a key/value table and an array of maps as a stack of them, so neither takes a slot from a value that fits; long text is an excerpt and sorts behind everything that does fit, but is still used when it is all there is, because an opening line beats a card with nothing under the title. A stated `previewProperties` is returned verbatim, ranking and limit included — asking for the biography still gets the biography. The two diverged copies of the picker are one implementation, in `@rebasepro/app`. Rendering was unconstrained: `PropertyPreview` rendered `admin.markdown` as a full document whatever size it was asked for. A preview inside an entity preview now renders a compact form — Markdown and multi-line text as their opening line, a map as its first labelled leaves, an array as a count. The signal is the nesting depth the card already publishes, so nothing between the card and the preview has to forward a prop. And the card could not defend itself. `truncate` is `white-space: nowrap`; it clamps a line of text and does nothing to block children, so the box grew to the height of whatever was inside it. Rows are height-capped and the container clips, which also holds for a custom `admin.Preview` component, which can render anything at all and cannot be reasoned about in advance. The card fills the same slots as every other surface that renders one record — image, title, subtitle, status — rather than its own list of "the first few properties". - **A relation in a card slot drew a card inside a card.** `SlotValue` took `textOnly` as opt-in. The list view passed it; the card and board views did not, so a relation filling their title or subtitle slot rendered its own bordered preview, complete with an id line and a side-panel button, wrapped over three lines of a narrow grid card. It is the default now — a slot is one line of a row, and the one caller that would want a card in one is none of them. - **Editing `@rebasepro/cms-types` did nothing in dev.** The app's Vite config resolves every workspace package to source, with a comment explaining that the one package left out came from its built `dist` and so ignored edits until it was rebuilt. `admin-types` was added later and missed the list, and repeated the same bug. - **A monthly cron job spun at 112 iterations a second instead of waiting.** `setTimeout` holds its delay in a 32-bit signed integer: past ~24.8 days Node does not wait and does not throw, it clamps the delay to 1 ms and fires immediately. `scheduleNext` had a floor on the delay and no ceiling, so a job like `0 4 3 * *` — whose next slot sits about 30 days out for most of the month — woke at once, claimed its own future slot, lost the race against that claim on the next wake, logged *claimed by another instance*, and rescheduled into the same overflow. A tenant ran this way for a day and a half: 1.9 GB of logs, a `cron_claims` INSERT every 9 ms, and the job itself never running. Pod restarts did not clear it, because the claim that makes it skip is a persistent row. Three things were wrong and all three are fixed. Delays past the ceiling are now slept in hops and re-derived on waking, so the cron expression stays the source of truth. A fire that arrives **before** its slot no longer claims — an early wake is also what a backwards NTP step or a resumed VM looks like, and claiming on one is unrecoverable, because the claim is permanent and the real run is skipped when it comes due. And startup now releases claims on slots that have not happened yet, which can only come from an early fire, so a database already poisoned by this heals on the next deploy rather than silently skipping one run. - **A collection whose slug contains slashes rendered a blank page.** `getCollection("content/de-DE/podcasts")` never tried an exact match. It split on `/`, read the pieces as collection/entityId/subcollection, looked for a root collection called `content`, found none and threw — and the catch logged at `console.debug` and returned undefined, which `RebaseRoute` renders as `null`. The result was an empty content pane inside working chrome: the sidebar, the nav highlight and the breadcrumb all still resolved, because those read the collections array directly rather than the registry. One app had thirty-five collections unreachable this way, seven content types across five locales. A slug is allowed to contain slashes and some drivers need it to — a Firestore collection partitioned by locale is *named* `content/de-DE/podcasts`, it is not a path to walk. An exact slug match now runs before the path walker, on the id-trimmed path, so a record inside such a collection still resolves to it. And an unresolved collection renders "Collection not found" naming the path, and warns at `console.warn`: returning bare `null` is what made a one-line lookup bug look like missing data, with nothing above `debug` to search for. - **`optionalAuth` returned 500 on backends that do not issue JWTs.** A backend authenticating through an adapter — Firebase, Clerk, anything with its own tokens — never calls `configureJwt`, so verifying a bearer token threw. That turned a route which had already decided anonymous callers were fine into a 500 for every request that happened to carry a token. The tolerate-the-absence-of-auth paths ask `isJwtConfigured()` first now. The signing paths still throw when it is false, because asking a server that cannot mint a token to mint one is worth hearing about. - **The read-only record kept the layout the form left behind.** Reading a record and editing it drew the same data two different ways. The form resolves sections, grid spans and a metadata rail from the collection; the read-only view was a flat two-column table of every value — 4/12 label against 8/12 value, no grouping, no second column. A boolean and a markdown body got the same room, an email wrapped over two lines while half the pane beside it stayed empty, and pressing Edit rearranged the record you were just looking at. It resolves its layout with `resolveFormLayout` now — the same call the form makes — and renders each field through the same `FieldBlock` and grid span, with a `PropertyPreview` where the form puts a control. So it gets the configured sections, the derived grouping when there are none, the per-type widths with row filling, and the rail with the sidebar fields and the id/created/updated block, folding into a trailing group on a pane too narrow for it. Two things fell out of sharing the resolver: `additionalFields` need a form context the delete dialog has none of, so they are dropped before the layout resolves rather than skipped while rendering (skipping left a hole where a full row had been allocated); and previews take `hideLabel`, because `BooleanPreview` printed the property name beside its checkbox, which under a field label read as "VIP" above a checkbox saying "VIP". - **The X that hid the list looked like it closed the record.** The split view's list-hiding control sat on the record's own app bar wearing an X, which every convention reads as "close the thing I am attached to". It is a double chevron now, pointing at what actually moves, matching the sidebar toggle. Hiding the list had also been a one-way trip — `#full` replaced the collection and left browser Back as the only route back — so showing it again is the same single route (dropping the hash), offered only where the URL would genuinely resolve to a split. The detail view's back arrow moves from the trailing edge, where it sat among the record's own actions, to the leading edge the edit view has always used; where the chevron renders it goes entirely, since both reach the same collection and the chevron keeps the record open. And the breadcrumb is a link now rather than inert text that looked like one, carrying the view mode so a collection reached from one of its own records comes back as you left it. Overlays keep plain text: they already sit on top of that collection, and navigating would dismiss the record as a side effect. - **The drawer's tooltips were on in the one place they were noise, and off everywhere they were needed.** The collapse/expand toggle carried a tooltip saying "Collapse" while the row it was attached to already said *Collapse* in plain text beside the chevron — the tooltip only backed off while the drawer floated open under the pointer, which is the other state where the word is already on screen. It now shows up only on a bare rail, where the chevron stands alone. The navigation entries had the opposite problem. Their tooltip's `open` was controlled by a flag that was true only while the drawer was hovered-but-not-open — and in exactly that state the entries are told the drawer *is* open, which forced the same tooltip shut. The two conditions could never both hold, so no entry tooltip could ever appear in any state. That went unnoticed while hover-expansion was unconditional, because the floating panel's labels covered for it; with `autoOpenDrawer={false}` now a real setting, it left a rail of unlabelled icons with nothing to identify them. Each row now owns its own tooltip state, so they follow the pointer one at a time rather than firing in unison, and they answer to keyboard focus as well. `tooltipsOpen` and `adminMenuOpen` are deprecated no-ops on `DrawerNavigationGroup` and `DrawerNavigationItem`. Both tooltips are *masked* where the label already says the same thing, rather than unmounted or switched to uncontrolled — either of those moves a Radix tooltip between controlled and uncontrolled mid-life, which strands whatever it was last told. The first attempt at this fix did exactly that and left a tooltip hanging beside the rail, naming a row the pointer had left seconds earlier. Masking has its own version of the trap: a hidden tooltip never hears the pointer leave, so the stale `true` is dropped on the way *into* the masked state rather than waiting for a close that will not come. - **An open dropdown left the drawer floating indefinitely.** The collapse-on-mouseleave already declined to fire while a popover was up — its content is portalled outside the drawer, so reaching for it registers as leaving. But nothing fires a second `mouseleave` when that popover finally closes, so the drawer just stayed expanded over the content until the pointer happened to cross it again. The collapse is owed now, not cancelled: the drawer watches for the popover to go and collapses then, unless the pointer came back in the meantime. - **Two admins on one origin shared a drawer, and the stored state broke server rendering.** The persisted open/closed state used one flat `rebase-drawer-open` key, so a second admin on the same origin — a different `basePath`, its own navigation — silently overwrote the first one's. The key is namespaced by base path now. Reading it also happened during the first render, which is a client-only fact and made the first client render disagree with server-rendered HTML; it is applied in a layout effect instead, before paint, so nothing flashes and nothing mismatches. The unreleased flat key is not migrated: a drawer starts collapsed once, and the next toggle sticks. - **The drawer's collapse control was a `div` pretending to be a button** — `role="button"` plus a hand-rolled Enter/Space handler, where a `
__key`. ```typescript { on: ["tenantId", "slug"], unique: true, reason: "one slug per tenant" } ``` #### `include` buys an index-only scan Payload columns live in the leaf pages: not searchable, not ordered, and they save a heap fetch at the cost of a fatter index. They may not overlap `on`. ```typescript { on: ["status"], include: ["title"], reason: "the status sidebar counts, without touching the heap" } ``` #### `using` `btree` (the default) answers equality, range, `ORDER BY` and uniqueness. `gin` is containment over an `array` property or a JSONB `map`. `brin` is for a naturally-ordered column on an append-only table — tiny, and useless the moment rows start arriving out of order. Neither has an ordering, so `direction` and `nulls` are unrepresentable on them rather than refused later by Postgres. There is no `gist` and no `hash`: every interesting gist operator class ships in an extension, and hash indexes cannot be unique, composite, or ordered. That restriction is what keeps the whole model on the Atlas path — `rebase db push` materialises the desired state in a bare scratch database to plan against, and `CREATE EXTENSION` cannot go in that file. **Trigram search is [`search:`](/docs/backend/search); ANN is a [`vector` property](/docs/sdk/aggregates-and-search#the-index).** An index needing `gin_trgm_ops` or `vector_cosine_ops` is refused at build time rather than emitted to fail later against a database you have never seen. #### `reason` is required It is the only required field with no SQL behind it. An index is the only thing a Rebase config can declare that costs money forever and whose benefit is invisible from the config. The reason is what gets printed beside "0 scans in 34 days, 412 MB", which is the one moment anybody is in a position to decide whether to delete it. Without it nobody can decide, so nobody does, and the table accretes indexes for the life of the product. It is deliberately **not** part of the index's identity — rewording a justification never rebuilds an index. ### What a declaration is called `
__ix_<7 hex>`, or `_ux_` when unique. For example `posts_status_publish_date_ix_a91c3f4`. The hash is over the index's *semantics* — method, columns, order, uniqueness, included columns, predicate — and not over its rendered SQL, so a change to how Rebase formats DDL never renames anything in your database. The hash is load-bearing. `CREATE INDEX IF NOT EXISTS` matches on the **name**, not the definition: with a readable name, changing a declaration would keep the old index and report success forever. With the hash in the name, a redefinition is a different object, so it is created and the old one dropped. Two consequences worth stating: - **Changing a declaration is a DROP and a CREATE**, emitted bare — no `CONCURRENTLY`, and a window with no index in between. Fine on a development database; on a large live table, apply it at a time you choose. - The name is [a frozen derived name](/docs/architecture/schema-as-code). It is in `contracts/derived-names.txt` and cannot change across releases. ### Who owns an index `_ix_`/`_ux_` plus seven hex is unreachable by every other namer here — `_fkey`, `_gin`, `_trgm`, `_pkey`, `_key`, the vector distances, auth's `idx_` prefix. So the name alone decides ownership: | The index | In the plan? | Named by Rebase? | What happens | |---|---|---|---| | declared | yes | yes | created, then kept | | declaration deleted | no | yes | **dropped**, as intended | | hand-written, or from introspection | no | no | **excluded — never touched** | Neither case needs a prompt. Deleting a declaration *should* remove the index quietly; what must never be dropped is one Rebase did not create. This is also what makes the introspection round trip safe: the existing indexes of a database you pointed Rebase at are foreign until somebody declares them. ### When they are created Both producers emit them, which matters because not every deployment runs `db push`: - **`rebase db push` / `rebase db generate`** put them in `schema.sql`, on the ordinary Atlas path — so they get migrations, drift detection and rollback like every other object. - **Boot-time schema ensure** creates them with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, on the same terms as the ANN indexes beside it. A managed-runtime tenant provisions at boot and never runs `db push`; without this it would start with none of its declared indexes and nothing would say so. ### What is refused, and when All of these throw at build time, naming the collection and the position in the array — an index that silently does not exist is the failure this whole feature removes: - a property that is not on the collection, or a relation whose foreign key lives on the other table - more than five keys in `on`, or the same column twice - exactly the primary key columns — `
_pkey` already indexes those - a column in both `on` and `include` - `unique` on one column whose property already declares `validation.unique` - `direction` or `nulls` under `gin` or `brin` - an `in` list that repeats a value - two declarations that derive the same name — they are the same index, twice - an empty or missing `reason` ### Related - [Search](/docs/backend/search) — ranked full-text, which builds its own GIN index over a generated `tsvector` - [Vector search](/docs/sdk/aggregates-and-search#vector-search) — the ANN index over an embedding column, configured on the property - [Schema as code](/docs/architecture/schema-as-code) — how declarations reach the database, and what a derived name is ## 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), }; }, }); ``` `rebase dev` watches the crons directory, so a job added while it is running is registered on the next reload — no restart. (It has to be told: the directory is scanned rather than imported, so the watcher cannot infer it.) :::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 :::note[Where this goes] **Managed runtime** — put the files in `backend/crons/`; the runtime discovers that directory on its own, and `entry.crons` in `rebase.json` is only needed if you moved it. `REBASE_CRON_SCHEDULER` in `.env` decides whether *this* process runs the timers. **Ejected** — `cronsDir` on `initializeRebaseBackend({ … })`, as below. The full map is in [Backend Overview](/docs/backend/#where-each-option-lives). ::: 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/admin/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 `timezone` is new — on 0.17.3 a schedule is always read in the host's own zone. Everything else on this interface has shipped. ```typescript interface CronJobDefinition { // Cron schedule expression (5-field format) schedule: string; // IANA zone the schedule is read in, e.g. "Europe/Madrid". Without it the // schedule is read in the host's own zone — UTC in nearly every container, // yours on a laptop — so name it. An unknown zone is refused when the job // loads rather than read as local time. timezone?: 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 no-verify 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; // Aborted when the run exceeds `timeoutSeconds` signal: AbortSignal; // The server-side Rebase singleton — the same object `import { rebase } // from "@rebasepro/server"` returns, and the same one `defineFunction` // hands its callback. rebase: RebaseServerClient; } ``` Use `ctx.log()` to emit structured output. These lines are captured in the execution log and visible in Studio and via the REST API. #### `ctx.signal` — stop the work when the run stops The timeout ends the *run*: the scheduler stops waiting and records a failure. It does not end the handler. Pass `ctx.signal` to anything that takes one, and the work stops with it: ```typescript no-verify export default defineCron({ name: "Sync inventory", schedule: "*/15 * * * *", timeoutSeconds: 60, async handler({ signal, log }) { const res = await fetch("https://supplier.example.com/stock", { signal }); log(`fetched ${res.status}`); } }); ``` Without it, a job whose timeout matches its interval leaks one abandoned request per tick — invisible, because every run is already recorded as failed. :::note[`ctx.client` was removed] It was a second name for `ctx.rebase`, and its type re-exposed `client.data` — the alias `RebaseServerClient` deliberately omits so that the privileged plane has exactly one name. A reader who learned `client.data` here carried it into a collection callback, where `context.data` is the *user-scoped* plane: same spelling, opposite privilege. Use `ctx.rebase.dataAsAdmin`. ::: #### Interacting with the database and services via `ctx.rebase` `ctx.rebase.dataAsAdmin` is the admin-scoped data plane. A cron has no per-request user, so there is no user-scoped alternative here — scope every query's filters yourself. :::caution[Admin-scoped is not RLS-bypassing] `dataAsAdmin` is scoped once, at boot, as `{ uid: "service", roles: ["admin"] }`. Every read and write still runs in a transaction that has done `SET LOCAL ROLE rebase_user` with `app.uid = 'service'`, and **your policies are evaluated** — against that identity. It clears the built-in default policies through their `rolesOverlap(['admin'])` arm, which is why the difference rarely shows. It shows when you write your own: `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore **false** here, so a collection with `disableDefaultPolicies: true` whose only rule is `serverContext()` denies these writes and returns zero rows — HTTP 200, empty — for these reads. `rebase.sql()` *is* an unconditional bypass: owner connection, no 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.rebase.dataAsAdmin.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 the Rebase email service await ctx.rebase.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/admin/cron` | List all registered cron jobs | | `GET` | `/api/admin/cron/:id` | Get a single job's status | | `POST` | `/api/admin/cron/:id/trigger` | Manually trigger a job | | `GET` | `/api/admin/cron/:id/logs` | Get execution history (`?limit=N`) | | `PUT` | `/api/admin/cron/:id` | Enable/disable a job (`{ "enabled": true }`) | #### Example: List All Jobs `$TOKEN` is an admin access token: sign in and use the `accessToken` the login response returns. `$API_URL` is whatever `rebase dev` printed — the port is derived from the project's path, so there is no fixed one. ```bash curl -H "Authorization: Bearer $TOKEN" "$API_URL/api/admin/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 } ] } ``` #### Jobs that are not there A job that never fires is not in `jobs` — nothing registered it — so "my cron is missing" and "my cron will never run" look identical from this endpoint unless it says otherwise. It does: ```json { "jobs": [], "skipped": 2, "rejected": [ { "id": "nightly-report", "name": "Nightly report", "schedule": "0 0 3 * * *", "reason": "Expected 5 fields, got 6" } ], "note": "1 cron file(s) failed to load and 1 job(s) have an invalid schedule — NOT scheduled. See `rejected` for the reason; the server log has the rest." } ``` `rejected` names the job and the reason. A file that failed to *load* has only a count: the failure happened before there was a job to name, so the reason is in the server log. The commonest entry here is the one above — six fields, from an expression copied out of a tool that supports seconds. Rebase takes five; drop the leading field. #### Example: Trigger a Job Manually ```bash curl -X POST -H "Authorization: Bearer $TOKEN" \ "$API_URL/api/admin/cron/health-check/trigger" ``` ### Client SDK The Rebase client SDK exposes a `cron` namespace for all operations: ```typescript const client = createRebaseClient({ baseUrl: import.meta.env.VITE_API_URL }); // 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 **Compute**, beside the JS console. 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 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. Either way a row is written to `rebase.cron_logs`, so the skip is in the run history rather than only in the process log: ```json { "jobId": "expire-users", "success": true, "result": { "skipped": true, "reason": "already_executing" }, "logs": ["Skipped: the previous run has not finished"] } ``` `success: true` because nothing failed — `result.skipped` is what marks it. A run of these in a row is the signature of a job that has outgrown its schedule, and that is a pattern you can only see if the skips are recorded. --- ### 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, `ctx.signal` is aborted and the promise is rejected, throwing: `Error: Cron job "" timed out after ms` The abort is the half that stops the *work*; the rejection only stops the scheduler waiting. A handler that ignores `ctx.signal` keeps running past its own run. - **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..."); // Admin-scoped data access — see `ctx.rebase` above. const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); const expired = await ctx.rebase.dataAsAdmin.sessions.findAll({ where: { last_seen_at: ["<", cutoff] } }); for (const session of expired) { await ctx.rebase.dataAsAdmin.sessions.delete(session.id as string); } ctx.log(`Cleaned up ${expired.length} expired sessions`); return { deletedSessions: expired.length }; }, }; export default job; ``` ### Crons in the resource graph Every cron file is also a declaration. `rebase resources` lists it under the name of the file — the same id the scheduler runs it as and the Studio shows — with its schedule and zone, so a host reads a project's schedules before it runs anything. A cron binds from no environment variable; `rebase status` shows it green with nothing to configure. Reading the schedule means importing the file, and `rebase resources` is a build step: no `.env`, no secrets. So keep a cron's **module scope** free of anything that reads configuration at import — a database client built at the top of a helper, an `env.ts` that validates `DATABASE_URL`. Import that work inside the handler instead: ```ts async handler({ log }) { const { runSeed } = await import("../src/seed.js"); await runSeed(); log("done"); } ``` The handler runs in the deployment, where those variables exist. A top-level import of the same module makes the graph derivable only on a machine that happens to have a `.env` — and it loads the whole dependency into every boot that merely registers the 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 ## Background Jobs ### Overview A job is a row in `rebase.jobs`. It is claimed by exactly one worker, retried with a widening delay if its handler throws, and left in the table when it finally gives up so somebody can look at it. There is nothing to install and nothing to run alongside Postgres. A job enqueued inside a transaction that rolls back was never enqueued. Use it for work that must not be lost and must not happen inside a request: sending mail, calling a third party, generating a file, reconciling with an external system. | | Runs | Survives a restart | |---|---|---| | [Cron](/docs/backend/cron-jobs) | On a schedule | Yes — the schedule is in code | | **Jobs** | Once, as soon as a worker is free | **Yes — the job is a row** | | A `setTimeout` in a callback | Once, in this process | No | ### Enabling :::note[Where this goes] **Managed runtime** — nowhere. `jobs` takes handler *functions*, which no environment variable and no `config/index.ts` export can carry, so a scaffolded project cannot enable the queue. Run `rebase eject` (or write a [custom server](/docs/backend/custom-server/)) to reach it. `REBASE_JOB_WORKERS` in `.env` only decides whether an already-enabled queue's workers run in *this* process. **Ejected** — the `jobs` block on `initializeRebaseBackend({ … })`, as below. The full map is in [Backend Overview](/docs/backend/#where-each-option-lives). ::: ```typescript no-verify await initializeRebaseBackend({ jobs: { enabled: true, tasks: { "send-welcome": async ({ payload }) => { await sendEmail((payload as { email: string }).email); } } } }); ``` Off unless you ask for it: a worker polls the database forever, which is not a default anyone chose. It needs a driver that can run SQL — on one that cannot (MongoDB), the queue is unavailable and you are told at boot rather than at the first enqueue. ### Enqueueing ```typescript no-verify const { jobQueue } = await initializeRebaseBackend({ jobs: { enabled: true, tasks } }); await jobQueue?.enqueue("send-welcome", { email: "ada@example.com" }); ``` #### Options ```typescript no-verify await jobQueue?.enqueue("send-digest", { userId: "u7" }, { delayMs: 60_000, // not before a minute from now maxAttempts: 5, // default 3 idempotencyKey: "digest:u7" // at most one *unfinished* job with this key }); ``` `idempotencyKey` collapses a double-click, a retried request, and two instances reacting to the same event into a single job. It is scoped to unfinished work, so the key becomes reusable once the job completes — otherwise "the nightly digest for user 7" would be sendable exactly once, ever. A duplicate enqueue resolves to `null` rather than throwing: the work you asked for is queued, which is the outcome you wanted. ### Failure A handler fails by throwing. There is no `return false` — a boolean would be silently ignored by every handler that forgot to return one, and failure has to be what you get by default. - **Attempts left** → back to `pending`, with `run_at` pushed out by the backoff (1s, 5s, 25s … capped at an hour; override with `backoff`). - **Out of attempts** → `failed`, and the row *stays*. A queue that silently drops what it could not deliver is indistinguishable from one with nothing to do. ```sql SELECT task, attempts, last_error, updated_at FROM rebase.jobs WHERE status = 'failed' ORDER BY updated_at DESC; ``` Failed rows are kept 30 days; successful ones 3. ### What happens when a worker dies A process killed mid-job cannot release its claim, so nothing but a timeout will free the row. Jobs claimed for longer than `visibilityTimeoutMs` (default 5 minutes) are reclaimed — back to `pending` if they have attempts left, otherwise dead-lettered with an error saying what happened. This is also why the timeout must exceed your slowest handler: past it, a second worker may start a job the first is still running. ```typescript no-verify jobs: { enabled: true, concurrency: 5, // jobs at once, per instance pollIntervalMs: 2_000, // when the last look found nothing visibilityTimeoutMs: 300_000 // must exceed the slowest handler } ``` ### Several instances Safe by construction. Workers claim with `SELECT … FOR UPDATE SKIP LOCKED`, so each job goes to exactly one of them and the others move on to the next row rather than queueing behind it. Nothing needs to be elected leader. During a rolling deploy an instance running older code will be handed jobs whose task it does not implement. Those are returned to the queue rather than failed, so they run as soon as an updated peer picks them up. ### Durable webhooks [`WebhookDispatcher`](/docs/recipes/webhooks) queues its deliveries in memory by default, which means a crash or a deploy between the change and the delivery drops the event. Hand it the queue and each delivery becomes a row: ```typescript no-verify const { jobQueue } = await initializeRebaseBackend({ jobs: { enabled: true } }); const dispatcher = new WebhookDispatcher({ jobQueue }); dispatcher.setWebhooks(myWebhooks); jobQueue?.register(WEBHOOK_DELIVERY_TASK, ctx => dispatcher.deliverQueuedJob(ctx.payload as never)); ``` Only the webhook's **id** is stored on the job, never the webhook itself — its signing secret would otherwise sit in `rebase.jobs` in cleartext for as long as retention keeps the row, and a webhook edited between the enqueue and the delivery should go out as it is now. ### Shutdown `shutdown()` stops the worker claiming new jobs and waits for the ones in flight, so a deploy does not run the tail of a batch twice. Anything still running when the process goes keeps its claim and is recovered by the visibility timeout. ### Next Steps - **[Cron Jobs](/docs/backend/cron-jobs)** — work on a schedule - **[Webhooks](/docs/recipes/webhooks)** — notify other systems on a change ## 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 export default defineFunction((app) => { app.post("/", async (c) => { const { name } = await c.req.json<{ name?: string }>().catch(() => ({ name: undefined })); return c.json({ message: `Hello, ${name ?? "world"}!` }); }); }); ``` This mounts at **`/api/functions/hello`**. The filename (without extension) becomes the route prefix. `POST`, because that is what the SDK sends by default — see [Invoke from the client](#invoke-from-the-client). A `GET` route is just as valid; the caller then has to say `{ method: "GET" }`. `rebase dev` watches the functions directory, so a file added while it is running is mounted on the next reload — no restart. (It has to be told: the directory is scanned rather than imported, so the watcher cannot infer it.) ### Invoke from the client ```typescript const client = createRebaseClient({ baseUrl: "http://localhost:3000" }); const { message } = await client.functions.invoke<{ message: string }>( "hello", // the filename, without extension — one path segment { name: "Ada" } // JSON body; omitted for a GET ); ``` `invoke` builds the URL, attaches the caller's token, and throws a `RebaseApiError` on a non-2xx — so the function's own error shape reaches the caller instead of a bare `fetch` rejection. Three things it takes beyond the name: ```typescript // A different method. The payload is dropped for GET, since GET has no body. await client.functions.invoke("hello", undefined, { method: "GET" }); // A sub-path — `/api/functions/hello/stats`. It goes here, never in the name: // a name containing "/" is refused rather than percent-encoded into a 404. await client.functions.invoke("hello", undefined, { method: "GET", path: "stats" }); // A query string. Passed as `path`, with no separator inserted before `?`. await client.functions.invoke("reports", undefined, { method: "GET", path: "?days=30" }); ``` :::note `client.call("functions/hello", …)` also reaches a function, and does something subtly different: it unwraps `res.data` when the response has one. Two ways in with two response contracts is a trap — use `functions.invoke`. `call` exists for routes mounted outside `/api/functions`, which `invoke` cannot express. ::: :::important Import from **`@rebasepro/server/functions`**, not from `@rebasepro/server`. Both work. The subpath is the *portable* authoring surface: it pulls in nothing that requires Node, so a function written against it can run on any JavaScript runtime. The package root reaches the whole framework — the boot sequence, the file loaders, the WebSocket layer — which is right for a server entrypoint and more than a route handler needs. It also gives you typed context accessors (`getUser`, `getDriver`) instead of casting `c.get("user")` by hand. See [Runtime portability](#runtime-portability) for the full contract. ::: ### Configuration :::note[Where this goes] **Managed runtime:** nothing to configure — the runtime discovers `backend/functions/` on its own (`entry.functions` in `rebase.json` if you moved it). `REBASE_FUNCTIONS_ONLY` / `REBASE_FUNCTIONS_EXCLUDE` narrow which ones a process serves. **Ejected:** `initializeRebaseBackend({ functionsDir })` in `backend/src/index.ts`. ::: 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-and-context-propagation) 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 The name is the function's identity everywhere else too: it is the URL segment, the `functions/` API-key permission, and the value `REBASE_FUNCTIONS_ONLY` selects by when you give one function its own process. ### Export Formats The loader accepts two export formats besides `defineFunction`: #### Hono App ```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; } ``` `defineFunction` returns exactly the Hono app these build by hand, so the three are interchangeable. It saves you declaring `Hono` and hands you the `rebase` singleton in the callback. --- ### 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 no-verify 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 no-verify const dynamicImport = new Function("url", "return import(url)"); const mod = await dynamicImport(fileUrl); ``` --- ### Authentication and 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 A caller who presents a *bad* token never reaches your handler: an unverifiable or expired token is rejected with 401 by the middleware itself, so an expired session is never silently downgraded to an anonymous one. #### Reading the caller ```typescript export default defineFunction((app) => { app.get("/me", (c) => { const user = getUser(c); // { uid, roles, ...claims } | undefined if (!user) return c.json({ error: "Unauthorized" }, 401); return c.json({ uid: user.uid, roles: user.roles, admin: isAdmin(c) }); }); }); ``` `getUser` returns a narrowed object: `uid` is a string and `roles` is always an array, whatever auth method the caller used. `getUserId(c)` and `getRoles(c)` are shortcuts. #### Protecting Routes ```typescript export default defineFunction((app) => { // Public endpoint — no guard, so anyone can call it. app.get("/public", (c) => c.json({ message: "Anyone can access this" })); // 401 for anonymous callers. app.post("/protected", requireAuth, (c) => c.json({ message: `Hello, ${getUserId(c)}` })); // 401 anonymous, 403 without an administrative role. Order matters. app.post("/admin-only", requireAuth, requireAdmin, (c) => c.json({ ok: true })); // Any one of the named roles. app.post("/publish", requireAuth, requireRole("editor", "admin"), (c) => c.json({ ok: true })); }); ``` Put guards in the **route's own middleware slot**, as above, rather than `app.use("/*", requireAuth)`. `use()` covers only the routes declared *below* it, so a route appended later — at the bottom of the file, months from now — is silently unprotected. :::important Reading `getUser(c)` is **not** a guard. An anonymous caller gets `undefined` and your handler runs anyway. Only a guard, or an explicit `if (!user) return 401`, stops the request. ::: #### 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 the caller to `{ uid: "service", roles: ["admin"] }`. 3. Injects a `DataDriver` 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 #### 1. The user-scoped driver — for anything serving a request `getDriver(c)` returns the driver **scoped to the caller**, so every read and write is evaluated against your Row-Level Security policies as that user: ```typescript export default defineFunction((app) => { app.get("/", requireAuth, async (c) => { const driver = requireDriver(c); const myProducts = await driver.fetchCollection({ path: "products", limit: 10 }); return c.json(myProducts); }); }); ``` `requireDriver(c)` is `getDriver(c)` without the `!` — it throws a message naming the wiring problem instead of failing twenty lines later on `undefined`. #### 2. `rebase.dataAsAdmin` — for trusted background work ```typescript export default defineFunction((app, { rebase }) => { app.post("/:id/approve", requireAuth, requireAdmin, async (c) => { const id = c.req.param("id"); await rebase.dataAsAdmin.collection>("jobs").update(id, { status: "published", approved_at: new Date().toISOString(), }); return c.json({ success: true }); }); }); ``` #### RLS-Scoped Driver vs. Rebase Singleton | | `getDriver(c)` (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) | | **Ideal for...** | General user CRUD, search, and queries | Background jobs, system triggers, webhooks | | **API style** | Driver-level methods (`fetchCollection`, `save`) | 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 `rebase.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. #### 3. `rebase.sql()` — raw SQL, and the one Node-only accessor 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. ```typescript export default defineFunction((app, { rebase }) => { app.get("/stats", requireAuth, requireAdmin, async (c) => { const rows = await rebase.sql( "SELECT count(*) AS total FROM jobs WHERE status = $1", { params: ["published"] } ); return c.json({ totalJobs: Number(rows[0]?.total ?? 0) }); }); }); ``` It runs on a TCP connection to your database, which makes it the only accessor tied to a Node process. That costs nothing on any deployment that exists today — it is simply the one thing to know about if a function may later move. See [Runtime portability](#runtime-portability). :::caution[Direct Drizzle access is Node-only] You can also import your own Drizzle instance and query it directly (`db.execute(sql\`…\`)`). It works, and on a self-hosted or managed Node deployment it is fine. It is worth knowing what it costs: a function that imports `drizzle-orm` and a `pg` pool is permanently a Node function, it bypasses your collection callbacks and validation, and it takes its connection from somewhere other than the request. `rebase.sql()` gives you the same raw SQL through the framework's own connection. Prefer it. ::: ### Configuration and Secrets Read configuration **inside** the handler, never at module scope: ```typescript // Built once, on the first request that needs it — not at import time. const apiKey = lazyResource((env) => env.PRICING_API_KEY ?? ""); export default defineFunction((app) => { app.get("/price", async (c) => { const endpoint = requireEnv(c, "PRICING_API_URL"); const response = await fetch(endpoint, { headers: { authorization: `Bearer ${apiKey(c)}` } }); return c.json(await response.json()); }); }); ``` Why this matters on **any** runtime, including Node: ```typescript no-verify // Don't. If STRIPE_SECRET_KEY is unset, this throws while the file is being // imported — and the loader reports that as a *skipped function*. The route // 404s, with the reason buried in a boot log line. const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); ``` A module-scope read is evaluated when the file is imported, before any request exists. On Node that means one missing variable takes the whole file down and every route in it with it. On a host that attaches configuration to the request rather than the process, there is nothing to read at import time at all. - `getEnv(c)` — every variable visible to this request - `env(c, "NAME")` — one variable, trimmed; blank counts as unset - `requireEnv(c, "NAME")` — the same, but throws a message naming the variable - `lazyResource(factory)` — build an expensive client once, on first use `rebase doctor` reports module-scope `process.env` reads in your functions directory. ### Background Work Work that should outlive the response goes in `waitUntil`: ```typescript export default defineFunction((app, { rebase }) => { app.post("/orders", requireAuth, async (c) => { const order = await c.req.json(); // The caller does not wait for this, but shutdown does. waitUntil(c, rebase.email.send({ to: "warehouse@example.com", subject: "New order", html: "

Pick and pack

" })); return c.json({ received: true }); }); }); ``` An un-awaited promise looks equivalent and is not. `waitUntil` buys two things: - **On Node**, the promise is tracked, so a graceful shutdown waits for it instead of the process exiting out from under a half-sent webhook. A floating promise at `SIGTERM` is simply lost. - **On an isolate-based host**, the host is told to keep the isolate alive until the promise settles. Without it, work is dropped the moment the response resolves — silently, with a clean 200 in the logs. A rejection is logged rather than left to the unhandled-rejection handler, so a failure names the route it came from. ### Runtime portability A custom function is a Hono app, and Hono runs on every JavaScript server runtime. Whether *your* function could run somewhere other than a Node process therefore comes down to what its own file imports and touches. Nothing here is a restriction on what you may write today. Every Rebase deployment is a Node process, a function that reads a file or opens a socket is a perfectly good function, and no build or deploy fails because of any of this. It is written down so the answer is knowable now rather than discovered per-file later. **Portable — works on any runtime:** - Everything exported from `@rebasepro/server/functions` - `getDriver(c)` and `rebase.dataAsAdmin` — both go over the same wire wherever they run - `rebase.auth`, `rebase.storage`, `rebase.email` - `fetch`, `Request`/`Response`, `URL`, `crypto.subtle`, `TextEncoder` — the web platform - Any dependency that does not need Node **Node-only:** - `rebase.sql()` — the database owner connection is a TCP socket - A directly imported Drizzle/`pg`/`mongodb` client, for the same reason - Node built-ins: `fs`, `path`, `crypto` (the Node module — `globalThis.crypto` is portable), `child_process`, … - Packages built on them: `jsonwebtoken`, `nodemailer`, `sharp`, `bcrypt`, … **Latent bugs on every runtime** — these are worth fixing regardless: - `process.env` read at module scope (see [Configuration and Secrets](#configuration-and-secrets)) - Fire-and-forget promises instead of [`waitUntil`](#background-work) - Relying on a handler continuing to run after its request timed out. On Node it does; that is a property of the process, not a promise the framework makes #### Checking your own functions `rebase build` prints a line per actionable finding and records the verdict per function in the bundle manifest: ```json { "functions": [ { "name": "hello", "file": "backend/functions/hello.js", "portable": true }, { "name": "reports", "file": "backend/functions/reports.js", "portable": false, "requires": ["imports the Node built-in \"fs\""] } ] } ``` `rebase doctor` reports the same thing without building. #### If you need a runtime-specific path `runtimeKey()` returns `"node"`, `"workerd"`, `"deno"`, `"bun"`, `"edge-light"`, `"fastly"` or `"other"`; `isNodeRuntime()` is the common check. Use them to degrade, not to fork an implementation — a function that needs two implementations is two functions. ```typescript export default defineFunction((app, { rebase }) => { app.get("/stats", async (c) => { if (!isNodeRuntime()) return c.json({ error: "Not available here" }, 501); const rows = await rebase.sql("SELECT count(*) AS total FROM jobs"); return c.json({ totalJobs: Number(rows[0]?.total ?? 0) }); }); }); ``` ### 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, /* ... */ }); ``` :::caution Routes you add to your own app that way are **outside** every Rebase router, so no auth middleware has run on them and `getDriver(c)` is unset. Guard those with `requireAuth` / `requireAdmin` imported from **`@rebasepro/server`** — the package root — which verify the token themselves. The guards on the `/functions` subpath read an identity a Rebase router has already resolved, and will answer 500 rather than pretend one exists. ::: ### Example: Webhook Handler ```typescript /** Constructed on the first request, from that request's configuration. */ const secret = lazyResource((env) => env.STRIPE_WEBHOOK_SECRET ?? ""); export default defineFunction((app, { rebase }) => { // Deliberately public: Stripe has no token to send. The signature is the // authentication, so verify it before doing anything else. app.post("/", async (c) => { const signature = c.req.header("stripe-signature"); const body = await c.req.text(); if (!signature || !verifySignature(body, signature, secret(c))) { return c.json({ error: "Bad signature" }, 400); } const event = JSON.parse(body) as { type: string; data: { object: Record } }; if (event.type === "checkout.session.completed") { const session = event.data.object; await rebase.dataAsAdmin.collection("subscriptions").create({ user_id: session.client_reference_id, stripe_id: session.subscription, status: "active", }); // Fulfilment can outlive the response; the 200 tells Stripe to stop retrying. waitUntil(c, notifyFulfilment(requireEnv(c, "FULFILMENT_URL"), session)); } return c.json({ received: true }); }); }); declare function verifySignature(body: string, signature: string, secret: string): boolean; declare function notifyFulfilment(url: string, session: Record): Promise; ``` ### 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 listing itself requires a signed-in caller, an API key or the service key — the functions stay callable by whoever each one admits, but the inventory of them is not public. 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. That the handler *keeps running* after the 504 is a property of a long-lived Node process, not a guarantee of the contract; anything that must complete belongs in [`waitUntil`](#background-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.dataAsAdmin`). 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 :::note[Where this goes] **Managed runtime** — `export const callbacks = { … }` from `config/index.ts`. The runtime reads that export at boot; nothing else needs changing. **Ejected** — the `callbacks` key on `initializeRebaseBackend({ … })`. The full map is in [Backend Overview](/docs/backend/#where-each-option-lives). ::: 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; // After the write, still in the transaction afterSaveError?(props): void; // Side-effects after a failed save beforeDelete?(props): boolean | void; // Return false (403) or throw to block deletion afterDelete?(props): void; // After the delete, still in the transaction }; ``` 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] │ [Database Driver] ┌─────┴───────────────────────────────────────────────────────┐ │ 1. Start PostgreSQL Transaction │ │ 2. Set Config: app.user_id = '', app.user_roles = ... │ │ │ │ 3. Global Callback: beforeSave ─┐ │ │ 4. Collection Callback: beforeSave ─┘ awaited │ │ 5. Drizzle SQL execution & Postgres RLS evaluation │ │ 6. Global Callback: afterSave ─┐ │ │ 7. Collection Callback: afterSave ─┘ awaited │ │ │ │ 8. Commit ← a throw anywhere in 3–7 rolls the write back │ └─────┬───────────────────────────────────────────────────────┘ │ [Realtime notifications flushed — after the commit, never before] │ ▼ [Client Response] ``` --- ### Blocking vs. Async Semantics **Every callback in the list below is awaited, and all of them run inside the transaction that carries the write.** There is no "fire and forget" tier: the row and everything its callbacks did commit together or not at all. - **`beforeSave`, `beforeDelete`** — if the callback throws, the operation is rejected with an HTTP 400 carrying your message and the code `CALLBACK_REJECTED`, and the database write never happens. Throw a `RebaseApiError` from `@rebasepro/types` to pick the status yourself — see [Entity Callbacks](/docs/collections/callbacks#beforesave). A `beforeDelete` that *returns* `false` is the same refusal with no message, and answers **403** with that code. - **`afterRead`** — the returned row (or transformed row) is what the caller receives. Its transaction is `READ ONLY` — see [below](#afterread-cannot-write). - **`afterSave`, `afterDelete`** — run *before* the commit, awaited. A throw here rolls the row back and answers the same **400 `CALLBACK_REJECTED`**, with `details.stage` naming the hook. They hold the transaction open while they run, so a slow one is a lock held. - **`afterSaveError`** — runs when the save failed, on the way out. :::caution[This page used to say the opposite] Earlier versions said `afterSave` and `afterDelete` "run after the transaction commits" and "do not block the HTTP response". They never did either. Code that was written against that sentence — a webhook call in `afterSave`, say — has been holding a database transaction open for the length of an HTTP round trip, and rolling the row back whenever the remote end was down. ::: #### Side effects that must not hold the transaction Anything slow, or anything that cannot be undone if the transaction rolls back, does not belong in the callback body: | Want | Do this instead | |---|---| | Call a third party, send mail, generate a file | [Enqueue a job](/docs/backend/jobs). A job enqueued in a transaction that rolls back was never enqueued — which is the behaviour you want. | | Tell other processes something happened | Publish on a [realtime channel](/docs/backend/realtime) after the write returns, not from inside the hook. | | Work in a [custom function](/docs/backend/custom-functions) that the caller need not wait for | `waitUntil(c, promise)` from `@rebasepro/server/functions` — it runs after the response, and the host waits for it before shutting down. | The rule of thumb: if the work should still happen when the write is undone, it is not part of the write, so it does not go in the hook. #### `afterRead` cannot write A request-scoped read opens its transaction `READ ONLY`. `afterRead` runs inside it, so **no write from that callback can succeed** — not a `context.data` create, not an update, not one buried in a helper it calls. Postgres refuses the statement with SQLSTATE `25006`, and the caller is answered: ```json { "error": { "message": "An `afterRead` callback tried to write. …", "code": "READ_ONLY_TRANSACTION", "details": { "dbCode": "25006" } } } ``` That is a 409, not a 500: it is your code being refused, not the server failing. The read-only mode is deliberate — a read that quietly writes is a read whose cost, locks and RLS surface nobody budgeted for. So **read auditing does not belong in `afterRead`**. Log the read outside the request instead — from a background job fed by whatever you already emit, or from a custom function that does the read *and* the write with two separate calls: ```typescript no-verify // ✗ Fails with READ_ONLY_TRANSACTION on every read. callbacks: { afterRead: async ({ path, row, context }) => { await context.data.read_log.create({ path, uid: context.user?.uid }); return row; } } ``` ```typescript no-verify // ✓ The read and the audit row are two operations, and only the second writes. export default defineFunction("read-article", (app) => { app.get("/:id", async (c) => { const article = await c.var.driver.fetchOne({ path: "articles", id: c.req.param("id") }); await rebase.dataAsAdmin.read_log.create({ path: "articles", uid: c.var.user?.uid }); return c.json(article); }); }); ``` Write-side auditing has no such problem: `afterSave` and `afterDelete` run in a read-write transaction, and the audit row commits with the change it records. --- ### 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 Record every deletion, across every collection, in an `audit_log` table. Because `afterDelete` runs in the delete's own transaction, the audit row and the deletion commit together — there is no window in which one exists without the other: ```typescript no-verify const instance = await initializeRebaseBackend({ // ... other config callbacks: { async afterDelete({ collection, id, row, context }) { if (collection.slug === "audit_log") return; // don't audit the audit await context.data.audit_log.create({ action: "delete", collection: collection.slug, entity_id: String(id), actor: context.user?.uid ?? "anonymous", snapshot: row }); } } }); ``` Note what this buys and what it costs: if the audit row cannot be written, the delete does not happen either. For an audit trail that is usually what you want. If it is not, catch the error in the callback and say so in a comment. #### 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 snapshot of entity values on every create, update, and delete. This gives you a full audit trail with diffs. ### Enabling History :::note[Where this goes] **Managed runtime** — on by default. `REBASE_HISTORY=false` in `.env` turns it off. **Ejected** — `history: true` in `initializeRebaseBackend({ … })`. The object form below — `{ maxEntries, ttlDays }` — is ejected-only; the environment variable is a boolean. The full map is in [Backend Overview](/docs/backend/#where-each-option-lives). ::: #### Backend :::note[Where this goes] **Managed runtime:** `REBASE_HISTORY` (`true` by default; set `false` to turn it off). Retention settings have no environment form — eject to change them. **Ejected:** `initializeRebaseBackend({ history })` in `backend/src/index.ts`. ::: 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 snapshot 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 an 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. The failure names what is connected rather than leaving you to guess: ``` Cannot create branch: the source database "leadgen" has active connections. Connected right now: 2 × psql A running `rebase dev` is the usual one — stop it, or re-run with --force to disconnect them for you. ``` `--force` terminates those sessions before templating, on `create` and `delete` alike. It never terminates the session running the command itself. `DatabasePoolManager` disconnects its own idle pools before cloning or dropping — but only the pools **inside the process doing the work**. `rebase db branch` runs as its own process, so this reaches nothing else on your machine: - **A running `rebase dev` blocks branching.** This is the common case, not an edge case: wanting a branch and running the app are usually the same moment. Stop the dev server, branch, and start it again. - **So does any other client.** DBeaver, pgAdmin, a `psql` session, a second app instance — PostgreSQL rejects the operation with `is being accessed by other users` and those connections have to be closed by hand. There is no way around this in PostgreSQL itself; `CREATE DATABASE ... TEMPLATE` is a filesystem-level copy and the template must be quiescent for the duration. --- ### 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 # Clone from a database other than the default rebase db branch create pr_review_42 --from rb_staging # List all branches and disk utilization rebase db branch list # Work on it — every later command in this checkout uses it rebase db branch switch dev_sandbox # Which branch am I on? rebase db branch switch # Back to the main database rebase db branch switch --off # Show one branch's parent, age and size rebase db branch info dev_sandbox # Delete a branch rebase db branch delete dev_sandbox ``` `switch` is what makes a branch usable. It records the branch in `.rebase/branch.json` — a name, never a connection string, so your credentials stay in `.env` alone — and every `rebase` command in that checkout then resolves the branch's database instead: `dev`, `db push`, `db migrate`, `db backup`. It sits between the shell and the project file in the resolution order: 1. `--database-url` on the command line 2. `DATABASE_URL` in the shell environment 3. **the branch this checkout is switched to** 4. `DATABASE_URL` in the project's `.env` A branch has to outrank `.env` or switching would do nothing on any project that sets `DATABASE_URL`; it must not outrank the two above it, because a flag on this command line is a more immediate instruction than a switch made yesterday. `.rebase/` is gitignored, so the branch you are on is a fact about your machine and never about the project. Branches are ordinary PostgreSQL databases named after the branch with an `rb_` prefix, so `dev_sandbox` above is the database `rb_dev_sandbox` on the same server. Creating a branch does **not** change which database your project talks to. `rebase db branch create` makes the copy and stops there; nothing writes to `.env`, and the next `rebase dev` still uses the database it used before. To work against a branch, point `DATABASE_URL` at it yourself — the connection string is the one you already have, with the database name swapped: ```bash # .env DATABASE_URL=postgresql://user:pass@localhost:5432/rb_dev_sandbox ``` --- ### Branching requires a real PostgreSQL server Branching does **not** work against the managed development database — the zero-setup PGlite database `rebase dev` starts when a project has no `DATABASE_URL`. PGlite serves exactly one database. `CREATE DATABASE ... TEMPLATE` against it writes a catalog entry and copies nothing, so the "branch" resolves to the database it was cloned from: writes you believe are isolated land in your development database, and there is no second copy to go back to. Use branching against a real server — your own PostgreSQL via `DATABASE_URL`, or `rebase dev --docker`. --- ### 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. `rebase db branch prune` is how you get the space back: ```bash rebase db branch prune # orphans only — always safe rebase db branch prune --older-than 2w # and anything older than two weeks ``` Nothing expires unless you ask: a branch can be the only copy of an afternoon's work, so `--older-than` is opt-in and ages are floored, and the command shows its plan and asks before removing anything unless you pass `--yes`. Prune also finds the two ways branches drift from their metadata — an entry whose database was dropped with plain SQL (which `list` would keep reporting forever), and a branch database whose entry was never written (a crash between the two statements `create` runs). Atlas's `_dev_diff` scratch databases are reported alongside but removed only with `--include-dev-diff`: they are not branches, and one may belong to a `db push` running right now. #### 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. ### Related - [CLI Commands](/docs/cli/) — `rebase db branch` and its flags - [Schema Generation](/docs/cli/schema/) — how the schema a branch copies is produced - [Environment & Configuration](/docs/getting-started/configuration/) — `DATABASE_URL`, and what a switched branch outranks ## 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) ``` Importing `z` from `@rebasepro/server` is new. On 0.17 and earlier the package exported no `z`: import it from `zod`, matching the major the runtime uses, and let your bundler dedupe the two copies. :::caution[The `z` you extend with must be the runtime's zod] Two copies of zod loaded at once is the one way this call goes wrong, and it used to go wrong silently. `.merge()` accepts a schema from the other copy — the shapes are identical — and then `.parse()` rejects every field carrying a `.default()`, because a default is recognised by class identity. The server came up, reported success, and ran none of its crons; nothing in the failure mentioned zod. Don't declare `zod` in your project's dependencies — the runtime provides it. If you must, match its major and let your bundler dedupe. `loadEnv` now refuses a foreign schema at boot with a message naming the fix, rather than accepting it and validating half of it. ::: **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. - Refuses an `extend` schema built by a different copy of zod. 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 // Both halves are optional on the contract: a driver need not create a // realtime provider, and a bootstrapper need not serve websockets at all. if (!realtimeProvider || !bootstrapper.initializeWebsockets) { throw new Error("This driver does not support 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. ### Related - [Backend Overview](/docs/backend/) — where each option lives when the runtime boots instead - [Split Processes](/docs/deployment/split-processes/) — the roles a custom server has to reproduce - [Storage Configuration](/docs/backend/storage/) — the sources a custom server must resolve itself ## 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). This is `frontend/src/App.tsx` as `rebase init` writes it — the whole admin panel, four declarations inside one provider: ```tsx const client = createRebaseClient({ baseUrl: import.meta.env.VITE_API_URL, auth: { authFlowMode: "cookie" } }); export function App() { const authController = useRebaseAuthController({ client }); return ( {/* Sign-in screen. Pass `loginView` to replace it. */} ); } ``` The first three render nothing: they *register* configuration into the provider. `` is what draws — it reads that registry and builds the navigation, routes and layout from it. So the order they appear in does not matter, and adding a feature means adding a component, not rewiring a tree. | Component | Package | Registers | |---|---|---| | `` | `@rebasepro/app` | the sign-in screen (`loginView`) | | `` | `@rebasepro/cms` | collections, custom views, the home page, the collection editor | | `` | `@rebasepro/studio` | the developer tools (SQL, RLS, logs, backups…) | | `` | `@rebasepro/cms` | nothing — it renders the admin from everything above | Drop `` and you have a content-only CMS; drop `` and you have the developer tools alone. To lay the shell out by hand instead, see [Advanced: manual layout](#advanced-manual-layout). ### The Rebase Provider `` is the root provider that makes all Rebase functionality available to child components via context. It accepts: All twenty-two of them, in full — the table used to list ten, and two of those were props the component never read: | Prop | Description | |------|-------------| | `children` | The admin's root components — ``, ``, ``. A render function is the manual-layout escape hatch. | | `apiUrl` | Base URL of the backend API, made available to every hook via `useApiConfig()` | | `dateTimeFormat` | How dates are printed. Defaults to `MMMM dd, yyyy, HH:mm:ss` | | `locale` | Initial language of the admin, and the locale dates are formatted in — see [Translations](/docs/frontend/i18n) | | `client` | `RebaseClient` instance: the default source for data, auth and storage | | `dataSources` | Extra data sources, for collections that name one — see [Multiple sources](/docs/backend/multiple-sources) | | `authController` | Authentication state and methods. Replaces the `client.auth` subscription outright | | `storageSource` | The default storage source, overriding `client.storage` | | `storageSources` | Named storage sources beyond the default | | `databaseAdmin` | Administrative database operations (SQL, schema discovery). Only Studio needs it | | `userConfigPersistence` | Local UI preferences — column widths, collapsed groups | | `onAnalyticsEvent` | Called for every analytics event the admin emits | | `entityLinkBuilder` | Returns a URL for the "open in your app" button on an entity form | | `plugins` | Plugin instances — see [Plugins](/docs/plugins) | | `slots` | Slot contributions declared directly, without a plugin | | `propertyConfigs` | Custom field widgets, keyed by the name a property names in `propertyConfig` | | `entityViews` | Global custom entity view tabs | | `collectionViews` | Custom collection view modes, available to any collection by `key` | | `entityActions` | Global entity actions | | `effectiveRoleController` | Simulate a different role while dev mode is on | | `translations` | Override or extend any UI string, keyed by locale — see [Translations](/docs/frontend/i18n) | | `components` | Replace built-in components — see [Component Overrides](/docs/frontend/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). Neither is the URL prefix. When the admin is mounted under a path, that belongs on ``, which is what resolves URLs to collections — and only when the router has no `basename` of its own. See [Changing the Base URL](/docs/getting-started/deployment#changing-the-base-url). ### 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; ``` ### Advanced: manual layout Everything below replaces ``. You need it only when the stock layout is in the way — a different chrome around the admin, a route tree of your own, an app where the admin is one page among many. If you are not replacing the layout, stop at [Custom Views](#custom-views). `` is sugar for four layers, and you can take them one at a time: ```tsx {/* login screen until there is a user */} {/* builds the navigation, URL and collection-registry controllers */} {/* the admin's routes, drawn inside the layout you pass */} }/> ``` The order is fixed: `RebaseAuthGate → RebaseNavigation → RebaseRouteDefs → RebaseLayout`. `RebaseAuthGate` shows the login view until there is a user, so nothing below it renders for a signed-out visitor; `RebaseNavigation` builds the navigation, URL and collection-registry controllers that `RebaseRouteDefs` and every collection view read, so `RebaseRouteDefs` outside it throws. Each layer is usable on its own. `` alone gates your own app behind Rebase's login. Swap `` for your own component to keep the routing and lose the chrome; drop `` too and you are building the routes yourself out of the components in [Scaffold Components](#scaffold-components). Below that floor `` also accepts a **render prop** instead of children, which hands you the context and the loading flag and leaves the entire tree to you: ```tsx {({ context, loading }) => ( )} ``` At that point nothing is wired for you: you build the controllers below by hand and render the routes yourself. #### Controllers Controllers are React hooks that configure specific aspects of the framework. `` calls all of them for you — reach for these only inside a render prop. ##### `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 ("cms" | "studio") ``` #### 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. An `AppView` is a flat object — everything below sits at the top level, there is no nested `admin` block: ```tsx const views: AppView[] = [ { slug: "dashboard", name: "Dashboard", icon: "LayoutDashboard", view: }, { slug: "settings", name: "App Settings", icon: "Settings", group: "Admin", // Register `settings/*` too, so the view can route inside itself. nestedRoutes: true, // Reachable by URL, but not listed in the drawer. hideFromNavigation: true, view: } ]; ``` Hand them to ``, next to your collections — that is the component that registers navigation: ```tsx ``` | Field | | |---|---| | `slug` | the path it is reached at, under the admin root | | `name` | the label in the drawer and on the home page | | `view` | the element to render, or a `ComponentType` to render it lazily | | `icon` | a [Lucide](https://lucide.dev/icons/) icon name, e.g. `"ShoppingCart"` — or any node | | `group` | groups views together in the drawer; `"Admin"` and `"Settings"` sink to the bottom | | `pinToBottom` | sinks the group to the bottom under any name — prefer it over the two magic strings | | `nestedRoutes` | also registers `slug/*`, for a view with routes of its own | | `hideFromNavigation` | keeps the route, drops the nav entry | | `roles` | only users holding one of these roles see the view, or can reach it | | `description` | Markdown, shown on the home-page card | To put a view under **Studio** instead of the CMS, pass it to [``](/docs/studio#adding-your-own-tool). ### 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 - **[Translations](/docs/frontend/i18n)** — Change any string, or add a language - **[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 an entity tab | `entityViews` | entity | [Entity Views](/docs/frontend/entity-views) | | Render one collection's rows a different way | `admin.customViews` | collection | [below](#customviews) | | Add a row/context action or entity button | `entityActions` | entity | [Entity Actions](/docs/frontend/entity-actions) | | Put a figure on a collection's home-page card | `home.card.widget` slot | app/plugin | [Slots](/docs/frontend/slots) | | 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) | | Style the thing I just built | `@rebasepro/ui` + theme tokens | any | [Styling Custom UI](/docs/frontend/styling) | :::tip[Whatever you pick, build it from the kit] Every mechanism below hands you a React component and says nothing about what to fill it with. Use `@rebasepro/ui` components and the theme's colour tokens rather than hand-written CSS — a custom view is still an admin view, and a hardcoded colour is invisible in one of the two themes. See [Styling Custom UI](/docs/frontend/styling). ::: ### 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) #### Custom view modes {#customviews} **Scope:** collection (adds a view mode). A map, a calendar, a gallery, a timeline — another rendering of *the same rows*, offered in the collection's view switcher beside List, Table, Cards and Board. ```ts // collection config admin: { customViews: [ { key: "map", name: "Map", icon: "Map", Builder: MapView } ], enabledViews: ["table", "map"], defaultViewMode: "map" } ``` Or register the component once and name it by key, which is also what makes it selectable from the collection editor: ```tsx ``` ```ts admin: { customViews: ["map"] } ``` `Builder` receives the live `tableController`, so the view inherits the collection's filters, the search box, sorting, pagination, permission checks and the entity side panel — that is the whole reason to declare one instead of building an `AppView`: ```tsx function MapView({ tableController, onEntityClick }: CollectionCustomViewParams) { return e.values.location)} onMarkerClick={i => onEntityClick?.(tableController.data[i])} />; } ``` Choosing the view updates `?__view=`, survives a reload, and persists per user. Declaring one is enough to offer it — `enabledViews` only needs setting when you want to *take built-ins away*. With a single entry the switcher is hidden. **This is not a way to build a view spanning several collections.** A view mode is another rendering of one collection's query. If your component ignores `tableController` and fetches four tables of its own, it wants to be an [`AppView`](/docs/frontend#custom-views) — the toolbar above it, with its search box and its record count, would be describing a query it does not render. #### `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.** ## Styling Custom UI ### Overview Every extension mechanism in this section hands you a React component and gets out of the way — a [custom view](/docs/frontend/extending), a [home page](/docs/frontend/component-overrides), an [entity view](/docs/frontend/entity-views), a [slot](/docs/frontend/slots). What none of them says is what to *build it out of*. The answer is: the same parts the admin is built from. A custom view is still an admin view. It sits inside the same shell, beside the same tables, under the same theme toggle — so it should use the same components, the same type scale and the same colour tokens. The alternative is inventing a second design language inside the same app. That is the common failure, and it does not merely look inconsistent — it breaks. A hand-written `color: #111` renders invisible the moment someone switches to the dark theme, and no test catches it. ### The rule **Import components from `@rebasepro/ui`. Reach for a raw `
` and a class only for layout.** ```tsx export function DashboardView() { return (
Outreach What ran last night, and what is waiting for you. 128 Signals 12 approved Delivery is not configured, so nothing can be sent.
); } ``` Every component in the kit is catalogued under [UI components](/docs/ui/components/card/) with its real props, generated from the source. Check there before hand-rolling: `Card`, `Chip`, `Badge`, `Alert`, `Button`, `Typography`, `Paper`, `Container`, `Table`, `Tooltip`, `Dialog` and about forty more already exist. ### Colour: use tokens, never literals The theme is a set of CSS variables exposed as Tailwind utilities. Use them, and pair every light value with a `dark:` one: | Use | Class | |---|---| | Body text | `text-surface-900 dark:text-surface-100` | | Secondary text | `text-surface-600 dark:text-surface-400` — or just `` | | Panel background | `bg-surface-accent-50 dark:bg-surface-800` | | Borders | `border-surface-200 dark:border-surface-700` | | Accent | `text-primary` / `bg-primary` (`#0070F4`) | Two rules that come from real breakage: - **Never write a colour literal.** `#111`, `rgba(128,128,128,.28)`, `white` — each one is correct in exactly one theme. A page whose numbers were `color: var(--fg, #111)` rendered black-on-black for every dark-theme user, and looked perfect to the person who wrote it. - **Never set a colour a component already sets.** `` picks the right foreground for the theme. Overriding it with a class is how a heading ends up the only element on the page that ignores the theme. ### Type: use the scale `Typography` carries the whole scale — `h1`–`h6`, `subtitle1`/`subtitle2`, `body1`/`body2`, `caption`, `label`. Use `variant`, not a font-size class. The scale already encodes the tracking each tier needs (`--tracking-display` at ≥30px, `--tracking-title` at 20–24px, `--tracking-heading` below that), which a `text-[27px]` does not. Product UI does not go below `text-xs`. The `text-2xs` and `text-3xs` tiers exist for marketing pages only. ### Setup Custom UI needs the theme's CSS and Tailwind pointed at the packages, or the utility classes used *inside* `@rebasepro/ui` never get generated: ```css @import "tailwindcss"; @import "@rebasepro/ui/index.css" layer(base); /* Without this, Tailwind never scans the kit's own classes. */ @source "../node_modules/@rebasepro"; @custom-variant dark (&:where(.dark, .dark *)); ``` `rebase init` writes this for you. If your custom view renders unstyled, this is the first thing to check. ### Checklist Before shipping a custom view: - No colour literals — every colour is a token or comes from a component. - Every `bg-`, `text-` and `border-` has a `dark:` counterpart. - Text is ``, not a font-size class. - Containers are `Card` / `Paper`, not a `
` with a hand-written border. - Toggle the theme and look at the page. That is the whole test, and it takes five seconds. ## Translations ### Overview Every string the admin panel renders comes from a key. Seven locales ship with it — English, Spanish, German, French, Italian, Portuguese and Hindi — and a project can override any key, add a language of its own, or translate its own components against the same table. Three things do the work: | | | |---|---| | `` | which language to start in | | `` | what any key says, per locale | | `useTranslation()` | reading a key from your own component | ### Choosing the language ```tsx ``` `locale` is the *initial* language. A reader who picks one from the language menu has that choice remembered in their browser, and it wins over the prop on their next visit — changing `locale` in code does not overrule someone's setting. Anything Rebase has no string for falls back to English rather than rendering a key. ### Changing what a string says `translations` is keyed by locale, then by key. Partial: name the handful you want to change and everything else stays as it was. ```tsx ``` The same shape adds a language Rebase does not ship. Give it a full set of keys and set `locale` to it: ```tsx ``` Keys you leave out fall back to English, so a partial set is a working translation, not a broken one. #### Where the keys are The whole table is `RebaseTranslations` — one interface with every key on it, so your editor completes them and a typo is a type error. Its English values live in `packages/app/src/locales/en.ts`, which is the reference for what each key actually says. Names follow the surface they belong to: `save`, `cancel`, `delete_confirmation_title` for the panel, `studio_*` for the developer tools (`studio_tool_sql`, `studio_group_database`, `studio_backups_denied_title`). ### Translating your own components `useTranslation()` gives you the same table your custom views, fields and actions can read: ```tsx function PublishButton() { const { t } = useTranslation(); return ; } ``` Interpolation uses the same `{{name}}` syntax as the built-in strings: ```tsx t("add_to_field", { fieldName: "Tags" }); ``` Your own keys go through `translations` like any others — a key Rebase does not declare is still resolved, so you can put your component's strings in the same table rather than running a second i18n stack beside it. `t` also returns the key itself when nothing matches, which is the honest answer and makes a missing key visible in the UI rather than blank. ### Group names are not labels An `AppView`'s `group` is an identifier: the drawer collapses by it, the Studio home page orders by it, and `navigationGroupMappings` attaches icons to it. Translating it where it is declared breaks all three in every language but the one you translated into. Declare the group in English and let the header translate itself — Rebase's own groups have `studio_group_*` keys and are looked up by name; a group of your own renders as written. ```tsx // Right: the name is stable, the heading is localized for the built-in groups. { slug: "queues", name: "Queues", group: "Compute", view: } ``` ### Next Steps - **[Frontend Overview](/docs/frontend)** — the `` props in full - **[Custom Fields](/docs/frontend/custom-fields)** — components that will want `useTranslation` ## 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 | Description | |---|---| | `"Collection.View"` | The entire collection landing page | | `"Collection.Table"` | The default spreadsheet tabular view | | `"Collection.Card"` | The card view item wrapper | | `"Collection.EmptyState"` | View shown when a collection is empty | | `"Collection.Actions"` | Toolbar buttons above the table/cards | | `"Collection.FilterField"` | Custom filter input for a column | | `"Entity.Form"` | The detail form for creating/updating | | `"EditView.FormActions"` | Form submission/cancel button bar | | `"DetailView"` | Read-only detail view | | `"Entity.SidePanel"` | The side panel container for form/detail | | `"EntityPreview"` | Inline reference/relation chip preview | | `"Entity.MissingReference"` | Rendered when a referenced entity is missing | :::note[Three keys break the `Entity.` pattern] `"DetailView"`, `"EntityPreview"` and `"EditView.FormActions"` carry no `Entity.` prefix. `"Entity.DetailView"`, `"Entity.Preview"` and `"Entity.FormActions"` are not in the union — they type-error, and in plain JavaScript the override simply never applies. ::: Your replacement receives the same props the built-in component was given. The override map does not name a props type per key — `ComponentOverride

` defaults `P` to `Record` — so type the parameter yourself, or pass a type argument, when you want the props checked. A few of the built-ins do export a props type you can import and reuse: `CollectionViewProps` (`@rebasepro/ui`); `CollectionEmptyStateProps`, `CollectionActionsProps` and `FilterFieldBindingProps` (`@rebasepro/cms-types`); `EntityFormProps` and `EntityFormActionsProps` (`@rebasepro/cms`). The rest have no exported props type — write the shape you actually read. ### Related - [Extending Rebase](/docs/frontend/extending/) — the extension points that do not need an override - [Custom Fields](/docs/frontend/custom-fields/) — replacing one property's editor rather than a component - [Slots](/docs/frontend/slots/) — adding to a component instead of replacing it ## 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, GitHub and LinkedIn 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" }, // The order key. A *string*, never a number — see "Ordering" below. __order: { name: "Order", type: "string", admin: { disabled: true, hideFromCollection: true } } }, name: "Products", table: "products", admin: { defaultViewMode: "table", // Default view enabledViews: ["list", "table", "kanban"], // Available views orderProperty: "__order", // Property for drag-and-drop ordering kanban: { columnProperty: "status" // Enum property for columns } } }); ``` ### List View 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 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 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" } ] }, __order: { type: "string", name: "Order", admin: { disabled: true, hideFromCollection: true } } }, admin: { defaultViewMode: "kanban", orderProperty: "__order", kanban: { columnProperty: "status" } } }); ``` Drag-and-drop between columns automatically updates the enum field and sort order. #### Ordering `kanban` and `orderProperty` are two halves of one feature. Declare both, every time — three mistakes here all produce a board that looks configured and is not. **`orderProperty` is not optional.** Without it a card still drags between columns, because that writes `columnProperty`. Its position *within* a column has nowhere to be stored, so it resets on the next read, and the board renders an amber bar telling you ordering is not configured. **The property must be a `string`.** Reordering writes a [fractional-indexing](https://github.com/rocicorp/fractional-indexing) key — `"i0"`, `"i1"`, `"i0i"` — not an index. A `number` property can never hold one, so a numeric `sortOrder` leaves the board asking to be initialised forever, and the initialisation itself fails against a numeric column. Declare it hidden; it is machinery, not content: ```typescript __order: { type: "string", name: "Order", admin: { disabled: true, hideFromCollection: true } } ``` **Rows created outside the admin arrive without a key.** Nothing assigns one on insert. A row written by a cron, a seed script, a migration or the REST API lands with `__order` null, and the board shows *"Some items don't have order values"* with an **Initialize** button — one click backfills the first page, and the next cron run brings the bar straight back. If a backend creates rows for a board, it should append the key itself. Use the same alphabet the admin uses: ```typescript // Base36, lower case. Postgres does the sorting and its default collation is // not byte ordering, so the library's default base62 alphabet — which mixes // cases — sorts differently in the database than in the key. Omitting this // third argument produces keys like "a0" that the board rejects. const ORDER_KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"; const tasks = client.data.collection("tasks"); // The last key currently in use. `is-not-null` is not optional: a descending // sort is NULLS FIRST, so without it this reads back one of the very rows that // has no key and every insert lands on the same "i0". const { data: last } = await tasks.find({ where: { __order: ["is-not-null", null] }, orderBy: ["__order", "desc"], limit: 1 }); await tasks.create({ title, status, __order: generateKeyBetween( (last[0]?.__order as string | undefined) ?? null, null, ORDER_KEY_DIGITS ) }); ``` Rows created through the admin form arrive unkeyed too — the difference is only that you see the bar the moment you add one. **Initialize** is the fix there; on a board fed by a backend it is a fix that undoes itself every run. ### Cards View 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 ## Firebase `@rebasepro/firebase` points the Rebase admin panel at Firebase. Your collections describe Firestore documents, and the panel reads and writes them through the Firebase SDK. :::caution[Experimental, and structurally different from the rest of Rebase] This is a **client-side adapter**. There is no Rebase server in the picture: the browser talks to Firebase directly, so everything Rebase's backend provides — row-level security, the REST API, the generated SDK, functions, crons, the storage access model — is not part of this arrangement. Authorization is **Firebase Security Rules**, written and deployed in Firebase. Rebase's `securityRules` on a collection do not apply. ::: ### Installation ```bash pnpm add @rebasepro/firebase firebase ``` Peer dependencies: `firebase` (10, 11 or 12), `react` ≥ 19, `react-dom` ≥ 19, and optionally `typesense` for text search. ### What it gives you - **`RebaseFirebaseApp`** — a complete admin app: Firebase Auth login, routing, and CRUD over Firestore built from your collection definitions. - **Hooks per service** — auth, Firestore, storage, App Check, user management. - **Text search adapters** — Algolia, Typesense, Pinecone, or local. ```tsx title="src/App.tsx" no-verify export default function App() { return ; } ``` A working example lives in [`examples/firebase`](https://github.com/rebasepro/rebase/tree/main/examples/firebase). ### What does not carry over Everything on this site that describes the Rebase **backend** describes the PostgreSQL (or MongoDB) path, not this one: | | | |---|---| | Row-level security | Firebase Security Rules instead, written in Firebase | | REST API and generated SDK | Absent — the browser uses the Firebase SDK | | Functions and crons | Cloud Functions for Firebase instead | | Storage access model | Firebase Storage rules instead | | Studio, `rls-check`, migrations | Postgres features; not applicable | ### Choosing it Take this when you already have a Firebase project and want a better admin panel over it. If you are choosing a backend rather than adapting to one you have, the [PostgreSQL path](/docs/getting-started/quickstart/) is the one the rest of this documentation is about. ### Related - [Frontend Setup](/docs/frontend/) — the panel this replaces the data layer of - [Authentication & Login](/docs/frontend/authentication/) — the sign-in surface, either way - [Defining Collections](/docs/collections/) — the collection shape both drivers read ## 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 (