From 182fe9e5847f532606d0a939d7370939cf36908f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Thu, 12 Mar 2026 12:37:03 +0100 Subject: [PATCH 1/7] feat: scaffold migration command --- package-lock.json | 3 - src/commands/database/database.ts | 27 ++- src/commands/database/db-connection.ts | 3 +- src/commands/database/migration-new.ts | 163 +++++++++++++ .../commands/database/migration-new.test.ts | 224 ++++++++++++++++++ 5 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/commands/database/migration-new.ts create mode 100644 tests/unit/commands/database/migration-new.test.ts diff --git a/package-lock.json b/package-lock.json index 318eecb5109..12951cd909c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6453,7 +6453,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6480,7 +6479,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6572,7 +6570,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index fd0f8fb7eb8..e91ddbfcd0e 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -2,6 +2,7 @@ import { Option } from 'commander' import inquirer from 'inquirer' import BaseCommand from '../base-command.js' import type { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js' +import type { MigrationNewOptions } from './migration-new.js' export type Extension = { id: string @@ -31,7 +32,9 @@ export const createDatabaseCommand = (program: BaseCommand) => { 'netlify db status', 'netlify db init', 'netlify db init --help', - ...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1' ? ['netlify db migrate', 'netlify db reset'] : []), + ...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1' + ? ['netlify db migrate', 'netlify db reset', 'netlify db migration new'] + : []), ]) dbCommand @@ -105,5 +108,27 @@ export const createDatabaseCommand = (program: BaseCommand) => { const { reset } = await import('./reset.js') await reset(options, command) }) + + const migrationCommand = dbCommand.command('migration').description('Manage database migrations') + + migrationCommand + .command('new') + .description('Create a new migration') + .option('-d, --description ', 'Purpose of the migration (used to generate the file name)') + .addOption( + new Option('-s, --scheme ', 'Numbering scheme for migration prefixes').choices([ + 'sequential', + 'timestamp', + ]), + ) + .option('--json', 'Output result as JSON') + .action(async (options: MigrationNewOptions, command: BaseCommand) => { + const { migrationNew } = await import('./migration-new.js') + await migrationNew(options, command) + }) + .addExamples([ + 'netlify db migration new', + 'netlify db migration new --description "add users table" --scheme sequential', + ]) } } diff --git a/src/commands/database/db-connection.ts b/src/commands/database/db-connection.ts index 232bad19864..c2de1c3ab30 100644 --- a/src/commands/database/db-connection.ts +++ b/src/commands/database/db-connection.ts @@ -1,7 +1,6 @@ import { Client } from 'pg' -import type { SQLExecutor } from '@netlify/dev' -import { NetlifyDev } from '@netlify/dev' +import { NetlifyDev, type SQLExecutor } from '@netlify/dev' import { LocalState } from '@netlify/dev-utils' import { PgClientExecutor } from './pg-client-executor.js' diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts new file mode 100644 index 00000000000..63289184057 --- /dev/null +++ b/src/commands/database/migration-new.ts @@ -0,0 +1,163 @@ +import { readdir, mkdir, writeFile } from 'fs/promises' +import { join } from 'path' + +import inquirer from 'inquirer' + +import { log, logJson } from '../../utils/command-helpers.js' +import BaseCommand from '../base-command.js' + +export type NumberingScheme = 'sequential' | 'timestamp' + +export interface MigrationNewOptions { + description?: string + scheme?: NumberingScheme + json?: boolean +} + +export const generateSlug = (description: string): string => { + return description + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s_-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +export const detectNumberingScheme = (existingNames: string[]): NumberingScheme | undefined => { + if (existingNames.length === 0) { + return undefined + } + + const prefixes = existingNames.map((name) => name.split(/[_-]/)[0]) + const allTimestamp = prefixes.every((p) => /^\d{14}$/.test(p)) + if (allTimestamp) { + return 'timestamp' + } + + const allSequential = prefixes.every((p) => /^\d{4}$/.test(p)) + if (allSequential) { + return 'sequential' + } + + return undefined +} + +export const generateNextPrefix = (existingNames: string[], scheme: NumberingScheme): string => { + if (scheme === 'timestamp') { + const now = new Date() + const pad = (n: number, width = 2) => String(n).padStart(width, '0') + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join('') + } + + const prefixes = existingNames.map((name) => { + const match = /^(\d+)/.exec(name) + return match ? parseInt(match[1], 10) : 0 + }) + const maxPrefix = prefixes.length > 0 ? Math.max(...prefixes) : 0 + return String(maxPrefix + 1).padStart(4, '0') +} + +const getExistingMigrationNames = async (migrationsDirectory: string): Promise => { + try { + const entries = await readdir(migrationsDirectory, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + } catch { + return [] + } +} + +const DEFAULT_MIGRATIONS_PATH = 'netlify/db/migrations' + +export const resolveMigrationsDirectory = (command: BaseCommand): string => { + const configuredPath = command.netlify.config.db?.migrations?.path + if (configuredPath) { + return configuredPath + } + + const projectRoot = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory + if (!projectRoot) { + throw new Error('Could not determine the project root directory.') + } + + return join(projectRoot, DEFAULT_MIGRATIONS_PATH) +} + +export const migrationNew = async (options: MigrationNewOptions, command: BaseCommand) => { + const { json } = options + + const migrationsDirectory = resolveMigrationsDirectory(command) + const existingMigrations = await getExistingMigrationNames(migrationsDirectory) + const detectedScheme = detectNumberingScheme(existingMigrations) + + let description = options.description + let scheme = options.scheme + + if (!description) { + const answers = await inquirer.prompt<{ description: string }>([ + { + type: 'input', + name: 'description', + message: 'What is the purpose of this migration?', + validate: (input: string) => (input.trim().length > 0 ? true : 'Description cannot be empty'), + }, + ]) + description = answers.description + } + + if (!scheme) { + if (detectedScheme) { + const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ + { + type: 'list', + name: 'scheme', + message: 'Numbering scheme:', + choices: [ + { name: 'Sequential (0001, 0002, ...)', value: 'sequential' }, + { name: 'Timestamp (20260312143000)', value: 'timestamp' }, + ], + default: detectedScheme, + }, + ]) + scheme = answers.scheme + } else { + const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ + { + type: 'list', + name: 'scheme', + message: 'Numbering scheme:', + choices: [ + { name: 'Sequential (0001, 0002, ...)', value: 'sequential' }, + { name: 'Timestamp (20260312143000)', value: 'timestamp' }, + ], + }, + ]) + scheme = answers.scheme + } + } + + const slug = generateSlug(description) + const prefix = generateNextPrefix(existingMigrations, scheme) + const folderName = `${prefix}_${slug}` + const folderPath = join(migrationsDirectory, folderName) + + await mkdir(folderPath, { recursive: true }) + await writeFile(join(folderPath, 'migration.sql'), '-- Write your migration SQL here\n') + + if (json) { + logJson({ path: folderPath, name: folderName }) + } else { + log(`Created migration: ${folderName}`) + log(` ${join(folderPath, 'migration.sql')}`) + } +} diff --git a/tests/unit/commands/database/migration-new.test.ts b/tests/unit/commands/database/migration-new.test.ts new file mode 100644 index 00000000000..a07bde6d185 --- /dev/null +++ b/tests/unit/commands/database/migration-new.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' + +const { mockReaddir, mockMkdir, mockWriteFile, logMessages, jsonMessages } = vi.hoisted(() => { + const mockReaddir = vi.fn().mockResolvedValue([]) + const mockMkdir = vi.fn().mockResolvedValue(undefined) + const mockWriteFile = vi.fn().mockResolvedValue(undefined) + const logMessages: string[] = [] + const jsonMessages: unknown[] = [] + return { mockReaddir, mockMkdir, mockWriteFile, logMessages, jsonMessages } +}) + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + readdir: (...args: unknown[]) => mockReaddir(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + mkdir: (...args: unknown[]) => mockMkdir(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + writeFile: (...args: unknown[]) => mockWriteFile(...args), + } +}) + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn() }, +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, +})) + +import { join } from 'path' + +import inquirer from 'inquirer' +import { + migrationNew, + generateSlug, + detectNumberingScheme, + generateNextPrefix, + resolveMigrationsDirectory, +} from '../../../../src/commands/database/migration-new.js' + +function createMockCommand(overrides: { migrationsPath?: string | undefined } = {}) { + const { migrationsPath = '/project/netlify/db/migrations' } = overrides + + return { + project: { root: '/project', baseDirectory: undefined }, + netlify: { + site: { root: '/project' }, + config: migrationsPath ? { db: { migrations: { path: migrationsPath } } } : {}, + }, + } as unknown as Parameters[1] +} + +function dirEntry(name: string) { + return { name, isDirectory: () => true } +} + +describe('generateSlug', () => { + test.each([ + { input: 'Add users table', expected: 'add-users-table' }, + { input: ' add posts table ', expected: 'add-posts-table' }, + { input: "Add user's email & phone!", expected: 'add-users-email-phone' }, + { input: 'add_user_table', expected: 'add-user-table' }, + { input: 'Create INDEX on Users', expected: 'create-index-on-users' }, + ])('generates "$expected" from "$input"', ({ input, expected }) => { + expect(generateSlug(input)).toBe(expected) + }) +}) + +describe('detectNumberingScheme', () => { + test('returns undefined for empty list', () => { + expect(detectNumberingScheme([])).toBeUndefined() + }) + + test('detects sequential numbering', () => { + expect(detectNumberingScheme(['0001_create-users', '0002_add-posts'])).toBe('sequential') + }) + + test('detects timestamp numbering', () => { + expect(detectNumberingScheme(['20260312143000_create-users', '20260312150000_add-posts'])).toBe('timestamp') + }) + + test('returns undefined for unrecognized patterns', () => { + expect(detectNumberingScheme(['v1_create-users', 'v2_add-posts'])).toBeUndefined() + }) +}) + +describe('generateNextPrefix', () => { + test('generates first sequential prefix when no existing migrations', () => { + expect(generateNextPrefix([], 'sequential')).toBe('0001') + }) + + test('increments sequential prefix', () => { + expect(generateNextPrefix(['0003_create-users', '0007_add-posts'], 'sequential')).toBe('0008') + }) + + test('generates timestamp prefix with 14 digits', () => { + const prefix = generateNextPrefix([], 'timestamp') + expect(prefix).toMatch(/^\d{14}$/) + }) +}) + +describe('resolveMigrationsDirectory', () => { + test('returns configured path when present', () => { + const command = createMockCommand({ migrationsPath: '/custom/migrations' }) + expect(resolveMigrationsDirectory(command)).toBe('/custom/migrations') + }) + + test('falls back to default path under project root', () => { + const command = { + project: { root: '/project', baseDirectory: undefined }, + netlify: { site: { root: '/project' }, config: {} }, + } as unknown as Parameters[0] + + expect(resolveMigrationsDirectory(command)).toBe(join('/project', 'netlify', 'db', 'migrations')) + }) + + test('throws when no config path and no project root', () => { + const command = { + project: { root: undefined, baseDirectory: undefined }, + netlify: { site: { root: undefined }, config: {} }, + } as unknown as Parameters[0] + + expect(() => resolveMigrationsDirectory(command)).toThrow('Could not determine the project root directory.') + }) +}) + +describe('migrationNew', () => { + beforeEach(() => { + logMessages.length = 0 + jsonMessages.length = 0 + vi.clearAllMocks() + mockReaddir.mockResolvedValue([]) + }) + + test('falls back to default path when no migrations path is configured', async () => { + const command = { + project: { root: '/project', baseDirectory: undefined }, + netlify: { site: { root: '/project' }, config: {} }, + } as unknown as Parameters[1] + + await migrationNew({ description: 'add users', scheme: 'sequential' }, command) + + expect(mockMkdir).toHaveBeenCalledWith(join('/project', 'netlify', 'db', 'migrations', '0001_add-users'), { + recursive: true, + }) + }) + + test('creates migration folder and file with non-interactive flags', async () => { + mockReaddir.mockResolvedValue([dirEntry('0001_create-users')]) + + await migrationNew({ description: 'add posts table', scheme: 'sequential' }, createMockCommand()) + + const expectedFolder = join('/project/netlify/db/migrations', '0002_add-posts-table') + expect(mockMkdir).toHaveBeenCalledWith(expectedFolder, { recursive: true }) + expect(mockWriteFile).toHaveBeenCalledWith( + join(expectedFolder, 'migration.sql'), + '-- Write your migration SQL here\n', + ) + }) + + test('outputs creation message to log', async () => { + await migrationNew({ description: 'add posts table', scheme: 'sequential' }, createMockCommand()) + + expect(logMessages[0]).toContain('Created migration: 0001_add-posts-table') + }) + + test('outputs JSON when --json flag is set', async () => { + await migrationNew({ description: 'add posts table', scheme: 'sequential', json: true }, createMockCommand()) + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + path: join('/project/netlify/db/migrations', '0001_add-posts-table'), + name: '0001_add-posts-table', + }) + }) + + test('prompts for description when not provided', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ description: 'create users table' }) + .mockResolvedValueOnce({ scheme: 'sequential' }) + + await migrationNew({}, createMockCommand()) + + expect(inquirer.prompt).toHaveBeenCalledTimes(2) + expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('create-users-table'), expect.any(Object)) + }) + + test('prompts for scheme with detected default when not provided', async () => { + mockReaddir.mockResolvedValue([dirEntry('0001_create-users'), dirEntry('0002_add-posts')]) + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ scheme: 'sequential' }) + + await migrationNew({ description: 'add comments' }, createMockCommand()) + + const promptCall = vi.mocked(inquirer.prompt).mock.calls[0][0] as { default?: string }[] + expect(promptCall[0].default).toBe('sequential') + }) + + test('uses timestamp scheme when specified', async () => { + await migrationNew({ description: 'add posts table', scheme: 'timestamp' }, createMockCommand()) + + const mkdirCall = mockMkdir.mock.calls[0][0] as string + const folderName = mkdirCall.split(/[/\\]/).pop() ?? '' + expect(folderName).toMatch(/^\d{14}_add-posts-table$/) + }) + + test('handles empty migrations directory gracefully', async () => { + mockReaddir.mockRejectedValue(new Error('ENOENT')) + + await migrationNew({ description: 'initial migration', scheme: 'sequential' }, createMockCommand()) + + expect(mockMkdir).toHaveBeenCalledWith(join('/project/netlify/db/migrations', '0001_initial-migration'), { + recursive: true, + }) + }) +}) From 6eb5333926aa5737e95aec3a7a0d2c3c296093b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Thu, 12 Mar 2026 14:35:12 +0100 Subject: [PATCH 2/7] address the rabbit --- src/commands/database/migration-new.ts | 11 +++++-- .../commands/database/migration-new.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts index 63289184057..9e9717996cd 100644 --- a/src/commands/database/migration-new.ts +++ b/src/commands/database/migration-new.ts @@ -58,7 +58,7 @@ export const generateNextPrefix = (existingNames: string[], scheme: NumberingSch } const prefixes = existingNames.map((name) => { - const match = /^(\d+)/.exec(name) + const match = /^(\d{4})[_-]/.exec(name) return match ? parseInt(match[1], 10) : 0 }) const maxPrefix = prefixes.length > 0 ? Math.max(...prefixes) : 0 @@ -147,12 +147,19 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo } const slug = generateSlug(description) + if (!slug) { + throw new Error( + `Description "${description}" produces an empty slug. Use a description with alphanumeric characters (e.g. "add users table").`, + ) + } + const prefix = generateNextPrefix(existingMigrations, scheme) const folderName = `${prefix}_${slug}` const folderPath = join(migrationsDirectory, folderName) + const migrationFilePath = join(folderPath, 'migration.sql') await mkdir(folderPath, { recursive: true }) - await writeFile(join(folderPath, 'migration.sql'), '-- Write your migration SQL here\n') + await writeFile(migrationFilePath, '-- Write your migration SQL here\n', { flag: 'wx' }) if (json) { logJson({ path: folderPath, name: folderName }) diff --git a/tests/unit/commands/database/migration-new.test.ts b/tests/unit/commands/database/migration-new.test.ts index a07bde6d185..db24846ab4e 100644 --- a/tests/unit/commands/database/migration-new.test.ts +++ b/tests/unit/commands/database/migration-new.test.ts @@ -73,6 +73,10 @@ describe('generateSlug', () => { ])('generates "$expected" from "$input"', ({ input, expected }) => { expect(generateSlug(input)).toBe(expected) }) + + test.each(['!!!', '___', ' ', '@#$%'])('returns empty string for non-alphanumeric input "%s"', (input) => { + expect(generateSlug(input)).toBe('') + }) }) describe('detectNumberingScheme', () => { @@ -106,6 +110,10 @@ describe('generateNextPrefix', () => { const prefix = generateNextPrefix([], 'timestamp') expect(prefix).toMatch(/^\d{14}$/) }) + + test('ignores timestamp-style names when computing sequential prefix', () => { + expect(generateNextPrefix(['20260312143000_add-users', '0003_add-posts'], 'sequential')).toBe('0004') + }) }) describe('resolveMigrationsDirectory', () => { @@ -164,6 +172,9 @@ describe('migrationNew', () => { expect(mockWriteFile).toHaveBeenCalledWith( join(expectedFolder, 'migration.sql'), '-- Write your migration SQL here\n', + { + flag: 'wx', + }, ) }) @@ -212,6 +223,25 @@ describe('migrationNew', () => { expect(folderName).toMatch(/^\d{14}_add-posts-table$/) }) + test('throws when description produces an empty slug', async () => { + await expect(migrationNew({ description: '!!!', scheme: 'sequential' }, createMockCommand())).rejects.toThrow( + 'produces an empty slug', + ) + + expect(mockMkdir).not.toHaveBeenCalled() + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('throws when migration file already exists', async () => { + const existsError = new Error('EEXIST: file already exists') as NodeJS.ErrnoException + existsError.code = 'EEXIST' + mockWriteFile.mockRejectedValueOnce(existsError) + + await expect( + migrationNew({ description: 'add posts table', scheme: 'sequential' }, createMockCommand()), + ).rejects.toThrow() + }) + test('handles empty migrations directory gracefully', async () => { mockReaddir.mockRejectedValue(new Error('ENOENT')) From 4b00915a7ce238e46c7afea5e490b3b19b4fb5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Mon, 16 Mar 2026 08:34:23 +0100 Subject: [PATCH 3/7] dedup code --- src/commands/database/migration-new.ts | 41 ++++++++------------------ 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts index 9e9717996cd..d39b1114ba3 100644 --- a/src/commands/database/migration-new.ts +++ b/src/commands/database/migration-new.ts @@ -116,34 +116,19 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo } if (!scheme) { - if (detectedScheme) { - const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ - { - type: 'list', - name: 'scheme', - message: 'Numbering scheme:', - choices: [ - { name: 'Sequential (0001, 0002, ...)', value: 'sequential' }, - { name: 'Timestamp (20260312143000)', value: 'timestamp' }, - ], - default: detectedScheme, - }, - ]) - scheme = answers.scheme - } else { - const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ - { - type: 'list', - name: 'scheme', - message: 'Numbering scheme:', - choices: [ - { name: 'Sequential (0001, 0002, ...)', value: 'sequential' }, - { name: 'Timestamp (20260312143000)', value: 'timestamp' }, - ], - }, - ]) - scheme = answers.scheme - } + const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ + { + type: 'list', + name: 'scheme', + message: 'Numbering scheme:', + choices: [ + { name: 'Sequential (0001, 0002, ...)', value: 'sequential' }, + { name: 'Timestamp (20260312143000)', value: 'timestamp' }, + ], + ...(detectedScheme && { default: detectedScheme }), + }, + ]) + scheme = answers.scheme } const slug = generateSlug(description) From 05a97d835696b4f682d1d646c0c9ee93be079b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Mon, 16 Mar 2026 08:39:35 +0100 Subject: [PATCH 4/7] add better example --- src/commands/database/migration-new.ts | 10 +++++++++- tests/unit/commands/database/migration-new.test.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts index d39b1114ba3..61b461f36f8 100644 --- a/src/commands/database/migration-new.ts +++ b/src/commands/database/migration-new.ts @@ -144,7 +144,15 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo const migrationFilePath = join(folderPath, 'migration.sql') await mkdir(folderPath, { recursive: true }) - await writeFile(migrationFilePath, '-- Write your migration SQL here\n', { flag: 'wx' }) + await writeFile(migrationFilePath, `-- Write your migration SQL here +-- +-- Example: +-- CREATE TABLE IF NOT EXISTS users ( +-- id SERIAL PRIMARY KEY, +-- name TEXT NOT NULL, +-- created_at TIMESTAMP DEFAULT NOW() +-- ); +`, { flag: 'wx' }) if (json) { logJson({ path: folderPath, name: folderName }) diff --git a/tests/unit/commands/database/migration-new.test.ts b/tests/unit/commands/database/migration-new.test.ts index db24846ab4e..2432432b366 100644 --- a/tests/unit/commands/database/migration-new.test.ts +++ b/tests/unit/commands/database/migration-new.test.ts @@ -171,7 +171,7 @@ describe('migrationNew', () => { expect(mockMkdir).toHaveBeenCalledWith(expectedFolder, { recursive: true }) expect(mockWriteFile).toHaveBeenCalledWith( join(expectedFolder, 'migration.sql'), - '-- Write your migration SQL here\n', + expect.stringContaining('-- Write your migration SQL here'), { flag: 'wx', }, From ec6a65dab80ebdbccdb1ac5c3d423dee168400fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Mon, 16 Mar 2026 08:49:32 +0100 Subject: [PATCH 5/7] lint --- src/commands/database/migration-new.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts index 61b461f36f8..b9e51d54174 100644 --- a/src/commands/database/migration-new.ts +++ b/src/commands/database/migration-new.ts @@ -144,7 +144,9 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo const migrationFilePath = join(folderPath, 'migration.sql') await mkdir(folderPath, { recursive: true }) - await writeFile(migrationFilePath, `-- Write your migration SQL here + await writeFile( + migrationFilePath, + `-- Write your migration SQL here -- -- Example: -- CREATE TABLE IF NOT EXISTS users ( @@ -152,7 +154,9 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo -- name TEXT NOT NULL, -- created_at TIMESTAMP DEFAULT NOW() -- ); -`, { flag: 'wx' }) +`, + { flag: 'wx' }, + ) if (json) { logJson({ path: folderPath, name: folderName }) From 426af3d914e4d5dfbb718b54b6e324ca5999d072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Mon, 16 Mar 2026 08:54:48 +0100 Subject: [PATCH 6/7] fix the rabbit --- src/commands/database/migration-new.ts | 7 +++++-- .../unit/commands/database/migration-new.test.ts | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/commands/database/migration-new.ts b/src/commands/database/migration-new.ts index b9e51d54174..ebfab8886e1 100644 --- a/src/commands/database/migration-new.ts +++ b/src/commands/database/migration-new.ts @@ -72,8 +72,11 @@ const getExistingMigrationNames = async (migrationsDirectory: string): Promise entry.isDirectory()) .map((entry) => entry.name) .sort() - } catch { - return [] + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error } } diff --git a/tests/unit/commands/database/migration-new.test.ts b/tests/unit/commands/database/migration-new.test.ts index 2432432b366..9dc60c32652 100644 --- a/tests/unit/commands/database/migration-new.test.ts +++ b/tests/unit/commands/database/migration-new.test.ts @@ -242,8 +242,10 @@ describe('migrationNew', () => { ).rejects.toThrow() }) - test('handles empty migrations directory gracefully', async () => { - mockReaddir.mockRejectedValue(new Error('ENOENT')) + test('handles missing migrations directory gracefully', async () => { + const readError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException + readError.code = 'ENOENT' + mockReaddir.mockRejectedValue(readError) await migrationNew({ description: 'initial migration', scheme: 'sequential' }, createMockCommand()) @@ -251,4 +253,14 @@ describe('migrationNew', () => { recursive: true, }) }) + + test('rethrows non-ENOENT readdir errors', async () => { + const permError = new Error('EACCES: permission denied') as NodeJS.ErrnoException + permError.code = 'EACCES' + mockReaddir.mockRejectedValue(permError) + + await expect( + migrationNew({ description: 'add table', scheme: 'sequential' }, createMockCommand()), + ).rejects.toThrow('EACCES') + }) }) From 226fc7bda05002516fdb6f7f97f7aaa0c40686ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Mon, 16 Mar 2026 09:18:12 +0100 Subject: [PATCH 7/7] lint again... --- tests/unit/commands/database/migration-new.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/commands/database/migration-new.test.ts b/tests/unit/commands/database/migration-new.test.ts index 9dc60c32652..f618b82520b 100644 --- a/tests/unit/commands/database/migration-new.test.ts +++ b/tests/unit/commands/database/migration-new.test.ts @@ -259,8 +259,8 @@ describe('migrationNew', () => { permError.code = 'EACCES' mockReaddir.mockRejectedValue(permError) - await expect( - migrationNew({ description: 'add table', scheme: 'sequential' }, createMockCommand()), - ).rejects.toThrow('EACCES') + await expect(migrationNew({ description: 'add table', scheme: 'sequential' }, createMockCommand())).rejects.toThrow( + 'EACCES', + ) }) })