Merge pull request #512 from Hexastack/feat/app-version

feat: add metadata collection to store the db version
This commit is contained in:
Med Marrouchi 2025-01-06 13:28:31 +01:00 committed by GitHub
commit 06e06a03b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1118 additions and 145 deletions

View File

@ -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\""

View File

@ -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: {

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`).
- 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

View File

@ -28,22 +28,37 @@ export class MigrationCommand extends CommandRunner {
async run(passedParam: string[]): Promise<void> {
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();

View File

@ -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],

View File

@ -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<MigrationDocument>;
export type MigrationDocument = THydratedDocument<Migration>;

View File

@ -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>(MigrationService);
loggerService = module.get<LoggerService>(LoggerService);
metadataService = module.get<MetadataService>(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();
});
});

View File

@ -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<Migration>,
) {}
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<string | null> {
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<MigrationSuccessCallback, 'terminal'>) {
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`);
}
}

View File

@ -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;
};

View File

@ -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

View File

@ -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) {}

View File

@ -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<Metadata> {
constructor(
readonly eventEmitter: EventEmitter2,
@InjectModel(Metadata.name) readonly model: Model<Metadata>,
) {
super(eventEmitter, model, Metadata);
}
}

View File

@ -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;

View File

@ -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[];

View File

@ -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<Metadata> {
constructor(private readonly metadataRepository: MetadataRepository) {
super(metadataRepository);
}
}

View File

@ -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<Metadata> {
constructor(readonly repository: MetadataRepository) {
super(repository);
}
}

View File

@ -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 {}

View File

@ -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<D extends Partial<U>>(
criteria: string | TFilterQuery<T>,
dto: UpdateQuery<D>,
options: QueryOptions<D> | null = {
new: true,
},
): Promise<T | null> {
const query = this.model.findOneAndUpdate<T>(
{
@ -482,9 +486,7 @@ export abstract class BaseRepository<
{
$set: dto,
},
{
new: true,
},
options,
);
const filterCriteria = query.getFilter();
const queryUpdates = query.getUpdate();

View File

@ -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);
});

View File

@ -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<D extends Partial<Omit<T, keyof BaseSchema>>>(
criteria: string | TFilterQuery<T>,
dto: D,
options?: QueryOptions<D> | null,
): Promise<T | null> {
return await this.repository.updateOne(criteria, dto);
return await this.repository.updateOne(criteria, dto, options);
}
async updateMany<D extends Partial<Omit<T, keyof BaseSchema>>>(

24
api/src/utils/test/fixtures/metadata.ts vendored Normal file
View File

@ -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);
};

View File

@ -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);
};

View File

@ -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