diff --git a/api/src/migration/migration.module.ts b/api/src/migration/migration.module.ts index 154c3eca..7827cf04 100644 --- a/api/src/migration/migration.module.ts +++ b/api/src/migration/migration.module.ts @@ -14,22 +14,17 @@ import { MongooseModule } from '@nestjs/mongoose'; import { LoggerModule } from '@/logger/logger.module'; import { MigrationCommand } from './migration.command'; -import { Migration, MigrationSchema } from './migration.schema'; +import { MigrationModel } from './migration.schema'; import { MigrationService } from './migration.service'; @Module({ - imports: [ - MongooseModule.forFeature([ - { name: Migration.name, schema: MigrationSchema }, - ]), - LoggerModule, - ], + imports: [MongooseModule.forFeature([MigrationModel]), LoggerModule], providers: [ MigrationService, MigrationCommand, { provide: 'MONGO_MIGRATION_DIR', - useValue: join(process.cwd(), 'src', 'migration', 'migrations'), + useValue: join(__dirname, 'migrations'), }, ], exports: [MigrationService], diff --git a/api/src/migration/migration.schema.ts b/api/src/migration/migration.schema.ts index 51f8d543..37f842d6 100644 --- a/api/src/migration/migration.schema.ts +++ b/api/src/migration/migration.schema.ts @@ -6,8 +6,10 @@ * 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 { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Document, Model } from 'mongoose'; +import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; +import { THydratedDocument } from '@/utils/types/filter.types'; import { MigrationAction } from './types'; @@ -17,11 +19,14 @@ export class Migration { name: string; @Prop({ type: String, required: true, enum: MigrationAction }) - status: string; + status: MigrationAction; } -export const MigrationSchema = SchemaFactory.createForClass(Migration); +export const MigrationModel: ModelDefinition = LifecycleHookManager.attach({ + name: Migration.name, + schema: SchemaFactory.createForClass(Migration), +}); -export type MigrationDocument = Migration & Document; +export default MigrationModel.schema; -export type MigrationModel = Model; +export type MigrationDocument = THydratedDocument; diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index c3669d59..ae22c4f3 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -13,7 +13,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { InjectModel } from '@nestjs/mongoose'; import { kebabCase } from 'lodash'; -import mongoose from 'mongoose'; +import mongoose, { Model } from 'mongoose'; import leanDefaults from 'mongoose-lean-defaults'; import leanGetters from 'mongoose-lean-getters'; import leanVirtuals from 'mongoose-lean-virtuals'; @@ -23,11 +23,7 @@ import { LoggerService } from '@/logger/logger.service'; import { MetadataService } from '@/setting/services/metadata.service'; import idPlugin from '@/utils/schema-plugin/id.plugin'; -import { - Migration, - MigrationDocument, - MigrationModel, -} from './migration.schema'; +import { Migration, MigrationDocument } from './migration.schema'; import { MigrationAction, MigrationRunParams, @@ -41,7 +37,7 @@ export class MigrationService implements OnApplicationBootstrap { private readonly logger: LoggerService, private readonly metadataService: MetadataService, @InjectModel(Migration.name) - private readonly migrationModel: MigrationModel, + private readonly migrationModel: Model, ) { this.validateMigrationPath(); } @@ -55,8 +51,8 @@ export class MigrationService implements OnApplicationBootstrap { const isCLI = Boolean(process.env.HEXABOT_CLI); if (!isCLI && config.mongo.autoMigrate) { this.logger.log('Executing migrations ...'); - const { value: version = '2.1.9' } = - await this.metadataService.getMetadata('db-version'); + const metadata = await this.metadataService.getMetadata('db-version'); + const version = metadata ? metadata.value : 'v2.1.9'; await this.run({ action: MigrationAction.UP, version, @@ -89,8 +85,7 @@ export class MigrationService implements OnApplicationBootstrap { // check if file already exists const files = await this.getDirFiles(); const exist = files.some((file) => { - const [, ...actualFileName] = file.split('-'); - const migrationName = actualFileName.join('-'); + const migrationName = this.getMigrationName(file); return migrationName === fileName; }); @@ -152,14 +147,19 @@ module.exports = { if (!name) { if (isAutoMigrate) { const newVersion = await this.runFromVersion(action, version); - await this.metadataService.setMetadata('db-version', newVersion); + + await this.metadataService.findOrCreate({ + name: 'db-version', + value: newVersion, + }); } else { await this.runAll(action); + this.exit(); } } else { await this.runOne({ action, name }); + this.exit(); } - this.exit(); } private async runOne({ name, action }: MigrationRunParams) { @@ -218,7 +218,7 @@ module.exports = { private async runFromVersion(action: MigrationAction, version: string) { const files = await this.getDirFiles(); const migrationFiles = files - .filter((fileName) => fileName.includes('migration')) + .filter((fileName) => fileName.endsWith('.migration.js')) .map((fileName) => { const [migrationFileName] = fileName.split('.'); const [, , ...migrationVersion] = migrationFileName.split('-'); @@ -273,20 +273,26 @@ module.exports = { return { exist, migrationDocument }; } - private async getMigrationFiles() { + async getMigrationFiles() { const files = await this.getDirFiles(); return files.filter((file) => /\.migration\.(js|ts)/.test(file)); } - private async findMigrationFileByName(name: string): Promise { + private getMigrationName(filename: string) { + const [, ...migrationNameParts] = filename.split('-'); + const migrationName = migrationNameParts.join('-'); + + return migrationName; + } + + async findMigrationFileByName(name: string): Promise { const files = await this.getMigrationFiles(); return ( files.find((file) => { - const [, ...migrationNameParts] = file.split('-'); - const migrationName = migrationNameParts - .join('-') - .replace(/\.migration\.(js|ts)/, ''); - + const migrationName = this.getMigrationName(file).replace( + /\.migration\.(js|ts)/, + '', + ); return migrationName === kebabCase(name); }) || null ); @@ -318,7 +324,7 @@ module.exports = { } } - private async updateStatus({ + async updateStatus({ name, action, migrationDocument, diff --git a/api/src/setting/services/metadata.service.ts b/api/src/setting/services/metadata.service.ts index 9eb8c021..dfcac8a4 100644 --- a/api/src/setting/services/metadata.service.ts +++ b/api/src/setting/services/metadata.service.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -19,6 +19,20 @@ export class MetadataService { private readonly metadataModel: Model, ) {} + async createMetadata(dto: Partial) { + return await this.metadataModel.create(dto); + } + + async findOrCreate(dto: Partial) { + const metadata = await this.metadataModel.findOne({ name: dto.name }); + + if (metadata) { + await this.setMetadata(dto.name, dto.value); + } else { + await this.createMetadata(dto); + } + } + async getMetadata(name: string) { return await this.metadataModel.findOne({ name }); } diff --git a/api/src/utils/test/fixtures/metadata.ts b/api/src/utils/test/fixtures/metadata.ts new file mode 100644 index 00000000..9de692cb --- /dev/null +++ b/api/src/utils/test/fixtures/metadata.ts @@ -0,0 +1,23 @@ +/* + * 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 mongoose from 'mongoose'; + +import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema'; + +const metadataFixtures: Metadata[] = [ + { + name: 'app-version', + value: '2.2.0', + }, +]; + +export const installMetadataFixtures = async () => { + const Metadata = mongoose.model(MetadataModel.name, MetadataModel.schema); + return await Metadata.insertMany(metadataFixtures); +}; diff --git a/api/src/utils/test/fixtures/migration.ts b/api/src/utils/test/fixtures/migration.ts new file mode 100644 index 00000000..74c80135 --- /dev/null +++ b/api/src/utils/test/fixtures/migration.ts @@ -0,0 +1,28 @@ +/* + * 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 mongoose from 'mongoose'; + +import { Migration, MigrationModel } from '@/migration/migration.schema'; +import { MigrationAction } from '@/migration/types'; + +const migrationFixtures: Migration[] = [ + { + name: 'v2.1.2', + status: MigrationAction.UP, + }, + { + name: 'v2.1.1', + status: MigrationAction.DOWN, + }, +]; + +export const installMigrationFixtures = async () => { + const Migration = mongoose.model(MigrationModel.name, MigrationModel.schema); + return await Migration.insertMany(migrationFixtures); +};