Skip to content

Commit 2947a99

Browse files
feat: various improvements to database commands (#8200)
## fd77a17 Reworks the human-friendly output of `netlify database status ` into a single _Migrations_ section (replacing the previous separate "Applied migrations" and "Migrations not applied" sections). Also fixed the apply-guidance: the `netlify database migrations apply` hint only appears when truly targeting the local dev DB, and switches to "Deploy these files to apply the migrations." when using a remote database. The `--json` output is now affected. ## 60c162d Fixed `netlify database migrations pull` to match the server's new split API. The list endpoint (`/database/migrations`) is now metadata-only and a new per-migration endpoint (`/database/migrations/{name}`) returns the SQL content, so the command now makes a list call followed by parallel content fetches. ## 9bb834f Added a shared `readApiErrorMessage` helper used by every CLI call site that hits `/database/*`, which extracts the server's message field from JSON error bodies so users see clean messages instead of raw JSON.
1 parent 0bc12bd commit 2947a99

7 files changed

Lines changed: 332 additions & 87 deletions

File tree

src/commands/database/db-migration-pull.ts

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import inquirer from 'inquirer'
66
import { log, logJson } from '../../utils/command-helpers.js'
77
import execa from '../../utils/execa.js'
88
import BaseCommand from '../base-command.js'
9+
import { readApiErrorMessage } from './util/api-errors.js'
910
import { PRODUCTION_BRANCH } from './util/constants.js'
1011
import { resolveMigrationsDirectory } from './util/migrations-path.js'
1112

@@ -15,15 +16,28 @@ export interface MigrationPullOptions {
1516
json?: boolean
1617
}
1718

18-
interface MigrationFile {
19+
interface MigrationListItem {
1920
version: number
2021
name: string
2122
path: string
22-
content: string
23+
applied: boolean
2324
}
2425

2526
interface ListMigrationsResponse {
26-
migrations: MigrationFile[]
27+
migrations: MigrationListItem[]
28+
}
29+
30+
interface MigrationDetailResponse {
31+
version: number
32+
name: string
33+
path: string
34+
content: string
35+
}
36+
37+
interface ApiContext {
38+
siteId: string
39+
token: string
40+
basePath: string
2741
}
2842

2943
const getLocalGitBranch = async (): Promise<string> => {
@@ -45,7 +59,7 @@ const resolveBranch = async (branchOption: string | true | undefined): Promise<s
4559
return branchOption
4660
}
4761

48-
const fetchMigrations = async (command: BaseCommand, branch: string | undefined): Promise<MigrationFile[]> => {
62+
const getApiContext = (command: BaseCommand): ApiContext => {
4963
const siteId = command.siteId
5064
if (!siteId) {
5165
throw new Error('The project must be linked with netlify link before pulling migrations.')
@@ -56,35 +70,60 @@ const fetchMigrations = async (command: BaseCommand, branch: string | undefined)
5670
throw new Error('You must be logged in with netlify login to pull migrations.')
5771
}
5872

59-
const token = accessToken.replace('Bearer ', '')
60-
const basePath = command.netlify.api.basePath
73+
return {
74+
siteId,
75+
token: accessToken.replace('Bearer ', ''),
76+
basePath: command.netlify.api.basePath,
77+
}
78+
}
6179

62-
const url = new URL(`${basePath}/sites/${encodeURIComponent(siteId)}/database/migrations`)
80+
const fetchMigrations = async (ctx: ApiContext, branch: string | undefined): Promise<MigrationListItem[]> => {
81+
const url = new URL(`${ctx.basePath}/sites/${encodeURIComponent(ctx.siteId)}/database/migrations`)
6382
if (branch) {
6483
url.searchParams.set('branch', branch)
6584
}
6685

6786
const response = await fetch(url, {
68-
headers: {
69-
Authorization: `Bearer ${token}`,
70-
},
87+
headers: { Authorization: `Bearer ${ctx.token}` },
7188
})
7289

7390
if (!response.ok) {
74-
const text = await response.text()
75-
throw new Error(`Failed to fetch migrations (${String(response.status)}): ${text}`)
91+
const message = await readApiErrorMessage(response)
92+
throw new Error(`Failed to fetch migrations (${String(response.status)}): ${message}`)
7693
}
7794

7895
const data = (await response.json()) as ListMigrationsResponse
7996
return data.migrations
8097
}
8198

99+
const fetchMigrationContent = async (ctx: ApiContext, name: string, branch: string | undefined): Promise<string> => {
100+
const url = new URL(
101+
`${ctx.basePath}/sites/${encodeURIComponent(ctx.siteId)}/database/migrations/${encodeURIComponent(name)}`,
102+
)
103+
if (branch) {
104+
url.searchParams.set('branch', branch)
105+
}
106+
107+
const response = await fetch(url, {
108+
headers: { Authorization: `Bearer ${ctx.token}` },
109+
})
110+
111+
if (!response.ok) {
112+
const message = await readApiErrorMessage(response)
113+
throw new Error(`Failed to fetch content for migration "${name}" (${String(response.status)}): ${message}`)
114+
}
115+
116+
const data = (await response.json()) as MigrationDetailResponse
117+
return data.content
118+
}
119+
82120
export const migrationPull = async (options: MigrationPullOptions, command: BaseCommand) => {
83121
const { force, json } = options
84122

85123
const branch = (await resolveBranch(options.branch)) ?? process.env.NETLIFY_DB_BRANCH
86124
const source = branch ?? PRODUCTION_BRANCH
87-
const migrations = await fetchMigrations(command, branch)
125+
const ctx = getApiContext(command)
126+
const migrations = await fetchMigrations(ctx, branch)
88127

89128
if (migrations.length === 0) {
90129
if (json) {
@@ -96,6 +135,18 @@ export const migrationPull = async (options: MigrationPullOptions, command: Base
96135
}
97136

98137
const migrationsDirectory = resolveMigrationsDirectory(command)
138+
const canonicalMigrationsDir = resolve(migrationsDirectory)
139+
140+
const resolvedPaths = migrations.map((migration) => {
141+
if (isAbsolute(migration.path) || migration.path.split(/[/\\]/).includes('..')) {
142+
throw new Error(`Migration path "${migration.path}" contains invalid path segments.`)
143+
}
144+
const filePath = resolve(canonicalMigrationsDir, migration.path)
145+
if (!filePath.startsWith(canonicalMigrationsDir)) {
146+
throw new Error(`Migration path "${migration.path}" resolves outside the migrations directory.`)
147+
}
148+
return filePath
149+
})
99150

100151
if (!force) {
101152
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
@@ -115,22 +166,13 @@ export const migrationPull = async (options: MigrationPullOptions, command: Base
115166
}
116167
}
117168

118-
const canonicalMigrationsDir = resolve(migrationsDirectory)
169+
const contents = await Promise.all(migrations.map((migration) => fetchMigrationContent(ctx, migration.name, branch)))
119170

120171
await rm(canonicalMigrationsDir, { recursive: true, force: true })
121172

122-
for (const migration of migrations) {
123-
if (isAbsolute(migration.path) || migration.path.split(/[/\\]/).includes('..')) {
124-
throw new Error(`Migration path "${migration.path}" contains invalid path segments.`)
125-
}
126-
127-
const filePath = resolve(canonicalMigrationsDir, migration.path)
128-
if (!filePath.startsWith(canonicalMigrationsDir)) {
129-
throw new Error(`Migration path "${migration.path}" resolves outside the migrations directory.`)
130-
}
131-
173+
for (const [index, filePath] of resolvedPaths.entries()) {
132174
await mkdir(dirname(filePath), { recursive: true })
133-
await writeFile(filePath, migration.content)
175+
await writeFile(filePath, contents[index])
134176
}
135177

136178
if (json) {

src/commands/database/db-status.ts

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFile, readdir } from 'fs/promises'
2-
import { join } from 'path'
2+
import { join, relative, sep } from 'path'
33

44
import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js'
55
import BaseCommand from '../base-command.js'
@@ -9,6 +9,7 @@ import {
99
type MigrationFile,
1010
remoteAppliedMigrations,
1111
} from './util/applied-migrations.js'
12+
import { readApiErrorMessage } from './util/api-errors.js'
1213
import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js'
1314
import { resolveMigrationsDirectory } from './util/migrations-path.js'
1415
import { fileExistsAsync } from '../../lib/fs.js'
@@ -48,7 +49,7 @@ const formatCommand = (suffix: string): string => chalk.cyanBright.bold(`${netli
4849

4950
const logConnectCommands = () => {
5051
secondary(`Run ${formatCommand('database connect')} to start an interactive database client`)
51-
secondary(`Run ${formatCommand('database connect --query "<SQL>"')} to run a one-shot query`)
52+
secondary(`Run ${formatCommand(`database connect --query "SELECT 'SQL goes here'"`)} to run a one-shot query`)
5253
}
5354

5455
const parseVersion = (name: string): number | null => {
@@ -176,8 +177,8 @@ const fetchBranchConnectionString = async (ctx: ServerContext, branchId: string)
176177
}
177178

178179
if (!response.ok) {
179-
const text = await response.text()
180-
throw new Error(`Failed to fetch database branch "${branchId}" (${String(response.status)}): ${text}`)
180+
const message = await readApiErrorMessage(response)
181+
throw new Error(`Failed to fetch database branch "${branchId}" (${String(response.status)}): ${message}`)
181182
}
182183

183184
const data = (await response.json()) as { connection_string?: string }
@@ -213,11 +214,11 @@ const fetchSiteDatabase = async (ctx: ServerContext): Promise<{ connectionString
213214
return { connectionString: data.connection_string }
214215
}
215216

216-
const renderList = (items: MigrationEntry[]): string => {
217+
const renderList = (items: MigrationEntry[], indent = ' '): string => {
217218
if (items.length === 0) {
218-
return chalk.dim(' (none)')
219+
return chalk.dim(`${indent}(none)`)
219220
}
220-
return items.map((m) => ` ${m.name}`).join('\n')
221+
return items.map((m) => `${indent}${m.name}`).join('\n')
221222
}
222223

223224
interface RenderParams {
@@ -228,7 +229,9 @@ interface RenderParams {
228229
showCredentials: boolean
229230
status: MigrationsStatus
230231
isLocal: boolean
232+
hasUrlOverride: boolean
231233
migrationsDirectory: string
234+
projectRoot: string
232235
adminUrl?: string
233236
}
234237

@@ -237,7 +240,8 @@ interface RenderParams {
237240
const INDENT = ' '
238241
const STATUS_GOOD = '🟢'
239242
const STATUS_WARN = '🟡'
240-
const STATUS_PAUSED = '⏸️ '
243+
const STATUS_NONE = '⚪'
244+
const STATUS_INFO = 'ℹ️ '
241245

242246
const primary = (emoji: string, text: string): void => {
243247
log(` ${emoji} ${text}`)
@@ -255,7 +259,9 @@ const renderPretty = (params: RenderParams) => {
255259
showCredentials,
256260
status,
257261
isLocal,
262+
hasUrlOverride,
258263
migrationsDirectory,
264+
projectRoot,
259265
adminUrl,
260266
} = params
261267

@@ -301,7 +307,7 @@ const renderPretty = (params: RenderParams) => {
301307
secondary(`To connect to the database directly, use the connection string: ${displayed}`)
302308
}
303309
} else if (isLocal) {
304-
primary(STATUS_PAUSED, 'The local database is not running')
310+
primary(STATUS_INFO, 'The local database is not running')
305311
secondary(
306312
`It starts automatically when you run ${formatCommand(
307313
'dev',
@@ -311,29 +317,17 @@ const renderPretty = (params: RenderParams) => {
311317
}
312318

313319
log('')
314-
log(`Migrations directory: ${chalk.bold(migrationsDirectory)}`)
315-
316-
log('')
317-
log(chalk.bold('Applied migrations'))
318-
log(chalk.gray('Migrations that have been applied to the database branch'))
320+
log(chalk.bold('Migrations'))
321+
log(chalk.gray('Database migrations managed by Netlify'))
319322
log('')
320-
log(renderList(status.applied))
321-
log('')
322-
log(
323-
chalk.gray(
324-
'Note that these migrations cannot be removed or edited. To change anything, you should generate a new migration.',
325-
),
326-
)
327323

328324
log('')
329-
log(chalk.bold('Migrations not applied'))
330-
log(chalk.gray("Migrations that exist locally that haven't yet been applied"))
331-
log('')
332-
log(renderList(status.pending))
333-
if (isLocal && status.pending.length > 0 && status.outOfOrder.length === 0) {
334-
log('')
335-
log(chalk.gray(`Run ${formatCommand('database migrations apply')} to apply these to the local database.`))
336-
}
325+
const relativePath = relative(projectRoot, migrationsDirectory)
326+
const isInsideProject = relativePath !== '' && !relativePath.startsWith('..')
327+
const displayPath = (isInsideProject ? relativePath : migrationsDirectory).split(sep).join('/')
328+
log(` ${STATUS_INFO} ${chalk.bold('Migrations directory')}`)
329+
log(chalk.gray(`${INDENT}Migration files in this directory are automatically applied when deploying to Netlify.`))
330+
log(`${INDENT}${displayPath}`)
337331

338332
if (status.missingOnDisk.length > 0 || status.outOfOrder.length > 0) {
339333
log('')
@@ -357,6 +351,35 @@ const renderPretty = (params: RenderParams) => {
357351
)
358352
}
359353
}
354+
355+
const appliedEmoji = status.applied.length > 0 ? STATUS_GOOD : STATUS_NONE
356+
log('')
357+
log(` ${appliedEmoji} ${chalk.bold(`Applied migrations (${String(status.applied.length)})`)}`)
358+
log(
359+
chalk.gray(
360+
`${INDENT}These migrations have been applied and cannot be edited or deleted. Any changes to the schema must involve a new migration.`,
361+
),
362+
)
363+
log(renderList(status.applied, INDENT))
364+
365+
log('')
366+
const pendingEmoji = status.pending.length === 0 ? STATUS_GOOD : STATUS_WARN
367+
log(` ${pendingEmoji} ${chalk.bold(`Pending migrations (${String(status.pending.length)})`)}`)
368+
log(
369+
chalk.gray(
370+
`${INDENT}These migrations are defined locally but haven't been applied, and you can change them or delete them.`,
371+
),
372+
)
373+
log(renderList(status.pending, INDENT))
374+
if (status.pending.length > 0 && status.outOfOrder.length === 0) {
375+
const canApplyLocally = isLocal && !hasUrlOverride
376+
log('')
377+
if (canApplyLocally) {
378+
log(chalk.gray(`${INDENT}Run ${formatCommand('database migrations apply')} to apply them to the local database.`))
379+
} else {
380+
log(chalk.gray(`${INDENT}Deploy these files to apply the migrations.`))
381+
}
382+
}
360383
}
361384

362385
export const statusDb = async (options: DatabaseStatusOptions, command: BaseCommand) => {
@@ -459,7 +482,9 @@ export const statusDb = async (options: DatabaseStatusOptions, command: BaseComm
459482
showCredentials: options.showCredentials ?? false,
460483
status,
461484
isLocal,
485+
hasUrlOverride: Boolean(envUrl),
462486
migrationsDirectory,
487+
projectRoot: buildDir,
463488
adminUrl: siteInfo?.admin_url,
464489
})
465490
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
interface ApiErrorBody {
2+
code?: number
3+
message?: string
4+
}
5+
6+
export const readApiErrorMessage = async (response: Response): Promise<string> => {
7+
const text = await response.text()
8+
if (!text) {
9+
return ''
10+
}
11+
try {
12+
const body = JSON.parse(text) as ApiErrorBody
13+
if (typeof body.message === 'string' && body.message.trim()) {
14+
return body.message
15+
}
16+
} catch {
17+
// body is not JSON; fall through to raw text
18+
}
19+
return text
20+
}

src/commands/database/util/applied-migrations.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type SQLExecutor } from '@netlify/dev'
22

3+
import { readApiErrorMessage } from './api-errors.js'
34
import { MIGRATIONS_TABLE } from './constants.js'
45

56
export interface MigrationFile {
@@ -53,8 +54,8 @@ export const remoteAppliedMigrations =
5354
})
5455

5556
if (!response.ok) {
56-
const text = await response.text()
57-
throw new Error(`Failed to fetch applied migrations (${String(response.status)}): ${text}`)
57+
const message = await readApiErrorMessage(response)
58+
throw new Error(`Failed to fetch applied migrations (${String(response.status)}): ${message}`)
5859
}
5960

6061
const data = (await response.json()) as ListMigrationsResponse

0 commit comments

Comments
 (0)