Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/commands/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatabaseBoilerplateType>(['drizzle'])
Expand Down Expand Up @@ -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 <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'])
Comment on lines +180 to +189
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
migrationsCommand
.command('reset')
.description('Delete local migration files that have not been applied yet')
.option('-b, --branch <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'])
migrationsCommand
.command('reset')
.description('Delete local migration files that have not been applied yet')
.option('-b, --branch <branch>', 'Target a remote 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', 'netlify db migrations reset --branch production'])

I don't exactly suggest this (I'm struggling on exact wording) - but just to sync with the removal of the production branch check removal.

Can be done in follow up

Copy link
Copy Markdown
Contributor

@pieh pieh Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at command like migrations pull - it also seem like you can do just --branch there (and it would use production) and maybe some wording for handling those cases can be borrowed here

}
}
16 changes: 12 additions & 4 deletions src/commands/database/db-migrate.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions src/commands/database/db-migration-pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
149 changes: 149 additions & 0 deletions src/commands/database/db-migrations-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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 { 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) {
await resetAgainstBranch(branch, json, command)
return
}

await resetAgainstLocal(json, command)
}

const resetAgainstLocal = async (json: boolean, command: BaseCommand): Promise<void> => {
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<void> => {
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<string>,
): Promise<string[]> => {
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<LocalMigration[]> => {
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
}
1 change: 1 addition & 0 deletions src/commands/database/util/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const MIGRATIONS_SCHEMA = 'netlify'
export const MIGRATIONS_TABLE = `${MIGRATIONS_SCHEMA}.migrations`
export const PRODUCTION_BRANCH = 'production'
34 changes: 31 additions & 3 deletions tests/unit/commands/database/db-migrate.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { join } from 'path'

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', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
applyMigrations: (...args: unknown[]) => mockApplyMigrations(...args),
}))

vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>()
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({
Expand Down Expand Up @@ -57,6 +69,7 @@ describe('migrate', () => {
jsonMessages.length = 0
vi.clearAllMocks()
mockApplyMigrations.mockResolvedValue([])
mockExistsSync.mockReturnValue(false)
})

test('calls cleanup after successful migration', async () => {
Expand All @@ -79,7 +92,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: {} },
Expand All @@ -88,6 +102,20 @@ 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<typeof migrate>[1]

await migrate({}, command)

const expectedPath = join('/project', 'netlify', 'database', 'migrations')
expect(mockExistsSync).toHaveBeenCalledWith(expectedPath)
expect(mockApplyMigrations).toHaveBeenCalledWith(mockExecutor, expectedPath, undefined)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('passes the --to target to applyMigrations', async () => {
await migrate({ to: '0002_add_posts' }, createMockCommand())

Expand Down
27 changes: 26 additions & 1 deletion tests/unit/commands/database/db-migration-pull.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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')
})
})
})
Loading
Loading