Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/commands/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
5 changes: 4 additions & 1 deletion 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 { 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) => {
Expand Down Expand Up @@ -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')

Expand Down
56 changes: 54 additions & 2 deletions src/commands/database/db-reset.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,73 @@
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
if (!buildDir) {
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)
Expand Down
38 changes: 37 additions & 1 deletion src/commands/database/util/db-connection.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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()

Expand Down
135 changes: 126 additions & 9 deletions tests/unit/commands/database/db-reset.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
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', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
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 () => ({
Expand All @@ -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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()
})
})
Loading
Loading