n8n/packages/@n8n/db/scripts/new-migration.mjs
Declan Carroll 6f365bf3c8
ci: Enforce migration ordering in code-health rule (no-changelog) (#30511)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:12:55 +00:00

146 lines
4.4 KiB
JavaScript

#!/usr/bin/env node
// @ts-check
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PKG_ROOT = join(__dirname, '..');
const MIGRATIONS_DIR = join(PKG_ROOT, 'src', 'migrations');
const FOLDERS = ['common', 'postgresdb', 'sqlite'];
const TIMESTAMP_PATTERN = /^(\d{10,16})-([A-Za-z][A-Za-z0-9]*)\.ts$/;
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
function parseArgs(argv) {
const args = { name: null, folder: 'common' };
for (const arg of argv) {
if (arg.startsWith('--folder=')) args.folder = arg.slice('--folder='.length);
else if (!arg.startsWith('--') && !args.name) args.name = arg;
}
return args;
}
function fail(msg) {
console.error(`error: ${msg}`);
process.exit(1);
}
function listExistingTimestamps() {
const timestamps = [];
for (const folder of FOLDERS) {
const dir = join(MIGRATIONS_DIR, folder);
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir)) {
const match = TIMESTAMP_PATTERN.exec(entry);
if (match) timestamps.push(Number(match[1]));
}
}
return timestamps;
}
function pickTimestamp(existing) {
const max = existing.length === 0 ? 0 : Math.max(...existing);
const now = Date.now();
if (now > max) return { timestamp: now, source: 'Date.now()' };
return {
timestamp: max + 1,
source: `max + 1 (existing head ${max} is in the future — see AGENTS.md)`,
};
}
function migrationTemplate(className) {
return `import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class ${className} implements ReversibleMigration {
async up({ schemaBuilder: _schemaBuilder }: MigrationContext) {
// TODO: implement up
}
async down({ schemaBuilder: _schemaBuilder }: MigrationContext) {
// TODO: implement down
}
}
`;
}
function indexFilesForFolder(folder) {
if (folder === 'common') return ['postgresdb', 'sqlite'];
if (folder === 'postgresdb') return ['postgresdb'];
if (folder === 'sqlite') return ['sqlite'];
return [];
}
function insertIntoIndex(indexPath, className, importPath) {
const original = readFileSync(indexPath, 'utf8');
if (original.includes(`{ ${className} }`)) {
fail(`${relative(PKG_ROOT, indexPath)} already references ${className}`);
}
const importLine = `import { ${className} } from '${importPath}';`;
const lines = original.split('\n');
let lastImportIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('import ')) lastImportIdx = i;
}
if (lastImportIdx === -1) fail(`could not find import block in ${indexPath}`);
lines.splice(lastImportIdx + 1, 0, importLine);
let arrayCloseIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim() === '];') {
arrayCloseIdx = i;
break;
}
}
if (arrayCloseIdx === -1) fail(`could not find array close in ${indexPath}`);
lines.splice(arrayCloseIdx, 0, `\t${className},`);
writeFileSync(indexPath, lines.join('\n'));
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name) {
console.error(
'usage: pnpm --filter=@n8n/db migration:new <Name> [--folder=common|postgresdb|sqlite]',
);
process.exit(1);
}
if (!PASCAL_CASE.test(args.name)) {
fail(`name must be PascalCase (got "${args.name}")`);
}
if (!FOLDERS.includes(args.folder)) {
fail(`--folder must be one of ${FOLDERS.join(', ')} (got "${args.folder}")`);
}
const existing = listExistingTimestamps();
const { timestamp, source } = pickTimestamp(existing);
const className = `${args.name}${timestamp}`;
const fileBase = `${timestamp}-${args.name}`;
const targetDir = join(MIGRATIONS_DIR, args.folder);
const targetFile = join(targetDir, `${fileBase}.ts`);
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
if (existsSync(targetFile)) fail(`${relative(PKG_ROOT, targetFile)} already exists`);
writeFileSync(targetFile, migrationTemplate(className));
const importPath = args.folder === 'common' ? `../common/${fileBase}` : `./${fileBase}`;
for (const indexFolder of indexFilesForFolder(args.folder)) {
insertIntoIndex(join(MIGRATIONS_DIR, indexFolder, 'index.ts'), className, importPath);
}
console.log(`created ${relative(PKG_ROOT, targetFile)}`);
console.log(`timestamp ${timestamp} (${source})`);
console.log(
`registered ${className} in ${indexFilesForFolder(args.folder)
.map((f) => `${f}/index.ts`)
.join(', ')}`,
);
}
main();