Legacy apps and migration

What generation 1 apps are, what still works on them, and the one-way gate to apps v2.

Every app carries a platform-assigned generation:

  • Generation 2 is the current shape: a static frontend plus a backend function. Everything created today is generation 2.
  • Generation 1 is the legacy browser-SDK line. Existing apps were backfilled to 1 and stay there until someone explicitly migrates them.
railcode apps show <app> --json | grep generation     # the text output does NOT print it

Generation is sticky: deploys and reverts never change it. The only transition is the one-way migration gate.

If you cannot reach the server, decide from the source tree. /_api/sdk.js in index.html means generation 1. A "server" key in railcode.json means generation 2.

What still works on a generation 1 app

A v1 app is a page plus a browser SDK. Small changes that stay inside what it already does (a new view, a fix, a field) can be made in place and deployed from any CLI. Deploys to existing apps are never gated on CLI version.

Never add a "server" key to a generation 1 app. The deploy is refused with a 422.

What a v1 app cannot do

There is no backend function. So there are no secrets, no cron, no server-side code, and no caller the app can trust. "Add X to this app" hides a migration whenever X lands on a row below.

The request needs…Why v1 can'tOn v2
A credential the browser must not seeNo secrets. Anything the page can read (KV, a settings collection, a bundled constant), every user who can open the app can readsecrets.NAME in the backend function
An API needing per-request signing (SigV4, mTLS, a bespoke handshake)Connectors do bearer/header/query/basic only, and there is nowhere server-side to run signing codeThe backend function calls it under egress:
Something to run on a scheduleNo cron; nothing runs unless a page is openA crons: entry hitting a backend route, or the agent's own schedule
A rule that must holdPage-side checks are advisory; any user can bypass them from devtoolsA check in the backend function against ctx.user
Server-side work (aggregating many records, chaining services)Nothing runs server-sideA backend route

Do not build the workaround. A key parked in KV so the page can sign requests itself, a poll loop standing in for a schedule, an approval check in a tab. Each one ships and demos. Each one is the wrong design. If you find yourself writing "the key is readable by anyone who can use the app", you are building the workaround.

Sizing the migration

It is smaller than it looks. v2 has no browser SDK, so every data call the page makes moves behind the backend function. But that is a rewrite of the app's one SDK wrapper into fetch() calls to backend routes, not of the whole app. Views, state, and business logic stay.

apps/crm in the examples repo is a large v1 app ported through exactly one module.

Two paths

Path A: a new slug. Build the v2 app under a new name, validate it, then move people over. Reversible, and it never touches the gate. Preferred.

Path B: in place. Run the migration gate on the existing app. One-way, with a downtime window.

railcode migrate [--app <slug>] [--yes]

Immediately on running it:

  • The browser SDK stops working for that app: /_api data calls refuse.
  • The app's user/role-scoped browser data freezes. The new backend function can read it through db.scoped() / db.scopedRole(), but nothing can write it.
  • Unscoped data is untouched; the v2 flat store is that same app scope.

You cannot rehearse Path B. Between migrate and the next deploy the app is down for its users, and a backend deploy is refused with a 422 while the app is still generation 1. So finish and validate the v2 build first, and get an explicit go before you run the gate.

Reverting a deploy never un-migrates.

Reading frozen data

await db.scoped(userId).collection("drafts").query();       // read-only
await db.scopedRole(roleUuid).collection("shared").get(k);  // read-only

There is no write path. Copy what you need into the flat store under your own keys.

The code rewrite

  1. Find the seam: the one module where the page talks to the platform SDK.
  2. Rewrite the wrapper, keep its signature. Components keep calling the same functions; the functions now fetch() your backend function.
  3. Build one backend route per capability the app uses, not one per SDK method that exists.
  4. Write the manifest. run_as: app is mandatory on v2.

Identity comes from /_api/me or, better, your own backend route.

One case needs care: an interactive LLM tool loop whose writes wait for a human approval click has to stay in the browser, relayed through llm.streamRaw(). See AI in an app.

On this page