Skip to content

Instantly share code, notes, and snippets.

@SippieCup
Last active February 27, 2026 08:35
Show Gist options
  • Select an option

  • Save SippieCup/72ae1b28cdb27a2f3b07358b7b4ecee7 to your computer and use it in GitHub Desktop.

Select an option

Save SippieCup/72ae1b28cdb27a2f3b07358b7b4ecee7 to your computer and use it in GitHub Desktop.
#!/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();
{
"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",
}
}
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