name: om-migrate-mikro-orm description: Migrate custom module code from MikroORM v6 to v7. Fixes v7 type errors (FilterQuery, RequiredEntityData), replaces Knex raw queries with Kysely, migrates persistAndFlush/removeAndFlush, updates decorator imports. Triggers on "mikro-orm v7", "persistAndFlush deprecated", "knex to kysely".
MikroORM v6 → v7 Migration
Migrate custom module code to MikroORM v7. The platform core is already migrated — this skill targets user modules in apps/mercato/src/modules/ and external integration packages under packages/.
Table of Contents
- Pre-Flight
- Entity Decorators
- persist/flush API
- Knex → Kysely
- Type Fixes
- ORM Configuration
- Jest / Test Setup
- count() Casting
- Migration Files And Snapshots
- Verification
1. Pre-Flight
Before migrating, confirm the platform packages are already on v7:
grep '@mikro-orm' package.json
# Expected: "@mikro-orm/core": "^7.0.10" (or higher)
Check which files in your module need changes:
# Find entity files with old decorator imports
grep -r "from '@mikro-orm/core'" apps/mercato/src/modules/<your-module>/
# Find deprecated persistAndFlush / removeAndFlush calls
grep -rn 'persistAndFlush\|removeAndFlush' apps/mercato/src/modules/<your-module>/
# Find Knex usage (must migrate to Kysely)
grep -rn 'getKnex\|getConnection()\.getKnex\|\.raw(' apps/mercato/src/modules/<your-module>/
2. Entity Decorators
v7 separated decorators from @mikro-orm/core. This codebase uses @mikro-orm/decorators/legacy (TypeScript emitDecoratorMetadata + reflect-metadata style).
Before
import { Entity, PrimaryKey, Property, ManyToOne, Index, Unique, OptionalProps } from '@mikro-orm/core'
After
import { OptionalProps } from '@mikro-orm/core'
import { Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
Rules
- Decorator exports (
Entity,PrimaryKey,Property,ManyToOne,OneToMany,Index,Unique,Enum,Formula, etc.) → import from@mikro-orm/decorators/legacy - Type-only exports (
OptionalProps,EntityManager,FilterQuery,RequiredEntityData,Loaded,ref,Collection, etc.) → keep importing from@mikro-orm/core - If a file imports BOTH decorators and types, split into two import lines
Quick sed
# Run from repo root — adjust the path glob for your module
find apps/mercato/src/modules/<your-module> -name 'entities.ts' -exec \
sed -i '' "s|from '@mikro-orm/core'|from '@mikro-orm/decorators/legacy'|" {} +
Then manually re-add a separate @mikro-orm/core import for any type-only exports (OptionalProps, etc.).
3. persist/flush API
persistAndFlush() and removeAndFlush() are removed in v7. Use the chained API instead.
Before
await em.persistAndFlush(entity)
await em.removeAndFlush(entity)
After
await em.persist(entity).flush()
await em.remove(entity).flush()
Multiple entities
// Before
em.persist(a)
em.persist(b)
await em.flush()
// After — same pattern, still valid
em.persist(a)
em.persist(b)
await em.flush()
Test mocks
If your tests mock persistAndFlush or removeAndFlush, update the mock to cover persist, remove, and flush separately:
// Before
const em = { persistAndFlush: jest.fn(), removeAndFlush: jest.fn() }
// After
const em = {
persist: jest.fn().mockReturnThis(),
remove: jest.fn().mockReturnThis(),
flush: jest.fn(),
}
4. Knex → Kysely
v7 replaced Knex with Kysely as the underlying SQL query builder. All raw SQL access changes.
Getting the query builder
// Before (v6)
const knex = em.getKnex()
// or
const knex = em.getConnection().getKnex()
// After (v7)
const db = em.getKysely<any>()
Simple queries
// Before (Knex)
const rows = await knex('users').where('tenant_id', tenantId).select('id', 'email')
// After (Kysely)
import { type Kysely } from 'kysely'
const db = em.getKysely<any>()
const rows = await db.selectFrom('users')
.where('tenant_id', '=', tenantId)
.select(['id', 'email'])
.execute()
Raw SQL with template literals
// Before (Knex)
await knex.raw('SELECT pg_advisory_xact_lock(hashtext(?))', [lockKey])
// After (Kysely)
import { sql } from 'kysely'
const db = em.getKysely<any>()
await sql`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(db)
INSERT with Kysely
// Before (Knex)
await knex('notifications').insert({ id, tenant_id, message })
// After (Kysely)
await db.insertInto('notifications')
.values({ id, tenant_id, message })
.execute()
UPDATE with Kysely
// Before (Knex)
await knex('notifications').where('id', id).update({ dismissed_at: knex.fn.now() })
// After (Kysely)
import { sql } from 'kysely'
await db.updateTable('notifications')
.set({ dismissed_at: sql`now()` })
.where('id', '=', id)
.execute()
DELETE with Kysely
// Before (Knex)
await knex('expired_tokens').where('expires_at', '<', knex.fn.now()).del()
// After (Kysely)
await db.deleteFrom('expired_tokens')
.where('expires_at' as any, '<', sql`now()`)
.execute()
JSONB casting
// Kysely does not auto-cast JSON — use sql template
await db.insertInto('table')
.values({ doc: sql`${JSON.stringify(data)}::jsonb` })
.execute()
Key differences
| Knex | Kysely |
|---|---|
knex('table') |
db.selectFrom('table') |
.where('col', val) |
.where('col', '=', val) (operator required) |
.select('a', 'b') |
.select(['a', 'b']) |
.raw(sql, bindings) |
sql`...${binding}...`.execute(db) |
knex.fn.now() |
sql`now()` |
.insert({}) |
.insertInto('t').values({}).execute() |
.update({}) |
.updateTable('t').set({}).execute() |
.del() |
.deleteFrom('t').execute() |
| Results returned directly | Must call .execute() at the end |
5. Type Fixes
v7 tightened generic constraints on FilterQuery<T> and RequiredEntityData<T>.
FilterQuery
If the compiler complains about a filter object not matching FilterQuery<T>, add an explicit cast:
// Before — worked in v6, fails in v7
await em.find(MyEntity, { tenantId, deletedAt: null })
// After — explicit cast
await em.find(MyEntity, { tenantId, deletedAt: null } as FilterQuery<MyEntity>)
RequiredEntityData
When creating entities with em.create() or em.persist(em.create(...)):
// Before — worked in v6, fails in v7
em.create(MyEntity, { field1: 'x', field2: 123 })
// After — explicit cast if needed
em.create(MyEntity, { field1: 'x', field2: 123 } as RequiredEntityData<MyEntity>)
Common patterns that need casts
- Filters with
nullcomparisons (deletedAt: null) - Filters mixing snake_case DB columns with camelCase entity props
em.create()calls where optional props trigger excess property checksem.nativeUpdate()/em.nativeDelete()filter arguments
6. ORM Configuration
The platform ORM config (packages/shared/src/lib/db/mikro.ts) is already migrated. If your module initializes its own ORM instance (e.g., for tests or CLI), ensure:
MetadataProvider (CRITICAL)
import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'
const orm = await MikroORM.init({
// ...
metadataProvider: ReflectMetadataProvider, // MUST set explicitly in v7
})
v7 removed the default ReflectMetadataProvider. Without this, entity metadata inference fails silently — columns get wrong types at runtime.
Pool configuration
// v6 pool shape (Knex-based)
pool: { min: 2, max: 10, acquireTimeoutMillis: 6000 }
// v7 pool shape (pg-pool based)
pool: { min: 2, max: 10, idleTimeoutMillis: 3000 }
// Acquire timeout moved to driverOptions:
driverOptions: { connectionTimeoutMillis: 6000 }
SSL configuration
driverOptions: {
ssl: process.env.DB_SSL === 'true'
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: undefined,
}
7. Jest / Test Setup
v7 is ESM-only and uses import.meta.resolve() internally. The repo includes a custom Jest transformer that bridges ESM → CJS.
Already configured (no action needed if using repo jest.config.cjs)
- Transformer:
scripts/jest-mikroorm-transformer.cjs— replacesimport.meta.*with CJS equivalents - Transform config in
jest.config.cjsalready handles@mikro-orm/*packages transformIgnorePatternsexcludes@mikro-ormfrom ignoring:'node_modules/(?!(@mikro-orm)/)'
If your module has a standalone Jest config
Ensure it includes:
module.exports = {
transform: {
'^.+\\.(t|j)sx?$': ['<rootDir>/scripts/jest-mikroorm-transformer.cjs', { tsconfig: { jsx: 'react-jsx' } }],
},
transformIgnorePatterns: ['node_modules/(?!(@mikro-orm)/)'],
}
tsconfig target
Ensure target is ES2022 or higher (required for v7 class features).
8. count() Casting
Kysely returns SQL count(*) results as strings. When using raw Kysely queries with counts, always cast to Number:
// WRONG — row.count is a string, arithmetic fails silently
const total = row?.count ?? 0
// CORRECT
const total = Number(row?.count ?? 0)
This applies to any raw Kysely sql or selectFrom query that uses aggregate functions (count, sum, avg).
Note: MikroORM's own em.count() and QueryBuilder .getCount() return proper numbers — no casting needed there.
9. Migration Files And Snapshots
MikroORM migration generation is module-scoped, but stale module snapshots can still make yarn db:generate emit unrelated migrations. Treat generation as a diff probe, not as an instruction to commit everything it writes.
Scoped workflow for agents
- Update only the intended entity files.
- Run
yarn db:generateto inspect the SQL MikroORM wants to produce. - Keep only the migration for the intended module/entity change. Delete unrelated generated migrations from the diff.
- If
yarn db:generatekeeps producing unrelated SQL because another module's.snapshot-open-mercato.jsonis stale, fix that snapshot separately or leave it out of the PR unless it is the bug you are addressing. - When you keep or manually write a scoped migration, update the same module's
migrations/.snapshot-open-mercato.jsonto the post-change schema. This file is the generator's baseline; missing snapshot updates cause duplicate migrations in standalone apps. - Re-run
yarn db:generate. The touched module should reportno changes; if it does not, the migration or snapshot is incomplete.
Do not run yarn db:migrate unless the user explicitly asks to apply migrations to the local database. Migration PRs should contain SQL files plus snapshots, not local DB state.
Manual SQL exception
The default rule remains: prefer MikroORM-generated migrations. A coding agent may write the SQL migration itself only when generation produces unrelated churn and the intended change is small and additive. In that case:
- Copy the existing migration style in the module.
- Include only the intended
create table,alter table add column, index, or constraint statements. - Avoid destructive SQL unless the spec and backward-compatibility review explicitly require it.
- Update
.snapshot-open-mercato.jsonin the same commit. - Run
yarn db:generateafterward to prove the module is clean.
10. Verification
After migrating, run the full validation:
# 1. Type-check
yarn build:packages
# 2. Lint
yarn lint
# 3. Unit tests
yarn test
# 4. Generate (ensures entities are discoverable)
yarn generate
# 5. Dev server (smoke test)
yarn dev
Self-Review Checklist
- No imports of decorators from
@mikro-orm/core— all moved to@mikro-orm/decorators/legacy - No
persistAndFlushorremoveAndFlushcalls remain - No
getKnex()orgetConnection().getKnex()calls remain — all useem.getKysely<any>() - All Kysely queries end with
.execute() - All raw count results wrapped in
Number() - All JSONB values use
sqltemplate with::jsonbcast -
FilterQuery<T>/RequiredEntityData<T>casts added where v7 type errors occur - Test mocks updated — no
persistAndFlush/removeAndFlushmocks - ORM init (if standalone) sets
metadataProvider: ReflectMetadataProvider -
tsconfigtarget isES2022or higher -
yarn build:packagespasses -
yarn testpasses