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
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.ts3. 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
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
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
- 01
Install / upgrade plugin
Core loads module.js and reads getMigrations().
- 02
Apply pending migrations
Each unused getName() runs up(queryRunner) against getSource().
- 03
onMigrate()
Optional plugin hook after the migration batch completes.
- 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