mirror of
https://github.com/hexastack/hexabot
synced 2025-05-31 10:57:06 +00:00
Merge pull request #512 from Hexastack/feat/app-version
feat: add metadata collection to store the db version
This commit is contained in:
commit
06e06a03b9
@ -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\""
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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],
|
||||
|
@ -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>;
|
||||
|
461
api/src/migration/migration.service.spec.ts
Normal file
461
api/src/migration/migration.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
|
23
api/src/setting/dto/metadata.dto.ts
Normal file
23
api/src/setting/dto/metadata.dto.ts
Normal 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) {}
|
26
api/src/setting/repositories/metadata.repository.ts
Normal file
26
api/src/setting/repositories/metadata.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
31
api/src/setting/schemas/metadata.schema.ts
Normal file
31
api/src/setting/schemas/metadata.schema.ts
Normal 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;
|
16
api/src/setting/seeds/metadata.seed-model.ts
Normal file
16
api/src/setting/seeds/metadata.seed-model.ts
Normal 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[];
|
21
api/src/setting/seeds/metadata.seed.ts
Normal file
21
api/src/setting/seeds/metadata.seed.ts
Normal 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);
|
||||
}
|
||||
}
|
21
api/src/setting/services/metadata.service.ts
Normal file
21
api/src/setting/services/metadata.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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
24
api/src/utils/test/fixtures/metadata.ts
vendored
Normal 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);
|
||||
};
|
28
api/src/utils/test/fixtures/migration.ts
vendored
Normal file
28
api/src/utils/test/fixtures/migration.ts
vendored
Normal 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);
|
||||
};
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user