diff --git a/src/commands/database/applied-migrations.ts b/src/commands/database/applied-migrations.ts new file mode 100644 index 00000000000..ed9bc47d4ae --- /dev/null +++ b/src/commands/database/applied-migrations.ts @@ -0,0 +1,88 @@ +import { type SQLExecutor } from '@netlify/dev' + +import { MIGRATIONS_TABLE } from './constants.js' + +export interface MigrationFile { + version: number + name: string + path: string +} + +export type AppliedMigrationsFetcher = () => Promise + +const MIGRATION_FILE_NAME = 'migration.sql' + +const parseVersion = (name: string): number | null => { + const match = /^(\d+)[_-]/.exec(name) + if (!match) { + return null + } + const parsed = Number.parseInt(match[1], 10) + return Number.isFinite(parsed) ? parsed : null +} + +interface RemoteOptions { + siteId: string + accessToken: string + basePath: string + branch: string +} + +interface RemoteMigration { + version: number + name: string + path: string + applied: boolean +} + +interface ListMigrationsResponse { + migrations: RemoteMigration[] +} + +export const remoteAppliedMigrations = + (options: RemoteOptions): AppliedMigrationsFetcher => + async () => { + const token = options.accessToken.replace('Bearer ', '') + const url = new URL(`${options.basePath}/sites/${encodeURIComponent(options.siteId)}/database/migrations`) + url.searchParams.set('branch', options.branch) + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Failed to fetch applied migrations (${String(response.status)}): ${text}`) + } + + const data = (await response.json()) as ListMigrationsResponse + return data.migrations.filter((m) => m.applied).map((m) => ({ version: m.version, name: m.name, path: m.path })) + } + +interface LocalOptions { + executor: SQLExecutor +} + +export const localAppliedMigrations = + (options: LocalOptions): AppliedMigrationsFetcher => + async () => { + const { rows } = await options.executor.query<{ name: string }>( + `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY applied_at ASC, name ASC`, + ) + + const migrations: MigrationFile[] = [] + for (const row of rows) { + const version = parseVersion(row.name) + if (version === null) { + continue + } + migrations.push({ + version, + name: row.name, + path: `${row.name}/${MIGRATION_FILE_NAME}`, + }) + } + return migrations + } diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts index 3512f40e303..a6ac5f85dd6 100644 --- a/src/commands/database/constants.ts +++ b/src/commands/database/constants.ts @@ -1,3 +1,5 @@ export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? 'neon' export const JIGSAW_URL = process.env.JIGSAW_URL ?? 'https://jigsaw.services-prod.nsvcs.net' export const NETLIFY_NEON_PACKAGE_NAME = '@netlify/neon' +export const MIGRATIONS_SCHEMA = 'netlify' +export const MIGRATIONS_TABLE = `${MIGRATIONS_SCHEMA}.migrations` diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 583289c79dc..7ccc40d9865 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 { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js' import type { MigrationNewOptions } from './migration-new.js' import type { MigrationPullOptions } from './migration-pull.js' +import type { DatabaseStatusOptions } from './status-db.js' export type Extension = { id: string @@ -79,18 +80,39 @@ export const createDatabaseCommand = (program: BaseCommand) => { await init(options, command) }) .addExamples([`netlify db init --assume-no`, `netlify db init --boilerplate=drizzle --overwrite`]) - } - dbCommand - .command('status') - .description(`Check the status of the database`) - .action(async (options, command) => { - const { status } = await import('./status.js') - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await status(options, command) - }) + dbCommand + .command('status') + .description(`Check the status of the database`) + .action(async (options, command) => { + const { status } = await import('./status.js') + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await status(options, command) + }) + } if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1') { + dbCommand + .command('status') + .description('Check the status of the database, including applied and pending migrations') + .option('-b, --branch ', 'Netlify branch name to query; defaults to the local development database') + .option( + '--show-credentials', + 'Include the full connection string (including username and password) in the output', + false, + ) + .option('--json', 'Output result as JSON') + .action(async (options: DatabaseStatusOptions, command: BaseCommand) => { + const { statusDb } = await import('./status-db.js') + await statusDb(options, command) + }) + .addExamples([ + 'netlify db status', + 'netlify db status --show-credentials', + 'netlify db status --json', + 'netlify db status --branch my-feature-branch', + ]) + dbCommand .command('connect') .description('Connect to the database') diff --git a/src/commands/database/db-connection.ts b/src/commands/database/db-connection.ts index 090adf6ef59..e4a2a9e0328 100644 --- a/src/commands/database/db-connection.ts +++ b/src/commands/database/db-connection.ts @@ -7,13 +7,15 @@ import { PgClientExecutor } from './pg-client-executor.js' interface DBConnection { executor: SQLExecutor + connectionString: string cleanup: () => Promise } -export async function connectToDatabase(buildDir: string): Promise { - const { client, cleanup } = await connectRawClient(buildDir) +export async function connectToDatabase(buildDir: string, urlOverride?: string): Promise { + const { client, connectionString, cleanup } = await connectRawClient(buildDir, urlOverride) return { executor: new PgClientExecutor(client), + connectionString, cleanup, } } @@ -24,31 +26,34 @@ interface RawDBConnection { cleanup: () => Promise } -export async function connectRawClient(buildDir: string): Promise { - const envConnectionString = process.env.NETLIFY_DB_URL - if (envConnectionString) { - const client = new Client({ connectionString: envConnectionString }) - await client.connect() - return { - client, - connectionString: envConnectionString, - cleanup: () => client.end(), - } +// detectExistingLocalConnectionString returns a connection string for an +// already-available local database (either the NETLIFY_DB_URL env override or +// the connection string persisted by a running local dev session) without +// starting a new dev database. Returns null when nothing's currently +// available β€” callers should decide whether starting one is worth the cost. +export function detectExistingLocalConnectionString(buildDir: string): string | null { + if (process.env.NETLIFY_DB_URL) { + return process.env.NETLIFY_DB_URL } - const state = new LocalState(buildDir) - const storedConnectionString = state.get('dbConnectionString') + const stored = state.get('dbConnectionString') + return stored ?? null +} - if (storedConnectionString) { - const client = new Client({ connectionString: storedConnectionString }) +export async function connectRawClient(buildDir: string, urlOverride?: string): Promise { + const existing = urlOverride ?? detectExistingLocalConnectionString(buildDir) + if (existing) { + const client = new Client({ connectionString: existing }) await client.connect() return { client, - connectionString: storedConnectionString, + connectionString: existing, cleanup: () => client.end(), } } + const state = new LocalState(buildDir) + const netlifyDev = new NetlifyDev({ projectRoot: buildDir, aiGateway: { enabled: false }, diff --git a/src/commands/database/status-db.ts b/src/commands/database/status-db.ts new file mode 100644 index 00000000000..50a21f28882 --- /dev/null +++ b/src/commands/database/status-db.ts @@ -0,0 +1,439 @@ +import { readFile, readdir } from 'fs/promises' + +import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' +import BaseCommand from '../base-command.js' +import { + type AppliedMigrationsFetcher, + localAppliedMigrations, + type MigrationFile, + remoteAppliedMigrations, +} from './applied-migrations.js' +import { connectToDatabase, detectExistingLocalConnectionString } from './db-connection.js' + +export interface DatabaseStatusOptions { + branch?: string + showCredentials?: boolean + json?: boolean +} + +interface MigrationEntry { + version: number + name: string +} + +interface OutOfOrderEntry extends MigrationEntry { + maxApplied: number +} + +interface MigrationsStatus { + applied: MigrationEntry[] + pending: MigrationEntry[] + missingOnDisk: MigrationEntry[] + outOfOrder: OutOfOrderEntry[] +} + +interface ServerContext { + siteId: string + accessToken: string + basePath: string +} + +const DEFAULT_MIGRATIONS_PATH = 'netlify/db/migrations' +const DOCS_URL = 'https://ntl.fyi/database' +const NETLIFY_DATABASE_PACKAGE = '@netlify/database' + +const formatCommand = (suffix: string): string => chalk.cyanBright.bold(`${netlifyCommand()} ${suffix}`) + +const logConnectCommands = () => { + secondary(`Run ${formatCommand('db connect')} to start an interactive database client`) + secondary(`Run ${formatCommand('db connect --query ""')} to run a one-shot query`) +} + +const parseVersion = (name: string): number | null => { + const match = /^(\d+)[_-]/.exec(name) + if (!match) { + return null + } + const parsed = Number.parseInt(match[1], 10) + return Number.isFinite(parsed) ? parsed : null +} + +const resolveMigrationsDirectory = (command: BaseCommand): string => { + const configuredPath = command.netlify.config.db?.migrations?.path + if (configuredPath) { + return configuredPath + } + + const projectRoot = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory + if (!projectRoot) { + throw new Error('Could not determine the project root directory.') + } + + return `${projectRoot}/${DEFAULT_MIGRATIONS_PATH}` +} + +const readLocalMigrations = async (migrationsDirectory: string): Promise => { + let entries + try { + entries = await readdir(migrationsDirectory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } + + const migrations: MigrationEntry[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const version = parseVersion(entry.name) + if (version === null) { + continue + } + migrations.push({ name: entry.name, version }) + } + migrations.sort((a, b) => a.version - b.version) + return migrations +} + +const toEntry = (m: MigrationFile): MigrationEntry => ({ version: m.version, name: m.name }) + +export const computeStatus = (applied: MigrationFile[], local: MigrationEntry[]): MigrationsStatus => { + const appliedEntries = applied.map(toEntry) + const appliedByName = new Map(appliedEntries.map((m) => [m.name, m])) + const localByName = new Map(local.map((m) => [m.name, m])) + + const pending = local.filter((m) => !appliedByName.has(m.name)) + const missingOnDisk = appliedEntries.filter((m) => !localByName.has(m.name)) + + const maxApplied = appliedEntries.reduce((max, m) => (m.version > max ? m.version : max), 0) + const outOfOrder: OutOfOrderEntry[] = pending + .filter((m) => m.version <= maxApplied) + .map((m) => ({ name: m.name, version: m.version, maxApplied })) + + return { applied: appliedEntries, pending, missingOnDisk, outOfOrder } +} + +const redactConnectionString = (connectionString: string): string => { + try { + const url = new URL(connectionString) + if (url.username) { + url.username = '***' + } + if (url.password) { + url.password = '***' + } + return url.toString() + } catch { + return '***' + } +} + +const connectionStringHasCredentials = (connectionString: string): boolean => { + try { + const url = new URL(connectionString) + return Boolean(url.username || url.password) + } catch { + return false + } +} + +const isNetlifyDatabasePackageInstalled = async (projectRoot: string): Promise => { + try { + const raw = await readFile(`${projectRoot}/package.json`, 'utf-8') + const pkg = JSON.parse(raw) as { + dependencies?: Record + devDependencies?: Record + } + return Boolean(pkg.dependencies?.[NETLIFY_DATABASE_PACKAGE] ?? pkg.devDependencies?.[NETLIFY_DATABASE_PACKAGE]) + } catch { + return false + } +} + +const fetchBranchConnectionString = async (ctx: ServerContext, branchId: string): Promise => { + const token = ctx.accessToken.replace('Bearer ', '') + const url = new URL( + `${ctx.basePath}/sites/${encodeURIComponent(ctx.siteId)}/database/branch/${encodeURIComponent(branchId)}`, + ) + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (response.status === 404) { + throw new Error(`No database branch found for "${branchId}". Has a deploy been published for this branch?`) + } + + if (!response.ok) { + const text = await response.text() + throw new Error(`Failed to fetch database branch "${branchId}" (${String(response.status)}): ${text}`) + } + + const data = (await response.json()) as { connection_string?: string } + if (!data.connection_string) { + throw new Error(`Database branch "${branchId}" has no connection string.`) + } + return data.connection_string +} + +const fetchSiteDatabase = async (ctx: ServerContext): Promise<{ connectionString: string } | null> => { + const token = ctx.accessToken.replace('Bearer ', '') + const url = new URL(`${ctx.basePath}/sites/${encodeURIComponent(ctx.siteId)}/database/`) + + let response: Response + try { + response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + } catch { + return null + } + + if (response.status === 404) { + return null + } + + if (!response.ok) { + return null + } + + const data = (await response.json()) as { connection_string?: string } + if (!data.connection_string) { + return null + } + return { connectionString: data.connection_string } +} + +const renderList = (items: MigrationEntry[]): string => { + if (items.length === 0) { + return chalk.dim(' (none)') + } + return items.map((m) => ` β€’ ${m.name}`).join('\n') +} + +interface RenderParams { + enabled: boolean + packageInstalled: boolean + branchLabel: string + connectionString: string | null + showCredentials: boolean + status: MigrationsStatus + isLocal: boolean + adminUrl?: string +} + +// INDENT clears the emoji column so secondary lines hang under the primary +// text. Width: 2-space section indent + 2-col emoji + 1 space = 5. +const INDENT = ' ' +const STATUS_GOOD = '🟒' +const STATUS_WARN = '🟑' +const STATUS_PAUSED = '⏸️ ' + +const primary = (emoji: string, text: string): void => { + log(` ${emoji} ${text}`) +} + +const secondary = (text: string): void => { + log(chalk.gray(`${INDENT}${text}`)) +} + +const renderPretty = (params: RenderParams) => { + const { enabled, packageInstalled, connectionString, showCredentials, status, isLocal, adminUrl } = params + + log(chalk.bold('Netlify Database')) + log(chalk.gray('Managed Postgres databases that seamlessly integrate with the Netlify workflow')) + log('') + + if (enabled) { + primary(STATUS_GOOD, 'Netlify Database is enabled for this project') + if (adminUrl) { + secondary(`Manage your database at ${adminUrl}/database`) + } + } else { + primary(STATUS_WARN, 'Netlify Database is not enabled for this project') + secondary(`Install the ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package and deploy your site to automatically`) + secondary(`provision a database. Refer to ${DOCS_URL} for more information.`) + } + log('') + + if (packageInstalled) { + primary(STATUS_GOOD, `The ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package is installed`) + secondary(`For a full API reference, visit ${DOCS_URL}`) + } else { + primary(STATUS_WARN, `The ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package is not installed`) + secondary(`Install it with \`npm install ${NETLIFY_DATABASE_PACKAGE}\``) + secondary(`Refer to ${DOCS_URL} for more information`) + } + log('') + + if (connectionString) { + const displayed = showCredentials ? connectionString : redactConnectionString(connectionString) + + primary(STATUS_GOOD, `Connected to database branch: ${displayed}`) + logConnectCommands() + + if (!showCredentials && connectionStringHasCredentials(connectionString)) { + secondary( + `To reveal the full connection string (including credentials), run ${formatCommand( + 'db status --show-credentials', + )}`, + ) + } else { + secondary(`To connect to the database directly, use the connection string: ${displayed}`) + } + } else if (isLocal) { + primary(STATUS_PAUSED, 'The local database is not running') + secondary( + `It starts automatically when you run ${formatCommand( + 'dev', + )}. Run that in a new terminal and try this command again`, + ) + logConnectCommands() + } + + log('') + log(chalk.bold('Applied migrations')) + log(chalk.gray('Migrations that have been applied to the database branch')) + log('') + log(renderList(status.applied)) + log('') + log( + chalk.gray( + 'Note that these migrations cannot be removed or edited. To change anything, you should generate a new migration.', + ), + ) + + log('') + log(chalk.bold('Migrations not applied')) + log(chalk.gray("Migrations that exist locally that haven't yet been applied")) + log('') + log(renderList(status.pending)) + if (isLocal && status.pending.length > 0 && status.outOfOrder.length === 0) { + log('') + log(chalk.gray(`Run ${formatCommand('db migrations apply')} to apply these to the local database.`)) + } + + if (status.missingOnDisk.length > 0 || status.outOfOrder.length > 0) { + log('') + log(chalk.bold.yellow('Issues')) + if (status.missingOnDisk.length > 0) { + log(` Applied but missing on disk: ${status.missingOnDisk.map((m) => chalk.red(m.name)).join(', ')}`) + } + if (status.outOfOrder.length > 0) { + log( + ` Out of order: ${status.outOfOrder + .map((m) => chalk.red(`${m.name} (version ${String(m.version)} <= max applied ${String(m.maxApplied)})`)) + .join(', ')}`, + ) + log('') + log( + chalk.gray( + `Run ${formatCommand( + 'db migrations reset', + )} to delete these local-only migrations, then generate them again with a higher prefix.`, + ), + ) + } + } +} + +export const statusDb = async (options: DatabaseStatusOptions, command: BaseCommand) => { + 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) + const local = await readLocalMigrations(migrationsDirectory) + const packageInstalled = await isNetlifyDatabasePackageInstalled(buildDir) + + const siteId = command.siteId + const accessToken = command.netlify.api.accessToken + const basePath = command.netlify.api.basePath + + // Enabled check: NETLIFY_DB_URL env OR site has a database configured. + const envUrl = process.env.NETLIFY_DB_URL + let siteHasDatabase = false + if (siteId && accessToken) { + const siteDb = await fetchSiteDatabase({ siteId, accessToken, basePath }) + siteHasDatabase = siteDb !== null + } + const enabled = Boolean(envUrl) || siteHasDatabase + + // Resolve what database we're looking at and how to fetch applied migrations. + let branchLabel: string + let connectionString: string | null = null + let fetchApplied: AppliedMigrationsFetcher + let cleanup: (() => Promise) | undefined + let isLocal = false + + if (options.branch) { + if (!siteId) { + throw new Error(`The project must be linked with ${netlifyCommand()} link to use --branch.`) + } + if (!accessToken) { + throw new Error(`You must be logged in with ${netlifyCommand()} login to use --branch.`) + } + + connectionString = await fetchBranchConnectionString({ siteId, accessToken, basePath }, options.branch) + fetchApplied = remoteAppliedMigrations({ siteId, accessToken, basePath, branch: options.branch }) + branchLabel = options.branch + } else { + isLocal = true + branchLabel = 'local' + + // If a local database is already running, its connection string is stable + // and safe to print. Otherwise we still connect (connectToDatabase starts + // one up) so we can read migration state β€” we just suppress the + // connection string in the output because it dies with this process. + const existing = detectExistingLocalConnectionString(buildDir) + const connection = await connectToDatabase(buildDir) + cleanup = connection.cleanup + fetchApplied = localAppliedMigrations({ executor: connection.executor }) + if (existing) { + connectionString = connection.connectionString + } + } + + let status: MigrationsStatus + try { + const applied = await fetchApplied() + status = computeStatus(applied, local) + } finally { + if (cleanup) { + await cleanup() + } + } + + if (options.json) { + const displayedConnection = connectionString + ? options.showCredentials + ? connectionString + : redactConnectionString(connectionString) + : null + logJson({ + enabled, + packageInstalled, + target: branchLabel, + database: displayedConnection === null ? null : { connectionString: displayedConnection }, + applied: status.applied.map((m) => ({ version: m.version, name: m.name })), + pending: status.pending.map((m) => ({ version: m.version, name: m.name })), + missingOnDisk: status.missingOnDisk.map((m) => ({ version: m.version, name: m.name })), + outOfOrder: status.outOfOrder, + }) + return + } + + const siteInfo = command.netlify.siteInfo as { admin_url?: string } | undefined + renderPretty({ + enabled, + packageInstalled, + branchLabel, + connectionString, + showCredentials: options.showCredentials ?? false, + status, + isLocal, + adminUrl: siteInfo?.admin_url, + }) +} diff --git a/tests/unit/commands/database/status-db.test.ts b/tests/unit/commands/database/status-db.test.ts new file mode 100644 index 00000000000..dc7bc55ad94 --- /dev/null +++ b/tests/unit/commands/database/status-db.test.ts @@ -0,0 +1,636 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' + +const { + mockReaddir, + mockReadFile, + mockConnectToDatabase, + mockDetectExisting, + mockQuery, + mockCleanup, + mockFetch, + logMessages, + jsonMessages, +} = vi.hoisted(() => { + const mockReaddir = vi.fn() + const mockReadFile = vi.fn() + const mockQuery = vi.fn() + const mockCleanup = vi.fn().mockResolvedValue(undefined) + const mockConnectToDatabase = vi.fn() + const mockDetectExisting = vi.fn() + const mockFetch = vi.fn() + const logMessages: string[] = [] + const jsonMessages: unknown[] = [] + return { + mockReaddir, + mockReadFile, + mockConnectToDatabase, + mockDetectExisting, + mockQuery, + mockCleanup, + mockFetch, + logMessages, + jsonMessages, + } +}) + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + readdir: (...args: unknown[]) => mockReaddir(...args), + readFile: (path: unknown, ...rest: unknown[]) => { + // Only intercept reads under the mocked project root β€” the CLI's own + // package.json and other ambient reads go straight through. + if (typeof path === 'string' && path.startsWith('/project/')) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return mockReadFile(path, ...rest) + } + return actual.readFile( + path as Parameters[0], + rest[0] as Parameters[1], + ) + }, + } +}) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, +})) + +vi.mock('../../../../src/commands/database/db-connection.js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + detectExistingLocalConnectionString: (...args: unknown[]) => mockDetectExisting(...args), +})) + +vi.stubGlobal('fetch', mockFetch) + +import { statusDb } from '../../../../src/commands/database/status-db.js' + +const SITE_NAME = 'my-site' +const LOCAL_CONN_WITH_CREDS = 'postgres://user:password@localhost:5432/netlify' +const LOCAL_CONN_NO_CREDS = 'postgres://localhost:5432/postgres' +const BRANCH_CONN = 'postgres://admin:secret@branch-host.neon.tech/db' +const PROD_CONN = 'postgres://owner:prodsecret@prod-host.neon.tech/db' + +const makeDirents = (names: string[]) => + names.map((name) => ({ + name, + isDirectory: () => true, + })) + +function createMockCommand( + overrides: { siteRoot?: string | null; migrationsPath?: string | null; siteId?: string | null } = {}, +) { + const siteRoot = overrides.siteRoot === null ? undefined : overrides.siteRoot ?? '/project' + const migrationsPath = + overrides.migrationsPath === null ? undefined : overrides.migrationsPath ?? '/project/netlify/db/migrations' + const siteId = overrides.siteId === null ? undefined : overrides.siteId ?? 'site-123' + + return { + siteId, + project: { root: '/project', baseDirectory: undefined }, + netlify: { + site: { root: siteRoot, id: siteId }, + siteInfo: { id: siteId, name: SITE_NAME }, + config: migrationsPath ? { db: { migrations: { path: migrationsPath } } } : {}, + api: { + accessToken: 'Bearer test-token', + basePath: 'https://api.netlify.com/api/v1', + }, + }, + } as unknown as Parameters[1] +} + +interface FetchRoutes { + siteDatabase?: { connection_string: string } | null + branch?: Record + migrations?: Record +} + +function setupFetchRouter(routes: FetchRoutes) { + mockFetch.mockImplementation((url: URL | string) => { + const urlString = typeof url === 'string' ? url : url.toString() + const path = typeof url === 'string' ? new URL(url).pathname : url.pathname + + if (path.endsWith('/database/')) { + if (!routes.siteDatabase) { + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('not found') }) + } + const body = routes.siteDatabase + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }) + } + + const branchMatch = /\/database\/branch\/([^/]+)$/.exec(path) + if (branchMatch) { + const branchId = decodeURIComponent(branchMatch[1]) + const branchData = routes.branch?.[branchId] + if (branchData) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(branchData) }) + } + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('not found') }) + } + + if (path.endsWith('/database/migrations')) { + const branchId = new URL(urlString).searchParams.get('branch') ?? 'production' + const migrations = routes.migrations?.[branchId] ?? [] + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ migrations }) }) + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)) + }) +} + +function mockLocalAppliedRows(names: string[]) { + mockQuery.mockResolvedValue({ rows: names.map((name) => ({ name })) }) +} + +function mockPackageJson(contents: Record) { + mockReadFile.mockImplementation((path: unknown) => { + if (typeof path === 'string' && path.endsWith('package.json')) { + return Promise.resolve(JSON.stringify(contents)) + } + // Fallback: treat anything else we intercept as an empty migration file. + return Promise.resolve('') + }) +} + +function setLocalDatabaseRunning(connectionString: string) { + mockDetectExisting.mockReturnValue(connectionString) + mockConnectToDatabase.mockImplementation(() => + Promise.resolve({ + executor: { query: mockQuery }, + connectionString, + cleanup: mockCleanup, + }), + ) +} + +beforeEach(() => { + logMessages.length = 0 + jsonMessages.length = 0 + vi.clearAllMocks() + mockReaddir.mockResolvedValue([]) + mockCleanup.mockResolvedValue(undefined) + mockLocalAppliedRows([]) + setupFetchRouter({ siteDatabase: null }) + mockPackageJson({ dependencies: { '@netlify/database': '^1.0.0' } }) + setLocalDatabaseRunning(LOCAL_CONN_WITH_CREDS) + delete process.env.NETLIFY_DB_URL +}) + +afterEach(() => { + delete process.env.NETLIFY_DB_URL +}) + +describe('statusDb', () => { + describe('enabled flag', () => { + test('reports enabled=true when NETLIFY_DB_URL is set', async () => { + process.env.NETLIFY_DB_URL = 'postgres://x/y' + setupFetchRouter({ siteDatabase: null }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ enabled: true }) + }) + + test('reports enabled=true when getSiteDatabase returns a DB', async () => { + setupFetchRouter({ siteDatabase: { connection_string: PROD_CONN } }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ enabled: true }) + }) + + test('reports enabled=false when neither env nor server has a DB', async () => { + setupFetchRouter({ siteDatabase: null }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ enabled: false }) + }) + + test('skips server check if siteId or token is missing', async () => { + await statusDb({ json: true }, createMockCommand({ siteId: null })) + + const calls = mockFetch.mock.calls + const anyDatabaseCall = calls.some((c) => { + const u = c[0] as URL | string + const p = typeof u === 'string' ? u : u.toString() + return p.endsWith('/database/') + }) + expect(anyDatabaseCall).toBe(false) + }) + }) + + describe('package-installed flag', () => { + test('reports packageInstalled=true when listed in dependencies', async () => { + mockPackageJson({ dependencies: { '@netlify/database': '^1.0.0' } }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ packageInstalled: true }) + }) + + test('reports packageInstalled=true when listed in devDependencies', async () => { + mockPackageJson({ devDependencies: { '@netlify/database': '^1.0.0' } }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ packageInstalled: true }) + }) + + test('reports packageInstalled=false when not listed', async () => { + mockPackageJson({ dependencies: { react: '^18.0.0' } }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ packageInstalled: false }) + }) + + test('reports packageInstalled=false when package.json is missing or unreadable', async () => { + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ packageInstalled: false }) + }) + }) + + describe('without --url (local database)', () => { + test('throws when project root cannot be determined', async () => { + const command = createMockCommand({ siteRoot: null }) + ;(command as { project: { root: string | undefined } }).project = { root: undefined } + + await expect(statusDb({}, command)).rejects.toThrow('Could not determine the project root') + }) + + test('connects to the local database when one is already running', async () => { + await statusDb({ json: true }, createMockCommand()) + + expect(mockDetectExisting).toHaveBeenCalledWith('/project') + expect(mockConnectToDatabase).toHaveBeenCalledWith('/project') + expect(mockQuery).toHaveBeenCalledTimes(1) + const sql = mockQuery.mock.calls[0][0] as string + expect(sql).toContain('netlify.migrations') + }) + + test('still connects and reads migration state when no local database is already running', async () => { + mockDetectExisting.mockReturnValue(null) + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + + await statusDb({ json: true }, createMockCommand()) + + // We still spin up a DB to read migration state. + expect(mockConnectToDatabase).toHaveBeenCalledTimes(1) + expect(mockQuery).toHaveBeenCalledTimes(1) + expect(jsonMessages[0]).toMatchObject({ + applied: [{ version: 1, name: '0001_a' }], + pending: [{ version: 2, name: '0002_b' }], + }) + }) + + test('suppresses the connection string in JSON output when no persistent local database is running', async () => { + mockDetectExisting.mockReturnValue(null) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + target: 'local', + database: null, + }) + }) + + test('default output hints at starting a persistent local database when none is running', async () => { + mockDetectExisting.mockReturnValue(null) + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + + await statusDb({}, createMockCommand()) + + const output = logMessages.join('\n') + expect(output).toContain('The local database is not running') + expect(output).toContain('netlify dev') + // Migration state is still rendered β€” connection string is the only thing suppressed. + expect(output).toContain('Applied migrations') + expect(output).toContain('β€’ 0001_a') + expect(output).toContain('β€’ 0002_b') + }) + + test('cleans up the database connection even when query throws', async () => { + mockQuery.mockRejectedValue(Object.assign(new Error('boom'), { code: '08000' })) + + await expect(statusDb({}, createMockCommand())).rejects.toThrow('boom') + + expect(mockCleanup).toHaveBeenCalledTimes(1) + }) + + test('reports applied and pending correctly', async () => { + mockLocalAppliedRows(['0001_a', '0002_b']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + target: 'local', + applied: [ + { version: 1, name: '0001_a' }, + { version: 2, name: '0002_b' }, + ], + pending: [{ version: 3, name: '0003_c' }], + }) + }) + + test('returns redacted connection string by default', async () => { + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + database: { connectionString: 'postgres://***:***@localhost:5432/netlify' }, + }) + }) + + test('returns full connection string with --show-credentials', async () => { + await statusDb({ json: true, showCredentials: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + database: { connectionString: LOCAL_CONN_WITH_CREDS }, + }) + }) + + test('default output shows applied and pending as bullets', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + + await statusDb({}, createMockCommand()) + + const output = logMessages.join('\n') + expect(output).toContain('Applied migrations') + expect(output).toContain('β€’ 0001_a') + expect(output).toContain('Migrations not applied') + expect(output).toContain('β€’ 0002_b') + }) + + test('default output includes the apply-command hint when pending migrations exist on local', async () => { + mockLocalAppliedRows([]) + mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('netlify db migrations apply') + }) + + test('default output omits the apply-command hint when there are no pending migrations', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).not.toContain('netlify db migrations apply') + }) + + test('shows --show-credentials hint when connection has credentials', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('--show-credentials') + }) + + test('omits --show-credentials hint when the connection string has no credentials', async () => { + setLocalDatabaseRunning(LOCAL_CONN_NO_CREDS) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).not.toContain('--show-credentials') + }) + + test('default output omits the --show-credentials hint when credentials are shown', async () => { + await statusDb({ showCredentials: true }, createMockCommand()) + + expect(logMessages.join('\n')).not.toContain('To reveal the full connection string') + }) + }) + + describe('secondary descriptive lines', () => { + test('renders a descriptive line under Enabled when true', async () => { + process.env.NETLIFY_DB_URL = 'postgres://x/y' + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('Netlify Database is enabled for this project') + }) + + test('renders a dashboard link under Enabled when siteInfo has an admin_url', async () => { + process.env.NETLIFY_DB_URL = 'postgres://x/y' + const command = createMockCommand() + ;(command as { netlify: { siteInfo: { admin_url?: string } } }).netlify.siteInfo.admin_url = + 'https://app.netlify.com/sites/my-site' + + await statusDb({}, command) + + expect(logMessages.join('\n')).toContain('Manage your database at https://app.netlify.com/sites/my-site') + }) + + test('renders an install hint under Enabled when disabled', async () => { + setupFetchRouter({ siteDatabase: null }) + + await statusDb({}, createMockCommand()) + + const output = logMessages.join('\n') + expect(output).toContain('Install the @netlify/database package and deploy your site') + }) + + test('renders an installed statement under Package when installed', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('The @netlify/database package is installed') + }) + + test('renders an API-reference link under Package when installed', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('For a full API reference, visit https://ntl.fyi/database') + }) + + test('renders an install hint under Package when not installed', async () => { + mockPackageJson({ dependencies: {} }) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('Install it with `npm install @netlify/database`') + }) + }) + + describe('section subtitles', () => { + test('renders a subtitle under the Netlify Database title', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain( + 'Managed Postgres databases that seamlessly integrate with the Netlify workflow', + ) + }) + + test('renders a subtitle under Applied migrations', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('Migrations that have been applied to the database branch') + }) + + test('renders a subtitle under Migrations not applied', async () => { + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain("Migrations that exist locally that haven't yet been applied") + }) + + test('always renders the immutability note below the Applied migrations list', async () => { + mockLocalAppliedRows(['0001_a']) + mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + + await statusDb({}, createMockCommand()) + + const output = logMessages.join('\n') + // Note appears after the bullet list. + const bulletIndex = output.indexOf('β€’ 0001_a') + const noteIndex = output.indexOf('Note that these migrations cannot be removed or edited') + expect(bulletIndex).toBeGreaterThanOrEqual(0) + expect(noteIndex).toBeGreaterThan(bulletIndex) + expect(output).toContain('you should generate a new migration') + }) + + test('renders the immutability note regardless of NETLIFY_AGENT_RUNNER_ID', async () => { + // env var intentionally unset in beforeEach + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('Note that these migrations cannot be removed or edited') + }) + }) + + describe('command invocation rendering', () => { + test('uses `netlify` directly when not invoked via npx/pnpm', async () => { + delete process.env.npm_lifecycle_event + delete process.env.npm_config_user_agent + delete process.env.npm_command + + await statusDb({}, createMockCommand()) + const output = logMessages.join('\n') + + expect(output).toContain('netlify db connect') + expect(output).toContain('netlify db status --show-credentials') + expect(output).not.toContain('npx netlify') + }) + + test('prefixes with `npx` when invoked through npx', async () => { + process.env.npm_lifecycle_event = 'npx' + + await statusDb({}, createMockCommand()) + const output = logMessages.join('\n') + + expect(output).toContain('npx netlify db connect') + expect(output).toContain('npx netlify db status --show-credentials') + }) + + test('uses the dynamic command in the apply-pending hint', async () => { + mockLocalAppliedRows([]) + mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('netlify db migrations apply') + }) + + test('uses the dynamic command in the not-running hint', async () => { + mockDetectExisting.mockReturnValue(null) + + await statusDb({}, createMockCommand()) + + expect(logMessages.join('\n')).toContain('netlify dev') + }) + }) + + describe('with --branch (remote branch)', () => { + test('fetches the branch connection and migrations using the provided branch name', async () => { + setupFetchRouter({ + siteDatabase: { connection_string: PROD_CONN }, + branch: { 'feature-x': { connection_string: BRANCH_CONN } }, + migrations: { 'feature-x': [] }, + }) + + await statusDb({ branch: 'feature-x', json: true }, createMockCommand()) + + const fetchedUrls = mockFetch.mock.calls.map((c) => { + const u = c[0] as URL | string + return typeof u === 'string' ? u : u.toString() + }) + expect(fetchedUrls.some((u) => u.includes('/database/branch/feature-x'))).toBe(true) + expect(fetchedUrls.some((u) => u.includes('/database/migrations') && u.includes('branch=feature-x'))).toBe(true) + expect(mockConnectToDatabase).not.toHaveBeenCalled() + expect(jsonMessages[0]).toMatchObject({ target: 'feature-x' }) + }) + + test('throws a helpful error when the branch endpoint 404s', async () => { + setupFetchRouter({ siteDatabase: { connection_string: PROD_CONN } }) + + await expect(statusDb({ branch: 'feature-x' }, createMockCommand())).rejects.toThrow( + 'No database branch found for "feature-x"', + ) + }) + + test('filters migrations to applied=true only', async () => { + setupFetchRouter({ + siteDatabase: { connection_string: PROD_CONN }, + branch: { 'feature-x': { connection_string: BRANCH_CONN } }, + migrations: { + 'feature-x': [ + { version: 1, name: '0001_a', path: '0001_a/migration.sql', applied: true }, + { version: 2, name: '0002_b', path: '0002_b/migration.sql', applied: false }, + ], + }, + }) + mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + + await statusDb({ branch: 'feature-x', json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + applied: [{ version: 1, name: '0001_a' }], + pending: [ + { version: 2, name: '0002_b' }, + { version: 3, name: '0003_c' }, + ], + }) + }) + + test('uses branch connection string for display (redacted by default)', async () => { + setupFetchRouter({ + siteDatabase: { connection_string: PROD_CONN }, + branch: { 'feature-x': { connection_string: BRANCH_CONN } }, + migrations: { 'feature-x': [] }, + }) + + await statusDb({ branch: 'feature-x', json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + database: { connectionString: 'postgres://***:***@branch-host.neon.tech/db' }, + }) + }) + + test('does not show the apply-command hint for remote', async () => { + setupFetchRouter({ + siteDatabase: { connection_string: PROD_CONN }, + branch: { 'feature-x': { connection_string: BRANCH_CONN } }, + migrations: { 'feature-x': [] }, + }) + mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + + await statusDb({ branch: 'feature-x' }, createMockCommand()) + + expect(logMessages.join('\n')).not.toContain('netlify db migrations apply') + }) + }) +})