Live schema editing
The schema editor in the admin panel rewrites your collection source. That works on your machine and nowhere else: a deployed server’s files are rebuilt from your repository on every deploy, so an edit made there would be discarded on the next one.
Live schema editing is the answer to that. It commits the change to your repository, then applies the DDL — so the edit survives the next deploy, because the deploy is built from it.
GET /api/admin/schema/status whether this backend can do it, and whether you mayPOST /api/admin/schema/plan what would happen, without doing itPOST /api/admin/schema/apply commit, then applyStatus and plan need the schema:read scope, and apply needs schema:write, like
every other /api/admin surface that names a scope. An admin holds both.
Applying also needs one thing more than the scope — see Who may apply.
Plan before you apply
Section titled “Plan before you apply”/plan has no side effects. Post the change, and it tells you what the change
means. A change to an existing collection is a patch — what changed, as
operations on paths of keys — and a new collection is the whole collection:
{ "collectionId": "posts", "patch": [ { "op": "set", "path": ["properties", "subtitle"], "value": { "name": "Subtitle", "type": "string" } }, { "op": "remove", "path": ["admin", "group"] }] }Only the keys a patch names are written into the collection file. Everything
else stays as it is — imports, comments, formatting, an entity action’s
onClick, a property shared from another module, an enum imported from
elsewhere. A patch that reaches into something defined in code
(status: statusProperty, enum: LOCALE_ENUM, a ...spread) is refused with
the expression it hit, so the change is made where that code lives. A whole
collection posted for a collection that exists is turned into the patch of
what differs from it, and a key whose value is code is never removed that way.
The admin panel sends patches. On 0.23 /plan and /apply take only the whole
collection as it should end up.
$ADMIN_TOKEN is an access token — the accessToken a sign-in returns — for an
account holding schema:read: an admin, or a role that declares it. Nothing on
the machine sets it for you.
curl -X POST https://your-app/api/admin/schema/plan \ -H "authorization: Bearer $ADMIN_TOKEN" \ -H "content-type: application/json" \ -d '{"collectionId":"posts","collection":{}}'{ "applicable": true, "verdict": "safe", "changes": [ { "kind": "add-property", "verdict": "safe", "collection": "posts", "property": "subtitle", "detail": "New optional property subtitle …" } ], "statements": ["ALTER TABLE \"public\".\"posts\" ADD COLUMN IF NOT EXISTS \"subtitle\" TEXT;"], "files": ["config/collections/posts.ts", "backend/src/schema.generated.ts"]}files is every file the apply commits: the collection’s source, when the backend can write it, then the generated schema.
This is not a convenience. Two of the three verdicts are refusals, and one of them is a refusal you would otherwise only discover by pressing the button on a live database.
The three verdicts
Section titled “The three verdicts”| Verdict | Meaning |
|---|---|
safe |
The boot-time ensure path expresses it and the result matches your configuration. Applied. |
diverges |
It applies in part, leaving a database that does not match your configuration — and nothing reports it. Refused. |
needs-migration |
The ensure path cannot express it at all. Refused. |
diverges is the one worth understanding, because these changes look like they
worked:
- A required property added to a table that already holds rows arrives
nullable.
NOT NULLis checked against every row already there, and rows written before the property existed have no value for it. On an empty table there is nothing to check, so the constraint is applied and this issafe. - Making an existing property required has the same shape:
SET NOT NULLscans the table, so it issafeon an empty one anddivergeson a populated one until you backfill.
Two changes that used to be diverges are now safe, because the ensure path
carries them out:
- A value added to an existing enum lands, via
ALTER TYPE … ADD VALUE IF NOT EXISTS. It used to be skipped along with the whole type, and the first row using the new value was rejected by a type that had never heard of it. - Relaxing a required property drops the
NOT NULL. It used to be left in place, so writes omitting the property still failed.
Constraints that are asked for and not applied
Section titled “Constraints that are asked for and not applied”A change can be applicable and still leave something your configuration asks for
unenforced — a required property over a populated table is the case. That is not
a refusal, so it does not appear in changes; it appears in
withheldConstraints, with the obstacle and what would clear it:
{ "withheldConstraints": [ { "target": "public.posts.author", "kind": "not-null", "reason": "\"author\" is required, but \"public.posts\" already holds rows …", "remedy": "Backfill the column, then apply this again." } ]}The boot-time ensure path reports the same thing as a warning. Until this existed, a withheld constraint was withheld in silence.
needs-migration covers everything the ensure path cannot do: dropping a
collection or a property, changing a column’s type (an integer toggle, a string
becoming an enum, an array’s element type, a varchar’s width), renaming a
column, changing a primary key, removing an enum value, making an existing
column unique, and changing a relation — its kind, its target, its localKey,
its onDelete. A hasMany or hasOne whose link column nothing creates is
refused too. Each refusal names the change and what to do instead.
The verdict is read from the schema each side produces — the same plan
schema.generated.ts and db push are rendered from — so an edit that changes
the database cannot be reported as no change. Two edits that look like changes
and are not refused (; 0.23 reports both as needing a migration):
- Renaming a property’s key while keeping its column (
columnNameset to the old column) moves no data. It issafe; API clients read the new name. - Setting, changing or dropping a default binds future writes only. It is
safe, and applied withALTER COLUMN … SET DEFAULT/DROP DEFAULT.
Edit source only
Section titled “Edit source only”A refused change can still be written to your collection source and committed,
leaving the database as it is — removing a property you no longer serve is the
usual case. Post /apply with "sourceOnly": true. Nothing runs; the commit
message names what the database keeps, for example
chore(schema): remove sku from products (source only — column products.sku kept),
and each change in the plan carries a sourceOnly sentence saying what it
leaves behind — including when a column left behind is NOT NULL with no
default, which makes every later insert fail until it is dropped or made
nullable. A change without one (moving a primary key, a relation whose link
column nothing creates) cannot be written to the source alone.
Deleting a collection from the admin panel takes the same path: /apply with
"remove": true and "sourceOnly": true deletes the collection’s file and its
entry in index.ts and commits both; the table and its rows stay. It is
refused while another collection imports the file (a relation to it), naming
the importer — deleting it would stop every collection from loading.
On 0.23 /apply takes neither sourceOnly nor remove, and the panel’s “Edit
source only” writes the file without a commit.
What gets committed
Section titled “What gets committed”Not just the collection file. The Drizzle schema is generated from it, and a stale one breaks the next deploy, so both go in the same commit:
config/collections/<name>.ts— the collection itselfbackend/src/schema.generated.ts— the Drizzle schema
Those paths are relative to your project, not to your repository. When the
two are the same — a rebase init project, which is the usual case — there is
nothing to think about. When your project sits in a subdirectory of a larger
repository, the paths are prefixed with it, found by walking up from your
collections directory to the nearest rebase.json. A project with no
rebase.json keeps the plain paths.
No SQL goes in the commit. rebase db push and rebase db generate write theirs
from the collections on every run, into .rebase/sql/, which is gitignored.
On 0.23 the commit also carries drizzle/schema.sql, drizzle/policies.sql and
drizzle/search.sql, written at the project root.
The commit message describes the change rather than announcing one, and is
attributed to the person who made it, by the account’s display name and email
(a user with neither is committed as <uid>@users.noreply.rebase.pro).
Since 0.25 On 0.24 the
built-in auth’s commits carry the uid as the name.
A schema change with an author and a diff in your project’s history is something neither Firebase nor Supabase gives you — their table edits are invisible to your repository.
Who may apply
Section titled “Who may apply”Holding schema:read is enough to plan. Planning has no side effects, and a
CI job asking whether a proposed collection change is applicable is a good use of
it.
Applying is a second privilege, because applying writes a commit and a commit carries an author:
| Caller | Plan | Apply |
|---|---|---|
A signed-in person holding schema:write |
yes | yes |
An API key holding schema:read / schema:write |
yes | no |
| The server’s service key | yes | no |
A credential is not an author. api-key:7c3f… in your CI environment is not
somebody, and letting it write to your repository produces exactly the
unattributable history this feature exists to replace.
On 0.23 the line is the admin role: an admin plans and applies, and any API
key may plan.
If an automated schema change is what you want — a migration pipeline, say — turn it on deliberately:
initializeRebaseBackend({ // …the rest of your config liveSchema: { allowMachineApply: true }})or REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY=true. The commit is then attributed
to the credential by name — Rebase API key (7c3f) — so reading git log a
month later still tells you which changes a person made.
GET /api/admin/schema/status reports what you may do, not only what the
server supports, so a panel can disable the control and say why rather than
refusing you after you have decided:
{ "enabled": true, "canPlan": true, "canApply": false, "applyRefusedCode": "SCHEMA_EDIT_REQUIRES_A_PERSON", "applyRefusedBecause": "This request is authenticated with an API key …"}If your project keeps versioned migrations
Section titled “If your project keeps versioned migrations”Applying here does not write a migration, and cannot: a migration is Atlas’s format with an integrity file, minted by an external binary against a throwaway database, and a running server has neither.
What it does commit is the collection, and that is what rebase db generate
writes the migration from. So the migration is one command away:
rebase db generateThe plan and the result both say so when your project has migrations, because the failure otherwise is quiet: your database has the change and your repository describes it, but the next environment built by replaying migrations does not, and nothing said anything.
A project provisioned by boot-ensure — the managed runtime, and any self-host
leaving REBASE_MIGRATE_ON_BOOT at its default — needs no migration at all. Its
collections are the schema, and the next boot reconciles.
Commit first, then apply
Section titled “Commit first, then apply”The order matters and it is not arbitrary.
If the DDL ran first and the commit failed, your database would have a column your repository does not describe. The ensure path never drops anything, so the next deploy would neither remove it nor mention it — an invisible column, absent from your collections, until somebody went looking.
Committing first fails the other way: the repository describes something the database does not have yet. That is the ordinary state of every project between an edit and a deploy, and boot reconciles it on the next start.
So a failed apply is not an error. The response says so:
{ "applied": false, "applyError": "connection refused", "committed": { "sha": "1a2b3c4de", "branch": "main" }, "summary": "Committed 1a2b3c4de on main, but the database was not changed. The change will be applied on the next boot."}Where this works
Section titled “Where this works”Live schema editing rewrites your collection source with the same editor the
panel’s source-only saves use (it needs ts-morph installed). Where that source
comes from decides where it runs:
- On disk (
collectionsDir, the default): it runs where the source-only editor runs. That editor is off underNODE_ENV=productionunless the server setsschemaEditor: true. - In a repository you name (
liveSchema.repository, orREBASE_LIVE_SCHEMA_REPOSITORYfor a bundle): the source is read from GitHub and the change is committed back there, so it runs anywhere, production included. This is the opt-in for a built bundle.
MongoDB
Section titled “MongoDB”Everything above describes Postgres, where a schema change means DDL. On MongoDB there is no table to alter: adding a property adds nothing, removing one removes nothing, and a document written yesterday is still valid tomorrow.
So every change is applicable, nothing is ever refused, and the plan has no statements — the commit is the change. The panel says “Commit” rather than “Commit and apply”, and does not claim anything ran against the database.
The one thing worth reading carefully is a removal. On Postgres, removing a property is refused because it would drop a column. On MongoDB the field stays in every document that has it; your API simply stops serving it. The change says so rather than leaving you to assume the relational answer.
| Deployment | Works |
|---|---|
rebase dev on your machine |
yes |
| Self-host with the project and its git working tree mounted | yes, when NODE_ENV is not production, or with schemaEditor: true |
| A built bundle: a self-host serving a build, or the published image | only when it opts in with REBASE_LIVE_SCHEMA_REPOSITORY — see From a built bundle |
Any server with liveSchema.repository configured |
yes, NODE_ENV=production included |
A bundle is compiled output, so there is no collection source in it, and the
runtime that serves it turns the source-only editor off. Unless it names a
repository, /api/admin/schema/status answers enabled: false with the code
SCHEMA_EDITOR_DISABLED: edit collections in development, commit, and deploy.
From a built bundle
Section titled “From a built bundle”A bundle has no code of its own to configure, so it opts in with environment variables. Nothing changes for a bundle that does not set them.
REBASE_LIVE_SCHEMA_REPOSITORY=acme/storefront # turns it onREBASE_LIVE_SCHEMA_GITHUB_TOKEN=github_pat_… # or the three _GITHUB_APP_* variables# REBASE_LIVE_SCHEMA_BRANCH=main # default# REBASE_LIVE_SCHEMA_COLLECTIONS_PATH=config/collections # defaultThe full list is in Configuration.
A repository named without a usable credential (none, both kinds, half a GitHub
App), or a value that is not owner/name, refuses to boot, rather than serving
an editor that fails when somebody presses Apply.
What stays the same when it is on:
- It is an
/api/adminsurface: no answer at all withoutschema:read, and/applyneedsschema:writeand a person (see Who may apply). - The write target is only the repository you named. The source-only editor, which writes files beside the process, stays off.
- Each change is planned, shown, committed, then applied, exactly as on your machine.
One thing is different. The running deployment keeps serving the collections it was built with until your pipeline builds and deploys the commit. The database has the change at once; the admin panel and the API see the new collection definition after that deploy.
So a deployment makes one change per deploy. After a commit lands, the next
apply is refused with SCHEMA_CHANGE_FAILED, naming the commit, until a process
running that commit replaces this one. A second change planned against the
collections this deployment still runs would commit generated schema files that
leave the first change out. Planning stays available meanwhile.
Committing to a remote repository
Section titled “Committing to a remote repository”liveSchema.repository makes the server commit each change to a GitHub
repository through the Git Data API instead of running git commit in a
working tree. It does not depend on the source-only editor being on: with a
repository configured, live editing works under NODE_ENV=production and with
schemaEditor: false, because nothing on this machine is written. A bundle
sets it through the variables above.
initializeRebaseBackend({ // …the rest of your config liveSchema: { repository: { kind: "github", owner: "acme", repo: "storefront", branch: "main", // Where the collection source lives in that repository. // Defaults to "config/collections". collectionsPath: "config/collections", auth: { kind: "token", token: process.env.GITHUB_TOKEN! } } }})The collections directory is then read from the repository, rewritten with the
same editor that runs locally, and what changed is committed back through the
Git Data API: a blob per file, a tree, a commit and a ref update. Nothing is
cloned and nothing is left on disk. The whole directory is read, not only the
edited file, so a relation can target another collection (its file is
imported) and deleting a collection works as it does locally: refused while
another collection imports it, and removed from index.ts in the same commit.
auth takes a token or a GitHub App installation:
auth: { kind: "app", appId: "123456", privateKey: process.env.GITHUB_APP_PRIVATE_KEY!, installationId: "987654"}Use the token for a single project committing to a repository you already own — standing up an App so your own server can commit to it is a lot of ceremony for a one-line credential. Use the App for a control plane holding one key across many projects, which is what Rebase Cloud does: one App, an installation per project, and no per-customer secret to rotate.
The token needs contents: read and write on that repository, and nothing else.
Without liveSchema.repository, the commit is a plain git commit in the
working tree that holds collectionsDir: nothing to authenticate, no token, no
network.
Two things that make it safe to run against a repository somebody else is working in:
- It stages only the files it generated. A schema commit that swept up half-finished work would be a commit nobody could review, and it refuses outright if the tree already has one of its own files modified.
- The remote path never force-updates a ref. If something landed while the commit was being built, the update is rejected — losing somebody’s commit silently is worse than failing.
Limits
Section titled “Limits”- Only additive changes. Everything else is refused with a reason, because the ensure path is the only thing that changes a schema and it can only add.
- No migration file is written. A project provisioned by boot-ensure needs none;
a project provisioned by migrations should run
rebase db generate, which mints one through Atlas with the integrity hash Atlas requires. - Postgres only. The capability is detected on the driver, and other engines
answer
SCHEMA_EDITING_UNSUPPORTED.
Related
Section titled “Related”- Schema Generation — the same edits from the command line
- Defining Collections — what the editor is rewriting
- Studio — the panel these routes are behind