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. Add it nullable, backfill, then tighten it.
  • A generated column's expression is fixed at creation. PostgreSQL cannot alter a generation expression, so changing it is rejected — retire the column and add a differently named one.
  • A column type cannot be changed to one PostgreSQL will not cast to. Treat a type change the same way you treat a rename: new column, migrate the values, tombstone the old one.

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.

Even with the tombstone, the apply refuses with 422 unless:

  • the column holds no data, 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.

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 it holds no data. 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 (publicshared, ordersales_order, classclassification).

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

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?