Migrations

How the platform applies an App's schema.json — additive by default, dropped_columns as the only way a column is ever removed, and the three-deploy rename.

You do not write migrations. The platform compares your schema.json against the live database and applies the difference when the app version is applied. Git history of that one file is the migration history.

What happens on apply

  1. Read the desired state from schema.json.
  2. Compare it with what exists.
  3. Generate the changes for additions and compatible edits.
  4. Apply them, and verify the constraints.
  5. Re-issue the access grants and refresh the generated data client.

Names are namespaced on the way in: an entity called device_serials in the app serials published by vendor acme becomes the table acme__serials__device_serials. tenant_id and org_id are injected, indexed and enforced — you never declare them. See Tenant isolation.

Additive by default

Adding an entity, a column, an index or a check applies cleanly. Three things do not:

  • A new notNull column with no default cannot be applied to a table that already holds rows, outside a release round. In a release round it is added nullable. Either way, add it nullable (or with a default), backfill, and enforce the requirement in your handler. Making an existing column notNull is applied only when no row holds NULL. If rows hold NULL, the deploy goes through with a warning and the column stays nullable; backfill it, then ship the next version. In a release round this check runs at approval (as of 2026-11).
  • A generated column's expression cannot change in place: changing it is refused. Retire the column and add a differently named one.
  • A type change is refused: the version your tenants run and the new one share the same table, and one column cannot have two types. Treat a type change the same way you treat a rename: add the new column in one release, migrate the values, and tombstone the old one in the next (as of 2026-11).

In a release round, the build applies only the additive part, and the rest waits for approval. See When the version waits for approval.

Foreign keys may only target entities inside the same app. A cross-app foreign key is rejected at apply time — see Relationships.

Removing a column takes a tombstone

Deleting a column from columns does not remove it from the database. The column stays with its data, and the apply reports a warning. That is deliberate: inferring destruction from an absence would turn a typo or a bad merge into permanent data loss.

The only mechanism that ever removes a column is dropped_columns:

schema.json
"device_serials": {
  "columns": { "…": "…" },
  "dropped_columns": [
    { "column": "legacy_serial", "reason": "replaced by the normalised serial column" }
  ]
}
FieldRequiredDescription
columnYesThe column to remove.
reasonYesWhy it is being retired. It appears in the repo diff, in the apply response and in the platform logs.

Both keys are required. The reason is the audit record — it is how somebody who was not in the room tells a deliberate retirement from an accident.

A tombstone over a column that still holds values copies them to an archive the platform keeps, then drops the column. Even with the tombstone, the apply refuses with 422 unless:

  • the column holds no more values than that archive takes in one drop, and
  • no view still reads it, and
  • the column is not part of the primary key, and
  • nothing references it — no table check, column check, index where, index expression or sibling generated.expression.

Remove those references first, in an earlier release. In a release round, a drop runs at approval, so a refused drop fails the approval run rather than the deploy.

A drop is not reversible. Once applied, the tombstone is a no-op and can be removed in a later version. Naming a column that no longer exists is a clean no-op, which is what keeps a re-apply idempotent.

Renaming a column

There is no rename. Adding the new name and dropping the old one from columns adds the new column and leaves the old one exactly as it was, data intact.

So a rename is three deliberate steps across more than one release:

  1. Add the new column alongside the old one. Deploy.
  2. Copy the data across in your app logic, and switch every reader — your handlers, your cockpit.json views, your capability response schemas — to the new column. Deploy.
  3. Add a dropped_columns tombstone for the old column, once nothing reads it. Deploy.

The middle step is the one you cannot skip, and the fact that step 1 is safe on its own is the point of the additive default.

The rename you will actually need

The most common reason to rename is not a change of mind. It is that a column name broke code generation.

Column names travel out of schema.json into the generated typed client, and from the capability response schemas into every SDK the platform emits. A column called class, function, new, default, return, public or order is a reserved word in at least one of those targets, and the generated code for that language will not compile.

You find out at build time, not at apply time — the schema accepted the name, and the database is perfectly happy with it. The fix is the three-step rename above, so it is much cheaper to catch the name before the first deploy. The scaffolder refuses reserved words for exactly this reason; if you hand-write an entity, reach for a synonym (public → shared, order → sales_order, class → classification).

Deploying a schema change

Terminal
# bump `version` in manifest.json, then:
revenexx tenants use acme-staging
revenexx deploy app

# verify against staging data, then:
revenexx tenants use acme-production
revenexx deploy app

The last tenants use stays saved: afterwards every repo on your machine deploys to acme-production until you switch back, and REVENEXX_TENANT or a tenant: in .revenexx.yaml won't change that. To target one deploy without saving anything, pass the flag instead: revenexx deploy app --tenant acme-staging. See how the tenant resolves.

Test the change against a tenant with real-shaped data before production. A mock adapter will not tell you that a new notNull column cannot be applied to a populated table — an in-memory store has no populated table.

After any schema change, regenerate before you deploy:

Terminal
revenexx apps capabilities --write
revenexx apps generate
npm test

Next steps

Was this page helpful?