Ship a schema change

Add a column to an App that tenants already have installed without breaking them — which changes are safe, how to deploy, backfill and verify, and what rolling back really means.

You have an App in production, tenants have it installed, and you need a new column. This guide takes one change from schema.json to verified on live data: you add the column, deploy it, backfill the rows that already exist, check the result, and know what to do if it goes wrong.

The example is the Locations app from Build your first App, with the metadata column that tutorial declares and Add a data model keeps. Suppose its locations entity keeps a timezone inside the metadata document, and you want it as a real column of its own.

This is for developers who already deploy an App with the CLI (0.5.0 or later, for the --tenant flag used below). If you have not, start with Deploy and verify an App. The code samples go in the src/main.js and scripts/capabilities.js you already have, where app, db and c are the router, data client and request context from that tutorial. $REVENEXX_API_KEY is a machine-to-machine key for the tenant you deploy to (create an API key); REVENEXX_TENANT is your tenant slug. The backfill and the checks need locations.*, the App's namespace.

How a schema change reaches your tenants

Four facts decide everything else on this page.

  • The apply belongs to the App, not to a tenant. Every tenant's rows for an entity live in the same table. When a version builds, the platform compares schema.json with the tables and applies the difference once. Design the change as if every tenant with the App installed receives it at the same moment.
  • Routes still arrive per install. A new capability or route is reachable only after the install step for that version. So a tenant can have the new column and still be serving the old routes. See Publish and install.
  • You cannot assume which version a tenant runs. Whether and when an installed tenant moves to your latest version is not something the platform promises (as of 2026-09). Your new schema must work with the old code, and your new code must work before the backfill has run.
  • Versions only move forward. The platform refuses a version lower than the latest one it applied. It also refuses a version it has already applied with a different schema.json; bump it.

What is safe and what is not

Decide this before you touch a file. The platform is additive by default: it adds what you declare and never removes what you stop declaring.

ChangeVerdictWhat to do
New entitySafeMinor version.
New nullable columnSafeMinor version. This guide.
New notNull column with a defaultSafeExisting rows take the default.
New indexUsually safeA unique index or a check can fail if existing rows already break it. Try it on real-shaped data first.
New notNull column with no defaultRefused on a table that holds rowsAdd it nullable, backfill, and enforce it in your handler.
Make an existing column notNullNot appliedThe deploy goes through with a warning and the column stays nullable. Enforce it in your handler instead.
Change a column's typeBreakingTreat it as a rename: new column, migrate the values, retire the old one.
Change a generated expressionRefusedAdd a differently named column.
Rename a columnNo such changeThree releases. See Migrations.
Delete a column from columnsDoes nothingThe column and its data stay. Only a dropped_columns tombstone removes one, and only when it is empty.

The API side has its own rule: a caller written against the old contract must not notice. A new optional request field or a new response field is fine. A newly required request field turns working callers into 400s. See Versioning.

1. Add the column, nullable

Add timezone to the entity. It is nullable on purpose: existing rows have no value yet. Every other column and index in the entity stays exactly as it is; leave them in the file.

schema.json
"locations": {
  "columns": {
    "…": "your existing columns, unchanged",
    "timezone": { "type": "text" }
  }
}

Bump the minor version in manifest.json (for example from 0.2.0 to 0.3.0, whatever comes next for you). It has to be a version the platform has not seen before.

Regenerate the capability list with the script from the tutorials, regenerate the typed client, then run the tests. (revenexx apps capabilities --write writes the same manifest.capabilities.json, as described in Scaffolding; this guide keeps one tool, the script, because step 3 edits it. Ship the sibling file or an inline capabilities array, never both.)

Terminal
npm run capabilities
revenexx apps generate
npm test

Commit the generated files with the change. The regenerated capability schemas now list timezone in the responses, which is an additive change callers ignore. Until the backfill has run, the field is null on rows that existed before.

2. Make the code work before the backfill

Wherever your handlers need a timezone, read it through one helper that falls back to the old location. That is what makes the new code correct on a row nobody has backfilled.

src/main.js
const timezoneOf = (row) => row.timezone ?? row.metadata?.timezone ?? null;

Keep writing metadata.timezone as you do today, and from this release also write the column in the same update, so every row your code touches from now on has both:

src/main.js
await db.locations.update(id, {
  timezone,
  metadata: { ...current.metadata, timezone }, // `current` is the row you just read
});

Rows that nobody touches stay null until the backfill in the next step copies them. A generic PUT that sends only metadata also leaves the column stale, so run the backfill again whenever you suspect drift. Raw CRUD responses return the stored timezone, so say in the capability description that null can mean "not copied yet" rather than "no timezone".

3. Add an idempotent backfill route

The rows that exist today need the value copied across. The backfill only copies: it reads metadata.timezone and writes timezone, and it leaves metadata alone. It scans every row in a stable order and writes only where the two differ, so running it twice is free and it also repairs a copy that has drifted.

src/main.js
app.post('/locations/backfill-timezone', async (c) => {
  const size = 100;
  const offset = Math.max(0, Math.floor(Number(c.body?.offset)) || 0);
  const rows = await db.locations.list({ order: 'id.asc', limit: size, offset });

  let updated = 0;
  for (const row of rows) {
    const timezone = row.metadata?.timezone;
    if (typeof timezone === 'string' && timezone && row.timezone !== timezone) {
      await db.locations.update(row.id, { timezone });
      updated += 1;
    }
  }
  return c.json({ updated, next_offset: offset + rows.length, done: rows.length < size });
});

Declare it in scripts/capabilities.js the way Add a data model declares the availability route. Give it a request schema of { type: 'object' } and a response schema listing updated, next_offset and done, then regenerate. Declaring a request schema is also what makes the {} body in step 5 mandatory. Anyone who can call your capabilities on that tenant can call it, which is low-risk because it only copies and is idempotent.

This route only reaches tenants you can call. An API key identifies one tenant, so you can run the backfill on your own tenants and on none of your customers'. On a merchant's install the old rows keep timezone as null until something copies them, so never make your code's correctness depend on the column being filled; that is what timezoneOf is for. To fill the column there as well, run the same copy from your app on every tenant that installs the version, for example as a scheduled job.

4. Deploy to a scratch tenant first

Use a tenant that holds real-shaped data, not an empty one. An empty table cannot show you a new unique index failing or a check rejecting old rows, and a mock adapter has no populated table at all.

Terminal
revenexx deploy app --tenant acme-scratch

--tenant targets this one deploy without saving anything. It is the global flag, not deploy app's --owner, which picks the tenant to install on. Prefer it to revenexx tenants use, which stays saved across every repo on your machine. See Deploying.

A plain deploy app also publishes the build to the Marketplace for a public App, so this version becomes the one offered to new installs. See Publish and install before you rehearse on a listed App.

Keep the first section in mind: the apply belongs to the App, and its tables hold every tenant's rows. A scratch tenant is where you rehearse your code, the backfill and the checks on real-shaped data. It is not a quarantine for the schema itself, so only deploy a change you would be comfortable seeing on every installed tenant. Whether your scratch tenant is isolated from production in other ways depends on your setup, which this page cannot tell you.

Read the deploy output. A refused change names the column and the rule, and the previous version keeps serving (see Operating). The apply runs in stages, so a failure can leave earlier stages in place. The next deploy compares against what exists, so fix the cause, bump the version, and deploy again.

5. Backfill and verify

Run the checks from Verifying live first: the app answers, and the contract lists the new field.

Terminal
curl -s https://api.revenexx.com/v1/openapi.json \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: acme-scratch" \
  | grep -o '"timezone"'

Then run the backfill until it reports nothing left. Send {} as the body; a POST with no body fails contract validation before your function runs.

Terminal
curl -s -X POST https://api.revenexx.com/v1/locations/backfill-timezone \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: acme-scratch" \
  -H "Content-Type: application/json" -d '{}'

The response tells you how far it got. Call again with -d '{"offset": <next_offset>}' until done is true. On a small table that is one call. If a call fails partway, run it again from offset 0; a repeat only rewrites what still differs. Then read a few rows back and confirm timezone matches metadata.timezone, and open the app in Cockpit to check that existing data survived and no view went blank. The Anatomy screen shows what was actually applied. See Operating.

6. Update the projections that read the column

A column is not in a search index or an analytics dataset until you say so. Both are declarations you re-deploy.

  • analytics.json: add the column to the view and deploy. A view is a declaration over your table, so there is no data to rebuild. See The contract.
  • search.json: add the field to the projection and deploy. Existing rows are indexed by the platform in the background after a schema apply, so results can lag the deploy for a while (as of 2026-09).

Both read the stored column, not timezoneOf, so a row shows a blank timezone until it has been copied. Check the new field in Analytics Studio or a search result afterwards, once the backfill has run. Remove a projection's reference to a column before you ever try to retire that column; a view that still reads it blocks the drop. See Projections.

7. Ship to production and repeat the checks

Once the scratch tenant is clean, deploy to your production tenant and run step 5 again against it. Every build needs a version nobody has used, and the scratch deploy used yours, so bump the patch in manifest.json first (for example 0.3.1); a version number is cheap.

Terminal
revenexx deploy app --tenant acme-production

Tenants you do not control already received the schema change with the apply in step 4, because the tables are shared. This step installs the routes on your production tenant. What your customers' tenants need from you is code that works before any backfill, which is why step 2 comes before the deploy. Which version of your code a customer's tenant runs is not yours to decide (as of 2026-09), so the schema must also work for the older code.

8. If it goes wrong: roll forward

There is no rollback to an earlier version. The platform refuses a version lower than the latest applied one and applies nothing in reverse. Recovery means shipping a new, higher version that contains the code you want.

This section is derived from the platform's apply rules, not from a rollback run end to end on a scratch tenant (as of 2026-09). Do the rehearsal on your own scratch tenant before you rely on it for a change that matters.
  1. Revert what depends on the column, not the schema. Revert the handler code only, and bump the version (a patch, for example 0.3.2). Leave timezone in schema.json, scripts/capabilities.js and the generated files: taking a field out of a response is a break for any caller that has started to read it.
  2. Deploy it. Nothing that ran before knows about the column, so nothing breaks. It simply sits unused.
  3. Your data is intact. The backfill copied values and never touched metadata. Code from before the change keeps writing only metadata, so the column can drift while you are rolled back; run the backfill again before you ship the new behaviour a second time.

What you cannot do, and why:

  • You cannot delete the column by deleting the line. Removing it from columns leaves the column and its data, and the deploy only warns. That is deliberate: an absence is not treated as an instruction to destroy data.
  • You cannot easily remove it at all. A dropped_columns tombstone works only on a column that holds no data, and a drop cannot be undone. After a backfill the column holds data on the tenants you filled, and one non-empty row anywhere in the shared table blocks the tombstone, so plan to keep it.
  • You cannot pull a bad version back from tenants. Unpublishing takes the App off the Marketplace for new installs; it does not uninstall it and does not stop traffic from tenants that have it. Fixing them means shipping a new version, and whether a customer's tenant moves to it is not yours to decide (as of 2026-09).
  • You cannot re-deploy the same version with a changed schema. Bump it, even for a one-line fix.

Keep the change reversible by construction: add before you switch, copy before you move, and retire the old copy in a later release, once nothing reads it. That is the same three-release shape as a rename.

Troubleshooting

  • The deploy is refused with a version error. The version is lower than the last applied one, or it was already applied with a different schema.json. Bump it.
  • The build fails on the schema. Look for a notNull column without a default, a changed generated expression, a unique index the existing rows break, or a cross-app foreign key. See Deploying.
  • The column is there but the field is missing from the API. The routes for that version are not installed yet, or you did not regenerate the capabilities. Check openapi.json and the Anatomy screen.
  • The backfill returns 400. You sent a POST with no body, or without Content-Type: application/json. Send {} with the header.
  • Some rows still have no timezone after done is true. Those rows have no metadata.timezone to copy, and the route skips them on purpose.

Where to go next

Was this page helpful?