Database Migrations
Migrations are module-local files with UTC timestamp names, registered once in a single static registry, and executed module by module, in a topological order of the module-manifest dependency graph. A timestamp orders a module's own migrations and nothing else. There is no repo-wide migration number, and no hand-maintained execution list.
An earlier scheme ordered by timestamp and corrected the result with the dependency
graph inside a 45-day horizon. The current one inverted that: the graph is the order, and
the horizon, the correction edges and the unresolvable-order failure are gone. The
reason is that the set of modules stopped being fixed at build time — an installed npm
package ships its own entities and migrations, and its author cannot know the host's
history, so a rule that ordered by a timestamp they choose orders nothing.
Two files carry the mechanism, both under backend/src/db/:
| File | Role |
|---|---|
migrations-registry.generated.ts | The single registration point — one static import + one entry per migration, grouped by owning module. Generated from a filesystem walk by scripts/generate-composer.ts and committed; never edited by hand. |
migration-order.ts | The pure orderMigrations() function that computes the execution order, plus the BASELINE_THROUGH watermark and historicalBaselineOrder(), the one derivation of the frozen prefix's order. No I/O, no clock, no ORM. |
@endora-commerce/platform/migrations | BASELINE_MIGRATIONS — the frozen historical prefix as an ordered list of class names, generated by composer:generate. Host-internal: no module may name this subpath. |
mikro-orm.config.ts only wires them together; it holds no ordering knowledge.
Why the global counter was removed
Every migration used to claim "the next free number" repo-wide
(001_foundation_init.ts → Migration001FoundationInit). That convention failed on
its own terms:
- Four numbers were used twice, each by a different module:
044(catalog/044_product_status_inactivevscatalog/044_product_value_overrides_init),068(prompt_actionsvscatalog),069(settingsvscarts),080(returnsvspwa). 078was skipped entirely — the counter carried no information anyone relied on.- The list order already diverged from numeric order. MikroORM hands
migrationsListto umzug unsorted, so the array order in the config file — not the number in the filename — is what deployed databases actually executed. The array ran…068_prompt_actions, 069_settings_secret, 068_product_packaging, 069_cart_item…. - Every branch conflicted. Two concurrent branches both appended an import and an entry at the same place in a 565-line file, so a merge conflict was guaranteed even when the two migrations had nothing to do with each other.
The timestamp scheme removes the coordination step entirely: two developers on two branches never need to agree on anything, and the registry is partitioned per module so their edits do not touch the same lines.
Naming convention
The convention is:
<YYYYMMDDTHHmmss>_<SEGMENT>_<SLUG>.ts
| Part | Rule |
|---|---|
| Timestamp | UTC, fixed width (15 chars), literal T at index 8. No Z, no separators. Lexicographically sortable. Unique within its own module — two modules may legally share a stamp, because a stamp orders nothing outside its module and two package authors cannot coordinate. |
<SEGMENT> | The owning module id with a leading underscore stripped; the literal core for the cross-cutting migrations in backend/src/db/migrations/. |
<SLUG> | snake_case ([a-z0-9_]+) describing the change. |
The class name is derived mechanically from the filename: strip the extension,
PascalCase each _-separated segment of the tail (only the first character of each
segment is upper-cased, so i18n → I18n), and prefix Migration + the timestamp:
| Filename | Class name |
|---|---|
20260424T165847_core_foundation_init.ts | Migration20260424T165847CoreFoundationInit |
20260507T091405_i18n_admin_i18n_init.ts | Migration20260507T091405I18nAdminI18nInit |
20260724T193611_orders_order_placement_intents.ts | Migration20260724T193611OrdersOrderPlacementIntents |
Segment normalization has exactly two special cases:
| Owning directory | <SEGMENT> | Registry moduleId |
|---|---|---|
packages/modules/orders/src/migrations/ | orders | 'orders' |
packages/modules/_i18n/src/migrations/ | i18n | '_i18n' |
packages/platform/src/lifecycle/migrations/ | lifecycle | '_lifecycle' |
backend/src/db/migrations/ | core | 'core' |
The class-name tail must begin with the module's segment, and that is a rule now,
not just a consequence of deriving the name from the path. It is what
makes class names globally unique without a registry, a namespace or a hash: module ids
are unique platform-wide, so Migration<stamp>Orders… cannot collide with any other
module's migration. orderMigrations() refuses a violation as unscoped-name — the
only place a package's name can be checked — and scripts/check-naming.sh refuses one
in the core tree at build time. All 141 migrations already satisfied it when the rule
landed.
The class name is the migration name persisted in mikro_orm_migrations.name.
Renaming an applied migration class is therefore a database problem, not a refactor:
every database that already ran it sees the new name as pending. Nothing in the
repository forbids the rename — see "Renaming an applied migration" below for what it
costs and how to ship it.
How to create a migration
This section is this repository's own modules — a workspace member declaring
endora: { type: 'module', id }, a module under the application's source root, or the
cross-cutting migrations under backend/src/db/migrations/. Both commands below need this
repository's layout. If you are writing a module that ships as an installed npm package,
neither is available to you: skip to
How to create a migration in an extension package.
pnpm --filter backend run migration:new -- --module orders --name placement_intents
The scaffolder (backend/scripts/new-migration.ts):
- validates
--moduleagainst the ids the generated manifest index registers (plus the literalcore), and lists the valid ids when it does not match; - resolves a free UTC timestamp, advancing by whole seconds until no migration file anywhere in the tree uses it. Tree-wide freedom is tidiness, not ordering: only per-module uniqueness is required;
- clamps the timestamp above
BASELINE_THROUGH. Today's wall clock can still be earlier thanBASELINE_THROUGH = 20260801T000000; a naive stamp would then land inside the frozen historical prefix, whose order is history and is never recomputed — the migration would be ordered by that history instead of by its module'sdependencies. The scaffolder emits a stamp one second past the watermark instead; - writes the file from a template into the module's own
migrations/directory, and refuses a target the registry generator would not pick up — a scaffolder that writes where nothing reads produces a migration that never runs and says nothing.
Where that directory is, is resolved and never spelled. All three questions above —
which ids are valid, which stamps are taken, where the file goes — come off
backend/scripts/lib/module-roots.ts, the same derivation the static-check estate shares:
the generated manifest index is located, and a module's directory is either one under the
application's source root or the workspace member declaring
endora: { type: 'module', id }. A module package's migrations/ is then the directory
its own exports map publishes as ./migrations. Each of those was a path literal reading
backend/src/modules until 2026-08-30, which is why the tool answered
Valid ids are: core. for every module in this repository once that directory was emptied.
Register it by regenerating the committed registry, and commit both files:
pnpm --filter backend run composer:generate
The generator walks src/db/migrations/ and every src/modules/<id>/migrations/,
derives each class name from its filename, and refuses — rather than skips — a file it
cannot place: an unrecognized .ts in a migrations directory, a class the file does
not export, two files deriving the same name, or a migration under
src/apps/<deployment>/ (overlay modules cannot ship migrations, so registering one
would be a new capability rather than a side effect of generating the list).
An unregistered migration does not run. The registry is a static-import list, not
a glob (glob discovery needs runtime dynamic import() of .ts, which Node's ESM
loader cannot transform and which breaks under Vitest — the same reason
entities-registry.generated.ts exists, and it is emitted by the same command). The
round-trip guard
backend/test/unit/db/migrations-registry.test.ts fails the build for a file with no
entry, an entry with no file, a class name that does not match its filename, a
declared moduleId that disagrees with the owning directory, a filename segment that
disagrees with the module id, an unrecognized .ts file in a migrations/ directory,
and any leftover NNN_-numbered file.
mikro-orm migration:create / migration:generate are not sanctioned: they write
into a single configured path and cannot know the owning module.
A non-migration .ts helper may live inside a migrations/ directory, but it must be
on the guard's explicit allow-list — today exactly one entry,
packages/modules/quote_requests/src/migrations/status-mapping.ts. The allow-list
exists so a typo'd migration filename fails loudly instead of silently disappearing
from the migrator.
How to create a migration in an extension package
Everything above is this repository's own tree. A module that ships as an installed npm
package (endora.type: "module" in its package.json) can run neither command:
migration:new resolves a module through this checkout's workspace members and its
generated manifest index, neither of which reaches node_modules, and both generated
registries are deliberately core-only — which packages an instance installed is a
fact about the process, not about the tree, so a committed artefact must not claim to know
it. The host discovers a package's migrations at runtime instead, through the package's own
./migrations export.
There is no scaffolder for this, and one is not required. A correct package migration is
written by hand and the whole shape fits on this page. (endora new migration is planned as a
subcommand of the @endora-commerce/cli binary; when it ships it will emit exactly what
follows, and this section will point at it.)
The worked example lives in this repository:
backend/acceptance/fixture-package/ — a synthetic third-party module package, built and
installed from a packed tarball by the packaging acceptance run, whose one migration creates a
real table in a real database.
1. Write the migration file
Put it under your package's src/migrations/, named the way core names them:
<YYYYMMDDTHHmmss>_<your module segment>_<slug>.ts
| Part | Rule |
|---|---|
| Timestamp | UTC, fixed width (15 characters), literal T at index 8. No Z, no separators. |
<segment> | Your module id — the endora.id field of your package.json — with a leading underscore stripped. |
<slug> | snake_case ([a-z0-9_]+) describing the change. |
The class name is derived mechanically from that filename: strip the extension, PascalCase
each _-separated segment of the tail (first character only, so i18n → I18n), and prefix
Migration + the timestamp. Export it as a named export.
endora.id | Filename | Class name |
|---|---|---|
acceptance_probe | 20260821T120000_acceptance_probe_init.ts | Migration20260821T120000AcceptanceProbeInit |
acme_gateway | 20260901T093000_acme_gateway_payouts.ts | Migration20260901T093000AcmeGatewayPayouts |
For a package the filename is a convention nothing in the host reads — the host receives
classes, not paths. The class name is not a convention. It is the string the host writes
into mikro_orm_migrations.name, and two rules bite on it:
- Its tail must begin with
PascalCase(<your module segment>). That is what makes class names globally unique across every module the platform can compose — including yours, and that is the entire reason the rule exists: module ids are unique platform-wide (discovery refuses a package that claims one already taken, andcomposeModulesasserts uniqueness before the first module registers), so a name scoped by the module id cannot collide with a core module's or with another vendor's. There is no registry, no namespace and no hash doing this job. Naming the file as above produces a compliant class name for free. - It is never renamed after it has applied anywhere. The host computes pending migrations
as "names not in
mikro_orm_migrations", so a rename in version 1.2 of your package is a new migration to every database that ran the old one, and it will be re-applied against a schema that already has it. Ship a new migration instead.
The fixture's migration class, quoted verbatim — the header comment above it in the source records the rules it followed:
export class Migration20260821T120000AcceptanceProbeInit extends Migration {
override async up(): Promise<void> {
this.addSql(`
create table "acceptance_probe_rows" (
"id" uuid not null,
"organization_id" uuid null,
"label" text not null,
"created_at" timestamptz not null default now(),
constraint "acceptance_probe_rows_pkey" primary key ("id")
);
`);
this.addSql(
`create index "acceptance_probe_rows_organization_id_index" on "acceptance_probe_rows" ("organization_id");`,
);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "acceptance_probe_rows" cascade;`);
}
}
2. List it in your ./migrations export — nothing else will
Your package's ./migrations entry point exports an array of your migration classes (bare
classes, or { name, class } pairs whose name must equal the class's own). The host reads
that array, tags every entry origin: 'external', and slots the chain into its execution
order at your module's topological position. backend/acceptance/fixture-package/src/migrations/index.ts
is the worked example.
A migration file your ./migrations array does not list is never run, and the first
symptom is a query error against a table that does not exist. In the core tree the equivalent
mistake is closed by a generator — composer:generate walks the directory and the round-trip
guard fails the build for a file with no entry. A package has no equivalent and will not get
one here: nothing in the host greps a package, by design. Keeping that array complete is the
author's job, and no check in this repository can do it for you.
Everything else fails loudly — see If you get the naming wrong below.
3. Declare, in your manifest dependencies, every module whose tables you reference
The execution order is a topological walk of the manifest dependency graph, so a manifest
dependencies entry is the only thing that puts your migration after the table it
references. If your migration adds a foreign key to orders, your manifest declares orders
— that, and only that, is what makes the constraint applicable on a fresh database. The
fixture declares auth for exactly this class of reason.
There is no other lever. Moving your timestamp cannot do it (see below), and there is no per-migration ordering edge.
What a timestamp does and does not order
This is the part that is the opposite of what a sequential-numbering scheme trains you to expect, and it is what makes hand-authoring safe:
- Within your module, the timestamp is the whole order: your migrations run ascending by
stamp, contiguously. Two of your own migrations sharing a stamp is an error
(
duplicate-timestamp). - Across modules, a timestamp means nothing. Two modules may legally share one. Permuting the stamps of two migrations in unrelated modules changes no emitted order. You cannot move your migration relative to the host's — or relative to another vendor's — by choosing a stamp, in either direction.
So the entire stamp rule for a package author is: newer than your own newest migration. You need no knowledge of the host's history, which is fortunate, because you have none.
BASELINE_THROUGH is a core-tree fact and does not apply to you
The core scaffolder clamps every new core stamp past BASELINE_THROUGH (20260801T000000),
and this repository's own conventions tell core authors never to scaffold a migration at or
before it. That instruction is not addressed to you, and the door it warns about is not
there. Baseline membership is an identity:
isBaseline(entry) ⇔ entry.class.name ∈ BASELINE_MIGRATIONS
BASELINE_MIGRATIONS is the ordered list of 112 class names @endora-commerce/platform
publishes on its ./migrations subpath — this platform's own history, which you receive by
installing the platform. Your migration is not on it and cannot join it, so your stamp is
never compared against the watermark at all. Pick a stamp below it — even years below it —
and your migration is still in the open block, still at your module's topological position,
still ordered by your manifest dependencies; without that guarantee a back-dated
third-party stamp would land ahead of the platform's own foundation migration (measured:
index 0 of 143).
Membership used to take two conditions, (entry.origin ?? 'core') === 'core' and the
stamp, and the first was what shut that door. It was replaced because it answers "did this
come out of the host's build" rather than "is this one of the migrations whose order is
history" — two questions that are the same only while every module is compiled into the
application. The moment the modules are installed packages, as they are in an instance, the
first answers false for every one of them: the prefix falls from 112 entries to 11, 181 of
182 positions move, and six migrations end up ordered before the migration that creates a
table they touch. The identity rule closes the same door more tightly, because a name is not
a claim an arriving package can make.
Pick a sensible stamp anyway. But pick it for your own chain, not for the host's.
If you get the naming wrong
All four naming rules are enforced at orderMigrations() Step 1, which the host reaches while
initialising its ORM. Each throws a MigrationOrderError naming your class, your module and
the contract section — before any migration runs, and with nothing half-applied:
| Error | What it means |
|---|---|
unparsable-name | The class name is not Migration<YYYYMMDDTHHmmss><PascalCaseTail>, or the stamp names no real UTC instant. |
unscoped-name | The tail does not begin with your module's segment. The message states the prefix it expected. |
duplicate-name | Two migrations in the whole composition share a class name — yours and someone else's, or two of yours. It is the database's key, so it must be globally unique. |
duplicate-timestamp | Two of your migrations share a stamp. Advance one by a whole second, in the file name and the class name together. |
A MigrationOrderError is raised during the host's ORM initialisation, so a naming mistake in
an installed package prevents the platform from starting rather than disabling the package that
made it. That asymmetry — a dependency cycle declared by a package is softened to a warning
ten lines away, for the stated reason that a stranger's manifest must not stop a shop's schema
from migrating — is a known open question. Until it is
answered, treat a naming mistake as an outage in someone else's shop.
What a package may and may not ship
A package may ship entities and migrations. A per-deployment overlay module under
backend/src/apps/ may not (the generator refuses both) — its remedy is always available and
costs nothing but a directory: own the table from a core module and read it through that
module's port. A third-party author has no core module, which is why the answer differs. See
docs/docs/architecture/overlay-pattern.md.
Ordering rules
orderMigrations() (backend/src/db/migration-order.ts) is a pure function: same
inputs, same output, no database, no clock, no environment. The emitted order is the
concatenation of two blocks.
1. The baseline block — the frozen historical prefix. Every entry whose class name
is on the list @endora-commerce/platform publishes as BASELINE_MIGRATIONS, emitted
in the order that list holds them. That block predates the current scheme: it was written and
applied in a hand-maintained array order its manifests do not describe — they contradict
it in 37 places — so emitting it any other way produces an order a fresh database cannot
apply. It is closed, it never grows (the scaffolder clamps every new core stamp past the
watermark), and nothing should try to drain it.
The list is generated, by composer:generate, from what the committed core registry
contributes at or before BASELINE_THROUGH (20260801T000000) — so the watermark is a
generation-time rule and no runtime membership test asks it. It lives in the platform
package because it is data about this platform's history: a client receives it by
installing the platform, and a correction to it by pnpm update. No module may name that
subpath.
Its 112 names are also pinned as a committed literal in
backend/test/unit/db/migration-order-baseline.test.ts, in the order a database applied
them, captured before the ordering algorithm was rewritten. That literal is never
regenerated — a baseline recomputed from the code it guards measures nothing — and the
generated list is held against it, name for name and position for position. That is what
makes the generated artefact trustworthy rather than merely deterministic.
Membership is by identity and by nothing else, which is the strongest form of the
rule this block has had. A stamp-only test lets a migration that arrived from outside the
committed registry join a prefix whose order is historical fact: measured, a package
migration stamped 20250101T000000 was emitted at index 0, ahead of the platform's own
foundation migration. That was closed for a time by also requiring origin === 'core',
and the conjunction answered "came out of this repository's build" while being used to
mean "is one of the migrations whose order is history" — two questions that coincide
only while every module is compiled in. A name cannot be claimed by an arriving
package at all, whatever it declares about its origin, so nothing about where an entry
came from is asked any more.
2. The open block — everything else, module by module. The modules are sorted topologically over the ordering graph, ties broken by module id ascending, and each module's migrations are emitted contiguously in ascending timestamp order.
- The ordering graph has module ids as nodes and manifest
dependenciesas edges, and reads no other manifest array — notacknowledgedDependencies, notnonBindingDependencies, neither of which is an ordering claim. - The
corepseudo-module sorts first. It declares nothing and nothing declares it, so it is always ready; every module's tables sit downstream of the bootstrap tables. - A timestamp never crosses a module boundary. If module
AdeclaresB, every open migration ofAfollows every open migration ofB— however far apart their stamps are, and in either chronological direction. There is no horizon. - Determinism. The topological sort drains its ready set by the smallest module id, so the emitted order is byte-identical across input permutations and across runs. Feeding the output back in yields the same output.
The hazard this exists for: branch A adds a catalog migration on Wednesday, branch B
adds an orders migration on Monday whose foreign key targets the column branch A
creates. orders transitively depends on catalog, so catalog's whole block is
emitted first and a fresh database applies cleanly — with no renumbering and no
coordination between the branches. Under the previous scheme that only worked while the two
stamps were within 45 days of each other; now it works unconditionally.
Cycles are reported, not thrown
If the ordering graph has a strongly connected component of more than one module, the order is still computed: the component's migrations are emitted as one contiguous block in timestamp order across the whole component — the baseline block's rule, applied locally, and the only defined answer when the declarations contain no order. The component comes back as a diagnostic, and nothing is thrown.
That is deliberate. Under the previous scheme the graph was a correction, so refusing a cycle
cost nothing. Now the graph is the primary ordering and a manifest can arrive from
node_modules, so a throw would mean one stranger's mis-declared package stops this
shop's core schema from migrating. Three readers react instead:
| Reader | Reaction |
|---|---|
backend/test/unit/db/module-graph.test.ts | asserts zero diagnostics over the committed manifests — a cycle in this repository is still a red build |
backend/src/db/mikro-orm.config.ts | logs each diagnostic at warn, naming its members |
the _lifecycle orchestrator | refuses an install whose arrival closes a cycle, naming every member and exiting 65 |
The three reactions differ on purpose, and the difference is where each one sits. A cycle in this repository is a mistake made by someone who can fix it before anything ships, so it is a red build. A cycle on a running platform is already deployed, so the platform says so and keeps serving — nothing else it could do would leave the operator better off. A cycle that is about to arrive is refused, because the install is the one moment where refusing costs nothing: nothing downstream of the module exists yet, and the operator is standing right there.
The refusal is scoped to the arriving module: the graph it walks is the installed set plus
that module, and only a component holding it is refused. A loop between two modules that
are already installed does not block a third module's install — that would reproduce, one
command later, exactly the platform-wide stall the no-throw rule exists to prevent. It
walks the graph with findModuleCycles from migration-order.ts, so the member list an
operator reads at the install prompt is the same list the boot warning would print.
Accepted limitation
A migration added later MAY change the relative order of two migrations that some
databases have already applied. That is harmless for those databases: umzug
computes pending as list.filter(name ∉ executed), so an applied migration is filtered
out regardless of its position in the list. Measured on a real database built under the
previous order and then read with the current one: pending 0, executed
142, up() applied 0. Fresh databases are covered by the CI backend job, which
creates an empty database and applies the whole chain on every pipeline.
What dependencies in a manifest means
A module manifest's dependencies array is now the whole cross-module ordering
input, so it must be honest — but it still means install-time necessity, not "every
table I hold a foreign key to". Read it as: this module cannot function without that
one.
Where two modules look mutually dependent, three precedence rules decide which direction to keep:
- Rule 1 — platform root. A platform-root module (
settings,audit_logs, …) never depends on a domain module. - Rule 2 — bridge owner. The owner of a junction table never depends on what it
bridges; the domain module that cannot function without the bridge declares the
bridge owner instead. (
sales_channelsownssales_channel_products;catalogdeclaressales_channels, not the reverse — aligned with sales-channel content scoping.) - Rule 3 — tenancy root.
organizationsis the tenancy root of multi-tenant isolation and never depends on tenant-owned modules.
Every dropped edge must be commented in the manifest that would have declared it,
naming the foreign keys it covers, the rule that drops it, and the cycle it would
create where it creates one. See packages/modules/organizations/src/manifest.ts for
the worked example.
A cycle in the graph is a red build — module-graph.test.ts fails on any
diagnostic. It is not a boot failure: see "Cycles are reported, not thrown" above.
The FK-drift validator
The backfilled graph is locked in by a blocking check:
backend/test/unit/db/fk-dependency-drift.test.ts.
It derives every cross-module foreign key from the migration SQL (statement-scoped
create table / alter table bodies, matching references "<table>"), resolves each
table to its owning module (entity tableName declarations first, then the explicitly
enumerated backend/test/unit/db/table-owner-overrides.ts for the bridge tables no
entity claims), and asserts that the referencing module transitively declares the
referenced module in its manifest dependencies.
- It is a pure unit test: no database, no ORM bootstrap, runs inside
pnpm --filter backend run test:unitin well under a second. - It has no runtime effect whatsoever. Nothing under
backend/src/imports it, it does not influenceorderMigrations(), and its artifacts live in the test tree. It validates the input the ordering algorithm consumes, nothing more. - A table created by a migration that no entity and no override claims fails the check, and so does a foreign key whose target cannot be resolved to a module. It never silently skips.
A failure reads:
[fk-drift] undeclared cross-module foreign key:
orders.order_placement_intents → api_keys
module "orders" references module "api_keys" but does not declare it
(transitively) in backend/src/modules/orders/manifest.ts.
Fix one of:
(a) add 'api_keys' to `dependencies` in orders/manifest.ts ← usually this
(b) if the edge must stay undeclared (it would create a cycle), add an entry to
backend/test/unit/db/acknowledged-fk-edges.ts with a reason and the cycle.
Option (a) is almost always right. Reach for the allow-list only when declaring the edge would create a cycle — and then you owe a reason, a precedence rule, and a cycle statement.
The acknowledged-edge allow-list
backend/test/unit/db/acknowledged-fk-edges.ts holds the edges the precedence rules
deliberately drop — currently 15, matching on the (from, to) module pair. Each entry
carries from, to, via (the concrete table pairs, for blast radius), a non-empty
reason, the rule, and a cycle statement.
The list is minimality-asserted, so it cannot grow monotonically:
| Assertion | Effect |
|---|---|
| M1 | An entry whose underlying foreign key no longer exists fails as stale. |
| M2 | An entry whose module pair is now satisfied by the manifest graph fails as dead weight. |
| M3 | An empty reason, an unknown rule, or an empty cycle fails. |
| M4 | A duplicate (from, to) pair fails. |
| M5 | A 16th entry fails — the cap is a visible, reviewable act, not a quiet append. |
cycle has wider semantics than "the cycle this closes"Measured against the shipped graph, only 6 of the 15 entries actually close a
cycle (organizations → customer_accounts; sales_channels → catalog / cms / customer_accounts / promotions; settings → sales_channels), and
organizations → inventory closes one only once the sales_channels bridge edge is
also declared. Rather than invent paths, the remaining entries carry an explicit
"no cycle on its own — dropped because …" statement naming the precedence reason. M3
still asserts the field is non-empty either way.
Failure modes and their fixes
All of these throw at config-build time — i.e. the first time anything imports
mikro-orm.config.ts — with MigrationOrderError and an actionable message.
| Symptom | Cause | Fix |
|---|---|---|
module "orders" has two migrations stamped 20260801T000001 | Two branches scaffolded in the same second inside one module (common: both were clamped to the same BASELINE_THROUGH + 1s floor) and then merged. | Advance one of them by a whole second — rename the file and the class, then regenerate. Inside a module the timestamp is the whole order, so the collision is loud and names both classes rather than silently reordering. Two different modules sharing a stamp is legal and is not reported. |
migration "…" is owned by module "orders", so it must be named Migration…Orders… | A migration class whose tail does not begin with its module's segment. | Rename the class (and the file, which derives it). The name is the database's key for what has run; scoping it by module is what keeps it unique platform-wide. |
migration "…" declares the unknown owning module "x" | A brand-new module whose manifest is not in the generated index. | pnpm --filter backend run manifest-index:generate |
migration class "…" does not match the naming convention | Hand-written or hand-renamed file; class and filename disagree. | Re-derive the class name from the filename (see the table above) or re-scaffold. |
| Round-trip guard fails naming a file/class | A migration on disk with no registry entry, or the reverse. | pnpm --filter backend run composer:generate and commit the artefact. |
db:fresh fails on a foreign key the chain should already have created | The referencing module does not declare the module that owns the referenced table. | Add it to dependencies in the referencing module's manifest.ts, or move the constraint into a migration owned by the module that owns the referencing table. Do not touch the timestamp — a stamp cannot fix a cross-module ordering problem, and fk-dependency-drift.test.ts fails the build for the undeclared edge anyway. |
Two things never fix an ordering surprise: moving a line in the generated registry
(declaration order is not execution order, and regenerating restores it) and bumping
a timestamp (a stamp orders nothing outside its own module). The remedy is always the
manifest dependencies.
The baseline block, and renaming an applied migration
mikro_orm_migrations records executed migrations by name, and those names are the
class names. The class name is derived mechanically from the filename, so moving a
migration file renames it: relocating
modules/settings/migrations/20260430T101450_settings_init.ts into db/migrations/
forces the core segment, and Migration20260430T101450SettingsInit becomes
Migration20260430T101450CoreSettingsInit.
An earlier scheme shipped a frozen rename map and a boot-time assertion that pinned
every historical class name in place, so a database deployed under the old scheme would
not re-run 112 migrations. Both were later retired: there is no deployed database, and
the assertion made a legitimate relocation impossible. What is left is the
position watermark, BASELINE_THROUGH, which fixes only the order of the
historical block. Renaming a class inside it is a no-op for the emitted order once the
published baseline list has been regenerated — the block is entered by name, so until
composer:generate runs, the renamed class is not on the list, joins the open block, and
instance-migration-order.test.ts reports it in both directions.
It is not a no-op for an existing database. After a rename, umzug — which computes
pending as list.filter(name not in executed) — sees the new name as unapplied and
re-runs the migration against a schema that already has it:
pnpm --filter backend run testfails inglobalSetup, which runsmigrator.up(), with something likerelation "settings" already exists. Every test file then fails and the message names a migration, not the change that caused it.pnpm run devfails the same way at boot.allOrNothing: trueandtransactional: truemean the replay rolls back rather than half-applying: you lose the run, not the database.
So a rename ships with a coordinated rebuild of the dev database — every developer runs
pnpm --filter backend run db:reset in the same window as the merge.
The test suite needs no intervention. The migrated template every invocation
is cloned from is named <base>_tpl_<digest>, the digest covering the ordered migration class
names and the content of every migration file — so a renamed class is a different migration
set, and the next invocation builds a template of its own instead of trying to re-apply
anything into the one you have. CI builds an empty database and needs no intervention either.
db:fresh and db:reset read backend/.env and default to the dev database, so always
pass DATABASE_URL explicitly when you mean anything else.
Module-uninstall migration revert
Hard-uninstalling a module reverts that module's migrations. They are resolved from
MIGRATION_REGISTRY filtered by moduleId, sorted ascending, and reverted in reverse
order via migrator.down({ migrations: [name] }). Sorting a single module's names
ascending is the resolved order — orderMigrations() guarantees intra-module
chronology — which lets the orchestrator import the registry (pure data) without
importing mikro-orm.config.ts.
This replaced a filename-pattern scan (^\d+_<moduleId>_ against
backend/src/db/migrations/ only) that could never work: it looked in the wrong
directory for module-local migrations and passed filename stems where the stored names
are class names, so hard-uninstall silently reverted nothing and only logged. If
a module owns no registered migration, the orchestrator logs a warning and reverts
nothing — hard-uninstall then relies on the module's uninstallHook.
That makes the registry moduleId load-bearing beyond ordering: file a migration
under the module that owns the tables it writes to. The settings and sales-channel
migrations were once filed under their modules although the kernel owns those tables,
so modules:uninstall --hard settings dropped settings, setting_groups and
setting_values. backend/test/unit/db/kernel-migration-ownership.test.ts
now fails the build when a module-owned migration writes to a kernel-owned table; both
sets are derived (from the entity tree and from each migration's SQL), never enumerated.