feat: migration commands (#828)

* feat: migration commands

* Update packages/server/src/modules/CLI/commands/TenantsMigrateRollback.command.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/server/src/modules/CLI/commands/TenantsMigrateLatest.command.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/server/src/modules/CLI/commands/TenantsList.command.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/server/src/modules/CLI/commands/SystemMigrateRollback.command.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/server/src/modules/CLI/commands/TenantsMigrateLatest.command.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ahmed Bouhuolia
2025-10-22 21:58:02 +02:00
committed by GitHub
parent 9d714ac78e
commit 3bd0e89146
17 changed files with 608 additions and 21 deletions

View File

@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { config } from '../../common/config';
import { CommandRunnerModule } from 'nest-commander';
import { SystemMigrateLatestCommand } from './commands/SystemMigrateLatest.command';
import { SystemMigrateRollbackCommand } from './commands/SystemMigrateRollback.command';
import { SystemMigrateMakeCommand } from './commands/SystemMigrateMake.command';
import { TenantsMigrateLatestCommand } from './commands/TenantsMigrateLatest.command';
import { TenantsMigrateRollbackCommand } from './commands/TenantsMigrateRollback.command';
import { TenantsMigrateMakeCommand } from './commands/TenantsMigrateMake.command';
import { TenantsListCommand } from './commands/TenantsList.command';
import { SystemSeedLatestCommand } from './commands/SystemSeedLatest.command';
import { TenantsSeedLatestCommand } from './commands/TenantsSeedLatest.command';
@Module({
imports: [
ConfigModule.forRoot({
load: config,
isGlobal: true,
}),
CommandRunnerModule,
],
providers: [
SystemMigrateLatestCommand,
SystemMigrateRollbackCommand,
SystemMigrateMakeCommand,
TenantsMigrateLatestCommand,
TenantsMigrateRollbackCommand,
TenantsMigrateMakeCommand,
TenantsListCommand,
SystemSeedLatestCommand,
TenantsSeedLatestCommand,
],
})
export class CLIModule { }

View File

@@ -0,0 +1,83 @@
import { CommandRunner } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Knex from 'knex';
import { knexSnakeCaseMappers } from 'objection';
@Injectable()
export abstract class BaseCommand extends CommandRunner {
constructor(protected readonly configService: ConfigService) {
super();
}
protected initSystemKnex(): any {
return Knex({
client: this.configService.get('systemDatabase.client'),
connection: {
host: this.configService.get('systemDatabase.host'),
user: this.configService.get('systemDatabase.user'),
password: this.configService.get('systemDatabase.password'),
database: this.configService.get('systemDatabase.databaseName'),
charset: 'utf8',
},
migrations: {
directory: this.configService.get('systemDatabase.migrationDir'),
},
seeds: {
directory: this.configService.get('systemDatabase.seedsDir'),
},
pool: { min: 0, max: 7 },
...knexSnakeCaseMappers({ upperCase: true }),
});
}
protected initTenantKnex(organizationId: string = ''): any {
return Knex({
client: this.configService.get('tenantDatabase.client'),
connection: {
host: this.configService.get('tenantDatabase.host'),
user: this.configService.get('tenantDatabase.user'),
password: this.configService.get('tenantDatabase.password'),
database: `${this.configService.get('tenantDatabase.dbNamePrefix')}${organizationId}`,
charset: 'utf8',
},
migrations: {
directory: this.configService.get('tenantDatabase.migrationsDir') || './src/database/migrations',
},
seeds: {
directory: this.configService.get('tenantDatabase.seedsDir') || './src/database/seeds/core',
},
pool: {
min: 0,
max: 5,
},
...knexSnakeCaseMappers({ upperCase: true }),
});
}
protected getAllSystemTenants(knex: any) {
return knex('tenants');
}
protected getAllInitializedTenants(knex: any) {
return knex('tenants').whereNotNull('initializedAt');
}
protected exit(text: any): never {
if (text instanceof Error) {
console.error(`Error: ${text.message}\n${text.stack}`);
} else {
console.error(`Error: ${text}`);
}
process.exit(1);
}
protected success(text: string): never {
console.log(text);
process.exit(0);
}
protected log(text: string): void {
console.log(text);
}
}

View File

@@ -0,0 +1,32 @@
import { Command } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseCommand } from './BaseCommand';
@Injectable()
@Command({
name: 'system:migrate:latest',
description: 'Migrate latest migration of the system database.',
})
export class SystemMigrateLatestCommand extends BaseCommand {
constructor(configService: ConfigService) {
super(configService);
}
async run(): Promise<void> {
try {
const sysKnex = this.initSystemKnex();
const [batchNo, log] = await sysKnex.migrate.latest();
if (log.length === 0) {
this.success('Already up to date');
}
this.success(
`Batch ${batchNo} run: ${log.length} migrations`
);
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,33 @@
import { Command } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseCommand } from './BaseCommand';
@Injectable()
@Command({
name: 'system:migrate:make',
description: 'Create a named migration file to the system database.',
arguments: '<name>',
})
export class SystemMigrateMakeCommand extends BaseCommand {
constructor(configService: ConfigService) {
super(configService);
}
async run(passedParams: string[]): Promise<void> {
const [name] = passedParams;
if (!name) {
this.exit('Migration name is required');
return;
}
try {
const sysKnex = this.initSystemKnex();
const migrationName = await sysKnex.migrate.make(name);
this.success(`Created Migration: ${migrationName}`);
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,32 @@
import { Command } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseCommand } from './BaseCommand';
@Injectable()
@Command({
name: 'system:migrate:rollback',
description: 'Rollback the last batch of system migrations.',
})
export class SystemMigrateRollbackCommand extends BaseCommand {
constructor(configService: ConfigService) {
super(configService);
}
async run(): Promise<void> {
try {
const sysKnex = this.initSystemKnex();
const [batchNo, _log] = await sysKnex.migrate.rollback();
if (_log.length === 0) {
this.success('Already at the base migration');
}
this.success(
`Batch ${batchNo} rolled back: ${_log.length} migrations`
);
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,12 @@
import { Command, CommandRunner } from 'nest-commander';
@Command({
name: 'system:seed:latest',
description: 'Seed system database with the latest data',
})
export class SystemSeedLatestCommand extends CommandRunner {
async run(): Promise<void> {
console.log('System seeding with latest data - No operation performed');
// TODO: Implement system seeding logic
}
}

View File

@@ -0,0 +1,47 @@
import { Command, Option } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseCommand } from './BaseCommand';
interface TenantsListOptions {
all?: boolean;
}
@Injectable()
@Command({
name: 'tenants:list',
description: 'Retrieve a list of all system tenants databases.',
})
export class TenantsListCommand extends BaseCommand {
constructor(configService: ConfigService) {
super(configService);
}
@Option({
flags: '-a, --all',
description: 'All tenants even if not initialized.',
})
parseAll(val: string): boolean {
return true;
}
async run(passedParams: string[], options: TenantsListOptions): Promise<void> {
try {
const sysKnex = this.initSystemKnex();
const tenants = options.all
? await this.getAllSystemTenants(sysKnex)
: await this.getAllInitializedTenants(sysKnex);
tenants.forEach((tenant: any) => {
const dbName = `${this.configService.get('tenantDatabase.dbNamePrefix')}${tenant.organizationId}`;
console.log(
`ID: ${tenant.id} | Organization ID: ${tenant.organizationId} | DB Name: ${dbName}`
);
});
this.success('---');
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,74 @@
import { Command, Option } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PromisePool } from '@supercharge/promise-pool';
import { BaseCommand } from './BaseCommand';
interface TenantsMigrateLatestOptions {
tenant_id?: string;
}
@Injectable()
@Command({
name: 'tenants:migrate:latest',
description: 'Migrate all tenants or the given tenant id.',
})
export class TenantsMigrateLatestCommand extends BaseCommand {
private readonly MIGRATION_CONCURRENCY = 10;
constructor(configService: ConfigService) {
super(configService);
}
@Option({
flags: '-t, --tenant_id [tenant_id]',
description: 'Which organization id do you migrate.',
})
parseTenantId(val: string): string {
return val;
}
async run(passedParams: string[], options: TenantsMigrateLatestOptions): Promise<void> {
try {
const sysKnex = this.initSystemKnex();
const tenants = await this.getAllInitializedTenants(sysKnex);
const tenantsOrgsIds = tenants.map((tenant: any) => tenant.organizationId);
if (options.tenant_id && tenantsOrgsIds.indexOf(options.tenant_id) === -1) {
this.exit(`The given tenant id ${options.tenant_id} does not exist.`);
}
const migrateTenant = async (organizationId: string) => {
try {
const tenantKnex = this.initTenantKnex(organizationId);
const [batchNo, _log] = await tenantKnex.migrate.latest();
const tenantDb = `${this.configService.get('tenantDatabase.dbNamePrefix')}${organizationId}`;
if (_log.length === 0) {
this.log('Already up to date');
}
this.log(
`Tenant ${tenantDb} > Batch ${batchNo} run: ${_log.length} migrations`
);
this.log('-------------------');
} catch (error) {
this.exit(error);
}
};
if (!options.tenant_id) {
await PromisePool.withConcurrency(this.MIGRATION_CONCURRENCY)
.for(tenants)
.process((tenant: any) => {
return migrateTenant(tenant.organizationId);
});
this.success('All tenants are migrated.');
} else {
await migrateTenant(options.tenant_id);
}
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,33 @@
import { Command } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseCommand } from './BaseCommand';
@Injectable()
@Command({
name: 'tenants:migrate:make',
description: 'Create a named migration file to the tenants database.',
arguments: '<name>',
})
export class TenantsMigrateMakeCommand extends BaseCommand {
constructor(configService: ConfigService) {
super(configService);
}
async run(passedParams: string[]): Promise<void> {
const [name] = passedParams;
if (!name) {
this.exit('Migration name is required');
return;
}
try {
const tenantKnex = this.initTenantKnex();
const migrationName = await tenantKnex.migrate.make(name);
this.success(`Created Migration: ${migrationName}`);
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,74 @@
import { Command, Option } from 'nest-commander';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PromisePool } from '@supercharge/promise-pool';
import { BaseCommand } from './BaseCommand';
interface TenantsMigrateRollbackOptions {
tenant_id?: string;
}
@Injectable()
@Command({
name: 'tenants:migrate:rollback',
description: 'Rollback the last batch of tenants migrations.',
})
export class TenantsMigrateRollbackCommand extends BaseCommand {
private readonly MIGRATION_CONCURRENCY = 10;
constructor(configService: ConfigService) {
super(configService);
}
@Option({
flags: '-t, --tenant_id [tenant_id]',
description: 'Which organization id do you migrate.',
})
parseTenantId(val: string): string {
return val;
}
async run(passedParams: string[], options: TenantsMigrateRollbackOptions): Promise<void> {
try {
const sysKnex = this.initSystemKnex();
const tenants = await this.getAllInitializedTenants(sysKnex);
const tenantsOrgsIds = tenants.map((tenant: any) => tenant.organizationId);
if (options.tenant_id && tenantsOrgsIds.indexOf(options.tenant_id) === -1) {
this.exit(`The given tenant id ${options.tenant_id} does not exist.`);
}
const migrateTenant = async (organizationId: string) => {
try {
const tenantKnex = this.initTenantKnex(organizationId);
const [batchNo, _log] = await tenantKnex.migrate.rollback();
const tenantDb = `${this.configService.get('tenantDatabase.dbNamePrefix')}${organizationId}`;
if (_log.length === 0) {
this.log('Already at the base migration');
}
this.log(
`Tenant: ${tenantDb} > Batch ${batchNo} rolled back: ${_log.length} migrations`
);
this.log('---------------');
} catch (error) {
this.exit(error);
}
};
if (!options.tenant_id) {
await PromisePool.withConcurrency(this.MIGRATION_CONCURRENCY)
.for(tenants)
.process((tenant: any) => {
return migrateTenant(tenant.organizationId);
});
this.success('All tenants are rollbacked.');
} else {
await migrateTenant(options.tenant_id);
}
} catch (error) {
this.exit(error);
}
}
}

View File

@@ -0,0 +1,12 @@
import { Command, CommandRunner } from 'nest-commander';
@Command({
name: 'tenants:seed:latest',
description: 'Seed all tenant databases with the latest data',
})
export class TenantsSeedLatestCommand extends CommandRunner {
async run(): Promise<void> {
console.log('Tenants seeding with latest data - No operation performed');
// TODO: Implement tenants seeding logic
}
}