From 92bec65862922eeffeaca3e2780b0db61b350268 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 2 Jan 2025 18:55:05 +0100 Subject: [PATCH] feat: add metadata collection to store the db version --- api/src/migration/migration.schema.ts | 4 +- api/src/migration/migration.service.ts | 74 +++++++++++++++++-- .../1735840203378-v-2-2-0.migration.ts | 10 +++ api/src/migration/types.ts | 2 + api/src/setting/schemas/metadata.schema.ts | 30 ++++++++ api/src/setting/services/metadata.service.ts | 29 ++++++++ api/src/setting/setting.module.ts | 13 +++- 7 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 api/src/migration/migrations/1735840203378-v-2-2-0.migration.ts create mode 100644 api/src/setting/schemas/metadata.schema.ts create mode 100644 api/src/setting/services/metadata.service.ts diff --git a/api/src/migration/migration.schema.ts b/api/src/migration/migration.schema.ts index a344d143..51f8d543 100644 --- a/api/src/migration/migration.schema.ts +++ b/api/src/migration/migration.schema.ts @@ -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; } diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index b0be1536..ae87c58d 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -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) { diff --git a/api/src/migration/migrations/1735840203378-v-2-2-0.migration.ts b/api/src/migration/migrations/1735840203378-v-2-2-0.migration.ts new file mode 100644 index 00000000..7f0e40fb --- /dev/null +++ b/api/src/migration/migrations/1735840203378-v-2-2-0.migration.ts @@ -0,0 +1,10 @@ +import mongoose from 'mongoose'; + +module.exports = { + async up() { + // Migration logic + }, + async down() { + // Rollback logic + }, +}; \ No newline at end of file diff --git a/api/src/migration/types.ts b/api/src/migration/types.ts index 43161c56..403a424c 100644 --- a/api/src/migration/types.ts +++ b/api/src/migration/types.ts @@ -16,6 +16,8 @@ enum MigrationAction { interface MigrationRunParams { name?: string; action: MigrationAction; + version?: string; + isAutoMigrate?: boolean; } interface MigrationSuccessCallback extends MigrationRunParams { diff --git a/api/src/setting/schemas/metadata.schema.ts b/api/src/setting/schemas/metadata.schema.ts new file mode 100644 index 00000000..ee7ddeaf --- /dev/null +++ b/api/src/setting/schemas/metadata.schema.ts @@ -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; diff --git a/api/src/setting/services/metadata.service.ts b/api/src/setting/services/metadata.service.ts new file mode 100644 index 00000000..9eb8c021 --- /dev/null +++ b/api/src/setting/services/metadata.service.ts @@ -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, + ) {} + + async getMetadata(name: string) { + return await this.metadataModel.findOne({ name }); + } + + async setMetadata(name: string, value: any) { + return await this.metadataModel.updateOne({ name }, { $set: { value } }); + } +} diff --git a/api/src/setting/setting.module.ts b/api/src/setting/setting.module.ts index b753dd0f..8f3cd400 100644 --- a/api/src/setting/setting.module.ts +++ b/api/src/setting/setting.module.ts @@ -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 {}