diff --git a/docs/commands/database.md b/docs/commands/database.md index ad69e3a1a6c..b3de82e4d64 100644 --- a/docs/commands/database.md +++ b/docs/commands/database.md @@ -201,7 +201,7 @@ netlify database migrations new - `description` (*string*) - Purpose of the migration (used to generate the file name) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON -- `scheme` (*sequential | timestamp*) - Numbering scheme for migration prefixes +- `scheme` (*timestamp | sequential*) - Numbering scheme for migration prefixes - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 1d39ec2dbaa..798bb02317d 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -96,8 +96,8 @@ export const createDatabaseCommand = (program: BaseCommand) => { .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', + 'sequential', ]), ) .option('--json', 'Output result as JSON') diff --git a/src/commands/database/db-migration-new.ts b/src/commands/database/db-migration-new.ts index 59693957f25..1a3ef73b0b6 100644 --- a/src/commands/database/db-migration-new.ts +++ b/src/commands/database/db-migration-new.ts @@ -7,6 +7,7 @@ import { log, logJson } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' import { utcTimestampPrefix } from './util/timestamp.js' +import { isInteractive } from '../../utils/scripted-commands.js' export type NumberingScheme = 'sequential' | 'timestamp' @@ -89,31 +90,43 @@ export const migrationNew = async (options: MigrationNewOptions, command: BaseCo 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 (isInteractive()) { + 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 + } else { + throw new Error( + `--description argument is required when not running interactively. Provide a description of the migration (e.g. --description "add users table").`, + ) + } } 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 defaultScheme = detectedScheme ?? 'timestamp' + + if (isInteractive()) { + const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([ + { + type: 'list', + name: 'scheme', + message: 'Numbering scheme:', + choices: [ + { name: 'Timestamp (e.g. 20260312143000) [Recommended]', value: 'timestamp' }, + { name: 'Sequential (e.g. 0001, 0002, ...)', value: 'sequential' }, + ], + default: defaultScheme, + }, + ]) + scheme = answers.scheme + } else { + scheme = defaultScheme + } } const slug = generateSlug(description) diff --git a/tests/unit/commands/database/db-migration-new.test.ts b/tests/unit/commands/database/db-migration-new.test.ts index 64d102e0148..aeed6876d57 100644 --- a/tests/unit/commands/database/db-migration-new.test.ts +++ b/tests/unit/commands/database/db-migration-new.test.ts @@ -26,6 +26,10 @@ vi.mock('inquirer', () => ({ default: { prompt: vi.fn() }, })) +vi.mock('../../../../src/utils/scripted-commands.js', () => ({ + isInteractive: vi.fn(), +})) + vi.mock('../../../../src/utils/command-helpers.js', async () => ({ ...(await vi.importActual('../../../../src/utils/command-helpers.js')), log: (...args: string[]) => { @@ -46,6 +50,7 @@ import { generateNextPrefix, } from '../../../../src/commands/database/db-migration-new.js' import { resolveMigrationsDirectory } from '../../../../src/commands/database/util/migrations-path.js' +import { isInteractive } from '../../../../src/utils/scripted-commands.js' function createMockCommand(overrides: { migrationsPath?: string | undefined } = {}) { const { migrationsPath = '/project/netlify/database/migrations' } = overrides @@ -195,6 +200,7 @@ describe('migrationNew', () => { }) test('prompts for description when not provided', async () => { + vi.mocked(isInteractive).mockReturnValue(true) vi.mocked(inquirer.prompt) .mockResolvedValueOnce({ description: 'create users table' }) .mockResolvedValueOnce({ scheme: 'sequential' }) @@ -205,7 +211,16 @@ describe('migrationNew', () => { expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('create-users-table'), expect.any(Object)) }) + test('throws when description is missing in non-interactive mode', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + + await expect(migrationNew({}, createMockCommand())).rejects.toThrow( + '--description argument is required when not running interactively', + ) + }) + test('prompts for scheme with detected default when not provided', async () => { + vi.mocked(isInteractive).mockReturnValue(true) mockReaddir.mockResolvedValue([dirEntry('0001_create-users'), dirEntry('0002_add-posts')]) vi.mocked(inquirer.prompt).mockResolvedValueOnce({ scheme: 'sequential' }) @@ -215,6 +230,41 @@ describe('migrationNew', () => { expect(promptCall[0].default).toBe('sequential') }) + test('defaults to timestamp scheme in non-interactive mode when no migrations exist', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + + await migrationNew({ description: 'add posts table' }, createMockCommand()) + + expect(inquirer.prompt).not.toHaveBeenCalled() + const mkdirCall = mockMkdir.mock.calls[0][0] as string + const folderName = mkdirCall.split(/[/\\]/).pop() ?? '' + expect(folderName).toMatch(/^\d{14}_add-posts-table$/) + }) + + test('detects sequential scheme in non-interactive mode when sequential migrations exist', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + mockReaddir.mockResolvedValue([dirEntry('0001_create-users'), dirEntry('0002_add-posts')]) + + await migrationNew({ description: 'add comments' }, createMockCommand()) + + expect(inquirer.prompt).not.toHaveBeenCalled() + expect(mockMkdir).toHaveBeenCalledWith(join('/project/netlify/database/migrations', '0003_add-comments'), { + recursive: true, + }) + }) + + test('detects timestamp scheme in non-interactive mode when timestamp migrations exist', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + mockReaddir.mockResolvedValue([dirEntry('20260312143000_create-users'), dirEntry('20260312150000_add-posts')]) + + await migrationNew({ description: 'add comments' }, createMockCommand()) + + expect(inquirer.prompt).not.toHaveBeenCalled() + const mkdirCall = mockMkdir.mock.calls[0][0] as string + const folderName = mkdirCall.split(/[/\\]/).pop() ?? '' + expect(folderName).toMatch(/^\d{14}_add-comments$/) + }) + test('uses timestamp scheme when specified', async () => { await migrationNew({ description: 'add posts table', scheme: 'timestamp' }, createMockCommand())