From 4913adc10c80ea141fa4980f6d8d80c9d1513706 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Tue, 21 Apr 2026 09:59:47 +0100 Subject: [PATCH 1/3] feat: add `db migrations reset` command --- src/commands/database/database.ts | 12 + src/commands/database/db-migrate.ts | 16 +- src/commands/database/db-migration-pull.ts | 5 +- src/commands/database/db-migrations-reset.ts | 157 +++++++++ src/commands/database/util/constants.ts | 1 + .../unit/commands/database/db-migrate.test.ts | 31 +- .../database/db-migration-pull.test.ts | 27 +- .../database/db-migrations-reset.test.ts | 326 ++++++++++++++++++ 8 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 src/commands/database/db-migrations-reset.ts create mode 100644 tests/unit/commands/database/db-migrations-reset.test.ts diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index aa0e809b931..5c45697238d 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -4,6 +4,7 @@ import BaseCommand from '../base-command.js' import type { DatabaseBoilerplateType, DatabaseInitOptions } from './legacy/db-init.js' import type { MigrationNewOptions } from './db-migration-new.js' import type { MigrationPullOptions } from './db-migration-pull.js' +import type { MigrationsResetOptions } from './db-migrations-reset.js' import type { DatabaseStatusOptions } from './db-status.js' const supportedBoilerplates = new Set(['drizzle']) @@ -175,5 +176,16 @@ export const createDatabaseCommand = (program: BaseCommand) => { 'netlify db migrations pull --branch', 'netlify db migrations pull --force', ]) + + migrationsCommand + .command('reset') + .description('Delete local migration files that have not been applied yet') + .option('-b, --branch ', 'Target a remote preview branch instead of the local development database') + .option('--json', 'Output result as JSON') + .action(async (options: MigrationsResetOptions, command: BaseCommand) => { + const { migrationsReset } = await import('./db-migrations-reset.js') + await migrationsReset(options, command) + }) + .addExamples(['netlify db migrations reset', 'netlify db migrations reset --branch my-feature-branch']) } } diff --git a/src/commands/database/db-migrate.ts b/src/commands/database/db-migrate.ts index c99943822b6..178cf49691a 100644 --- a/src/commands/database/db-migrate.ts +++ b/src/commands/database/db-migrate.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'fs' +import { join } from 'path' + import { applyMigrations } from '@netlify/dev' import { log, logJson } from '../../utils/command-helpers.js' @@ -17,11 +20,16 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => throw new Error('Could not determine the project root directory.') } - const migrationsDirectory = command.netlify.config.db?.migrations?.path + let migrationsDirectory = command.netlify.config.db?.migrations?.path if (!migrationsDirectory) { - throw new Error( - `No migrations directory found. Create a directory at ${DEFAULT_MIGRATIONS_PATH} or set \`db.migrations.path\` in \`netlify.toml\`.`, - ) + const defaultDirectory = join(buildDir, DEFAULT_MIGRATIONS_PATH) + if (existsSync(defaultDirectory)) { + migrationsDirectory = defaultDirectory + } else { + throw new Error( + `No migrations directory found. Create a directory at ${DEFAULT_MIGRATIONS_PATH} or set \`db.migrations.path\` in \`netlify.toml\`.`, + ) + } } const { executor, cleanup } = await connectToDatabase(buildDir) diff --git a/src/commands/database/db-migration-pull.ts b/src/commands/database/db-migration-pull.ts index 8a9b55a92ed..2c56b763201 100644 --- a/src/commands/database/db-migration-pull.ts +++ b/src/commands/database/db-migration-pull.ts @@ -6,6 +6,7 @@ import inquirer from 'inquirer' import { log, logJson } from '../../utils/command-helpers.js' import execa from '../../utils/execa.js' import BaseCommand from '../base-command.js' +import { PRODUCTION_BRANCH } from './util/constants.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' export interface MigrationPullOptions { @@ -81,8 +82,8 @@ const fetchMigrations = async (command: BaseCommand, branch: string | undefined) export const migrationPull = async (options: MigrationPullOptions, command: BaseCommand) => { const { force, json } = options - const branch = await resolveBranch(options.branch) - const source = branch ?? 'production' + const branch = (await resolveBranch(options.branch)) ?? process.env.NETLIFY_DB_BRANCH + const source = branch ?? PRODUCTION_BRANCH const migrations = await fetchMigrations(command, branch) if (migrations.length === 0) { diff --git a/src/commands/database/db-migrations-reset.ts b/src/commands/database/db-migrations-reset.ts new file mode 100644 index 00000000000..bb3a7b01f32 --- /dev/null +++ b/src/commands/database/db-migrations-reset.ts @@ -0,0 +1,157 @@ +import { readdir, rm } from 'fs/promises' +import { join } from 'path' + +import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' +import BaseCommand from '../base-command.js' +import { localAppliedMigrations, remoteAppliedMigrations } from './util/applied-migrations.js' +import { PRODUCTION_BRANCH } from './util/constants.js' +import { connectToDatabase } from './util/db-connection.js' +import { resolveMigrationsDirectory } from './util/migrations-path.js' + +export interface MigrationsResetOptions { + branch?: string + json?: boolean +} + +const SQL_EXTENSION = '.sql' + +interface LocalMigration { + // Name as stored in the tracking table — the directory name for directory- + // style migrations, or the filename with `.sql` stripped for flat files. + name: string + // Absolute path on disk (directory or file). + path: string +} + +export const migrationsReset = async (options: MigrationsResetOptions, command: BaseCommand) => { + const branch = options.branch ?? process.env.NETLIFY_DB_BRANCH + const json = options.json ?? false + + if (branch) { + if (branch === PRODUCTION_BRANCH) { + throw new Error( + `Refusing to target the production branch. ${chalk.bold( + 'db migrations reset', + )} against a remote branch is only for preview branches.`, + ) + } + await resetAgainstBranch(branch, json, command) + return + } + + await resetAgainstLocal(json, command) +} + +const resetAgainstLocal = async (json: boolean, command: BaseCommand): Promise => { + const buildDir = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory + if (!buildDir) { + throw new Error('Could not determine the project root directory.') + } + + const migrationsDirectory = resolveMigrationsDirectory(command) + + if (!json) { + log('Removing local migration files that have not been applied to the local development database.') + } + + const { executor, cleanup } = await connectToDatabase(buildDir) + + let deleted: string[] + try { + const applied = await localAppliedMigrations({ executor })() + const appliedNames = new Set(applied.map((m) => m.name)) + deleted = await deletePendingMigrationFiles(migrationsDirectory, appliedNames) + } finally { + await cleanup() + } + + logOutcome(deleted, { json, target: 'local' }) +} + +const resetAgainstBranch = async (branch: string, json: boolean, command: BaseCommand): Promise => { + const siteId = command.siteId + const accessToken = command.netlify.api.accessToken + const basePath = command.netlify.api.basePath + + if (!siteId) { + throw new Error(`The project must be linked with ${netlifyCommand()} link to target a remote branch.`) + } + if (!accessToken) { + throw new Error(`You must be logged in with ${netlifyCommand()} login to target a remote branch.`) + } + + const migrationsDirectory = resolveMigrationsDirectory(command) + + if (!json) { + log( + `Removing local migration files that have not been applied to database branch ${chalk.bold(branch)}. ` + + 'Files that are already applied to the branch are kept untouched.', + ) + } + + const applied = await remoteAppliedMigrations({ siteId, accessToken, basePath, branch })() + const appliedNames = new Set(applied.map((m) => m.name)) + + const deleted = await deletePendingMigrationFiles(migrationsDirectory, appliedNames) + + logOutcome(deleted, { json, target: 'branch', branch }) +} + +const logOutcome = ( + deleted: string[], + params: { json: boolean; target: 'local' | 'branch'; branch?: string }, +): void => { + if (params.json) { + logJson({ + reset: true, + target: params.target, + ...(params.branch ? { branch: params.branch } : {}), + pendingMigrationFilesDeleted: deleted, + }) + return + } + + if (deleted.length === 0) { + log('No pending migration files to delete — all local migrations are already applied.') + return + } + log(`Deleted ${String(deleted.length)} pending migration file(s):`) + for (const name of deleted) { + log(` • ${name}`) + } +} + +const deletePendingMigrationFiles = async ( + migrationsDirectory: string, + appliedNames: Set, +): Promise => { + const local = await readLocalMigrations(migrationsDirectory) + const pending = local.filter((m) => !appliedNames.has(m.name)) + for (const migration of pending) { + await rm(migration.path, { recursive: true, force: true }) + } + return pending.map((m) => m.name) +} + +const readLocalMigrations = async (migrationsDirectory: string): Promise => { + let entries + try { + entries = await readdir(migrationsDirectory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } + + const migrations: LocalMigration[] = [] + for (const entry of entries) { + const entryPath = join(migrationsDirectory, entry.name) + if (entry.isDirectory()) { + migrations.push({ name: entry.name, path: entryPath }) + } else if (entry.isFile() && entry.name.endsWith(SQL_EXTENSION)) { + migrations.push({ name: entry.name.slice(0, -SQL_EXTENSION.length), path: entryPath }) + } + } + return migrations +} diff --git a/src/commands/database/util/constants.ts b/src/commands/database/util/constants.ts index 9c69ee94dc9..5178f735d3c 100644 --- a/src/commands/database/util/constants.ts +++ b/src/commands/database/util/constants.ts @@ -1,2 +1,3 @@ export const MIGRATIONS_SCHEMA = 'netlify' export const MIGRATIONS_TABLE = `${MIGRATIONS_SCHEMA}.migrations` +export const PRODUCTION_BRANCH = 'production' diff --git a/tests/unit/commands/database/db-migrate.test.ts b/tests/unit/commands/database/db-migrate.test.ts index 488df92be1e..c373d374068 100644 --- a/tests/unit/commands/database/db-migrate.test.ts +++ b/tests/unit/commands/database/db-migrate.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test, vi, beforeEach } from 'vitest' -const { mockApplyMigrations, mockCleanup, mockExecutor, logMessages, jsonMessages } = vi.hoisted(() => { +const { mockApplyMigrations, mockCleanup, mockExecutor, mockExistsSync, logMessages, jsonMessages } = vi.hoisted(() => { const mockApplyMigrations = vi.fn().mockResolvedValue([]) const mockCleanup = vi.fn().mockResolvedValue(undefined) const mockExecutor = {} + const mockExistsSync = vi.fn().mockReturnValue(false) const logMessages: string[] = [] const jsonMessages: unknown[] = [] - return { mockApplyMigrations, mockCleanup, mockExecutor, logMessages, jsonMessages } + return { mockApplyMigrations, mockCleanup, mockExecutor, mockExistsSync, logMessages, jsonMessages } }) vi.mock('@netlify/dev', () => ({ @@ -14,6 +15,15 @@ vi.mock('@netlify/dev', () => ({ applyMigrations: (...args: unknown[]) => mockApplyMigrations(...args), })) +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + existsSync: (...args: unknown[]) => mockExistsSync(...args), + } +}) + vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ connectToDatabase: vi.fn().mockImplementation(() => Promise.resolve({ @@ -57,6 +67,7 @@ describe('migrate', () => { jsonMessages.length = 0 vi.clearAllMocks() mockApplyMigrations.mockResolvedValue([]) + mockExistsSync.mockReturnValue(false) }) test('calls cleanup after successful migration', async () => { @@ -79,7 +90,8 @@ describe('migrate', () => { expect(mockApplyMigrations).toHaveBeenCalledWith(mockExecutor, '/custom/migrations', undefined) }) - test('throws when no migrations directory is configured', async () => { + test('throws when no migrations directory is configured and the default path does not exist', async () => { + mockExistsSync.mockReturnValue(false) const command = { project: { root: '/project', baseDirectory: undefined }, netlify: { site: { root: '/project' }, config: {} }, @@ -88,6 +100,19 @@ describe('migrate', () => { await expect(migrate({}, command)).rejects.toThrow('No migrations directory found') }) + test('falls back to the default path when no config is set and the default directory exists', async () => { + mockExistsSync.mockReturnValue(true) + const command = { + project: { root: '/project', baseDirectory: undefined }, + netlify: { site: { root: '/project' }, config: {} }, + } as unknown as Parameters[1] + + await migrate({}, command) + + expect(mockExistsSync).toHaveBeenCalledWith('/project/netlify/database/migrations') + expect(mockApplyMigrations).toHaveBeenCalledWith(mockExecutor, '/project/netlify/database/migrations', undefined) + }) + test('passes the --to target to applyMigrations', async () => { await migrate({ to: '0002_add_posts' }, createMockCommand()) diff --git a/tests/unit/commands/database/db-migration-pull.test.ts b/tests/unit/commands/database/db-migration-pull.test.ts index 5170272b9ce..461fef4d688 100644 --- a/tests/unit/commands/database/db-migration-pull.test.ts +++ b/tests/unit/commands/database/db-migration-pull.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi, beforeEach } from 'vitest' +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' const { mockRm, mockMkdir, mockWriteFile, mockFetch, mockExeca, logMessages, jsonMessages } = vi.hoisted(() => { const mockRm = vi.fn().mockResolvedValue(undefined) @@ -98,6 +98,11 @@ describe('migrationPull', () => { logMessages.length = 0 jsonMessages.length = 0 vi.clearAllMocks() + delete process.env.NETLIFY_DB_BRANCH + }) + + afterEach(() => { + delete process.env.NETLIFY_DB_BRANCH }) test('throws when project is not linked', async () => { @@ -325,5 +330,25 @@ describe('migrationPull', () => { 'Could not determine the current git branch', ) }) + + test('falls back to NETLIFY_DB_BRANCH env var when --branch is not passed', async () => { + process.env.NETLIFY_DB_BRANCH = 'feature-env' + mockFetchResponse(sampleMigrations) + + await migrationPull({ force: true }, createMockCommand()) + + const calledUrl = mockFetch.mock.calls[0][0] as URL + expect(calledUrl.searchParams.get('branch')).toBe('feature-env') + }) + + test('--branch wins over NETLIFY_DB_BRANCH when both are set', async () => { + process.env.NETLIFY_DB_BRANCH = 'env-branch' + mockFetchResponse(sampleMigrations) + + await migrationPull({ branch: 'flag-branch', force: true }, createMockCommand()) + + const calledUrl = mockFetch.mock.calls[0][0] as URL + expect(calledUrl.searchParams.get('branch')).toBe('flag-branch') + }) }) }) diff --git a/tests/unit/commands/database/db-migrations-reset.test.ts b/tests/unit/commands/database/db-migrations-reset.test.ts new file mode 100644 index 00000000000..e0d04244ede --- /dev/null +++ b/tests/unit/commands/database/db-migrations-reset.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' + +const { mockCleanup, mockExecutor, mockQuery, mockReaddir, mockRm, mockFetch, logMessages, jsonMessages } = vi.hoisted( + () => { + const mockCleanup = vi.fn().mockResolvedValue(undefined) + const mockQuery = vi.fn() + const mockExecutor = { query: mockQuery } + const mockReaddir = vi.fn() + const mockRm = vi.fn().mockResolvedValue(undefined) + const mockFetch = vi.fn() + const logMessages: string[] = [] + const jsonMessages: unknown[] = [] + return { mockCleanup, mockExecutor, mockQuery, mockReaddir, mockRm, mockFetch, logMessages, jsonMessages } + }, +) + +vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ + connectToDatabase: vi.fn().mockImplementation(() => + Promise.resolve({ + executor: mockExecutor, + cleanup: mockCleanup, + }), + ), +})) + +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 + rm: (...args: unknown[]) => mockRm(...args), + } +}) + +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) + }, +})) + +vi.stubGlobal('fetch', mockFetch) + +import { migrationsReset } from '../../../../src/commands/database/db-migrations-reset.js' + +const makeDirents = (specs: { name: string; type?: 'directory' | 'file' }[]) => + specs.map(({ name, type = 'directory' }) => ({ + name, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + })) + +function mockLocalAppliedRows(names: string[]) { + mockQuery.mockResolvedValue({ rows: names.map((name) => ({ name })) }) +} + +function createMockCommand( + overrides: { buildDir?: string; projectRoot?: string; siteId?: string | null; accessToken?: string | null } = {}, +) { + const { buildDir = '/project', projectRoot = '/project' } = overrides + const siteId = overrides.siteId === null ? undefined : overrides.siteId ?? 'site-123' + const accessToken = overrides.accessToken === null ? undefined : overrides.accessToken ?? 'Bearer test-token' + + return { + siteId, + project: { root: projectRoot, baseDirectory: undefined }, + netlify: { + site: { root: buildDir, id: siteId }, + config: {}, + api: { + accessToken, + basePath: 'https://api.netlify.com/api/v1', + }, + }, + } as unknown as Parameters[1] +} + +describe('migrationsReset', () => { + beforeEach(() => { + logMessages.length = 0 + jsonMessages.length = 0 + vi.clearAllMocks() + mockCleanup.mockResolvedValue(undefined) + mockRm.mockResolvedValue(undefined) + mockReaddir.mockResolvedValue([]) + mockLocalAppliedRows([]) + delete process.env.NETLIFY_DB_BRANCH + }) + + afterEach(() => { + delete process.env.NETLIFY_DB_BRANCH + }) + + describe('local (no --branch, no env)', () => { + test('deletes only pending migration subdirectories; leaves applied untouched', async () => { + mockLocalAppliedRows(['0001_a', '0002_b']) + mockReaddir.mockResolvedValue( + makeDirents([{ name: '0001_a' }, { name: '0002_b' }, { name: '0003_c' }, { name: '0004_d' }]), + ) + + await migrationsReset({}, createMockCommand()) + + const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) + expect(rmPaths).toEqual([ + '/project/netlify/database/migrations/0003_c', + '/project/netlify/database/migrations/0004_d', + ]) + expect(mockRm.mock.calls[0][1]).toEqual({ recursive: true, force: true }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('handles flat `.sql` migrations alongside directories', async () => { + mockLocalAppliedRows([]) + mockReaddir.mockResolvedValue( + makeDirents([ + { name: '0001_a', type: 'directory' }, + { name: '0002_b.sql', type: 'file' }, + ]), + ) + + await migrationsReset({}, createMockCommand()) + + const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) + expect(rmPaths).toEqual([ + '/project/netlify/database/migrations/0001_a', + '/project/netlify/database/migrations/0002_b.sql', + ]) + }) + + test('is a no-op when there are no pending migrations', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }])) + + await migrationsReset({}, createMockCommand()) + + expect(mockRm).not.toHaveBeenCalled() + expect(logMessages.join('\n')).toContain('No pending migration files to delete') + }) + + test('logs a before + after summary', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }, { name: '0002_b' }])) + + await migrationsReset({}, createMockCommand()) + + const output = logMessages.join('\n') + expect(output).toContain( + 'Removing local migration files that have not been applied to the local development database', + ) + expect(output).toContain('Deleted 1 pending migration file(s)') + expect(output).toContain('0002_b') + }) + + test('outputs JSON when --json is set', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }, { name: '0002_b' }])) + + await migrationsReset({ json: true }, createMockCommand()) + + expect(logMessages).toHaveLength(0) + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + reset: true, + target: 'local', + pendingMigrationFilesDeleted: ['0002_b'], + }) + }) + + test('calls cleanup even when something downstream fails', async () => { + mockReaddir.mockRejectedValueOnce(new Error('boom')) + + await expect(migrationsReset({}, createMockCommand())).rejects.toThrow('boom') + expect(mockCleanup).toHaveBeenCalledOnce() + }) + + test('throws when project root cannot be determined', async () => { + const command = { + project: { root: undefined, baseDirectory: undefined }, + netlify: { site: { root: undefined }, config: {}, api: {} }, + } as unknown as Parameters[1] + + await expect(migrationsReset({}, command)).rejects.toThrow('Could not determine the project root directory.') + }) + }) + + describe('remote branch (--branch / NETLIFY_DB_BRANCH)', () => { + function mockBranchAppliedResponse(names: string[]) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + migrations: names.map((name, i) => ({ + version: i + 1, + name, + path: `${name}/migration.sql`, + applied: true, + })), + }), + }) + } + + test('fetches branch applied list and deletes local files not in it', async () => { + mockBranchAppliedResponse(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }, { name: '0002_b' }, { name: '0003_c' }])) + + await migrationsReset({ branch: 'feature-x' }, createMockCommand()) + + const fetchUrl = (mockFetch.mock.calls[0][0] as URL | string).toString() + expect(fetchUrl).toContain('/database/migrations') + expect(fetchUrl).toContain('branch=feature-x') + + const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) + expect(rmPaths).toEqual([ + '/project/netlify/database/migrations/0002_b', + '/project/netlify/database/migrations/0003_c', + ]) + // Does NOT connect to the local DB. + expect(mockCleanup).not.toHaveBeenCalled() + }) + + test('logs before and after messages naming the branch', async () => { + mockBranchAppliedResponse(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }, { name: '0002_b' }])) + + await migrationsReset({ branch: 'feature-x' }, createMockCommand()) + + const output = logMessages.join('\n') + expect(output).toContain('not been applied to database branch') + expect(output).toContain('feature-x') + expect(output).toContain('Deleted 1 pending migration file(s)') + expect(output).toContain('0002_b') + }) + + test('no-op when nothing is pending', async () => { + mockBranchAppliedResponse(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }])) + + await migrationsReset({ branch: 'feature-x' }, createMockCommand()) + + expect(mockRm).not.toHaveBeenCalled() + expect(logMessages.join('\n')).toContain('No pending migration files to delete') + }) + + test('JSON output shape for remote', async () => { + mockBranchAppliedResponse([]) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }])) + + await migrationsReset({ branch: 'feature-x', json: true }, createMockCommand()) + + expect(logMessages).toHaveLength(0) + expect(jsonMessages[0]).toEqual({ + reset: true, + target: 'branch', + branch: 'feature-x', + pendingMigrationFilesDeleted: ['0001_a'], + }) + }) + + test('refuses to target the production branch', async () => { + await expect(migrationsReset({ branch: 'production' }, createMockCommand())).rejects.toThrow('Refusing to target') + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('requires linked project', async () => { + await expect(migrationsReset({ branch: 'feature-x' }, createMockCommand({ siteId: null }))).rejects.toThrow( + 'project must be linked', + ) + }) + + test('requires login', async () => { + await expect(migrationsReset({ branch: 'feature-x' }, createMockCommand({ accessToken: null }))).rejects.toThrow( + 'must be logged in', + ) + }) + + test('surfaces API errors when fetching applied list fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('boom'), + }) + + await expect(migrationsReset({ branch: 'feature-x' }, createMockCommand())).rejects.toThrow( + 'Failed to fetch applied migrations (500)', + ) + }) + + test('falls back to NETLIFY_DB_BRANCH env var when --branch is not passed', async () => { + process.env.NETLIFY_DB_BRANCH = 'feature-env' + mockBranchAppliedResponse([]) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }])) + + await migrationsReset({ json: true }, createMockCommand()) + + const fetchUrl = (mockFetch.mock.calls[0][0] as URL | string).toString() + expect(fetchUrl).toContain('branch=feature-env') + expect(jsonMessages[0]).toMatchObject({ target: 'branch', branch: 'feature-env' }) + expect(mockCleanup).not.toHaveBeenCalled() + }) + + test('--branch wins over NETLIFY_DB_BRANCH when both are set', async () => { + process.env.NETLIFY_DB_BRANCH = 'env-branch' + mockBranchAppliedResponse([]) + mockReaddir.mockResolvedValue(makeDirents([{ name: '0001_a' }])) + + await migrationsReset({ branch: 'flag-branch', json: true }, createMockCommand()) + + const fetchUrl = (mockFetch.mock.calls[0][0] as URL | string).toString() + expect(fetchUrl).toContain('branch=flag-branch') + expect(jsonMessages[0]).toMatchObject({ target: 'branch', branch: 'flag-branch' }) + }) + + test('refuses NETLIFY_DB_BRANCH=production', async () => { + process.env.NETLIFY_DB_BRANCH = 'production' + + await expect(migrationsReset({}, createMockCommand())).rejects.toThrow('Refusing to target') + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) From 3f759b1722c04933ec495dab5a41140d6bad6895 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Tue, 21 Apr 2026 10:23:01 +0100 Subject: [PATCH 2/3] refactor: remove production branch constraint --- src/commands/database/db-migrations-reset.ts | 8 -------- .../commands/database/db-migrations-reset.test.ts | 12 ------------ 2 files changed, 20 deletions(-) diff --git a/src/commands/database/db-migrations-reset.ts b/src/commands/database/db-migrations-reset.ts index bb3a7b01f32..652047c28f6 100644 --- a/src/commands/database/db-migrations-reset.ts +++ b/src/commands/database/db-migrations-reset.ts @@ -4,7 +4,6 @@ import { join } from 'path' import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' import { localAppliedMigrations, remoteAppliedMigrations } from './util/applied-migrations.js' -import { PRODUCTION_BRANCH } from './util/constants.js' import { connectToDatabase } from './util/db-connection.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' @@ -28,13 +27,6 @@ export const migrationsReset = async (options: MigrationsResetOptions, command: const json = options.json ?? false if (branch) { - if (branch === PRODUCTION_BRANCH) { - throw new Error( - `Refusing to target the production branch. ${chalk.bold( - 'db migrations reset', - )} against a remote branch is only for preview branches.`, - ) - } await resetAgainstBranch(branch, json, command) return } diff --git a/tests/unit/commands/database/db-migrations-reset.test.ts b/tests/unit/commands/database/db-migrations-reset.test.ts index e0d04244ede..73d79bdc9c1 100644 --- a/tests/unit/commands/database/db-migrations-reset.test.ts +++ b/tests/unit/commands/database/db-migrations-reset.test.ts @@ -262,11 +262,6 @@ describe('migrationsReset', () => { }) }) - test('refuses to target the production branch', async () => { - await expect(migrationsReset({ branch: 'production' }, createMockCommand())).rejects.toThrow('Refusing to target') - expect(mockFetch).not.toHaveBeenCalled() - }) - test('requires linked project', async () => { await expect(migrationsReset({ branch: 'feature-x' }, createMockCommand({ siteId: null }))).rejects.toThrow( 'project must be linked', @@ -315,12 +310,5 @@ describe('migrationsReset', () => { expect(fetchUrl).toContain('branch=flag-branch') expect(jsonMessages[0]).toMatchObject({ target: 'branch', branch: 'flag-branch' }) }) - - test('refuses NETLIFY_DB_BRANCH=production', async () => { - process.env.NETLIFY_DB_BRANCH = 'production' - - await expect(migrationsReset({}, createMockCommand())).rejects.toThrow('Refusing to target') - expect(mockFetch).not.toHaveBeenCalled() - }) }) }) From 19f5846bdd318c7a070b2312d5aaf1489113ca22 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Tue, 21 Apr 2026 10:28:34 +0100 Subject: [PATCH 3/3] chore: fix Windows tests --- tests/unit/commands/database/db-migrate.test.ts | 7 +++++-- .../commands/database/db-migrations-reset.test.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/unit/commands/database/db-migrate.test.ts b/tests/unit/commands/database/db-migrate.test.ts index c373d374068..55be4150f99 100644 --- a/tests/unit/commands/database/db-migrate.test.ts +++ b/tests/unit/commands/database/db-migrate.test.ts @@ -1,3 +1,5 @@ +import { join } from 'path' + import { describe, expect, test, vi, beforeEach } from 'vitest' const { mockApplyMigrations, mockCleanup, mockExecutor, mockExistsSync, logMessages, jsonMessages } = vi.hoisted(() => { @@ -109,8 +111,9 @@ describe('migrate', () => { await migrate({}, command) - expect(mockExistsSync).toHaveBeenCalledWith('/project/netlify/database/migrations') - expect(mockApplyMigrations).toHaveBeenCalledWith(mockExecutor, '/project/netlify/database/migrations', undefined) + const expectedPath = join('/project', 'netlify', 'database', 'migrations') + expect(mockExistsSync).toHaveBeenCalledWith(expectedPath) + expect(mockApplyMigrations).toHaveBeenCalledWith(mockExecutor, expectedPath, undefined) }) test('passes the --to target to applyMigrations', async () => { diff --git a/tests/unit/commands/database/db-migrations-reset.test.ts b/tests/unit/commands/database/db-migrations-reset.test.ts index 73d79bdc9c1..4c30cf69aed 100644 --- a/tests/unit/commands/database/db-migrations-reset.test.ts +++ b/tests/unit/commands/database/db-migrations-reset.test.ts @@ -1,3 +1,5 @@ +import { join } from 'path' + import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' const { mockCleanup, mockExecutor, mockQuery, mockReaddir, mockRm, mockFetch, logMessages, jsonMessages } = vi.hoisted( @@ -107,8 +109,8 @@ describe('migrationsReset', () => { const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) expect(rmPaths).toEqual([ - '/project/netlify/database/migrations/0003_c', - '/project/netlify/database/migrations/0004_d', + join('/project', 'netlify', 'database', 'migrations', '0003_c'), + join('/project', 'netlify', 'database', 'migrations', '0004_d'), ]) expect(mockRm.mock.calls[0][1]).toEqual({ recursive: true, force: true }) expect(mockFetch).not.toHaveBeenCalled() @@ -127,8 +129,8 @@ describe('migrationsReset', () => { const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) expect(rmPaths).toEqual([ - '/project/netlify/database/migrations/0001_a', - '/project/netlify/database/migrations/0002_b.sql', + join('/project', 'netlify', 'database', 'migrations', '0001_a'), + join('/project', 'netlify', 'database', 'migrations', '0002_b.sql'), ]) }) @@ -217,8 +219,8 @@ describe('migrationsReset', () => { const rmPaths = mockRm.mock.calls.map((c) => c[0] as string) expect(rmPaths).toEqual([ - '/project/netlify/database/migrations/0002_b', - '/project/netlify/database/migrations/0003_c', + join('/project', 'netlify', 'database', 'migrations', '0002_b'), + join('/project', 'netlify', 'database', 'migrations', '0003_c'), ]) // Does NOT connect to the local DB. expect(mockCleanup).not.toHaveBeenCalled()