Skip to content

Data Migrations

Change the shape of a collection and Homestead's schema sync handles it for you on the next boot — it creates new fields, patches drifted definitions, and no-ops when everything already matches. What it deliberately never touches is the data already sitting in those rows.

A data migration fills that gap. Use one to:

  • backfill a newly-added field on existing records,
  • rewrite a value (rename an enum option, reformat a string, split one field into two),
  • or correct rows a bug wrote badly.

A migration is a one-shot server-side function that runs once, is recorded in a ledger, and is never run again after it succeeds.

Writing one

A migration is a plain async function. It receives a short-lived admin token and a log(), and it reads and rewrites data through the same server-side aepbase helpers the rest of the server uses — so references, file fields, validation, and update_time all keep working.

Keep the handler under your app's migrations/ directory so the production build stubs it out of the browser bundle:

ts
// packages/homestead-apps/gift-cards/migrations/backfill-status.ts
import type { MigrationHandler } from '@rambleraptor/homestead-core/apps/migrations';
import { serverClient } from '@rambleraptor/homestead-core/server/client';

interface GiftCard {
  id: string;
  status?: string;
  balance?: number;
}

const migrate: MigrationHandler = async ({ token, log }) => {
  const cards = serverClient(token).collection<GiftCard>('gift-cards');
  const all = await cards.listAll();
  let patched = 0;
  for (const card of all) {
    if (card.status) continue; // idempotent guard — skip rows already done
    const status = (card.balance ?? 0) > 0 ? 'active' : 'depleted';
    await cards.record(card.id).update({ status });
    if (++patched % 50 === 0) await log(`patched ${patched}…`);
  }
  return { scanned: all.length, patched }; // recorded in the ledger
};

export default migrate;

Declare it on your app config — the same shape as crons:

ts
// packages/homestead-apps/gift-cards/app.config.ts
export const giftCardsApp: AppConfig = {
  id: 'gift-cards',
  // …
  migrations: [
    {
      id: 'gift-cards-backfill-status', // stable, globally unique — never rename
      title: 'Backfill gift-card status from balance',
      load: () => import('./migrations/backfill-status'),
    },
  ],
};

That's the whole authoring surface. Restart the server and the migration runs after the schema sync; its result and any log() lines land in the ledger.

How it runs

  • Once, at boot. After the schema sync applies every resource definition (so the collections a migration targets already exist), the runner applies each pending migration in declaration order.
  • Remembered. Each attempt is written to a _homestead_migrations ledger table (id, app, status, result/error, logs). A migration that has succeeded is skipped on every later boot.
  • Retried on failure. A migration that threw — or whose process died before the ledger recorded success — is pending again next boot. A thrown error is recorded and does not stop the other migrations; they touch independent data.

Rules

  1. The id is the ledger key — never rename or reuse it. Change an id and an already-applied migration runs again; reuse one and a new migration is silently skipped. Prefer an app-prefixed slug like gift-cards-backfill-status.
  2. Write handlers to be idempotent. The ledger stops a succeeded migration from re-running, but a resumed one (crash mid-run) re-executes from the top — so guard each record (skip rows already in the target shape) rather than assuming a clean slate.
  3. Go through the engine, not raw SQL. Use the shared client (serverClient(token) from @rambleraptor/homestead-core/server/client) so references, file fields, and validation stay intact.
  4. Return a small summary ({ scanned, patched }) — it's stored as the migration's result and makes the ledger useful.
  5. Mark destructive migrations with destructive: true (a migration that declares drops is recorded as destructive automatically). It's stored in the ledger for visibility; a future homestead migrate command will use it to gate destructive runs behind an explicit opt-in.

Retiring a field (deprecate, then drop)

Removing a field from a resources.ts definition drops its column. The engine refuses to drop a column that still holds data — an accidental deletion, or a rename with no data migration, would otherwise destroy data silently at boot. The failure is loud and names the columns; nothing is applied.

Because the schema sync runs before migrations, you can't move a field's data and drop the field in the same release. Retire it in two steps:

  1. Deprecate. Mark the field deprecated: true in resources.ts. Its column and data are kept, but consumers stop steering writes to it (the chat tools drop it; the wire description flags it). Ship a data migration in the same release that moves its data wherever it now belongs.

  2. Drop. In a later release, remove the field from resources.ts and add a migration that authorizes the drop:

    ts
    export const migrations: Migration[] = [
      {
        id: 'gift-cards-drop-legacy-code',
        drops: [{ resource: 'gift-card', field: 'legacy_code' }],
        load: () => import('./migrations/drop-legacy-code'),
      },
    ];

    The boot-time schema sync reads every declared drops and authorizes exactly those column drops; declaring drops implies destructive. A column that holds no data drops freely and needs no authorization.

When not to use one

Adding a field, an enum value, or a whole resource is a schema change, not a migration — declare it in your app's resources.ts and the schema sync applies it. Reach for a migration only when existing data needs to change to match.