Last active
February 27, 2026 08:35
-
-
Save SippieCup/72ae1b28cdb27a2f3b07358b7b4ecee7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ts-node | |
| import dotenv from 'dotenv'; | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| // Parse --env argument BEFORE loading dotenv to ensure correct .env file is loaded | |
| const args = process.argv.slice(2); | |
| const envIndex = args.findIndex((arg) => arg === '--env' || arg === '-e'); | |
| if (envIndex !== -1 && envIndex < args.length - 1) { | |
| const envValue = args[envIndex + 1]; | |
| console.log(`Setting environment to: ${envValue}`); | |
| process.env.TENANT_ENV = envValue; | |
| // Load environment-specific .env file first (e.g., .env.production) | |
| // This ensures DB_HOST, DB_PORT, etc. from the specific env file take precedence | |
| const specificEnvPath = path.join(process.cwd(), `.env.${envValue}`); | |
| if (fs.existsSync(specificEnvPath)) { | |
| console.log(`Loading environment from: ${specificEnvPath}`); | |
| dotenv.config({ path: specificEnvPath, override: true }); | |
| } else { | |
| // Fall back to default .env if specific one doesn't exist | |
| dotenv.config(); | |
| } | |
| // Remove the env arguments so they don't interfere with command processing | |
| args.splice(envIndex, 2); | |
| process.argv.splice(envIndex + 2, 2); | |
| } else { | |
| // No --env specified, load default .env | |
| dotenv.config(); | |
| } | |
| // Import Umzug lazily after environment variables are processed so TENANT_ENV overrides apply | |
| type UmzugModule = typeof import('src/database/umzug'); | |
| let umzugModulePromise: Promise<UmzugModule> | null = null; | |
| const loadUmzugModule = (): Promise<UmzugModule> => { | |
| if (!umzugModulePromise) { | |
| umzugModulePromise = import('src/database/umzug.js'); | |
| } | |
| return umzugModulePromise; | |
| }; | |
| import { DateTime } from 'luxon'; | |
| const databaseRootDir = import.meta.dirname; | |
| const artifactDirByType = { | |
| migration: 'migrations', | |
| seeder: 'seeders', | |
| } as const; | |
| const setupMigrationFilePattern = /^(0+\d+)-/; | |
| const defaultSetupPrefixWidth = 14; | |
| // Template for new migrations | |
| const migrationTemplate = `import { Migration } from 'src/umzug'; | |
| export const up: Migration = async ({ context: sequelize }) => { | |
| await sequelize.transaction(async (_transaction) => { | |
| // Add migration code here | |
| }); | |
| }; | |
| export const down: Migration = async ({ context: sequelize }) => { | |
| await sequelize.transaction(async (_transaction) => { | |
| // Add migration code here | |
| }); | |
| }; | |
| `; | |
| // Template for new seeders | |
| const seederTemplate = `import { Seeder } from 'src/umzug'; | |
| export const up: Seeder = async ({ context: sequelize }) => { | |
| await sequelize.transaction(async (_transaction) => { | |
| // Add Seed code here | |
| }); | |
| }; | |
| export const down: Seeder = async ({ context: sequelize }) => { | |
| await sequelize.transaction(async (_transaction) => { | |
| // Add Seed code here | |
| }); | |
| }; | |
| `; | |
| // Template for new setup migration (same shape as a regular migration) | |
| const setupTemplate = migrationTemplate; | |
| const getArtifactDir = (type: keyof typeof artifactDirByType): string => { | |
| return path.join(databaseRootDir, artifactDirByType[type]); | |
| }; | |
| const getTimestampPrefix = (): string => DateTime.now().toFormat('yyyyMMddHHmmss'); | |
| const getTimestampFilename = (name: string, type: keyof typeof artifactDirByType): string => { | |
| const artifactDir = getArtifactDir(type); | |
| return path.join(artifactDir, `${getTimestampPrefix()}-${name}.ts`); | |
| }; | |
| // Setup migrations are zero-prefixed and monotonic (e.g. 00000000000001-name.ts). | |
| const getSetupFilename = async (name: string): Promise<string> => { | |
| const migrationsDir = getArtifactDir('migration'); | |
| await fs.promises.mkdir(migrationsDir, { recursive: true }); | |
| const files = await fs.promises.readdir(migrationsDir); | |
| let maxNum = -1; | |
| let width = defaultSetupPrefixWidth; | |
| for (const f of files) { | |
| const match = f.match(setupMigrationFilePattern); | |
| if (!match) continue; | |
| const prefix = match[1]; | |
| width = Math.max(width, prefix.length); | |
| const n = parseInt(prefix, 10); | |
| if (!Number.isNaN(n)) maxNum = Math.max(maxNum, n); | |
| } | |
| const nextNum = Math.max(0, maxNum + 1); | |
| const padded = String(nextNum).padStart(width, '0'); | |
| return path.join(migrationsDir, `${padded}-${name}.ts`); | |
| }; | |
| // Define the actions | |
| const actions = { | |
| // Migration commands | |
| 'migrate:up': async () => { | |
| console.log('Running all pending migrations...'); | |
| const { migrator } = await loadUmzugModule(); | |
| await migrator().up(); | |
| console.log('Migrations completed successfully.'); | |
| }, | |
| 'migrate:down': async () => { | |
| console.log('Rolling back the last migration...'); | |
| const { migrator } = await loadUmzugModule(); | |
| await migrator().down(); | |
| console.log('Rollback completed successfully.'); | |
| }, | |
| 'migrate:pending': async () => { | |
| const { migrator } = await loadUmzugModule(); | |
| const pending = await migrator().pending(); | |
| console.log(`${pending.length} pending migrations:`); | |
| pending.forEach((m) => console.log(`- ${m.name}`)); | |
| }, | |
| 'migrate:executed': async () => { | |
| const { migrator } = await loadUmzugModule(); | |
| const executed = await migrator().executed(); | |
| console.log(`${executed.length} executed migrations:`); | |
| executed.forEach((m) => console.log(`- ${m.name}`)); | |
| }, | |
| 'migrate:create': async (name?: string) => { | |
| if (!name) { | |
| console.error('Migration name is required'); | |
| process.exit(1); | |
| } | |
| const filename = getTimestampFilename(name, 'migration'); | |
| await fs.promises.writeFile(filename, migrationTemplate); | |
| console.log(`Created migration: ${filename}`); | |
| }, | |
| // Setup commands | |
| 'setup:up': async () => { | |
| console.log('Running all pending setup migrations...'); | |
| const { setup } = await loadUmzugModule(); | |
| await setup().up(); | |
| console.log('Setup migrations completed successfully.'); | |
| }, | |
| 'setup:down': async () => { | |
| console.log('Rolling back the last setup migration...'); | |
| const { setup } = await loadUmzugModule(); | |
| await setup().down(); | |
| console.log('Setup rollback completed successfully.'); | |
| }, | |
| 'setup:create': async (name?: string) => { | |
| if (!name) { | |
| console.error('Setup migration name is required'); | |
| process.exit(1); | |
| } | |
| const filename = await getSetupFilename(name); | |
| await fs.promises.writeFile(filename, setupTemplate); | |
| console.log(`Created setup migration: ${filename}`); | |
| }, | |
| // Seeder commands | |
| 'seed:up': async () => { | |
| console.log('Running all pending seeders...'); | |
| const { seeder } = await loadUmzugModule(); | |
| await seeder().up(); | |
| console.log('Seeders completed successfully.'); | |
| }, | |
| 'seed:down': async () => { | |
| console.log('Rolling back the last seeder...'); | |
| const { seeder } = await loadUmzugModule(); | |
| await seeder().down(); | |
| console.log('Rollback completed successfully.'); | |
| }, | |
| 'seed:pending': async () => { | |
| const { seeder } = await loadUmzugModule(); | |
| const pending = await seeder().pending(); | |
| console.log(`${pending.length} pending seeders:`); | |
| pending.forEach((s) => console.log(`- ${s.name}`)); | |
| }, | |
| 'seed:executed': async () => { | |
| const { seeder } = await loadUmzugModule(); | |
| const executed = await seeder().executed(); | |
| console.log(`${executed.length} executed seeders:`); | |
| executed.forEach((s) => console.log(`- ${s.name}`)); | |
| }, | |
| 'seed:create': async (name?: string) => { | |
| if (!name) { | |
| console.error('Seeder name is required'); | |
| process.exit(1); | |
| } | |
| const filename = getTimestampFilename(name, 'seeder'); | |
| await fs.promises.writeFile(filename, seederTemplate); | |
| console.log(`Created seeder: ${filename}`); | |
| }, | |
| // Show help | |
| help: () => { | |
| console.log(` | |
| Database Migration CLI | |
| Usage: | |
| ts-node cli.ts <command> [options] [--env|-e <environment>] | |
| Commands: | |
| migrate:up Run all pending migrations | |
| migrate:down Rollback the last migration | |
| migrate:pending List pending migrations | |
| migrate:executed List executed migrations | |
| migrate:create Create a new migration file (requires name) | |
| setup:up Run all pending setup migrations (0000000*) | |
| setup:down Rollback the last setup migration | |
| setup:create Create a new setup migration file (requires name) | |
| seed:up Run all pending seeders | |
| seed:down Rollback the last seeder | |
| seed:pending List pending seeders | |
| seed:executed List executed seeders | |
| seed:create Create a new seeder file (requires name) | |
| help Show this help message | |
| Options: | |
| --env, -e Specify application environment (staging, dev, demo, test, customer) | |
| If not provided, uses TENANT_ENV from .env file | |
| Examples: | |
| ts-node cli.ts migrate:create add-users-table | |
| ts-node cli.ts seed:create sample-users | |
| ts-node cli.ts migrate:up --env production | |
| ts-node cli.ts seed:up -e staging | |
| `); | |
| }, | |
| }; | |
| // Entry point | |
| const run = async () => { | |
| const command = args[0]; | |
| const param = args[1]; | |
| if (!command || !(command in actions)) { | |
| actions.help(); | |
| if (command && !(command in actions)) { | |
| console.error(`\nUnknown command: ${command}`); | |
| process.exit(1); | |
| } | |
| return; | |
| } | |
| try { | |
| // @ts-ignore - Dynamically access the function | |
| await actions[command](param); | |
| // Close database connection after operations complete | |
| if (command.startsWith('migrate:') || command.startsWith('seed:') || command.startsWith('setup:')) { | |
| const { getSequelize } = await loadUmzugModule(); | |
| const sequelize = getSequelize(); | |
| if (sequelize) { | |
| console.log('Closing database connection...'); | |
| await sequelize.close(); | |
| console.log('Database connection closed.'); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error:', error); | |
| process.exit(1); | |
| } | |
| }; | |
| void run(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "scripts": { | |
| "db:setup:create": "tsx src/database/cli.ts setup:create", | |
| "db:setup:up": "tsx src/database/cli.ts setup:up", | |
| "db:setup:down": "tsx src/database/cli.ts setup:down", | |
| "db:setup:up:env": "tsx src/database/cli.ts setup:up --env", | |
| "db:setup:down:env": "tsx src/database/cli.ts setup:down --env", | |
| "db:migrate:create": "tsx src/database/cli.ts migrate:create", | |
| "db:migrate:up": "tsx src/database/cli.ts migrate:up", | |
| "db:migrate:down": "tsx src/database/cli.ts migrate:down", | |
| "db:migrate:pending": "tsx src/database/cli.ts migrate:pending", | |
| "db:migrate:executed": "tsx src/database/cli.ts migrate:executed", | |
| "db:seed:create": "tsx src/database/cli.ts seed:create", | |
| "db:seed:up": "tsx src/database/cli.ts seed:up", | |
| "db:seed:down": "tsx src/database/cli.ts seed:down", | |
| "db:seed:pending": "tsx src/database/cli.ts seed:pending", | |
| "db:seed:executed": "tsx src/database/cli.ts seed:executed", | |
| "db:migrate:up:env": "tsx src/database/cli.ts migrate:up --env", | |
| "db:migrate:down:env": "tsx src/database/cli.ts migrate:down --env", | |
| "db:migrate:pending:env": "tsx src/database/cli.ts migrate:pending --env", | |
| "db:migrate:executed:env": "tsx src/database/cli.ts migrate:executed --env", | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { AbstractDialect, Sequelize } from '@sequelize/core'; | |
| import dotenv from 'dotenv'; | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import { join } from 'path'; | |
| import { SequelizeStorage, Umzug } from 'umzug'; | |
| import * as Configs from 'src/configurations/index'; | |
| import { ConfigsDefinition } from 'src/configurations/index'; | |
| import { modelMap } from 'src/database/models'; | |
| // Lazy initialization variables | |
| let sequelize: Sequelize; | |
| let _migrator: Umzug<any>; | |
| let _seeder: Umzug<any>; | |
| let _setup: Umzug<any>; | |
| // Function to load specific .env file | |
| function loadEnvFile(envName: string): void { | |
| // First try specific env file (e.g., .env.dev) | |
| const specificEnvPath = path.join(process.cwd(), `.env.${envName}`); | |
| const defaultEnvPath = path.join(process.cwd(), '.env'); | |
| if (fs.existsSync(specificEnvPath)) { | |
| console.debug(`Loading environment from: ${specificEnvPath}`); | |
| dotenv.config({ path: specificEnvPath, override: true }); | |
| } else if (fs.existsSync(defaultEnvPath)) { | |
| // Fall back to default .env if specific one doesn't exist | |
| console.debug(`Specific .env file not found, loading from: ${defaultEnvPath}`); | |
| dotenv.config({ path: defaultEnvPath, override: true }); | |
| } else { | |
| console.debug('No .env file found, using existing environment variables'); | |
| } | |
| } | |
| export type InitializeDatabaseOptions = { | |
| /** Skip loading .env file based on TENANT_ENV (use when env is already loaded) */ | |
| skipEnvLoad?: boolean; | |
| }; | |
| // Initialize database connection and migrations/seeders | |
| export function initializeDatabase(options: InitializeDatabaseOptions = {}): { migrator: Umzug<any>; seeder: Umzug<any>; setup: Umzug<any> } { | |
| // Only initialize once | |
| if (sequelize) return { migrator: _migrator, seeder: _seeder, setup: _setup }; | |
| const tenant_env = process.env.TENANT_ENV; | |
| if (!tenant_env) { | |
| throw new Error('TENANT_ENV is not set. Please set TENANT_ENV to the desired environment (e.g., dev, staging, production, etc).'); | |
| } | |
| console.debug(`Initializing database with tenant environment: ${tenant_env}`); | |
| // Only load env file if not already loaded externally | |
| if (!options.skipEnvLoad) { | |
| loadEnvFile(tenant_env); | |
| } | |
| if (fs.existsSync(join(import.meta.dirname, `../configurations/config.${tenant_env}.json`))) { | |
| console.log(`Loading configuration for environment: config.${tenant_env}.json`); | |
| ConfigsDefinition.loadFile(join(import.meta.dirname, `../configurations/config.${tenant_env}.json`)); | |
| } else { | |
| console.log(`No specific configuration file found for environment: ${tenant_env}, loading default config.json`); | |
| ConfigsDefinition.loadFile(join(import.meta.dirname, '../configurations/config.json')); | |
| } | |
| const env = Configs.get<string>('tenant_env') || 'unknown'; | |
| console.log(` | |
| ========================================================== | |
| Initializing Database Connection | |
| Tenant Environment: ${Configs.get<string>('tenant_env')} | |
| Node Environment: ${process.env.NODE_ENV} | |
| DB Host: ${Configs.get<string>('db.host')} | |
| DB Port: ${Configs.get<number>('db.port')} | |
| DB Name: ${Configs.get<string>('db.name')} | |
| DB User: ${Configs.get<string>('db.user')} | |
| DB Dialect: ${Configs.get<string>('db.dialect')} | |
| ========================================================== | |
| `); | |
| // Initialize Sequelize with configuration | |
| sequelize = new Sequelize({ | |
| host: Configs.get<string>('db.host'), | |
| port: Configs.get<number>('db.port'), | |
| database: Configs.get<string>('db.name'), | |
| user: Configs.get<string>('db.user'), | |
| password: Configs.get<string>('db.password'), | |
| dialect: Configs.get<string>('db.dialect'), | |
| logging: Configs.get<boolean>('db.logging') ? console.debug : false, | |
| models: Object.values(modelMap), | |
| timezone: '+00:00', | |
| pool: { | |
| max: 20, | |
| min: 10, | |
| idle: 36000, | |
| }, | |
| }); | |
| // Initialize setup configuration | |
| _setup = new Umzug({ | |
| migrations: { | |
| glob: ['migrations/setup/*.{js,ts}', { cwd: import.meta.dirname }], | |
| }, | |
| context: sequelize, | |
| storage: new SequelizeStorage({ | |
| sequelize, | |
| modelName: '_setup_meta', | |
| }), | |
| // log to info | |
| logger: env === 'test' ? undefined : console, | |
| }); | |
| // Initialize migration configuration | |
| _migrator = new Umzug({ | |
| migrations: { | |
| glob: ['migrations/*.{js,ts}', { cwd: import.meta.dirname }], | |
| }, | |
| context: sequelize, | |
| storage: new SequelizeStorage({ | |
| sequelize, | |
| modelName: '_migration_meta', | |
| }), | |
| logger: env === 'test' ? undefined : console, | |
| }); | |
| // Initialize seeder configuration | |
| _seeder = new Umzug({ | |
| migrations: { | |
| glob: ['seeders/*.{js,ts}', { cwd: import.meta.dirname }], | |
| }, | |
| context: sequelize, | |
| storage: new SequelizeStorage({ | |
| sequelize, | |
| modelName: '_seeder_meta', | |
| }), | |
| logger: env === 'test' ? undefined : console, | |
| }); | |
| return { migrator: _migrator, seeder: _seeder, setup: _setup }; | |
| } | |
| // Getter for migrator - initializes on first access | |
| export const migrator = (): Umzug<any> => { | |
| const { migrator } = initializeDatabase(); | |
| return migrator; | |
| }; | |
| // Getter for seeder - initializes on first access | |
| export const seeder = (): Umzug<any> => { | |
| const { seeder } = initializeDatabase(); | |
| return seeder; | |
| }; | |
| export const setup = (): Umzug<any> => { | |
| const { setup } = initializeDatabase(); | |
| return setup; | |
| }; | |
| // Getter for sequelize instance - for closing connection | |
| export const getSequelize = (): Sequelize | undefined => { | |
| return sequelize; | |
| }; | |
| // Define proper types for migrations and seeders | |
| export type Migration = (params: { context: Sequelize<AbstractDialect> }) => Promise<unknown>; | |
| export type Seeder = (params: { context: Sequelize<AbstractDialect> }) => Promise<unknown>; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment