From 825d1d90401cd2a310c3290b7ffeffb320dae20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Tue, 18 Aug 2026 21:25:31 +0200 Subject: [PATCH] fix(database): recover a local database that cannot start A damaged local database directory made every `netlify database` command fail, including `reset`, which had to start the database before clearing it. Reset now offers to delete the directory instead, and startup failures report the underlying error rather than a generic message. --- docs/commands/database.md | 8 ++ src/commands/database/database.ts | 5 +- src/commands/database/db-reset.ts | 56 +++++++- src/commands/database/util/db-connection.ts | 38 ++++- tests/unit/commands/database/db-reset.test.ts | 135 ++++++++++++++++-- .../database/util/db-connection.test.ts | 131 +++++++++++++++++ 6 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 tests/unit/commands/database/util/db-connection.test.ts diff --git a/docs/commands/database.md b/docs/commands/database.md index b3de82e4d64..cbe5de6673c 100644 --- a/docs/commands/database.md +++ b/docs/commands/database.md @@ -137,10 +137,18 @@ netlify database reset **Flags** - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `force` (*boolean*) - Skip the confirmation prompt shown when the local database has to be deleted - `json` (*boolean*) - Output result as JSON - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +**Examples** + +```bash +netlify database reset +netlify database reset --force +``` + --- ## `database migrations` diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 798bb02317d..fc6f089c451 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 { MigrationNewOptions } from './db-migration-new.js' import type { MigrationPullOptions } from './db-migration-pull.js' import type { MigrationsResetOptions } from './db-migrations-reset.js' +import type { ResetOptions } from './db-reset.js' import type { DatabaseStatusOptions } from './db-status.js' export const createDatabaseCommand = (program: BaseCommand) => { @@ -72,11 +73,13 @@ export const createDatabaseCommand = (program: BaseCommand) => { dbCommand .command('reset') .description('Reset the local development database, removing all data and tables') + .option('--force', 'Skip the confirmation prompt shown when the local database has to be deleted', false) .option('--json', 'Output result as JSON') - .action(async (options: { json?: boolean }, command: BaseCommand) => { + .action(async (options: ResetOptions, command: BaseCommand) => { const { reset } = await import('./db-reset.js') await reset(options, command) }) + .addExamples(['netlify database reset', 'netlify database reset --force']) const migrationsCommand = dbCommand.command('migrations').description('Manage database migrations') diff --git a/src/commands/database/db-reset.ts b/src/commands/database/db-reset.ts index 4a54d678183..d0ffdfa5bc9 100644 --- a/src/commands/database/db-reset.ts +++ b/src/commands/database/db-reset.ts @@ -1,13 +1,54 @@ +import { rm } from 'fs/promises' + +import inquirer from 'inquirer' + import { resetDatabase } from '@netlify/dev' import { log, logJson } from '../../utils/command-helpers.js' +import { isInteractive } from '../../utils/scripted-commands.js' import BaseCommand from '../base-command.js' -import { connectToDatabase } from './util/db-connection.js' +import { connectToDatabase, LocalDatabaseStartError } from './util/db-connection.js' export interface ResetOptions { + force?: boolean json?: boolean } +const discardLocalDatabase = async (error: LocalDatabaseStartError, options: ResetOptions) => { + const { directory } = error + const { force, json } = options + + if (!force) { + if (json || !isInteractive()) { + throw new Error(`${error.summary}\nRe-run with --force to delete ${directory} and start from an empty database.`) + } + + log(error.summary) + + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Delete ${directory} and start from an empty database?`, + default: false, + }, + ]) + + if (!confirmed) { + log('Reset cancelled.') + return + } + } + + await rm(directory, { recursive: true, force: true }) + + if (json) { + logJson({ reset: true, discarded: true }) + } else { + log(`Deleted ${directory}. An empty database will be created the next time the project starts.`) + } +} + export const reset = async (options: ResetOptions, command: BaseCommand) => { const { json } = options const buildDir = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory @@ -15,7 +56,18 @@ export const reset = async (options: ResetOptions, command: BaseCommand) => { throw new Error('Could not determine the project root directory.') } - const { executor, cleanup } = await connectToDatabase(buildDir) + let connection + try { + connection = await connectToDatabase(buildDir) + } catch (error) { + if (!(error instanceof LocalDatabaseStartError)) { + throw error + } + await discardLocalDatabase(error, options) + return + } + + const { executor, cleanup } = connection try { await resetDatabase(executor) diff --git a/src/commands/database/util/db-connection.ts b/src/commands/database/util/db-connection.ts index a611951ecbc..fa5f2778deb 100644 --- a/src/commands/database/util/db-connection.ts +++ b/src/commands/database/util/db-connection.ts @@ -1,10 +1,31 @@ +import { join } from 'path' + import { Client } from 'pg' import { NetlifyDev, type SQLExecutor } from '@netlify/dev' import { LocalState } from '@netlify/dev-utils' +import { warn } from '../../../utils/command-helpers.js' import { PgClientExecutor } from './pg-client-executor.js' +export const getLocalDatabaseDirectory = (buildDir: string): string => join(buildDir, '.netlify', 'db') + +// `summary` omits the recovery hint carried by `message`, so `netlify database +// reset` — which is the recovery — doesn't tell the user to run it. +export class LocalDatabaseStartError extends Error { + readonly directory: string + readonly summary: string + + constructor(directory: string, causes: string[]) { + const summary = [`Failed to start the local database at ${directory}.`, ...causes].join('\n') + super( + `${summary}\nThe persisted data directory may be unusable. Run \`netlify database reset\` to discard it and start from an empty database.`, + ) + this.directory = directory + this.summary = summary + } +} + interface DBConnection { executor: SQLExecutor connectionString: string @@ -100,7 +121,18 @@ export async function connectRawClient(buildDir: string, urlOverride?: string): const state = new LocalState(buildDir) + // NetlifyDev swallows database startup failures into a warning, so the only + // way to report the underlying PGlite error is to capture what it logs. + const startupWarnings: string[] = [] + const netlifyDev = new NetlifyDev({ + logger: { + error: console.error, + log: console.log, + warn: (message = '') => { + startupWarnings.push(message) + }, + }, projectRoot: buildDir, aiGateway: { enabled: false }, blobs: { enabled: false }, @@ -120,9 +152,13 @@ export async function connectRawClient(buildDir: string, urlOverride?: string): const connectionString = state.get('dbConnectionString') if (!connectionString) { await netlifyDev.stop() - throw new Error('Local database failed to start.') + throw new LocalDatabaseStartError(getLocalDatabaseDirectory(buildDir), startupWarnings) } + startupWarnings.forEach((message) => { + warn(message) + }) + const client = new Client({ connectionString }) await client.connect() diff --git a/tests/unit/commands/database/db-reset.test.ts b/tests/unit/commands/database/db-reset.test.ts index 6e7a31e7913..2c3da7281c4 100644 --- a/tests/unit/commands/database/db-reset.test.ts +++ b/tests/unit/commands/database/db-reset.test.ts @@ -1,12 +1,36 @@ import { describe, expect, test, vi, beforeEach } from 'vitest' -const { mockResetDatabase, mockCleanup, mockExecutor, logMessages, jsonMessages } = vi.hoisted(() => { +const { + mockResetDatabase, + mockCleanup, + mockConnectToDatabase, + mockExecutor, + mockRm, + mockPrompt, + mockIsInteractive, + logMessages, + jsonMessages, +} = vi.hoisted(() => { const mockResetDatabase = vi.fn().mockResolvedValue(undefined) const mockCleanup = vi.fn().mockResolvedValue(undefined) const mockExecutor = {} + const mockConnectToDatabase = vi.fn() + const mockRm = vi.fn().mockResolvedValue(undefined) + const mockPrompt = vi.fn() + const mockIsInteractive = vi.fn().mockReturnValue(true) const logMessages: string[] = [] const jsonMessages: unknown[] = [] - return { mockResetDatabase, mockCleanup, mockExecutor, logMessages, jsonMessages } + return { + mockResetDatabase, + mockCleanup, + mockConnectToDatabase, + mockExecutor, + mockRm, + mockPrompt, + mockIsInteractive, + logMessages, + jsonMessages, + } }) vi.mock('@netlify/dev', () => ({ @@ -14,13 +38,26 @@ vi.mock('@netlify/dev', () => ({ resetDatabase: (...args: unknown[]) => mockResetDatabase(...args), })) -vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ - connectToDatabase: vi.fn().mockImplementation(() => - Promise.resolve({ - executor: mockExecutor, - cleanup: mockCleanup, - }), - ), +vi.mock('../../../../src/commands/database/util/db-connection.js', async () => ({ + ...(await vi.importActual('../../../../src/commands/database/util/db-connection.js')), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), +})) + +vi.mock('fs/promises', async () => ({ + ...(await vi.importActual('fs/promises')), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + rm: (...args: unknown[]) => mockRm(...args), +})) + +vi.mock('inquirer', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: { prompt: (...args: unknown[]) => mockPrompt(...args) }, +})) + +vi.mock('../../../../src/utils/scripted-commands.js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + isInteractive: () => mockIsInteractive(), })) vi.mock('../../../../src/utils/command-helpers.js', async () => ({ @@ -34,6 +71,7 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ })) import { reset } from '../../../../src/commands/database/db-reset.js' +import { LocalDatabaseStartError } from '../../../../src/commands/database/util/db-connection.js' function createMockCommand(overrides: { buildDir?: string; projectRoot?: string } = {}) { const { buildDir = '/project', projectRoot = '/project' } = overrides @@ -53,6 +91,9 @@ describe('reset', () => { jsonMessages.length = 0 vi.clearAllMocks() mockResetDatabase.mockResolvedValue(undefined) + mockIsInteractive.mockReturnValue(true) + mockRm.mockResolvedValue(undefined) + mockConnectToDatabase.mockResolvedValue({ executor: mockExecutor, cleanup: mockCleanup }) }) test('resets the database and calls cleanup', async () => { @@ -91,4 +132,80 @@ describe('reset', () => { await expect(reset({}, command)).rejects.toThrow('Could not determine the project root directory.') }) + + describe('when the local database cannot be started', () => { + const DB_DIRECTORY = '/project/.netlify/db' + const PGLITE_ABORT = 'Failed to start Netlify Database locally: RuntimeError: Aborted().' + + beforeEach(() => { + mockConnectToDatabase.mockRejectedValue(new LocalDatabaseStartError(DB_DIRECTORY, [PGLITE_ABORT])) + }) + + test('discards the data directory once the user confirms', async () => { + mockPrompt.mockResolvedValue({ confirmed: true }) + + await reset({}, createMockCommand()) + + expect(mockRm).toHaveBeenCalledWith(DB_DIRECTORY, { recursive: true, force: true }) + }) + + test('explains the underlying startup failure before prompting', async () => { + mockPrompt.mockResolvedValue({ confirmed: true }) + + await reset({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain(PGLITE_ABORT) + }) + + test('keeps the data directory when the user declines', async () => { + mockPrompt.mockResolvedValue({ confirmed: false }) + + await reset({}, createMockCommand()) + + expect(mockRm).not.toHaveBeenCalled() + expect(logMessages).toContain('Reset cancelled.') + }) + + test('discards without prompting when --force is set', async () => { + await reset({ force: true }, createMockCommand()) + + expect(mockPrompt).not.toHaveBeenCalled() + expect(mockRm).toHaveBeenCalledWith(DB_DIRECTORY, { recursive: true, force: true }) + }) + + test('outputs JSON when --json and --force are set', async () => { + await reset({ force: true, json: true }, createMockCommand()) + + expect(jsonMessages).toEqual([{ reset: true, discarded: true }]) + }) + + test('refuses to prompt in a non-interactive shell and points at --force', async () => { + mockIsInteractive.mockReturnValue(false) + + await expect(reset({}, createMockCommand())).rejects.toThrow('--force') + + expect(mockRm).not.toHaveBeenCalled() + }) + + test('refuses to prompt when --json is set without --force', async () => { + await expect(reset({ json: true }, createMockCommand())).rejects.toThrow('--force') + + expect(mockPrompt).not.toHaveBeenCalled() + expect(mockRm).not.toHaveBeenCalled() + }) + + test('never resets logically when the database could not start', async () => { + await reset({ force: true }, createMockCommand()) + + expect(mockResetDatabase).not.toHaveBeenCalled() + }) + }) + + test('propagates connection errors that are not local startup failures', async () => { + mockConnectToDatabase.mockRejectedValue(new Error('password authentication failed')) + + await expect(reset({}, createMockCommand())).rejects.toThrow('password authentication failed') + + expect(mockRm).not.toHaveBeenCalled() + }) }) diff --git a/tests/unit/commands/database/util/db-connection.test.ts b/tests/unit/commands/database/util/db-connection.test.ts new file mode 100644 index 00000000000..a271c679cb5 --- /dev/null +++ b/tests/unit/commands/database/util/db-connection.test.ts @@ -0,0 +1,131 @@ +import { join } from 'path' + +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const { capturedOptions, mockStart, mockStop, localState, mockClientConnect, warnMessages } = vi.hoisted(() => ({ + capturedOptions: [] as { logger?: { warn: (message?: string) => void } }[], + mockStart: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockStop: vi.fn<() => Promise>().mockResolvedValue(undefined), + localState: new Map(), + mockClientConnect: vi.fn<() => Promise>().mockResolvedValue(undefined), + warnMessages: [] as string[], +})) + +vi.mock('@netlify/dev', () => ({ + NetlifyDev: class { + constructor(options: (typeof capturedOptions)[number]) { + capturedOptions.push(options) + } + start() { + return mockStart() + } + stop() { + return mockStop() + } + }, +})) + +vi.mock('@netlify/dev-utils', () => ({ + LocalState: class { + get(key: string) { + return localState.get(key) + } + delete(key: string) { + localState.delete(key) + } + }, +})) + +vi.mock('pg', () => ({ + Client: class { + connect() { + return mockClientConnect() + } + end() { + return Promise.resolve() + } + }, +})) + +vi.mock('../../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../../src/utils/command-helpers.js')), + warn: (message: string) => { + warnMessages.push(message) + }, +})) + +import { + connectRawClient, + getLocalDatabaseDirectory, + LocalDatabaseStartError, +} from '../../../../../src/commands/database/util/db-connection.js' + +const BUILD_DIR = '/project' +const DB_DIRECTORY = join(BUILD_DIR, '.netlify', 'db') +const PGLITE_ABORT = 'Failed to start Netlify Database locally: RuntimeError: Aborted().' + +const CONNECTION_STRING = 'postgres://localhost:5432/postgres' + +const mockStartup = ({ warning, succeeds }: { warning?: string; succeeds: boolean }) => { + mockStart.mockImplementation(() => { + if (warning !== undefined) { + capturedOptions.at(-1)?.logger?.warn(warning) + } + if (succeeds) { + localState.set('dbConnectionString', CONNECTION_STRING) + } + return Promise.resolve() + }) +} + +describe('connectRawClient', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedOptions.length = 0 + warnMessages.length = 0 + localState.clear() + delete process.env.NETLIFY_DB_URL + mockStart.mockResolvedValue(undefined) + mockStop.mockResolvedValue(undefined) + }) + + describe('when the local database fails to start', () => { + beforeEach(() => { + mockStartup({ warning: PGLITE_ABORT, succeeds: false }) + }) + + test('throws a LocalDatabaseStartError carrying the data directory', async () => { + await expect(connectRawClient(BUILD_DIR)).rejects.toBeInstanceOf(LocalDatabaseStartError) + await expect(connectRawClient(BUILD_DIR)).rejects.toMatchObject({ directory: DB_DIRECTORY }) + }) + + test('reports the underlying startup error instead of a generic message', async () => { + await expect(connectRawClient(BUILD_DIR)).rejects.toThrow(PGLITE_ABORT) + }) + + test('points the user at the reset command', async () => { + await expect(connectRawClient(BUILD_DIR)).rejects.toThrow('netlify database reset') + }) + + test('stops the dev instance it started', async () => { + await expect(connectRawClient(BUILD_DIR)).rejects.toThrow(LocalDatabaseStartError) + expect(mockStop).toHaveBeenCalledOnce() + }) + }) + + test('forwards startup warnings to the user when the database starts', async () => { + const unrelatedWarning = 'Failed to reload config: boom' + mockStartup({ warning: unrelatedWarning, succeeds: true }) + + const { connectionString } = await connectRawClient(BUILD_DIR) + + expect(connectionString).toBe(CONNECTION_STRING) + expect(warnMessages).toEqual([unrelatedWarning]) + }) +}) + +describe('getLocalDatabaseDirectory', () => { + test('resolves the persisted database directory inside the project', () => { + expect(getLocalDatabaseDirectory(BUILD_DIR)).toBe(DB_DIRECTORY) + }) +})