diff --git a/api/package.json b/api/package.json index 98b6334b..43a78026 100644 --- a/api/package.json +++ b/api/package.json @@ -39,7 +39,7 @@ "typecheck": "tsc --noEmit", "reset": "npm install && npm run containers:restart", "reset:hard": "npm clean-install && npm run containers:rebuild", - "cli": "DEBUG=ts-node* ts-node --files --log-error --compiler-options '{\"diagnostics\": true}' src/cli.ts" + "cli": "HEXABOT_CLI=1 ts-node --files --log-error --compiler-options '{\"diagnostics\": true}' src/cli.ts" }, "lint-staged": { "*.{js,ts}": "eslint --fix -c \".eslintrc-staged.js\"" diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 1869bc81..74033d61 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -149,9 +149,11 @@ export const config: Config = { process.env.MONGO_URI || 'mongodb://dev_only:dev_only@localhost:27017/', dbName: process.env.MONGO_DB || 'hexabot', autoMigrate: - process.env.MONGO_AUTO_MIGRATE === 'true' - ? Boolean(process.env.MONGO_AUTO_MIGRATE) - : false, + // Either auto-migration is explicitly enabled and the node is primary (cluster case) + (process.env.MONGO_AUTO_MIGRATE === 'true' && + (process.env.API_IS_PRIMARY_NODE || 'true') === 'true') || + // Otherwise, run only in dev mode + !(process.env.NODE_ENV || 'development').toLowerCase().includes('prod'), }, env: process.env.NODE_ENV || 'development', authentication: { diff --git a/api/src/migration/README.md b/api/src/migration/README.md index dc3a511f..27ba8db8 100644 --- a/api/src/migration/README.md +++ b/api/src/migration/README.md @@ -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`). - Run individual or all migrations with ease. - Built-in support for rollback logic. +- Keeps track of the database schema version in the metadata collection (SettingModule). ## Usage diff --git a/api/src/migration/migration.command.ts b/api/src/migration/migration.command.ts index ef56da2a..346175dd 100644 --- a/api/src/migration/migration.command.ts +++ b/api/src/migration/migration.command.ts @@ -28,22 +28,37 @@ export class MigrationCommand extends CommandRunner { async run(passedParam: string[]): Promise { const [subcommand] = passedParam; switch (subcommand) { - case 'create': - const [, filename] = passedParam; - return await this.migrationService.create(filename); - case 'migrate': - const [, action, name] = passedParam; + case 'create': { + const [, version] = passedParam; + + if (!this.migrationService.isValidVersion(version)) { + throw new TypeError('Invalid version value.'); + } + + return this.migrationService.create(version); + } + case 'migrate': { + const [, action, version] = passedParam; + if ( !Object.values(MigrationAction).includes(action as MigrationAction) ) { this.logger.error('Invalid Operation'); this.exit(); } - return await this.migrationService.run({ - action: action as MigrationAction, - name, - }); + if ( + typeof version === 'undefined' || + this.migrationService.isValidVersion(version) + ) { + return await this.migrationService.run({ + action: action as MigrationAction, + version, + }); + } else { + throw new TypeError('Invalid version value.'); + } + } default: this.logger.error('No valid command provided'); this.exit(); diff --git a/api/src/migration/migration.module.ts b/api/src/migration/migration.module.ts index 154c3eca..1f09f473 100644 --- a/api/src/migration/migration.module.ts +++ b/api/src/migration/migration.module.ts @@ -8,28 +8,28 @@ import { join } from 'path'; +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; 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 }, - ]), + MongooseModule.forFeature([MigrationModel]), LoggerModule, + HttpModule, ], 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 a344d143..aec68ad7 100644 --- a/api/src/migration/migration.schema.ts +++ b/api/src/migration/migration.schema.ts @@ -6,22 +6,27 @@ * 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 { MigrationAction } from './types'; +import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; +import { THydratedDocument } from '@/utils/types/filter.types'; + +import { MigrationAction, MigrationVersion } from './types'; @Schema({ timestamps: true }) export class Migration { - @Prop({ required: true, unique: true }) - name: string; + @Prop({ type: String, required: true, unique: true }) + version: MigrationVersion; - @Prop({ required: true, enum: MigrationAction }) - status: string; + @Prop({ type: String, required: true, enum: Object.values(MigrationAction) }) + 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.spec.ts b/api/src/migration/migration.service.spec.ts new file mode 100644 index 00000000..87a64ba3 --- /dev/null +++ b/api/src/migration/migration.service.spec.ts @@ -0,0 +1,461 @@ +/* + * 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 fs from 'fs'; + +import { HttpService } from '@nestjs/axios'; +import { ModuleRef } from '@nestjs/core'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { getModelToken, MongooseModule } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { LoggerService } from '@/logger/logger.service'; +import { MetadataRepository } from '@/setting/repositories/metadata.repository'; +import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema'; +import { MetadataService } from '@/setting/services/metadata.service'; +import { + closeInMongodConnection, + rootMongooseTestModule, +} from '@/utils/test/test'; + +import { Migration, MigrationModel } from './migration.schema'; +import { MigrationService } from './migration.service'; +import { MigrationAction } from './types'; + +describe('MigrationService', () => { + let service: MigrationService; + let loggerService: LoggerService; + let metadataService: MetadataService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + rootMongooseTestModule(async () => await Promise.resolve()), + MongooseModule.forFeature([MetadataModel, MigrationModel]), + ], + providers: [ + MetadataRepository, + MetadataService, + MigrationService, + EventEmitter2, + { + provide: LoggerService, + useValue: { + log: jest.fn(), + error: jest.fn(), + }, + }, + { + provide: HttpService, + useValue: {}, + }, + { + provide: ModuleRef, + useValue: { + get: jest.fn((token: string) => { + if (token === 'MONGO_MIGRATION_DIR') { + return '/migrations'; + } + }), + }, + }, + { + provide: getModelToken(Migration.name), + useValue: jest.fn(), + }, + ], + }).compile(); + + service = module.get(MigrationService); + loggerService = module.get(LoggerService); + metadataService = module.get(MetadataService); + }); + + afterEach(jest.clearAllMocks); + afterAll(closeInMongodConnection); + + describe('create', () => { + beforeEach(() => { + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + process.env.HEXABOT_CLI = 'true'; + }); + + afterEach(jest.restoreAllMocks); + + it('should create a migration file and log success', () => { + const mockFiles = ['12345-some-migration.migration.ts']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + jest + .spyOn(service as any, 'getMigrationTemplate') + .mockReturnValue('template'); + jest + .spyOn(service as any, 'getMigrationName') + .mockImplementation((file) => file); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'writeFileSync').mockReturnValue(); + + service.create('v2.2.0'); + + const expectedFilePath = expect.stringMatching( + /\/migrations\/\d+-v-2-2-0.migration.ts$/, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expectedFilePath, + 'template', + ); + expect(loggerService.log).toHaveBeenCalledWith( + expect.stringMatching(/Migration file for "v2.2.0" created/), + ); + expect(exitSpy).toHaveBeenCalled(); + }); + + it('should log an error and exit if a migration with the same name exists', () => { + const mockFiles = ['12345-v-2-2-1.migration.ts']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + service.create('v2.2.1'); + + expect(loggerService.error).toHaveBeenCalledWith( + 'Migration file for "v2.2.1" already exists', + ); + expect(exitSpy).toHaveBeenCalled(); + }); + }); + + describe('onApplicationBootstrap', () => { + beforeEach(() => { + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + }); + + afterEach(jest.restoreAllMocks); + + it('should log a message and execute migrations when autoMigrate is true', async () => { + process.env.HEXABOT_CLI = ''; + jest + .spyOn(metadataService, 'findOne') + .mockResolvedValue({ name: 'db-version', value: 'v2.1.9' } as Metadata); + jest.spyOn(service, 'run').mockResolvedValue(); + + await service.onApplicationBootstrap(); + + expect(loggerService.log).toHaveBeenCalledWith( + 'Executing migrations ...', + ); + expect(service.run).toHaveBeenCalledWith({ + action: 'up', + isAutoMigrate: true, + }); + }); + }); + + describe('run', () => { + beforeEach(() => { + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + }); + + afterEach(jest.restoreAllMocks); + + it('should call runUpgrades when version is not provided and isAutoMigrate is true', async () => { + process.env.HEXABOT_CLI = ''; + const runUpgradesSpy = jest + .spyOn(service as any, 'runUpgrades') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + isAutoMigrate: true, + }); + + expect(runUpgradesSpy).toHaveBeenCalledWith('up', 'v2.1.9'); + expect(service.exit).not.toHaveBeenCalled(); + }); + + it('should call runAll and exit when version is not provided and isAutoMigrate is false', async () => { + process.env.HEXABOT_CLI = 'true'; + const runAllSpy = jest + .spyOn(service as any, 'runAll') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + version: null, + isAutoMigrate: false, + }); + + expect(runAllSpy).toHaveBeenCalledWith('up'); + expect(service.exit).toHaveBeenCalled(); + }); + + it('should call runOne and exit when version is provided', async () => { + process.env.HEXABOT_CLI = 'true'; + const runOneSpy = jest + .spyOn(service as any, 'runOne') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + version: 'v2.1.9', + isAutoMigrate: false, + }); + + expect(runOneSpy).toHaveBeenCalledWith({ + action: 'up', + version: 'v2.1.9', + }); + expect(service.exit).toHaveBeenCalled(); + }); + }); + + describe('runOne', () => { + let verifyStatusSpy: jest.SpyInstance; + let loadMigrationFileSpy: jest.SpyInstance; + let successCallbackSpy: jest.SpyInstance; + let failureCallbackSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + + verifyStatusSpy = jest + .spyOn(service as any, 'verifyStatus') + .mockResolvedValue({ exist: false, migrationDocument: {} }); + loadMigrationFileSpy = jest + .spyOn(service as any, 'loadMigrationFile') + .mockResolvedValue({ + up: jest.fn().mockResolvedValue(true), + down: jest.fn().mockResolvedValue(true), + }); + successCallbackSpy = jest + .spyOn(service as any, 'successCallback') + .mockResolvedValue(undefined); + failureCallbackSpy = jest + .spyOn(service as any, 'failureCallback') + .mockResolvedValue(undefined); + }); + + afterEach(jest.restoreAllMocks); + + it('should return false and not execute if migration already exists', async () => { + verifyStatusSpy.mockResolvedValue({ exist: true, migrationDocument: {} }); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + expect(loadMigrationFileSpy).not.toHaveBeenCalled(); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + + it('should load the migration file and execute the migration action successfully', async () => { + const migrationMock = { + up: jest.fn().mockResolvedValue(true), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + + expect(result).toBe(true); + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + migrationDocument: {}, + }); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + + it('should call failureCallback and log the error if the migration action throws an error', async () => { + const migrationMock = { + up: jest.fn().mockRejectedValue(new Error('Test Error')), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + const loggerSpy = jest.spyOn(service['logger'], 'log'); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Test Error'), + ); + }); + + it('should not call successCallback if the migration action returns false', async () => { + const migrationMock = { + up: jest.fn().mockResolvedValue(false), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + }); + + describe('runUpgrades', () => { + let getAvailableUpgradeVersionsSpy: jest.SpyInstance; + let isNewerVersionSpy: jest.SpyInstance; + let runOneSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + + getAvailableUpgradeVersionsSpy = jest + .spyOn(service as any, 'getAvailableUpgradeVersions') + .mockReturnValue(['v2.2.0', 'v2.3.0', 'v2.4.0']); // Mock available versions + isNewerVersionSpy = jest + .spyOn(service as any, 'isNewerVersion') + .mockImplementation( + (v1: string, v2: string) => + parseInt(v1.substring(1).replaceAll('.', '')) > + parseInt(v2.substring(1).replaceAll('.', '')), + ); // Simplified mock for version comparison + runOneSpy = jest.spyOn(service as any, 'runOne').mockResolvedValue(true); + }); + + afterEach(jest.restoreAllMocks); + + it('should filter versions and call runOne for each newer version', async () => { + const result = await (service as any).runUpgrades('up', 'v2.2.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(runOneSpy).toHaveBeenCalledTimes(2); // Only for 'v2.3.0' and 'v2.4.0' + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.3.0', + action: 'up', + }); + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.4.0', + action: 'up', + }); + expect(result).toBe('v2.4.0'); // Last processed version + }); + + it('should return the initial version if no newer versions are available', async () => { + isNewerVersionSpy.mockImplementation(() => false); // Mock to return no newer versions + + const result = await (service as any).runUpgrades('up', 'v2.4.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).toHaveBeenCalledTimes(3); + expect(runOneSpy).not.toHaveBeenCalled(); + expect(result).toBe('v2.4.0'); // Initial version is returned + }); + + it('should handle empty available versions gracefully', async () => { + getAvailableUpgradeVersionsSpy.mockReturnValue([]); + + const result = await (service as any).runUpgrades('up', 'v2.2.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).not.toHaveBeenCalled(); + expect(runOneSpy).not.toHaveBeenCalled(); + expect(result).toBe('v2.2.0'); // Initial version is returned + }); + + it('should propagate errors from runOne', async () => { + runOneSpy.mockRejectedValue(new Error('Test Error')); + + await expect( + (service as any).runUpgrades('up', 'v2.2.0'), + ).rejects.toThrow('Test Error'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).toHaveBeenCalled(); + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.3.0', + action: 'up', + }); + }); + }); + + it('should return the migration name without the timestamp and file extension', () => { + const result = (service as any).getMigrationName( + '1234567890-v-1-0-1.migration.ts', + ); + + expect(result).toBe('v-1-0-1'); + }); + + it('should load a valid migration file and return it', async () => { + const version = 'v2.1.9'; + + const mockFiles = ['1234567890-v-2-1-9.migration.js']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + const mockMigration = { + up: jest.fn(), + down: jest.fn(), + }; + + jest + .spyOn(service, 'migrationFilePath', 'get') + .mockReturnValue('/migrations'); + jest.spyOn(service['logger'], 'error').mockImplementation(); + jest.mock( + `/migrations/1234567890-v-2-1-9.migration.js`, + () => mockMigration, + { + virtual: true, + }, + ); + + const result = await (service as any).loadMigrationFile(version); + + expect(result.default).toBe(mockMigration); + expect(service['logger'].error).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index b0be1536..88db7aca 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -6,54 +6,61 @@ * 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 { existsSync, readdirSync, writeFileSync } from 'fs'; +import fs from 'fs'; import { join } from 'path'; +import { HttpService } from '@nestjs/axios'; 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'; 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 { - Migration, - MigrationDocument, - MigrationModel, -} from './migration.schema'; +import { Migration, MigrationDocument } from './migration.schema'; import { MigrationAction, + MigrationName, MigrationRunParams, MigrationSuccessCallback, + MigrationVersion, } from './types'; +// Version starting which we added the migrations +const INITIAL_DB_VERSION = 'v2.1.9'; + @Injectable() export class MigrationService implements OnApplicationBootstrap { constructor( private moduleRef: ModuleRef, private readonly logger: LoggerService, + private readonly metadataService: MetadataService, + private readonly httpService: HttpService, @InjectModel(Migration.name) - private readonly migrationModel: MigrationModel, - ) { - this.validateMigrationPath(); - } + private readonly migrationModel: Model, + ) {} async onApplicationBootstrap() { + await this.ensureMigrationPathExists(); + if (mongoose.connection.readyState !== 1) { await this.connect(); } this.logger.log('Mongoose connection established'); - const isProduction = config.env.toLowerCase().includes('prod'); - if (!isProduction && config.mongo.autoMigrate) { + if (!this.isCLI && config.mongo.autoMigrate) { this.logger.log('Executing migrations ...'); - await this.run({ action: MigrationAction.UP }); + await this.run({ + action: MigrationAction.UP, + isAutoMigrate: true, + }); } } @@ -61,43 +68,75 @@ export class MigrationService implements OnApplicationBootstrap { process.exit(0); } - // CREATE + /** + * Get The migrations dir path configured in migration.module.ts + * @returns The migrations dir path + */ public get migrationFilePath() { return this.moduleRef.get('MONGO_MIGRATION_DIR'); } - public validateMigrationPath() { - if (!existsSync(this.migrationFilePath)) { - this.logger.error( - `Migration directory "${this.migrationFilePath}" not exists.`, - ); - this.exit(); + /** + * Checks if current running using CLI + * @returns True if using CLI + */ + public get isCLI() { + return Boolean(process.env.HEXABOT_CLI); + } + + /** + * 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); + } + + /** + * Checks if the migration path is well set and exists + */ + private async ensureMigrationPathExists() { + if (config.env !== 'test' && !fs.existsSync(this.migrationFilePath)) { + await fs.promises.mkdir(this.migrationFilePath, { + recursive: true, + }); } } - 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 name = kebabCase(version) as MigrationName; // check if file already exists - const files = await this.getDirFiles(); + const files = this.getMigrationFiles(); const exist = files.some((file) => { - const [, ...actualFileName] = file.split('-'); - const migrationName = actualFileName.join('-'); - return migrationName === fileName; + const migrationName = this.getMigrationName(file); + return migrationName === name; }); if (exist) { - this.logger.error(`Migration file for "${name}" already exists`); + this.logger.error(`Migration file for "${version}" already exists`); this.exit(); } - const migrationFileName = `${Date.now()}-${fileName}`; + const migrationFileName = `${Date.now()}-${name}.migration.ts`; const filePath = join(this.migrationFilePath, migrationFileName); const template = this.getMigrationTemplate(); try { - writeFileSync(filePath, template); + fs.writeFileSync(filePath, template); this.logger.log( - `Migration file for "${name}" created: ${migrationFileName}`, + `Migration file for "${version}" created: ${migrationFileName}`, ); } catch (e) { this.logger.error(e.stack); @@ -106,20 +145,36 @@ export class MigrationService implements OnApplicationBootstrap { } } + /** + * Get a migration template to be used while creating a new migration + * @returns A migration template + */ private getMigrationTemplate() { return `import mongoose from 'mongoose'; +import { MigrationServices } from '../types'; + module.exports = { - async up() { + async up(services: MigrationServices) { // Migration logic + return false; }, - async down() { + async down(services: MigrationServices) { // Rollback logic + return false; }, };`; } + /** + * Establishes a MongoDB connection + */ private async connect() { + // Disable for unit tests + if (config.env === 'test') { + return; + } + try { const connection = await mongoose.connect(config.mongo.uri, { dbName: config.mongo.dbName, @@ -135,73 +190,196 @@ module.exports = { } } - public async run({ action, name }: MigrationRunParams) { - if (!name) { - await this.runAll(action); + /** + * Executes migration operations based on the provided parameters. + * + * Determines the migration operation to perform (run all, run a specific migration, or run upgrades) + * based on the input parameters. The process may exit after completing the operation. + * + * @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 (!this.isCLI) { + if (isAutoMigrate) { + const metadata = await this.metadataService.findOne({ + name: 'db-version', + }); + const version = metadata ? metadata.value : INITIAL_DB_VERSION; + await this.runUpgrades(action, version); + } else { + // Do nothing ... + return; + } } else { - await this.runOne({ action, name }); + if (!version) { + await this.runAll(action); + } else { + 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({ - name, + version, action, }); + if (exist) { - return true; // stop exec; + return false; // stop exec; } try { - const migration = await this.loadMigrationFile(name); - await migration[action](); - await this.successCallback({ - name, - action, - migrationDocument, + const migration = await this.loadMigrationFile(version); + const result = await migration[action]({ + logger: this.logger, + http: this.httpService, }); + + if (result) { + await this.successCallback({ + version, + action, + migrationDocument, + }); + } + + return result; // stop exec; } catch (e) { this.failureCallback({ - name, + version, action, }); this.logger.log(e.stack); + return false; } } + /** + * Compares two version strings to determine if the first version is newer than the second. + * + * @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 + 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; + } + + /** + * 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) => + this.isNewerVersion(v, version), + ); + + if (!filteredVersions.length) { + this.logger.log('No migrations to execute ...'); + return version; + } + + let lastVersion = version; + + for (const version of filteredVersions) { + await this.runOne({ version, action }); + lastVersion = version; + } + + 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) { - 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 versions = this.getAvailableUpgradeVersions(); - for (const name of migrationFiles) { - await this.runOne({ name, action }); + let lastVersion: MigrationVersion = INITIAL_DB_VERSION; + for (const version of versions) { + await this.runOne({ version, action }); + lastVersion = version; } + + return lastVersion; } - private async getDirFiles() { - return readdirSync(this.migrationFilePath); - } - - private async verifyStatus({ name, action }: MigrationRunParams): Promise<{ + /** + * Verifies the migration status for a specific version and action. + * + * @param version - The version of the migration to verify. + * @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; migrationDocument: MigrationDocument | null; }> { let exist = false; - const migrationDocument = await this.migrationModel.findOne({ name }); + const migrationDocument = await this.migrationModel.findOne({ + version, + }); if (migrationDocument) { exist = Boolean(migrationDocument.status === action); if (exist) { this.logger.warn( - `Cannot proceed migration "${name}" is already in "${action}" state`, + `Cannot proceed migration "${version}" is already in "${action}" state`, ); } } @@ -209,31 +387,81 @@ module.exports = { return { exist, migrationDocument }; } - private async getMigrationFiles() { - const files = await this.getDirFiles(); - return files.filter((file) => /\.migration\.(js|ts)/.test(file)); + /** + * Retrieves all migration files from the migration directory. + * + * 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 = fs.readdirSync(this.migrationFilePath); + return files.filter((file) => /\.migration\.(js|ts)$/.test(file)); } - private async findMigrationFileByName(name: string): Promise { - const files = await this.getMigrationFiles(); + /** + * 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 migrationName = migrationNameParts.join('-'); + + return migrationName.replace(/\.migration\.(js|ts)/, '') as MigrationName; + } + + /** + * 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) => this.getMigrationName(filename)) + .map((name) => { + const [, ...migrationVersion] = name.split('-'); + return `v${migrationVersion.join('.')}` as MigrationVersion; + }) + .filter((value, index, self) => self.indexOf(value) === index); + } + + /** + * 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 ( files.find((file) => { - const [, ...migrationNameParts] = file.split('-'); - const migrationName = migrationNameParts - .join('-') - .replace(/\.migration\.(js|ts)/, ''); - - return migrationName === kebabCase(name); + const name = this.getMigrationName(file); + return migrationName === name; }) || 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 { // Map the provided name to the actual file with timestamp - const fileName = await this.findMigrationFileByName(name); + const fileName = this.findMigrationFileByVersion(version); if (!fileName) { - this.logger.error(`Migration file for "${name}" not found.`); + this.logger.error(`Migration file for "${version}" not found.`); process.exit(1); } @@ -245,41 +473,78 @@ module.exports = { typeof migration.down !== 'function' ) { 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; } catch (e) { - throw new Error(`Failed to load migration "${name}".\n${e.message}`); + throw new Error(`Failed to load migration "${version}".\n${e.message}`); } } - private async updateStatus({ - name, + /** + * 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({ + version, action, migrationDocument, }: Omit) { const document = migrationDocument || new this.migrationModel({ - name, + version, }); document.status = action; 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({ - name, + version, action, migrationDocument, }: MigrationSuccessCallback) { - await this.updateStatus({ name, action, migrationDocument }); - const migrationDisplayName = `${name} [${action}]`; + await this.updateStatus({ version, action, migrationDocument }); + const migrationDisplayName = `${version} [${action}]`; this.logger.log(`"${migrationDisplayName}" migration done`); + // Create or Update DB version + await this.metadataService.updateOne( + { name: 'db-version' }, + { + value: version, + }, + { + // Create or update + upsert: true, + new: true, + }, + ); + this.logger.log(`db-version metadata "${version}"`); } - 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`); } } diff --git a/api/src/migration/types.ts b/api/src/migration/types.ts index 43161c56..2bf93cdd 100644 --- a/api/src/migration/types.ts +++ b/api/src/migration/types.ts @@ -6,20 +6,32 @@ * 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 { HttpService } from '@nestjs/axios'; + +import { LoggerService } from '@/logger/logger.service'; + import { MigrationDocument } from './migration.schema'; -enum MigrationAction { +export enum MigrationAction { UP = 'up', DOWN = 'down', } -interface MigrationRunParams { - name?: string; +export type MigrationVersion = `v${number}.${number}.${number}`; + +export type MigrationName = `v-${number}-${number}-${number}`; + +export interface MigrationRunParams { action: MigrationAction; + version?: MigrationVersion; + isAutoMigrate?: boolean; } -interface MigrationSuccessCallback extends MigrationRunParams { +export interface MigrationSuccessCallback extends MigrationRunParams { migrationDocument: MigrationDocument; } -export { MigrationAction, MigrationRunParams, MigrationSuccessCallback }; +export type MigrationServices = { + logger: LoggerService; + http: HttpService; +}; diff --git a/api/src/seeder.ts b/api/src/seeder.ts index 006fad27..1811ae3f 100644 --- a/api/src/seeder.ts +++ b/api/src/seeder.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. @@ -21,6 +21,8 @@ import { NlpEntitySeeder } from './nlp/seeds/nlp-entity.seed'; import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model'; import { NlpValueSeeder } from './nlp/seeds/nlp-value.seed'; import { nlpValueModels } from './nlp/seeds/nlp-value.seed-model'; +import { MetadataSeeder } from './setting/seeds/metadata.seed'; +import { DEFAULT_METADATA } from './setting/seeds/metadata.seed-model'; import { SettingSeeder } from './setting/seeds/setting.seed'; import { DEFAULT_SETTINGS } from './setting/seeds/setting.seed-model'; import { PermissionCreateDto } from './user/dto/permission.dto'; @@ -41,6 +43,7 @@ export async function seedDatabase(app: INestApplicationContext) { const contextVarSeeder = app.get(ContextVarSeeder); const roleSeeder = app.get(RoleSeeder); const settingSeeder = app.get(SettingSeeder); + const metadataSeeder = app.get(MetadataSeeder); const permissionSeeder = app.get(PermissionSeeder); const userSeeder = app.get(UserSeeder); const languageSeeder = app.get(LanguageSeeder); @@ -106,13 +109,15 @@ export async function seedDatabase(app: INestApplicationContext) { logger.error('Unable to seed the database with users!'); throw e; } - // Seed users - try { - await settingSeeder.seed(DEFAULT_SETTINGS); - } catch (e) { - logger.error('Unable to seed the database with settings!'); - throw e; - } + } + + // Seed settings and metadata + try { + await settingSeeder.seed(DEFAULT_SETTINGS); + await metadataSeeder.seed(DEFAULT_METADATA); + } catch (e) { + logger.error('Unable to seed the database with settings and metadata!'); + throw e; } // Seed categories diff --git a/api/src/setting/dto/metadata.dto.ts b/api/src/setting/dto/metadata.dto.ts new file mode 100644 index 00000000..2868b221 --- /dev/null +++ b/api/src/setting/dto/metadata.dto.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 { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class MetadataCreateDto { + @ApiProperty({ description: 'Metadata name', type: String }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Metadata value' }) + @IsNotEmpty() + value: any; +} + +export class MetadataUpdateDto extends PartialType(MetadataCreateDto) {} diff --git a/api/src/setting/repositories/metadata.repository.ts b/api/src/setting/repositories/metadata.repository.ts new file mode 100644 index 00000000..2997f3cf --- /dev/null +++ b/api/src/setting/repositories/metadata.repository.ts @@ -0,0 +1,26 @@ +/* + * 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 { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { BaseRepository } from '@/utils/generics/base-repository'; + +import { Metadata } from '../schemas/metadata.schema'; + +@Injectable() +export class MetadataRepository extends BaseRepository { + constructor( + readonly eventEmitter: EventEmitter2, + @InjectModel(Metadata.name) readonly model: Model, + ) { + super(eventEmitter, model, Metadata); + } +} diff --git a/api/src/setting/schemas/metadata.schema.ts b/api/src/setting/schemas/metadata.schema.ts new file mode 100644 index 00000000..4f36a9bf --- /dev/null +++ b/api/src/setting/schemas/metadata.schema.ts @@ -0,0 +1,31 @@ +/* + * 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 { BaseSchema } from '@/utils/generics/base-schema'; +import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; + +@Schema({ timestamps: true }) +export class Metadata extends BaseSchema { + @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/seeds/metadata.seed-model.ts b/api/src/setting/seeds/metadata.seed-model.ts new file mode 100644 index 00000000..56aa5588 --- /dev/null +++ b/api/src/setting/seeds/metadata.seed-model.ts @@ -0,0 +1,16 @@ +/* + * 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 { MetadataCreateDto } from '../dto/metadata.dto'; + +export const DEFAULT_METADATA = [ + { + name: 'db-version', + value: process.env.npm_package_version, + }, +] as const satisfies MetadataCreateDto[]; diff --git a/api/src/setting/seeds/metadata.seed.ts b/api/src/setting/seeds/metadata.seed.ts new file mode 100644 index 00000000..dc77c110 --- /dev/null +++ b/api/src/setting/seeds/metadata.seed.ts @@ -0,0 +1,21 @@ +/* + * 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 { Injectable } from '@nestjs/common'; + +import { BaseSeeder } from '@/utils/generics/base-seeder'; + +import { MetadataRepository } from '../repositories/metadata.repository'; +import { Metadata } from '../schemas/metadata.schema'; + +@Injectable() +export class MetadataSeeder extends BaseSeeder { + constructor(private readonly metadataRepository: MetadataRepository) { + super(metadataRepository); + } +} diff --git a/api/src/setting/services/metadata.service.ts b/api/src/setting/services/metadata.service.ts new file mode 100644 index 00000000..048f3258 --- /dev/null +++ b/api/src/setting/services/metadata.service.ts @@ -0,0 +1,21 @@ +/* + * 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 { Injectable } from '@nestjs/common'; + +import { BaseService } from '@/utils/generics/base-service'; + +import { MetadataRepository } from '../repositories/metadata.repository'; +import { Metadata } from '../schemas/metadata.schema'; + +@Injectable() +export class MetadataService extends BaseService { + constructor(readonly repository: MetadataRepository) { + super(repository); + } +} diff --git a/api/src/setting/setting.module.ts b/api/src/setting/setting.module.ts index b753dd0f..4a4eaa3d 100644 --- a/api/src/setting/setting.module.ts +++ b/api/src/setting/setting.module.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. @@ -11,21 +11,32 @@ import { MongooseModule } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; import { SettingController } from './controllers/setting.controller'; +import { MetadataRepository } from './repositories/metadata.repository'; import { SettingRepository } from './repositories/setting.repository'; +import { MetadataModel } from './schemas/metadata.schema'; import { SettingModel } from './schemas/setting.schema'; +import { MetadataSeeder } from './seeds/metadata.seed'; 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, + MetadataRepository, + SettingSeeder, + MetadataSeeder, + SettingService, + MetadataService, + ], controllers: [SettingController], - exports: [SettingService], + exports: [SettingService, MetadataService], }) export class SettingModule {} diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index fecf6be9..65167f1f 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.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. @@ -20,6 +20,7 @@ import { Model, ProjectionType, Query, + QueryOptions, SortOrder, UpdateQuery, UpdateWithAggregationPipeline, @@ -474,6 +475,9 @@ export abstract class BaseRepository< async updateOne>( criteria: string | TFilterQuery, dto: UpdateQuery, + options: QueryOptions | null = { + new: true, + }, ): Promise { const query = this.model.findOneAndUpdate( { @@ -482,9 +486,7 @@ export abstract class BaseRepository< { $set: dto, }, - { - new: true, - }, + options, ); const filterCriteria = query.getFilter(); const queryUpdates = query.getUpdate(); diff --git a/api/src/utils/generics/base-service.spec.ts b/api/src/utils/generics/base-service.spec.ts index cad2a670..20ffc076 100644 --- a/api/src/utils/generics/base-service.spec.ts +++ b/api/src/utils/generics/base-service.spec.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. @@ -84,6 +84,7 @@ describe('BaseService', () => { expect(dummyRepository.updateOne).toHaveBeenCalledWith( createdId, updatedPayload, + undefined, ); expect(result).toEqualPayload(updatedPayload); }); @@ -98,6 +99,7 @@ describe('BaseService', () => { expect(dummyRepository.updateOne).toHaveBeenCalledWith( updatedPayload, updatedCriteriaPayload, + undefined, ); expect(result).toEqualPayload(updatedCriteriaPayload); }); diff --git a/api/src/utils/generics/base-service.ts b/api/src/utils/generics/base-service.ts index 26eb8e56..70ec686b 100644 --- a/api/src/utils/generics/base-service.ts +++ b/api/src/utils/generics/base-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. @@ -9,7 +9,7 @@ import { ConflictException } from '@nestjs/common'; import { ClassTransformOptions } from 'class-transformer'; import { MongoError } from 'mongodb'; -import { ProjectionType } from 'mongoose'; +import { ProjectionType, QueryOptions } from 'mongoose'; import { TFilterQuery } from '@/utils/types/filter.types'; @@ -173,8 +173,9 @@ export abstract class BaseService< async updateOne>>( criteria: string | TFilterQuery, dto: D, + options?: QueryOptions | null, ): Promise { - return await this.repository.updateOne(criteria, dto); + return await this.repository.updateOne(criteria, dto, options); } async updateMany>>( diff --git a/api/src/utils/test/fixtures/metadata.ts b/api/src/utils/test/fixtures/metadata.ts new file mode 100644 index 00000000..a51ced75 --- /dev/null +++ b/api/src/utils/test/fixtures/metadata.ts @@ -0,0 +1,24 @@ +/* + * 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 { MetadataCreateDto } from '@/setting/dto/metadata.dto'; +import { MetadataModel } from '@/setting/schemas/metadata.schema'; + +const metadataFixtures: MetadataCreateDto[] = [ + { + 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..b3610312 --- /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[] = [ + { + version: 'v2.1.2', + status: MigrationAction.UP, + }, + { + version: 'v2.1.1', + status: MigrationAction.DOWN, + }, +]; + +export const installMigrationFixtures = async () => { + const Migration = mongoose.model(MigrationModel.name, MigrationModel.schema); + return await Migration.insertMany(migrationFixtures); +}; diff --git a/docker/.env.example b/docker/.env.example index e14ad335..31accd7c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,11 +1,12 @@ # API & Common -NODE_ENV=dev +NODE_ENV=development APP_DOMAIN=localhost SSL_EMAIL=hello@hexabot.ai API_PORT=4000 APP_FRONTEND_PORT=8080 APP_SCRIPT_COMPODOC_PORT=9003 API_ORIGIN=http://${APP_DOMAIN}:${API_PORT} +API_IS_PRIMARY_NODE=true FRONTEND_BASE_URL=http://${APP_DOMAIN}:${APP_FRONTEND_PORT} FRONTEND_ORIGIN=${FRONTEND_BASE_URL},http://${APP_DOMAIN}:8081,http://${APP_DOMAIN}:5173,http://${APP_DOMAIN},https://${APP_DOMAIN} JWT_SECRET=dev_only