feat: add compodoc + enforce typing

This commit is contained in:
Mohamed Marrouchi 2025-01-05 10:14:39 +01:00
parent 652ca78120
commit 252857fbf7
7 changed files with 264 additions and 94 deletions

View File

@ -11,6 +11,7 @@ will apply migrations automatically but only if it's a dev environement and `con
- Track migration execution status in a MongoDB collection (`migrations`). - Track migration execution status in a MongoDB collection (`migrations`).
- Run individual or all migrations with ease. - Run individual or all migrations with ease.
- Built-in support for rollback logic. - Built-in support for rollback logic.
- Keeps track of the database schema version in the metadata collection (SettingModule).
## Usage ## Usage

View File

@ -11,7 +11,7 @@ import { Command, CommandRunner } from 'nest-commander';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { MigrationService } from './migration.service'; import { MigrationService } from './migration.service';
import { MigrationAction } from './types'; import { MigrationAction, MigrationVersion } from './types';
@Command({ @Command({
name: 'migration', name: 'migration',
@ -28,22 +28,34 @@ export class MigrationCommand extends CommandRunner {
async run(passedParam: string[]): Promise<void> { async run(passedParam: string[]): Promise<void> {
const [subcommand] = passedParam; const [subcommand] = passedParam;
switch (subcommand) { switch (subcommand) {
case 'create': case 'create': {
const [, filename] = passedParam; const [, version] = passedParam;
return await this.migrationService.create(filename);
case 'migrate': if (!this.isValidVersion(version)) {
const [, action, name] = passedParam; throw new TypeError('Invalid version value.');
}
return this.migrationService.create(version);
}
case 'migrate': {
const [, action, version] = passedParam;
if ( if (
!Object.values(MigrationAction).includes(action as MigrationAction) !Object.values(MigrationAction).includes(action as MigrationAction)
) { ) {
this.logger.error('Invalid Operation'); this.logger.error('Invalid Operation');
this.exit(); this.exit();
} }
if (!this.isValidVersion(version)) {
throw new TypeError('Invalid version value.');
}
return await this.migrationService.run({ return await this.migrationService.run({
action: action as MigrationAction, action: action as MigrationAction,
name, version,
}); });
}
default: default:
this.logger.error('No valid command provided'); this.logger.error('No valid command provided');
this.exit(); this.exit();
@ -55,4 +67,14 @@ export class MigrationCommand extends CommandRunner {
this.logger.log('Exiting migration process.'); this.logger.log('Exiting migration process.');
process.exit(0); process.exit(0);
} }
/**
* Checks if the migration version is in valid format
* @param version migration version name
* @returns True, if the migration version name is valid
*/
public isValidVersion(version: string): version is MigrationVersion {
const regex = /^v?(\d+)\.(\d+)\.(\d+)$/;
return regex.test(version);
}
} }

View File

@ -8,6 +8,7 @@
import { join } from 'path'; import { join } from 'path';
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
@ -18,7 +19,11 @@ import { MigrationModel } from './migration.schema';
import { MigrationService } from './migration.service'; import { MigrationService } from './migration.service';
@Module({ @Module({
imports: [MongooseModule.forFeature([MigrationModel]), LoggerModule], imports: [
MongooseModule.forFeature([MigrationModel]),
LoggerModule,
HttpModule,
],
providers: [ providers: [
MigrationService, MigrationService,
MigrationCommand, MigrationCommand,

View File

@ -11,12 +11,12 @@ import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { THydratedDocument } from '@/utils/types/filter.types'; import { THydratedDocument } from '@/utils/types/filter.types';
import { MigrationAction } from './types'; import { MigrationAction, MigrationVersion } from './types';
@Schema({ timestamps: true }) @Schema({ timestamps: true })
export class Migration { export class Migration {
@Prop({ type: String, required: true, unique: true }) @Prop({ type: String, required: true, unique: true })
name: string; version: MigrationVersion;
@Prop({ type: String, required: true, enum: Object.values(MigrationAction) }) @Prop({ type: String, required: true, enum: Object.values(MigrationAction) })
status: MigrationAction; status: MigrationAction;

View File

@ -9,6 +9,7 @@
import { existsSync, readdirSync, writeFileSync } from 'fs'; import { existsSync, readdirSync, writeFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { HttpService } from '@nestjs/axios';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
@ -26,8 +27,10 @@ import idPlugin from '@/utils/schema-plugin/id.plugin';
import { Migration, MigrationDocument } from './migration.schema'; import { Migration, MigrationDocument } from './migration.schema';
import { import {
MigrationAction, MigrationAction,
MigrationName,
MigrationRunParams, MigrationRunParams,
MigrationSuccessCallback, MigrationSuccessCallback,
MigrationVersion,
} from './types'; } from './types';
@Injectable() @Injectable()
@ -36,6 +39,7 @@ export class MigrationService implements OnApplicationBootstrap {
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
private readonly logger: LoggerService, private readonly logger: LoggerService,
private readonly metadataService: MetadataService, private readonly metadataService: MetadataService,
private readonly httpService: HttpService,
@InjectModel(Migration.name) @InjectModel(Migration.name)
private readonly migrationModel: Model<Migration>, private readonly migrationModel: Model<Migration>,
) { ) {
@ -65,11 +69,17 @@ export class MigrationService implements OnApplicationBootstrap {
process.exit(0); process.exit(0);
} }
// CREATE /**
* Get The migrations dir path configured in migration.module.ts
* @returns The migrations dir path
*/
public get migrationFilePath() { public get migrationFilePath() {
return this.moduleRef.get('MONGO_MIGRATION_DIR'); return this.moduleRef.get('MONGO_MIGRATION_DIR');
} }
/**
* Checks if the migration path is well set and exists
*/
public validateMigrationPath() { public validateMigrationPath() {
if (!existsSync(this.migrationFilePath)) { if (!existsSync(this.migrationFilePath)) {
this.logger.error( this.logger.error(
@ -79,18 +89,29 @@ export class MigrationService implements OnApplicationBootstrap {
} }
} }
public async create(name: string) { /**
const fileName: string = kebabCase(name) + '.migration.ts'; * Creates a new migration file with the specified name.
*
* The file name is generated in kebab-case format, prefixed with a timestamp.
* If a migration file with the same name already exists, an error is logged, and the process exits.
*
* @param version - The name of the migration to create.
* @returns Resolves when the migration file is successfully created.
*
* @throws If there is an issue writing the migration file.
*/
public create(version: MigrationVersion) {
const fileName: string = kebabCase(version) + '.migration.ts';
// check if file already exists // check if file already exists
const files = await this.getDirFiles(); const files = this.getMigrationFiles();
const exist = files.some((file) => { const exist = files.some((file) => {
const migrationName = this.getMigrationName(file); const migrationName = this.getMigrationName(file);
return migrationName === fileName; return migrationName === fileName;
}); });
if (exist) { if (exist) {
this.logger.error(`Migration file for "${name}" already exists`); this.logger.error(`Migration file for "${version}" already exists`);
this.exit(); this.exit();
} }
@ -100,7 +121,7 @@ export class MigrationService implements OnApplicationBootstrap {
try { try {
writeFileSync(filePath, template); writeFileSync(filePath, template);
this.logger.log( this.logger.log(
`Migration file for "${name}" created: ${migrationFileName}`, `Migration file for "${version}" created: ${migrationFileName}`,
); );
} catch (e) { } catch (e) {
this.logger.error(e.stack); this.logger.error(e.stack);
@ -109,21 +130,30 @@ export class MigrationService implements OnApplicationBootstrap {
} }
} }
/**
* Get a migration template to be used while creating a new migration
* @returns A migration template
*/
private getMigrationTemplate() { private getMigrationTemplate() {
return `import mongoose from 'mongoose'; return `import mongoose from 'mongoose';
import { MigrationServices } from '../types';
module.exports = { module.exports = {
async up() { async up(services: MigrationServices) {
// Migration logic // Migration logic
return false; return false;
}, },
async down() { async down(services: MigrationServices) {
// Rollback logic // Rollback logic
return false; return false;
}, },
};`; };`;
} }
/**
* Establishes a MongoDB connection
*/
private async connect() { private async connect() {
try { try {
const connection = await mongoose.connect(config.mongo.uri, { const connection = await mongoose.connect(config.mongo.uri, {
@ -140,13 +170,21 @@ module.exports = {
} }
} }
public async run({ /**
action, * Executes migration operations based on the provided parameters.
name, *
version, * Determines the migration operation to perform (run all, run a specific migration, or run upgrades)
isAutoMigrate, * based on the input parameters. The process may exit after completing the operation.
}: MigrationRunParams) { *
if (!name) { * @param action - The migration action to perform (e.g., 'up' or 'down').
* @param name - The specific migration name to execute. If not provided, all migrations are considered.
* @param version - The target version for automatic migration upgrades.
* @param isAutoMigrate - A flag indicating whether to perform automatic migration upgrades.
*
* @returns Resolves when the migration operation is successfully completed.
*/
public async run({ action, version, isAutoMigrate }: MigrationRunParams) {
if (!version) {
if (isAutoMigrate) { if (isAutoMigrate) {
await this.runUpgrades(action, version); await this.runUpgrades(action, version);
} else { } else {
@ -154,46 +192,67 @@ module.exports = {
this.exit(); this.exit();
} }
} else { } else {
await this.runOne({ action, name }); await this.runOne({ action, version });
this.exit(); this.exit();
} }
} }
private async runOne({ name, action }: MigrationRunParams) { /**
// verify DB status * Executes a specific migration action for a given version.
*
* Verifies the migration status in the database before attempting to execute the action.
* If the migration has already been executed, the process stops. Otherwise, it loads the
* migration file, performs the action, and handles success or failure through callbacks.
*
* @param version - The version of the migration to run.
* @param action - The action to perform (e.g., 'up' or 'down').
*
* @returns Resolves when the migration action is successfully executed or stops if the migration already exists.
*/
private async runOne({ version, action }: MigrationRunParams) {
// Verify DB status
const { exist, migrationDocument } = await this.verifyStatus({ const { exist, migrationDocument } = await this.verifyStatus({
name, version,
action, action,
}); });
if (exist) { if (exist) {
return true; // stop exec; return true; // stop exec;
} }
try { try {
const migration = await this.loadMigrationFile(name); const migration = await this.loadMigrationFile(version);
const result = await migration[action](); const result = await migration[action]({
logger: this.logger,
http: this.httpService,
});
if (result) { if (result) {
await this.successCallback({ await this.successCallback({
name, version,
action, action,
migrationDocument, migrationDocument,
}); });
} }
} catch (e) { } catch (e) {
this.failureCallback({ this.failureCallback({
name, version,
action, action,
}); });
this.logger.log(e.stack); this.logger.log(e.stack);
} }
} }
isNewerVersion(version1: string, version2: string): boolean { /**
const regex = /^v?(\d+)\.(\d+)\.(\d+)$/; * Compares two version strings to determine if the first version is newer than the second.
if (!regex.test(version1) || !regex.test(version2)) { *
throw new TypeError('Invalid version number!'); * @param version1 - The first version string (e.g., 'v1.2.3').
} * @param version2 - The second version string (e.g., 'v1.2.2').
* @returns `true` if the first version is newer than the second, otherwise `false`.
*/
private isNewerVersion(
version1: MigrationVersion,
version2: MigrationVersion,
): boolean {
// Split both versions into their numeric components // Split both versions into their numeric components
const v1Parts = version1.replace('v', '').split('.').map(Number); const v1Parts = version1.replace('v', '').split('.').map(Number);
const v2Parts = version2.replace('v', '').split('.').map(Number); const v2Parts = version2.replace('v', '').split('.').map(Number);
@ -214,39 +273,65 @@ module.exports = {
return false; return false;
} }
private async runUpgrades(action: MigrationAction, version: string) { /**
const versions = await this.getAvailableUpgradeVersions(); * Executes migration upgrades for all available versions newer than the specified version.
*
* @param action - The migration action to perform (e.g., 'up').
* @param version - The current version to compare against for upgrades.
*
* @returns The last successfully upgraded version.
*/
private async runUpgrades(
action: MigrationAction,
version: MigrationVersion,
) {
const versions = this.getAvailableUpgradeVersions();
const filteredVersions = versions.filter((v) => const filteredVersions = versions.filter((v) =>
this.isNewerVersion(v, version), this.isNewerVersion(v, version),
); );
let lastVersion = version; let lastVersion = version;
for (const version of filteredVersions) { for (const version of filteredVersions) {
await this.runOne({ name: version, action }); await this.runOne({ version, action });
lastVersion = version; lastVersion = version;
} }
return lastVersion; return lastVersion;
} }
/**
* Executes the specified migration action for all available versions.
*
* @param action - The migration action to perform (e.g., 'up' or 'down').
*
* @returns Resolves when all migration actions are successfully completed.
*/
private async runAll(action: MigrationAction) { private async runAll(action: MigrationAction) {
const versions = await this.getAvailableUpgradeVersions(); const versions = this.getAvailableUpgradeVersions();
for (const version of versions) { for (const version of versions) {
await this.runOne({ name: version, action }); await this.runOne({ version, action });
} }
} }
private async getDirFiles() { /**
return readdirSync(this.migrationFilePath); * Verifies the migration status for a specific version and action.
} *
* @param version - The version of the migration to verify.
private async verifyStatus({ name, action }: MigrationRunParams): Promise<{ * @param action - The migration action to verify (e.g., 'up' or 'down').
*
* @returns A promise resolving to an object containing:
* - `exist`: A boolean indicating if the migration already exists in the specified state.
* - `migrationDocument`: The existing migration document, or `null` if not found.
*/
private async verifyStatus({ version, action }: MigrationRunParams): Promise<{
exist: boolean; exist: boolean;
migrationDocument: MigrationDocument | null; migrationDocument: MigrationDocument | null;
}> { }> {
let exist = false; let exist = false;
const migrationDocument = await this.migrationModel.findOne({ name }); const migrationDocument = await this.migrationModel.findOne({
version,
});
if (migrationDocument) { if (migrationDocument) {
exist = Boolean(migrationDocument.status === action); exist = Boolean(migrationDocument.status === action);
@ -260,47 +345,79 @@ module.exports = {
return { exist, migrationDocument }; return { exist, migrationDocument };
} }
async getMigrationFiles() { /**
const files = await this.getDirFiles(); * Retrieves all migration files from the migration directory.
return files.filter((file) => /\.migration\.(js|ts)/.test(file)); *
* Reads the files in the migration directory and filters for those matching
* the `.migration.js` or `.migration.ts` file extensions.
*
* @returns A promise resolving to an array of migration file names.
*/
getMigrationFiles() {
const files = readdirSync(this.migrationFilePath);
return files.filter((file) => /\.migration\.(js|ts)$/.test(file));
} }
private getMigrationName(filename: string) { /**
* Extracts the migration name from a given filename.
*
* @param filename - The migration file name to process (e.g., '1234567890-my-migration.migration.ts').
* @returns The extracted migration name (e.g., 'my-migration').
*/
private getMigrationName(filename: string): MigrationName {
const [, ...migrationNameParts] = filename.split('-'); const [, ...migrationNameParts] = filename.split('-');
const migrationName = migrationNameParts.join('-'); const migrationName = migrationNameParts.join('-');
return migrationName; return migrationName.replace(/\.migration\.(js|ts)/, '') as MigrationName;
} }
private async getAvailableUpgradeVersions() { /**
const filenames = await this.getMigrationFiles(); * Retrieves a list of available migration upgrade versions.
*
* Processes all migration files to extract and format their version identifiers.
*
* @returns An array of formatted migration versions (e.g., ['v1.0.0', 'v1.1.0']).
*/
private getAvailableUpgradeVersions() {
const filenames = this.getMigrationFiles();
return filenames.map((filename: string) => { return filenames
const [migrationFileName] = filename.split('.'); .map((filename) => this.getMigrationName(filename))
const [, , ...migrationVersion] = migrationFileName.split('-'); .map((name) => {
return `v${migrationVersion.join('.')}`; const [, ...migrationVersion] = name.split('-');
}); return `v${migrationVersion.join('.')}` as MigrationVersion;
});
} }
async findMigrationFileByName(version: string): Promise<string | null> { /**
const files = await this.getMigrationFiles(); * Finds the migration file corresponding to a specific version.
*
* @param version - The migration version to search for (e.g., 'v1.0.0').
* @returns The file name of the matching migration, or `null` if no match is found.
*/
findMigrationFileByVersion(version: MigrationVersion): string | null {
const files = this.getMigrationFiles();
const migrationName = kebabCase(version) as MigrationName;
return ( return (
files.find((file) => { files
const migrationName = this.getMigrationName(file).replace( .map((file) => this.getMigrationName(file))
/\.migration\.(js|ts)/, .find((name) => migrationName === name) || null
'',
);
return migrationName === kebabCase(version);
}) || null
); );
} }
private async loadMigrationFile(name: string) { /**
* Loads a migration file for a specific version.
*
* @param version - The migration version to load.
*
* @returns The loaded migration object containing `up` and `down` methods.
*/
private async loadMigrationFile(version: MigrationVersion) {
try { try {
// Map the provided name to the actual file with timestamp // Map the provided name to the actual file with timestamp
const fileName = await this.findMigrationFileByName(name); const fileName = this.findMigrationFileByVersion(version);
if (!fileName) { if (!fileName) {
this.logger.error(`Migration file for "${name}" not found.`); this.logger.error(`Migration file for "${version}" not found.`);
process.exit(1); process.exit(1);
} }
@ -312,48 +429,72 @@ module.exports = {
typeof migration.down !== 'function' typeof migration.down !== 'function'
) { ) {
throw new Error( throw new Error(
`Migration file "${name}" must export an object with "up" and "down" methods.`, `Migration file "${version}" must export an object with "up" and "down" methods.`,
); );
} }
return migration; return migration;
} catch (e) { } catch (e) {
throw new Error(`Failed to load migration "${name}".\n${e.message}`); throw new Error(`Failed to load migration "${version}".\n${e.message}`);
} }
} }
/**
* Updates the status of a migration in the database.
*
* @param version - The version of the migration to update.
* @param action - The action performed on the migration (e.g., 'up' or 'down').
* @param migrationDocument - An optional existing migration document to update. If not provided, a new document is created.
*
* @returns Resolves when the migration status is successfully updated.
*/
async updateStatus({ async updateStatus({
name, version,
action, action,
migrationDocument, migrationDocument,
}: Omit<MigrationSuccessCallback, 'terminal'>) { }: Omit<MigrationSuccessCallback, 'terminal'>) {
const document = const document =
migrationDocument || migrationDocument ||
new this.migrationModel({ new this.migrationModel({
name, version,
}); });
document.status = action; document.status = action;
await document.save(); await document.save();
} }
/**
* Handles successful completion of a migration operation.
*
* @param version - The version of the successfully completed migration.
* @param action - The action performed (e.g., 'up' or 'down').
* @param migrationDocument - The migration document to update.
*
* @returns Resolves when all success-related operations are completed.
*/
private async successCallback({ private async successCallback({
name, version,
action, action,
migrationDocument, migrationDocument,
}: MigrationSuccessCallback) { }: MigrationSuccessCallback) {
await this.updateStatus({ name, action, migrationDocument }); await this.updateStatus({ version, action, migrationDocument });
const migrationDisplayName = `${name} [${action}]`; const migrationDisplayName = `${version} [${action}]`;
this.logger.log(`"${migrationDisplayName}" migration done`); this.logger.log(`"${migrationDisplayName}" migration done`);
// Update DB version // Update DB version
const result = await this.metadataService.createOrUpdate({ const result = await this.metadataService.createOrUpdate({
name: 'db-version', name: 'db-version',
value: name, value: version,
}); });
const operation = result ? 'updated' : 'created'; const operation = result ? 'updated' : 'created';
this.logger.log(`db-version metadata ${operation} "${name}"`); this.logger.log(`db-version metadata ${operation} "${name}"`);
} }
private failureCallback({ name, action }: MigrationRunParams) { /**
const migrationDisplayName = `${name} [${action}]`; * Handles the failure of a migration operation.
*
* @param version - The version of the migration that failed.
* @param action - The action that failed (e.g., 'up' or 'down').
*/
private failureCallback({ version, action }: MigrationRunParams) {
const migrationDisplayName = `${version} [${action}]`;
this.logger.error(`"${migrationDisplayName}" migration failed`); this.logger.error(`"${migrationDisplayName}" migration failed`);
} }
} }

View File

@ -8,20 +8,21 @@
import { MigrationDocument } from './migration.schema'; import { MigrationDocument } from './migration.schema';
enum MigrationAction { export enum MigrationAction {
UP = 'up', UP = 'up',
DOWN = 'down', DOWN = 'down',
} }
interface MigrationRunParams { export type MigrationVersion = `v${number}.${number}.${number}`;
name?: string;
export type MigrationName = `v-${number}-${number}-${number}`;
export interface MigrationRunParams {
action: MigrationAction; action: MigrationAction;
version?: string; version?: MigrationVersion;
isAutoMigrate?: boolean; isAutoMigrate?: boolean;
} }
interface MigrationSuccessCallback extends MigrationRunParams { export interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument; migrationDocument: MigrationDocument;
} }
export { MigrationAction, MigrationRunParams, MigrationSuccessCallback };

View File

@ -13,11 +13,11 @@ import { MigrationAction } from '@/migration/types';
const migrationFixtures: Migration[] = [ const migrationFixtures: Migration[] = [
{ {
name: 'v2.1.2', version: 'v2.1.2',
status: MigrationAction.UP, status: MigrationAction.UP,
}, },
{ {
name: 'v2.1.1', version: 'v2.1.1',
status: MigrationAction.DOWN, status: MigrationAction.DOWN,
}, },
]; ];