Skip to content

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 may
POST /api/admin/schema/plan what would happen, without doing it
POST /api/admin/schema/apply commit, then apply

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

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 NULL is 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 is safe.
  • Making an existing property required has the same shape: SET NOT NULL scans the table, so it is safe on an empty one and diverges on 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 (columnName set to the old column) moves no data. It is safe; API clients read the new name.
  • Setting, changing or dropping a default binds future writes only. It is safe, and applied with ALTER COLUMN … SET DEFAULT / DROP DEFAULT.

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.

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 itself
  • backend/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.

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 generate

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

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."
}

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 under NODE_ENV=production unless the server sets schemaEditor: true.
  • In a repository you name (liveSchema.repository, or REBASE_LIVE_SCHEMA_REPOSITORY for 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.

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.

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 on
REBASE_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 # default

The 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/admin surface: no answer at all without schema:read, and /apply needs schema:write and 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.

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