diff --git a/package-lock.json b/package-lock.json index 0212e224306..d68e36d21e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6509,7 +6509,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6537,7 +6536,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6635,7 +6633,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..ebfab8886e1 --- /dev/null +++ b/src/commands/database/migration-new.ts @@ -0,0 +1,170 @@ +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{4})[_-]/.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 (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } +} + +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) { + 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) + 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( + 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 }) + } 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..f618b82520b --- /dev/null +++ b/tests/unit/commands/database/migration-new.test.ts @@ -0,0 +1,266 @@ +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) + }) + + test.each(['!!!', '___', ' ', '@#$%'])('returns empty string for non-alphanumeric input "%s"', (input) => { + expect(generateSlug(input)).toBe('') + }) +}) + +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}$/) + }) + + test('ignores timestamp-style names when computing sequential prefix', () => { + expect(generateNextPrefix(['20260312143000_add-users', '0003_add-posts'], 'sequential')).toBe('0004') + }) +}) + +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'), + expect.stringContaining('-- Write your migration SQL here'), + { + flag: 'wx', + }, + ) + }) + + 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('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 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()) + + expect(mockMkdir).toHaveBeenCalledWith(join('/project/netlify/db/migrations', '0001_initial-migration'), { + 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', + ) + }) +})