Merge pull request #496 from Hexastack/feat/migration-module
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker NLU Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

Feat/migration module
This commit is contained in:
Med Marrouchi 2025-01-02 17:06:55 +01:00 committed by GitHub
commit ad45a70743
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 695 additions and 231 deletions

View File

@ -65,64 +65,9 @@ $ npm run test:cov
## Migrations
Hexabot includes a migrations feature to help manage database schema and data changes over time. Migrations allow you to apply or revert changes to the database in a consistent and controlled manner.
Hexabot includes a migration module to help manage database schema and data changes over time. Migrations allows us to apply or revert changes to the database and keep it in sync with the version release.
### Creating a Migration
To create a new migration, use the following command from the root directory of Hexabot:
```bash
$ npx hexabot migrate create <migration-name>
```
Example:
```bash
$ npx hexabot migrate create all-users-language-fr
```
This command generates a new migration file in the `/api/migrations` folder. The file will look like this:
```typescript
import getModels from '@/models/index';
export async function up(): Promise<void> {
// Write migration here
}
export async function down(): Promise<void> {
// Write migration here
}
```
Within the migration file, you can define the changes to be made in the up() function. For example, if you want to update the language field of all users to 'fr', your migration might look like this:
```typescript
import getModels from '@/models/index';
export async function up(): Promise<void> {
const { UserModel } = await getModels();
await UserModel.updateMany({}, { language: 'fr' });
}
export async function down(): Promise<void> {}
```
### Running Migrations Up
You can run the following command to run all pending migrations:
```bash
$ npx hexabot migrate up
```
### Running Migrations Manually
If you want to run specific actions manually, you can get help by running the following command:
```bash
$ npx hexabot migrate help
```
Check the Migration README file for more : [Migration Module](./src/migration/README.md)
## Documentation

View File

@ -1,145 +0,0 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import mongoose from 'mongoose';
import leanDefaults from 'mongoose-lean-defaults';
import leanGetters from 'mongoose-lean-getters';
import leanVirtuals from 'mongoose-lean-virtuals';
import botStatsSchema, { BotStats } from '@/analytics/schemas/bot-stats.schema';
import attachmentSchema, {
Attachment,
} from '@/attachment/schemas/attachment.schema';
import blockSchema, { Block } from '@/chat/schemas/block.schema';
import contextVarSchema, {
ContextVar,
} from '@/chat/schemas/context-var.schema';
import conversationSchema, {
Conversation,
} from '@/chat/schemas/conversation.schema';
import labelSchema, { Label } from '@/chat/schemas/label.schema';
import messageSchema, { Message } from '@/chat/schemas/message.schema';
import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema';
import { ContentType } from '@/cms/schemas/content-type.schema';
import contentSchema, { Content } from '@/cms/schemas/content.schema';
import menuSchema, { Menu } from '@/cms/schemas/menu.schema';
import { config } from '@/config';
import translationSchema, {
Translation,
} from '@/i18n/schemas/translation.schema';
import nlpEntitySchema, { NlpEntity } from '@/nlp/schemas/nlp-entity.schema';
import nlpSampleEntitySchema, {
NlpSampleEntity,
} from '@/nlp/schemas/nlp-sample-entity.schema';
import nlpSampleSchema, { NlpSample } from '@/nlp/schemas/nlp-sample.schema';
import nlpValueSchema, { NlpValue } from '@/nlp/schemas/nlp-value.schema';
import settingSchema, { Setting } from '@/setting/schemas/setting.schema';
import invitationSchema, { Invitation } from '@/user/schemas/invitation.schema';
import modelSchema, { Model } from '@/user/schemas/model.schema';
import permissionSchema, { Permission } from '@/user/schemas/permission.schema';
import roleSchema, { Role } from '@/user/schemas/role.schema';
import userSchema, { User } from '@/user/schemas/user.schema';
import idPlugin from '@/utils/schema-plugin/id.plugin';
async function mongoMigrationConnection() {
try {
const connection = await mongoose.connect(config.mongo.uri, {
dbName: config.mongo.dbName,
});
connection.plugin(idPlugin);
connection.plugin(leanVirtuals);
connection.plugin(leanGetters);
connection.plugin(leanDefaults);
} catch (err) {
throw err;
}
}
async function getModels() {
await mongoMigrationConnection();
const AttachmentModel = mongoose.model<Attachment>(
Attachment.name,
attachmentSchema,
);
const BlockModel = mongoose.model<Block>(Block.name, blockSchema);
const BotstatsModel = mongoose.model<BotStats>(BotStats.name, botStatsSchema);
const ContentModel = mongoose.model<Content>(Content.name, contentSchema);
const ContenttypeModel = mongoose.model<ContentType>(
ContentType.name,
contentSchema,
);
const ContextVarModel = mongoose.model<ContextVar>(
ContextVar.name,
contextVarSchema,
);
const ConversationModel = mongoose.model<Conversation>(
Conversation.name,
conversationSchema,
);
const InvitationModel = mongoose.model<Invitation>(
Invitation.name,
invitationSchema,
);
const LabelModel = mongoose.model<Label>(Label.name, labelSchema);
const MenuModel = mongoose.model<Menu>(Menu.name, menuSchema);
const MessageModel = mongoose.model<Message>(Message.name, messageSchema);
const ModelModel = mongoose.model<Model>(Model.name, modelSchema);
const NlpEntityModel = mongoose.model<NlpEntity>(
NlpEntity.name,
nlpEntitySchema,
);
const NlpSampleEntityModel = mongoose.model<NlpSampleEntity>(
NlpSampleEntity.name,
nlpSampleEntitySchema,
);
const NlpSampleModel = mongoose.model<NlpSample>(
NlpSample.name,
nlpSampleSchema,
);
const NlpValueModel = mongoose.model<NlpValue>(NlpValue.name, nlpValueSchema);
const PermissionModel = mongoose.model<Permission>(
Permission.name,
permissionSchema,
);
const RoleModel = mongoose.model<Role>(Role.name, roleSchema);
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
const SubscriberModel = mongoose.model(Subscriber.name, subscriberSchema);
const TranslationModel = mongoose.model<Translation>(
Translation.name,
translationSchema,
);
const UserModel = mongoose.model<User>(User.name, userSchema);
return {
AttachmentModel,
BlockModel,
BotstatsModel,
ContentModel,
ContenttypeModel,
ContextVarModel,
ConversationModel,
InvitationModel,
LabelModel,
MenuModel,
MessageModel,
ModelModel,
NlpEntityModel,
NlpSampleEntityModel,
NlpSampleModel,
NlpValueModel,
PermissionModel,
RoleModel,
SettingModel,
SubscriberModel,
TranslationModel,
UserModel,
};
}
export default getModels;

86
api/package-lock.json generated
View File

@ -42,6 +42,7 @@
"mongoose-lean-getters": "^1.1.0",
"mongoose-lean-virtuals": "^0.9.1",
"multer": "^1.4.5-lts.1",
"nest-commander": "^3.15.0",
"nestjs-dynamic-providers": "^0.3.4",
"nestjs-i18n": "^10.4.0",
"nodemailer": "^6.9.13",
@ -108,6 +109,7 @@
},
"optionalDependencies": {
"@css-inline/css-inline-linux-arm64-musl": "^0.14.1",
"@resvg/resvg-js-darwin-arm64": "^2.6.2",
"@resvg/resvg-js-linux-arm64-musl": "^2.6.2"
}
},
@ -3631,6 +3633,18 @@
"integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==",
"dev": true
},
"node_modules/@golevelup/nestjs-discovery": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz",
"integrity": "sha512-HFXBJayEkYcU/bbxOztozONdWaZR34ZeJ2zRbZIWY8d5K26oPZQTvJ4L0STW3XVRGWtoE0WBpmx2YPNgYvcmJQ==",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"@nestjs/common": "^10.x",
"@nestjs/core": "^10.x"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@ -5242,6 +5256,21 @@
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
}
},
"node_modules/@resvg/resvg-js-darwin-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
@ -6347,6 +6376,16 @@
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"node_modules/@types/inquirer": {
"version": "8.2.10",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.10.tgz",
"integrity": "sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==",
"peer": true,
"dependencies": {
"@types/through": "*",
"rxjs": "^7.2.0"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -6591,6 +6630,15 @@
"@types/superagent": "*"
}
},
"node_modules/@types/through": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz",
"integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==",
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/uid-safe/-/uid-safe-2.1.5.tgz",
@ -8707,7 +8755,6 @@
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
@ -15295,6 +15342,42 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"devOptional": true
},
"node_modules/nest-commander": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz",
"integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==",
"dependencies": {
"@fig/complete-commander": "^3.0.0",
"@golevelup/nestjs-discovery": "4.0.1",
"commander": "11.1.0",
"cosmiconfig": "8.3.6",
"inquirer": "8.2.6"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@types/inquirer": "^8.1.3"
}
},
"node_modules/nest-commander/node_modules/@fig/complete-commander": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fig/complete-commander/-/complete-commander-3.2.0.tgz",
"integrity": "sha512-1Holl3XtRiANVKURZwgpjCnPuV4RsHp+XC0MhgvyAX/avQwj7F2HUItYOvGi/bXjJCkEzgBZmVfCr0HBA+q+Bw==",
"dependencies": {
"prettier": "^3.2.5"
},
"peerDependencies": {
"commander": "^11.1.0"
}
},
"node_modules/nest-commander/node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"engines": {
"node": ">=16"
}
},
"node_modules/nestjs-dynamic-providers": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/nestjs-dynamic-providers/-/nestjs-dynamic-providers-0.3.4.tgz",
@ -16160,7 +16243,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"engines": {
"node": ">=8"
}

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",
"migrate": "npx ts-migrate-mongoose --config-path ./migrations/config/migrate.ts"
"cli": "DEBUG=ts-node* ts-node --files --log-error --compiler-options '{\"diagnostics\": true}' src/cli.ts"
},
"lint-staged": {
"*.{js,ts}": "eslint --fix -c \".eslintrc-staged.js\""
@ -77,6 +77,7 @@
"mongoose-lean-getters": "^1.1.0",
"mongoose-lean-virtuals": "^0.9.1",
"multer": "^1.4.5-lts.1",
"nest-commander": "^3.15.0",
"nestjs-dynamic-providers": "^0.3.4",
"nestjs-i18n": "^10.4.0",
"nodemailer": "^6.9.13",
@ -143,7 +144,8 @@
},
"optionalDependencies": {
"@css-inline/css-inline-linux-arm64-musl": "^0.14.1",
"@resvg/resvg-js-linux-arm64-musl": "^2.6.2"
"@resvg/resvg-js-linux-arm64-musl": "^2.6.2",
"@resvg/resvg-js-darwin-arm64": "^2.6.2"
},
"overrides": {
"mjml": "5.0.0-alpha.4"
@ -176,4 +178,4 @@
"@/(.*)": "<rootDir>/$1"
}
}
}
}

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.
@ -39,6 +39,7 @@ import extraModules from './extra';
import { HelperModule } from './helper/helper.module';
import { I18nModule } from './i18n/i18n.module';
import { LoggerModule } from './logger/logger.module';
import { MigrationModule } from './migration/migration.module';
import { NlpModule } from './nlp/nlp.module';
import { PluginsModule } from './plugins/plugins.module';
import { SettingModule } from './setting/setting.module';
@ -142,7 +143,7 @@ const i18nOptions: I18nOptions = {
ttl: config.cache.ttl,
max: config.cache.max,
}),
MigrationModule,
...extraModules,
],
controllers: [AppController],

View File

@ -1,17 +1,22 @@
/*
* 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.
* 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 getModels from 'migrations/models/index';
import moduleAlias from 'module-alias';
import { CommandFactory } from 'nest-commander';
export async function up(): Promise<void> {
// Write migration here
moduleAlias.addAliases({
'@': __dirname,
});
import { HexabotModule } from './app.module';
async function bootstrap() {
await CommandFactory.run(HexabotModule);
}
export async function down(): Promise<void> {
// Write migration here
}
bootstrap();

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.
@ -145,8 +145,13 @@ export const config: Config = {
mongo: {
user: process.env.MONGO_USER || 'dev_only',
password: process.env.MONGO_PASSWORD || 'dev_only',
uri: process.env.MONGO_URI || 'mongodb://dev_only:dev_only@mongo:27017/',
uri:
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,
},
env: process.env.NODE_ENV || 'development',
authentication: {

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.
@ -102,6 +102,7 @@ export type Config = {
password: string;
uri: string;
dbName: string;
autoMigrate: boolean;
};
env: string;
authentication: {

131
api/src/migration/README.md Normal file
View File

@ -0,0 +1,131 @@
# Migration Module
The `migration` module for **Hexabot** provides a simple and effective way to manage database migrations. It allows you to create, execute, and roll back migrations, ensuring the database schema stays in sync with the version DB updates.
Whenever a new version is released which requires some DB updates, the onApplicationBootstrap()
will apply migrations automatically but only if it's a dev environement and `config.mongo.autoMigrate` is enabled.
## Features
- Generate timestamped migration files automatically in kebab-case.
- Track migration execution status in a MongoDB collection (`migrations`).
- Run individual or all migrations with ease.
- Built-in support for rollback logic.
## Usage
### Creating a Migration
To create a new migration:
```bash
npm run cli migration create <version>
```
Replace `<version>` with the next version for your migration, such as `v2.1.1`.
Example:
```bash
npm run cli migration create v2.1.1
```
This will generate a new file under `src/migration/migrations/` with a timestamped filename in kebab-case.
### Running Migrations
#### Running a Specific Migration
To execute a specific migration, use:
```bash
npm run cli migration migrate up <version>
```
Example:
```bash
npm run cli migration migrate up v2.1.1
```
#### Rolling Back a Specific Migration
To roll back a specific migration, use:
```bash
npm run cli migration migrate down <version>
```
Example:
```bash
npm run cli migration migrate down v2.1.1
```
#### Running All Migrations
To execute all pending migrations:
```bash
npm run cli migration migrate up
```
#### Rolling Back All Migrations
To roll back all migrations:
```bash
npm run cli migration migrate down
```
### Tracking Migration Status
The migration status is stored in a MongoDB collection called `migrations`. This collection helps ensure that each migration is executed or rolled back only once, avoiding duplicate operations.
## Example Migration File
Below is an example migration file:
```typescript
import mongoose from 'mongoose';
import attachmentSchema, {
Attachment,
} from '@/attachment/schemas/attachment.schema';
module.exports = {
async up() {
const AttachmentModel = mongoose.model<Attachment>(
Attachment.name,
attachmentSchema,
);
await AttachmentModel.updateMany({
type: 'csv'
}, {
$set: { type: 'text/csv' }
});
},
async down() {
// Rollback logic
const AttachmentModel = mongoose.model<Attachment>(
Attachment.name,
attachmentSchema,
);
await AttachmentModel.updateMany({
type: 'text/csv'
}, {
$set: { type: 'csv' }
});
},
};
```
### Explanation
- **`up` Method**: Defines the operations to apply the migration (e.g., modifying schemas or inserting data).
- **`down` Method**: Defines the rollback logic to revert the migration.
## Best Practices
- Use semantic versioning (e.g., `v2.1.1`) for your migration names to keep track of changes systematically.
- Always test migrations in a development or staging environment before running them in production.
- Keep the `up` and `down` methods idempotent to avoid side effects from repeated execution.

View File

@ -0,0 +1,58 @@
/*
* 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 { Command, CommandRunner } from 'nest-commander';
import { LoggerService } from '@/logger/logger.service';
import { MigrationService } from './migration.service';
import { MigrationAction } from './types';
@Command({
name: 'migration',
description: 'Manage Mongodb Migrations',
})
export class MigrationCommand extends CommandRunner {
constructor(
private readonly logger: LoggerService,
private readonly migrationService: MigrationService,
) {
super();
}
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;
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,
});
default:
this.logger.error('No valid command provided');
this.exit();
break;
}
}
exit(): void {
this.logger.log('Exiting migration process.');
process.exit(0);
}
}

View File

@ -0,0 +1,37 @@
/*
* 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 { join } from 'path';
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 { MigrationService } from './migration.service';
@Module({
imports: [
MongooseModule.forFeature([
{ name: Migration.name, schema: MigrationSchema },
]),
LoggerModule,
],
providers: [
MigrationService,
MigrationCommand,
{
provide: 'MONGO_MIGRATION_DIR',
useValue: join(process.cwd(), 'src', 'migration', 'migrations'),
},
],
exports: [MigrationService],
})
export class MigrationModule {}

View File

@ -0,0 +1,27 @@
/*
* 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 { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Model } from 'mongoose';
import { MigrationAction } from './types';
@Schema({ timestamps: true })
export class Migration {
@Prop({ required: true, unique: true })
name: string;
@Prop({ required: true, enum: MigrationAction })
status: string;
}
export const MigrationSchema = SchemaFactory.createForClass(Migration);
export type MigrationDocument = Migration & Document;
export type MigrationModel = Model<MigrationDocument>;

View File

@ -0,0 +1,285 @@
/*
* 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 { existsSync, readdirSync, writeFileSync } from 'fs';
import { join } from 'path';
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 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 idPlugin from '@/utils/schema-plugin/id.plugin';
import {
Migration,
MigrationDocument,
MigrationModel,
} from './migration.schema';
import {
MigrationAction,
MigrationRunParams,
MigrationSuccessCallback,
} from './types';
@Injectable()
export class MigrationService implements OnApplicationBootstrap {
constructor(
private moduleRef: ModuleRef,
private readonly logger: LoggerService,
@InjectModel(Migration.name)
private readonly migrationModel: MigrationModel,
) {
this.validateMigrationPath();
}
async onApplicationBootstrap() {
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) {
this.logger.log('Executing migrations ...');
await this.run({ action: MigrationAction.UP });
}
}
public exit() {
process.exit(0);
}
// CREATE
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();
}
}
public async create(name: string) {
const fileName: string = kebabCase(name) + '.migration.ts';
// check if file already exists
const files = await this.getDirFiles();
const exist = files.some((file) => {
const [, ...actualFileName] = file.split('-');
const migrationName = actualFileName.join('-');
return migrationName === fileName;
});
if (exist) {
this.logger.error(`Migration file for "${name}" already exists`);
this.exit();
}
const migrationFileName = `${Date.now()}-${fileName}`;
const filePath = join(this.migrationFilePath, migrationFileName);
const template = this.getMigrationTemplate();
try {
writeFileSync(filePath, template);
this.logger.log(
`Migration file for "${name}" created: ${migrationFileName}`,
);
} catch (e) {
this.logger.error(e.stack);
} finally {
this.exit();
}
}
private getMigrationTemplate() {
return `import mongoose from 'mongoose';
module.exports = {
async up() {
// Migration logic
},
async down() {
// Rollback logic
},
};`;
}
private async connect() {
try {
const connection = await mongoose.connect(config.mongo.uri, {
dbName: config.mongo.dbName,
});
connection.plugin(idPlugin);
connection.plugin(leanVirtuals);
connection.plugin(leanGetters);
connection.plugin(leanDefaults);
} catch (err) {
this.logger.error('Failed to connect to MongoDB');
throw err;
}
}
public async run({ action, name }: MigrationRunParams) {
if (!name) {
await this.runAll(action);
} else {
await this.runOne({ action, name });
}
this.exit();
}
private async runOne({ name, action }: MigrationRunParams) {
// verify DB status
const { exist, migrationDocument } = await this.verifyStatus({
name,
action,
});
if (exist) {
return true; // stop exec;
}
try {
const migration = await this.loadMigrationFile(name);
await migration[action]();
await this.successCallback({
name,
action,
migrationDocument,
});
} catch (e) {
this.failureCallback({
name,
action,
});
this.logger.log(e.stack);
}
}
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('-');
});
for (const name of migrationFiles) {
await this.runOne({ name, action });
}
}
private async getDirFiles() {
return readdirSync(this.migrationFilePath);
}
private async verifyStatus({ name, action }: MigrationRunParams): Promise<{
exist: boolean;
migrationDocument: MigrationDocument | null;
}> {
let exist = false;
const migrationDocument = await this.migrationModel.findOne({ name });
if (migrationDocument) {
exist = Boolean(migrationDocument.status === action);
if (exist) {
this.logger.warn(
`Cannot proceed migration "${name}" is already in "${action}" state`,
);
}
}
return { exist, migrationDocument };
}
private async getMigrationFiles() {
const files = await this.getDirFiles();
return files.filter((file) => /\.migration\.(js|ts)/.test(file));
}
private async findMigrationFileByName(name: string): Promise<string | null> {
const files = await this.getMigrationFiles();
return (
files.find((file) => {
const [, ...migrationNameParts] = file.split('-');
const migrationName = migrationNameParts
.join('-')
.replace(/\.migration\.(js|ts)/, '');
return migrationName === kebabCase(name);
}) || null
);
}
private async loadMigrationFile(name: string) {
try {
// Map the provided name to the actual file with timestamp
const fileName = await this.findMigrationFileByName(name);
if (!fileName) {
this.logger.error(`Migration file for "${name}" not found.`);
process.exit(1);
}
const filePath = join(this.migrationFilePath, fileName);
const migration = await import(filePath);
if (
!migration ||
typeof migration.up !== 'function' ||
typeof migration.down !== 'function'
) {
throw new Error(
`Migration file "${name}" must export an object with "up" and "down" methods.`,
);
}
return migration;
} catch (e) {
throw new Error(`Failed to load migration "${name}".\n${e.message}`);
}
}
private async updateStatus({
name,
action,
migrationDocument,
}: Omit<MigrationSuccessCallback, 'terminal'>) {
const document =
migrationDocument ||
new this.migrationModel({
name,
});
document.status = action;
await document.save();
}
private async successCallback({
name,
action,
migrationDocument,
}: MigrationSuccessCallback) {
await this.updateStatus({ name, action, migrationDocument });
const migrationDisplayName = `${name} [${action}]`;
this.logger.log(`"${migrationDisplayName}" migration done`);
}
private failureCallback({ name, action }: MigrationRunParams) {
const migrationDisplayName = `${name} [${action}]`;
this.logger.error(`"${migrationDisplayName}" migration failed`);
}
}

View File

View File

@ -1,17 +1,25 @@
/*
* 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.
* 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 { config } from '@/config';
import { MigrationDocument } from './migration.schema';
export default {
uri: `${config.mongo.uri}${config.mongo.dbName}?authSource=admin`,
collection: 'migrations',
migrationsPath: './migrations',
templatePath: './migrations/config/template.ts',
autosync: false,
};
enum MigrationAction {
UP = 'up',
DOWN = 'down',
}
interface MigrationRunParams {
name?: string;
action: MigrationAction;
}
interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument;
}
export { MigrationAction, MigrationRunParams, MigrationSuccessCallback };

View File

@ -13,3 +13,21 @@ export const isEmpty = (value: string): boolean => {
export const hyphenToUnderscore = (str: string) => {
return str.replaceAll('-', '_');
};
export const kebabCase = (input: string): string => {
return input
.replace(/([a-z])([A-Z])/g, '$1-$2') // Add a dash between lowercase and uppercase letters
.replace(/[\s_]+/g, '-') // Replace spaces and underscores with a dash
.toLowerCase(); // Convert the entire string to lowercase
};
export const camelCase = (input: string): string => {
return input
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : '')) // Replace dashes, underscores, and spaces, capitalizing the following letter
.replace(/^./, (char) => char.toLowerCase()); // Ensure the first character is lowercase
};
export const upperFirst = (input: string): string => {
if (!input) return input; // Return as is if the input is empty
return input.charAt(0).toUpperCase() + input.slice(1);
};

View File

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "migrations"]
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -7,7 +7,6 @@
"test",
"dist",
"**/*spec.ts",
"**/*mock.ts",
"migrations"
"**/*mock.ts"
]
}

View File

@ -33,6 +33,7 @@ MONGO_USER=dev_only
MONGO_PASSWORD=dev_only
MONGO_URI=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/
MONGO_DB=hexabot
MONGO_AUTO_MIGRATE=true
# SMTP Config (for local dev env, use smtp4dev by doing `npx hexabot start --enable=smtp4dev`)
APP_SMTP_4_DEV_PORT=9002

View File

@ -14,6 +14,10 @@ services:
- ../api/migrations:/app/migrations
#- ../api/node_modules:/app/node_modules
mongo:
ports:
- 27017:27017
mongo-express:
container_name: mongoUi
image: mongo-express:1-20