Skip to content

Commit 55a3390

Browse files
committed
feat: implement migration command and service for forced execution
- Added a new command `MigrationsForceCommand` to allow forcing the execution of specific migrations by timestamp or filename. - Enhanced the `MigrationsService` with a `forceRun` method to handle the logic for executing migrations even when the lock is active. - Introduced a new migration job `IdentitiesEmployeeTypeLocalToLOCAL` to update employee types in the database, ensuring proper handling of unique constraints during the migration process. - Updated the `MigrationsModule` to register the new command for CLI usage.
1 parent 61d00f4 commit 55a3390

File tree

4 files changed

+192
-0
lines changed

4 files changed

+192
-0
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Logger } from '@nestjs/common'
2+
import { InjectConnection } from '@nestjs/mongoose'
3+
import { Connection } from 'mongoose'
4+
5+
export default class IdentitiesEmployeeTypeLocalToLOCAL1775547941 {
6+
private readonly logger = new Logger(IdentitiesEmployeeTypeLocalToLOCAL1775547941.name)
7+
8+
public constructor(@InjectConnection() private mongo: Connection) {}
9+
10+
public async up(): Promise<void> {
11+
this.logger.log('IdentitiesEmployeeTypeLocalToLOCAL1775547941 up started')
12+
13+
const identitiesCollection = this.mongo.collection('identities')
14+
15+
// Important: on ne peut pas faire updateMany(local->LOCAL) avant de réallouer employeeNumber,
16+
// sinon on peut violer l'index unique (employeeNumber, employeeType).
17+
const cursor = identitiesCollection
18+
.find({
19+
'inetOrgPerson.employeeType': "local",
20+
})
21+
.project({ _id: 1 })
22+
.sort({ _id: 1 })
23+
24+
const toMigrate = await identitiesCollection.countDocuments({
25+
'inetOrgPerson.employeeType': "local",
26+
})
27+
this.logger.log(`Identities matched for migration: ${toMigrate}`)
28+
29+
let migrated = 0
30+
let failed = 0
31+
for await (const doc of cursor) {
32+
try {
33+
const newNumber = await this.allocateNextEmployeeNumber()
34+
await identitiesCollection.updateOne(
35+
{ _id: doc._id },
36+
{
37+
$set: {
38+
'inetOrgPerson.employeeType': 'LOCAL',
39+
'inetOrgPerson.employeeNumber': [String(newNumber)],
40+
},
41+
},
42+
)
43+
migrated += 1
44+
} catch (e: any) {
45+
failed += 1
46+
this.logger.error(`Failed to migrate identity <${doc._id}>: ${e?.message || e}`)
47+
}
48+
}
49+
50+
this.logger.log(`EmployeeType migration done (migrated=${migrated}, failed=${failed})`)
51+
52+
this.logger.log('Migration terminée avec succès')
53+
}
54+
55+
/**
56+
* Réattribue un nouvel employeeNumber aux identités migrées, en utilisant la même source de vérité
57+
* que l'auto-incrément Mongoose: la collection `identitycounters`.
58+
*
59+
* On fait un `findOneAndUpdate($inc)` par identité, ce qui garantit l'unicité et évite
60+
* toute approximation (max en base / overflow / compteur en retard).
61+
*/
62+
private async allocateNextEmployeeNumber(): Promise<number> {
63+
const counters = this.mongo.collection('identitycounters')
64+
const filter = { field: 'inetOrgPerson.employeeNumber', modelName: 'Identities' }
65+
const opts: any = { upsert: true, returnDocument: 'after', returnOriginal: false }
66+
67+
// MongoDB interdit de modifier le même champ dans $setOnInsert et $inc dans la même requête.
68+
// On s'assure d'abord que le document compteur existe, puis on incrémente.
69+
await counters.updateOne(
70+
filter,
71+
{
72+
$setOnInsert: {
73+
...filter,
74+
// Mimics AutoIncrementPlugin initialization for startAt=1, incrementBy=1
75+
count: 0,
76+
},
77+
},
78+
{ upsert: true },
79+
)
80+
81+
const counter = await counters.findOneAndUpdate(filter, { $inc: { count: 1 } }, opts)
82+
83+
const counterDoc = counter?.value || (await counters.findOne(filter))
84+
const allocated = Number(counterDoc?.count)
85+
if (!Number.isFinite(allocated) || allocated <= 0) {
86+
throw new Error(`Failed to allocate employeeNumber (allocated=${allocated})`)
87+
}
88+
return allocated
89+
}
90+
}
91+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Logger } from '@nestjs/common'
2+
import { ModuleRef } from '@nestjs/core'
3+
import { Command, CommandRunner, SubCommand } from 'nest-commander'
4+
import { MigrationsService } from './migrations.service'
5+
6+
@SubCommand({ name: 'force' })
7+
export class MigrationsForceCommand extends CommandRunner {
8+
private readonly logger = new Logger(MigrationsForceCommand.name)
9+
10+
public constructor(
11+
protected moduleRef: ModuleRef,
12+
private readonly migrationsService: MigrationsService,
13+
) {
14+
super()
15+
}
16+
17+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
18+
async run(inputs: string[], options: any): Promise<void> {
19+
const selector = inputs?.[0]
20+
if (!selector) {
21+
console.error('Usage: yarn run console migrations force <timestamp|filename>')
22+
return
23+
}
24+
25+
this.logger.warn(`Forcing migration <${selector}> ...`)
26+
await this.migrationsService.forceRun(selector)
27+
this.logger.log('Done.')
28+
}
29+
}
30+
31+
@Command({ name: 'migrations', arguments: '<task>', subCommands: [MigrationsForceCommand] })
32+
export class MigrationsCommand extends CommandRunner {
33+
public constructor(protected moduleRef: ModuleRef) {
34+
super()
35+
}
36+
37+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
38+
async run(inputs: string[], options: any): Promise<void> {}
39+
}
40+

apps/api/src/migrations/migrations.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { DynamicModule, Module } from '@nestjs/common'
22
import { MigrationsService } from './migrations.service'
3+
import { useOnCli } from '~/_common/functions/is-cli'
4+
import { MigrationsCommand } from './migrations.command'
35

46
/**
57
* Module NestJS pour la gestion des migrations de base de données
@@ -12,6 +14,9 @@ import { MigrationsService } from './migrations.service'
1214
@Module({
1315
providers: [
1416
MigrationsService,
17+
...useOnCli([
18+
...MigrationsCommand.registerWithSubCommands(),
19+
]),
1520
],
1621
})
1722
export class MigrationsModule {

apps/api/src/migrations/migrations.service.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,60 @@ export class MigrationsService implements OnModuleInit {
261261
throw new Error('Error while updating migration lock file !')
262262
}
263263
}
264+
265+
/**
266+
* Exécute une migration spécifique en la forçant, même si le lock est au-dessus.
267+
*
268+
* @param selector Timestamp (ex: "1775547941") ou nom de fichier (ex: "jobs/1775547941-foo.js")
269+
*/
270+
public async forceRun(selector: string): Promise<void> {
271+
if (!selector || typeof selector !== 'string') {
272+
throw new Error('Missing migration selector (timestamp or filename)')
273+
}
274+
275+
let files = await glob(`./jobs/*.js`, {
276+
cwd: __dirname,
277+
root: __dirname,
278+
})
279+
280+
const normalizedSelector = selector.trim()
281+
const selectorTimestamp = (normalizedSelector.match(/\d{10,}/) || [])[0]
282+
283+
const matches = files.filter((file) => {
284+
if (normalizedSelector.includes('/') || normalizedSelector.includes('.js')) {
285+
return file.endsWith(normalizedSelector.replace(/^.*jobs\//, 'jobs/'))
286+
}
287+
if (selectorTimestamp) {
288+
return file.includes(selectorTimestamp)
289+
}
290+
return false
291+
})
292+
293+
if (matches.length === 0) {
294+
throw new Error(`No migration file found for selector <${selector}>`)
295+
}
296+
if (matches.length > 1) {
297+
throw new Error(`Multiple migrations match selector <${selector}>: ${matches.join(', ')}`)
298+
}
299+
300+
const key = matches[0]
301+
const [migrationTimestamp] = key.match(/\d{10,}/) || []
302+
if (!migrationTimestamp) {
303+
throw new Error(`Selected migration <${key}> has no timestamp in filename`)
304+
}
305+
306+
const migration = await import(`${__dirname}/${key}`)
307+
if (!migration?.default) {
308+
throw new Error(`Migration <${key}> does not have a default export`)
309+
}
310+
311+
const instance = await this.moduleRef.create(migration.default)
312+
if (typeof instance.up !== 'function') {
313+
throw new Error(`Migration <${key}> does not have an up method`)
314+
}
315+
316+
this.logger.warn(chalk.yellow(`Forcing migration ${chalk.bold('<' + key + '>')}...`))
317+
await instance.up()
318+
await this._writeMigrationLockFile(key, migrationTimestamp)
319+
}
264320
}

0 commit comments

Comments
 (0)