Demo

How to create a DB migration for plugin

Ship schema changes with IDatabaseMigration classes (getName, getSource, up, down) returned from IPlugin.getMigrations(). Prefer migrations over synchronize for staging and production upgrades.

Goal & when

Entity registration still declares tables for the runtime, but real schema evolution should go through migrations. Use them for additive or destructive DDL, renames, indexes, constraints, and one-shot data backfills that must run as part of a plugin upgrade.

  • Staging / production — do not rely on synchronize
  • Reversible changes — keep up() and down() paired
  • Already-shipped files — never edit; add a new numbered migration

1. Prerequisites

You need a plugin with a default-exported IPlugin in backend/src/index.ts and a clear target datasource (usually the shared default).

  • backend/src/index.ts exports the plugin class that implements IPlugin
  • Know which connection owns the change — usually { plugin: "default", name: "default" }
  • Entity definitions will be updated to match the final migrated schema
  • Numbering convention — 001, 002, … under backend/src/migrations/

2. Folder layout

Keep migration classes under backend/src/migrations/ and export them so getMigrations() can return them.

  • One class per file; keep names / numbers ordered
  • Import from index with .js extensions if the plugin uses ESM
TSlayout
1backend/ 2 src/ 3 index.ts # IPlugin — getMigrations() 4 migrations/ 5 001-initial.migration.ts 6 002-add-status.migration.ts 7 feature/ 8 my.module.ts

3. Implement IDatabaseMigration

Implement getName(), getSource(), up(QueryRunner), and down(QueryRunner). Prefix getName() with metadata.name so names stay unique across plugins. getSource() usually returns { plugin: "default", name: "default" }.

  • getName() — unique id; prefix with metadata.name (e.g. `${metadata.name}_002_add_status`)
  • getSource() — which DataSource runs the SQL (default shared DB for most plugins)
  • up() — apply the change; prefer IF EXISTS / IF NOT EXISTS when safe for local re-runs
  • down() — undo the same change
TSXmigration class
1import type { IDatabaseMigration } from "@quan-erp/shared-types"; 2import type { QueryRunner } from "typeorm"; 3import metadata from "../../module.metadata.json" with { type: "json" }; 4 5export class AddStatusMigration implements IDatabaseMigration { 6 getName() { 7 return `${metadata.name}_002_add_status`; 8 } 9 10 getSource() { 11 return { plugin: "default", name: "default" }; 12 } 13 14 async up(queryRunner: QueryRunner): Promise<void> { 15 await queryRunner.query( 16 `ALTER TABLE ${metadata.name}_order ADD COLUMN IF NOT EXISTS status varchar(32)`, 17 ); 18 } 19 20 async down(queryRunner: QueryRunner): Promise<void> { 21 await queryRunner.query( 22 `ALTER TABLE ${metadata.name}_order DROP COLUMN IF EXISTS status`, 23 ); 24 } 25}

4. Register via getMigrations()

Return the migration classes from getMigrations() on the default-exported plugin class in backend/src/index.ts. Use optional onMigrate() for post-migration seed or repair.

  • Order in the array should match intended apply order
  • onMigrate() runs after the pending migration batch completes
  • Custom datasources — getSource() must match that connection’s plugin + name
TSXbackend/src/index.ts
1import type { GetMigrationsType, IPlugin } from "@quan-erp/shared-types"; 2import { InitialMigration } from "./migrations/001-initial.migration.js"; 3import { AddStatusMigration } from "./migrations/002-add-status.migration.js"; 4 5export default class MyPlugin implements IPlugin { 6 getMigrations(): GetMigrationsType { 7 return [InitialMigration, AddStatusMigration]; 8 } 9 10 async onMigrate(): Promise<void> { 11 // optional: seed / repair after migrations apply 12 } 13 14 // … getRootModule, getName, getVersion, getMetadata, … 15}

5. Verify & rules

Install or upgrade the plugin so core loads getMigrations(), applies pending names, then calls onMigrate() before module init continues.

  • Pending getName() values run up() against getSource()
  • Entity columns / indexes match the final migrated schema
  • down() is tested in local / staging when rollbacks matter
  • Do not edit a migration that already shipped — add 003-… instead
  • Prefer migrations over synchronize for real upgrades
  1. 01

    Install / upgrade plugin

    Core loads module.js and reads getMigrations().

  2. 02

    Apply pending migrations

    Each unused getName() runs up(queryRunner) against getSource().

  3. 03

    onMigrate()

    Optional plugin hook after the migration batch completes.

  4. 04

    Module init

    Entities, DI, and @OnInit continue as usual.

Common mistakes

  • Using a plain name property instead of getName() / getSource() on IDatabaseMigration
  • Forgetting to prefix getName() with metadata.name (collisions across plugins)
  • Wrong getSource() — migration runs on a different DataSource than the entities
  • Editing a shipped migration instead of adding a new numbered file
  • Updating entities without a matching migration (or the reverse)
  • Depending on synchronize: true for production upgrades