feat: add metadata collection to store the db version

This commit is contained in:
Mohamed Marrouchi 2025-01-02 18:55:05 +01:00
parent ad45a70743
commit 92bec65862
7 changed files with 152 additions and 10 deletions

View File

@ -13,10 +13,10 @@ import { MigrationAction } from './types';
@Schema({ timestamps: true })
export class Migration {
@Prop({ required: true, unique: true })
@Prop({ type: String, required: true, unique: true })
name: string;
@Prop({ required: true, enum: MigrationAction })
@Prop({ type: String, required: true, enum: MigrationAction })
status: string;
}

View File

@ -20,6 +20,7 @@ import leanVirtuals from 'mongoose-lean-virtuals';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { MetadataService } from '@/setting/services/metadata.service';
import idPlugin from '@/utils/schema-plugin/id.plugin';
import {
@ -38,6 +39,7 @@ export class MigrationService implements OnApplicationBootstrap {
constructor(
private moduleRef: ModuleRef,
private readonly logger: LoggerService,
private readonly metadataService: MetadataService,
@InjectModel(Migration.name)
private readonly migrationModel: MigrationModel,
) {
@ -53,7 +55,13 @@ export class MigrationService implements OnApplicationBootstrap {
const isProduction = config.env.toLowerCase().includes('prod');
if (!isProduction && config.mongo.autoMigrate) {
this.logger.log('Executing migrations ...');
await this.run({ action: MigrationAction.UP });
const { value: version = '2.1.9' } =
await this.metadataService.getMetadata('db-version');
await this.run({
action: MigrationAction.UP,
version,
isAutoMigrate: true,
});
}
}
@ -135,9 +143,19 @@ module.exports = {
}
}
public async run({ action, name }: MigrationRunParams) {
public async run({
action,
name,
version,
isAutoMigrate,
}: MigrationRunParams) {
if (!name) {
await this.runAll(action);
if (isAutoMigrate) {
const newVersion = await this.runFromVersion(action, version);
await this.metadataService.setMetadata('db-version', newVersion);
} else {
await this.runAll(action);
}
} else {
await this.runOne({ action, name });
}
@ -171,14 +189,60 @@ module.exports = {
}
}
isNewerVersion(version1: string, version2: string): boolean {
const regex = /^v?(\d+)\.(\d+)\.(\d+)$/;
if (!regex.test(version1) || !regex.test(version2)) {
throw new TypeError('Invalid version number!');
}
// Split both versions into their numeric components
const v1Parts = version1.replace('v', '').split('.').map(Number);
const v2Parts = version2.replace('v', '').split('.').map(Number);
// Compare each part of the version number
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1Part = v1Parts[i] || 0; // Default to 0 if undefined
const v2Part = v2Parts[i] || 0; // Default to 0 if undefined
if (v1Part > v2Part) {
return true;
} else if (v1Part < v2Part) {
return false;
}
}
// If all parts are equal, the versions are the same
return false;
}
private async runFromVersion(action: MigrationAction, version: string) {
const files = await this.getDirFiles();
const migrationFiles = files
.filter((fileName) => fileName.includes('migration'))
.map((fileName) => {
const [migrationFileName] = fileName.split('.');
const [, , ...migrationVersion] = migrationFileName.split('-');
return `v${migrationVersion.join('.')}`;
})
.filter((v) => this.isNewerVersion(v, version));
let lastVersion = version;
for (const name of migrationFiles) {
await this.runOne({ name, action });
lastVersion = name;
}
return lastVersion;
}
private async runAll(action: MigrationAction) {
const files = await this.getDirFiles();
const migrationFiles = files
.filter((fileName) => fileName.includes('migration'))
.map((fileName) => {
const [migrationFileName] = fileName.split('.');
const [, ...migrationName] = migrationFileName.split('-');
return migrationName.join('-');
const [, , ...migrationVersion] = migrationFileName.split('-');
return `v${migrationVersion.join('.')}`;
});
for (const name of migrationFiles) {

View File

@ -0,0 +1,10 @@
import mongoose from 'mongoose';
module.exports = {
async up() {
// Migration logic
},
async down() {
// Rollback logic
},
};

View File

@ -16,6 +16,8 @@ enum MigrationAction {
interface MigrationRunParams {
name?: string;
action: MigrationAction;
version?: string;
isAutoMigrate?: boolean;
}
interface MigrationSuccessCallback extends MigrationRunParams {

View File

@ -0,0 +1,30 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true })
export class Metadata {
@Prop({ type: String, required: true, unique: true })
name: string;
@Prop({ type: JSON, required: true })
value: any;
}
export const MetadataSchema = SchemaFactory.createForClass(Metadata);
export const MetadataModel: ModelDefinition = LifecycleHookManager.attach({
name: Metadata.name,
schema: SchemaFactory.createForClass(Metadata),
});
export type MetadataDocument = Metadata & Document;

View File

@ -0,0 +1,29 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataService {
constructor(
@InjectModel(Metadata.name)
private readonly metadataModel: Model<Metadata>,
) {}
async getMetadata(name: string) {
return await this.metadataModel.findOne({ name });
}
async setMetadata(name: string, value: any) {
return await this.metadataModel.updateOne({ name }, { $set: { value } });
}
}

View File

@ -12,20 +12,27 @@ import { PassportModule } from '@nestjs/passport';
import { SettingController } from './controllers/setting.controller';
import { SettingRepository } from './repositories/setting.repository';
import { MetadataModel } from './schemas/metadata.schema';
import { SettingModel } from './schemas/setting.schema';
import { SettingSeeder } from './seeds/setting.seed';
import { MetadataService } from './services/metadata.service';
import { SettingService } from './services/setting.service';
@Global()
@Module({
imports: [
MongooseModule.forFeature([SettingModel]),
MongooseModule.forFeature([SettingModel, MetadataModel]),
PassportModule.register({
session: true,
}),
],
providers: [SettingRepository, SettingSeeder, SettingService],
providers: [
SettingRepository,
SettingSeeder,
SettingService,
MetadataService,
],
controllers: [SettingController],
exports: [SettingService],
exports: [SettingService, MetadataService],
})
export class SettingModule {}