Skip to content
Merged
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
88 changes: 88 additions & 0 deletions src/commands/database/applied-migrations.ts
Original file line number Diff line number Diff line change
@@ -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<MigrationFile[]>

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
}
2 changes: 2 additions & 0 deletions src/commands/database/constants.ts
Original file line number Diff line number Diff line change
@@ -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`
40 changes: 31 additions & 9 deletions 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 { 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
Expand Down Expand Up @@ -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 <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')
Expand Down
39 changes: 22 additions & 17 deletions src/commands/database/db-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { PgClientExecutor } from './pg-client-executor.js'

interface DBConnection {
executor: SQLExecutor
connectionString: string
cleanup: () => Promise<void>
}

export async function connectToDatabase(buildDir: string): Promise<DBConnection> {
const { client, cleanup } = await connectRawClient(buildDir)
export async function connectToDatabase(buildDir: string, urlOverride?: string): Promise<DBConnection> {
const { client, connectionString, cleanup } = await connectRawClient(buildDir, urlOverride)
return {
executor: new PgClientExecutor(client),
connectionString,
cleanup,
}
}
Expand All @@ -24,31 +26,34 @@ interface RawDBConnection {
cleanup: () => Promise<void>
}

export async function connectRawClient(buildDir: string): Promise<RawDBConnection> {
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<RawDBConnection> {
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 },
Expand Down
Loading
Loading