Skip to content

Commit 9bccaf9

Browse files
feat: add db status command (#8173)
<img width="972" height="611" alt="Screenshot 2026-04-20 at 17 27 57" src="https://github.com/user-attachments/assets/b29f4f47-c000-42bc-8783-281afec62ebd" />
1 parent 289933d commit 9bccaf9

6 files changed

Lines changed: 1218 additions & 26 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { type SQLExecutor } from '@netlify/dev'
2+
3+
import { MIGRATIONS_TABLE } from './constants.js'
4+
5+
export interface MigrationFile {
6+
version: number
7+
name: string
8+
path: string
9+
}
10+
11+
export type AppliedMigrationsFetcher = () => Promise<MigrationFile[]>
12+
13+
const MIGRATION_FILE_NAME = 'migration.sql'
14+
15+
const parseVersion = (name: string): number | null => {
16+
const match = /^(\d+)[_-]/.exec(name)
17+
if (!match) {
18+
return null
19+
}
20+
const parsed = Number.parseInt(match[1], 10)
21+
return Number.isFinite(parsed) ? parsed : null
22+
}
23+
24+
interface RemoteOptions {
25+
siteId: string
26+
accessToken: string
27+
basePath: string
28+
branch: string
29+
}
30+
31+
interface RemoteMigration {
32+
version: number
33+
name: string
34+
path: string
35+
applied: boolean
36+
}
37+
38+
interface ListMigrationsResponse {
39+
migrations: RemoteMigration[]
40+
}
41+
42+
export const remoteAppliedMigrations =
43+
(options: RemoteOptions): AppliedMigrationsFetcher =>
44+
async () => {
45+
const token = options.accessToken.replace('Bearer ', '')
46+
const url = new URL(`${options.basePath}/sites/${encodeURIComponent(options.siteId)}/database/migrations`)
47+
url.searchParams.set('branch', options.branch)
48+
49+
const response = await fetch(url, {
50+
headers: {
51+
Authorization: `Bearer ${token}`,
52+
},
53+
})
54+
55+
if (!response.ok) {
56+
const text = await response.text()
57+
throw new Error(`Failed to fetch applied migrations (${String(response.status)}): ${text}`)
58+
}
59+
60+
const data = (await response.json()) as ListMigrationsResponse
61+
return data.migrations.filter((m) => m.applied).map((m) => ({ version: m.version, name: m.name, path: m.path }))
62+
}
63+
64+
interface LocalOptions {
65+
executor: SQLExecutor
66+
}
67+
68+
export const localAppliedMigrations =
69+
(options: LocalOptions): AppliedMigrationsFetcher =>
70+
async () => {
71+
const { rows } = await options.executor.query<{ name: string }>(
72+
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY applied_at ASC, name ASC`,
73+
)
74+
75+
const migrations: MigrationFile[] = []
76+
for (const row of rows) {
77+
const version = parseVersion(row.name)
78+
if (version === null) {
79+
continue
80+
}
81+
migrations.push({
82+
version,
83+
name: row.name,
84+
path: `${row.name}/${MIGRATION_FILE_NAME}`,
85+
})
86+
}
87+
return migrations
88+
}

src/commands/database/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? 'neon'
22
export const JIGSAW_URL = process.env.JIGSAW_URL ?? 'https://jigsaw.services-prod.nsvcs.net'
33
export const NETLIFY_NEON_PACKAGE_NAME = '@netlify/neon'
4+
export const MIGRATIONS_SCHEMA = 'netlify'
5+
export const MIGRATIONS_TABLE = `${MIGRATIONS_SCHEMA}.migrations`

src/commands/database/database.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import BaseCommand from '../base-command.js'
44
import type { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js'
55
import type { MigrationNewOptions } from './migration-new.js'
66
import type { MigrationPullOptions } from './migration-pull.js'
7+
import type { DatabaseStatusOptions } from './status-db.js'
78

89
export type Extension = {
910
id: string
@@ -79,18 +80,39 @@ export const createDatabaseCommand = (program: BaseCommand) => {
7980
await init(options, command)
8081
})
8182
.addExamples([`netlify db init --assume-no`, `netlify db init --boilerplate=drizzle --overwrite`])
82-
}
8383

84-
dbCommand
85-
.command('status')
86-
.description(`Check the status of the database`)
87-
.action(async (options, command) => {
88-
const { status } = await import('./status.js')
89-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
90-
await status(options, command)
91-
})
84+
dbCommand
85+
.command('status')
86+
.description(`Check the status of the database`)
87+
.action(async (options, command) => {
88+
const { status } = await import('./status.js')
89+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
90+
await status(options, command)
91+
})
92+
}
9293

9394
if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1') {
95+
dbCommand
96+
.command('status')
97+
.description('Check the status of the database, including applied and pending migrations')
98+
.option('-b, --branch <branch>', 'Netlify branch name to query; defaults to the local development database')
99+
.option(
100+
'--show-credentials',
101+
'Include the full connection string (including username and password) in the output',
102+
false,
103+
)
104+
.option('--json', 'Output result as JSON')
105+
.action(async (options: DatabaseStatusOptions, command: BaseCommand) => {
106+
const { statusDb } = await import('./status-db.js')
107+
await statusDb(options, command)
108+
})
109+
.addExamples([
110+
'netlify db status',
111+
'netlify db status --show-credentials',
112+
'netlify db status --json',
113+
'netlify db status --branch my-feature-branch',
114+
])
115+
94116
dbCommand
95117
.command('connect')
96118
.description('Connect to the database')

src/commands/database/db-connection.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import { PgClientExecutor } from './pg-client-executor.js'
77

88
interface DBConnection {
99
executor: SQLExecutor
10+
connectionString: string
1011
cleanup: () => Promise<void>
1112
}
1213

13-
export async function connectToDatabase(buildDir: string): Promise<DBConnection> {
14-
const { client, cleanup } = await connectRawClient(buildDir)
14+
export async function connectToDatabase(buildDir: string, urlOverride?: string): Promise<DBConnection> {
15+
const { client, connectionString, cleanup } = await connectRawClient(buildDir, urlOverride)
1516
return {
1617
executor: new PgClientExecutor(client),
18+
connectionString,
1719
cleanup,
1820
}
1921
}
@@ -24,31 +26,34 @@ interface RawDBConnection {
2426
cleanup: () => Promise<void>
2527
}
2628

27-
export async function connectRawClient(buildDir: string): Promise<RawDBConnection> {
28-
const envConnectionString = process.env.NETLIFY_DB_URL
29-
if (envConnectionString) {
30-
const client = new Client({ connectionString: envConnectionString })
31-
await client.connect()
32-
return {
33-
client,
34-
connectionString: envConnectionString,
35-
cleanup: () => client.end(),
36-
}
29+
// detectExistingLocalConnectionString returns a connection string for an
30+
// already-available local database (either the NETLIFY_DB_URL env override or
31+
// the connection string persisted by a running local dev session) without
32+
// starting a new dev database. Returns null when nothing's currently
33+
// available — callers should decide whether starting one is worth the cost.
34+
export function detectExistingLocalConnectionString(buildDir: string): string | null {
35+
if (process.env.NETLIFY_DB_URL) {
36+
return process.env.NETLIFY_DB_URL
3737
}
38-
3938
const state = new LocalState(buildDir)
40-
const storedConnectionString = state.get('dbConnectionString')
39+
const stored = state.get('dbConnectionString')
40+
return stored ?? null
41+
}
4142

42-
if (storedConnectionString) {
43-
const client = new Client({ connectionString: storedConnectionString })
43+
export async function connectRawClient(buildDir: string, urlOverride?: string): Promise<RawDBConnection> {
44+
const existing = urlOverride ?? detectExistingLocalConnectionString(buildDir)
45+
if (existing) {
46+
const client = new Client({ connectionString: existing })
4447
await client.connect()
4548
return {
4649
client,
47-
connectionString: storedConnectionString,
50+
connectionString: existing,
4851
cleanup: () => client.end(),
4952
}
5053
}
5154

55+
const state = new LocalState(buildDir)
56+
5257
const netlifyDev = new NetlifyDev({
5358
projectRoot: buildDir,
5459
aiGateway: { enabled: false },

0 commit comments

Comments
 (0)