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
3 changes: 0 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion src/commands/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Option } from 'commander'
import inquirer from 'inquirer'
import BaseCommand from '../base-command.js'
import type { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js'
import type { MigrationNewOptions } from './migration-new.js'

export type Extension = {
id: string
Expand Down Expand Up @@ -31,7 +32,9 @@ export const createDatabaseCommand = (program: BaseCommand) => {
'netlify db status',
'netlify db init',
'netlify db init --help',
...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1' ? ['netlify db migrate', 'netlify db reset'] : []),
...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1'
? ['netlify db migrate', 'netlify db reset', 'netlify db migration new']
: []),
])

dbCommand
Expand Down Expand Up @@ -105,5 +108,27 @@ export const createDatabaseCommand = (program: BaseCommand) => {
const { reset } = await import('./reset.js')
await reset(options, command)
})

const migrationCommand = dbCommand.command('migration').description('Manage database migrations')

migrationCommand
.command('new')
.description('Create a new migration')
.option('-d, --description <description>', 'Purpose of the migration (used to generate the file name)')
.addOption(
new Option('-s, --scheme <scheme>', 'Numbering scheme for migration prefixes').choices([
'sequential',
'timestamp',
]),
)
.option('--json', 'Output result as JSON')
.action(async (options: MigrationNewOptions, command: BaseCommand) => {
const { migrationNew } = await import('./migration-new.js')
await migrationNew(options, command)
})
.addExamples([
'netlify db migration new',
'netlify db migration new --description "add users table" --scheme sequential',
])
}
}
3 changes: 1 addition & 2 deletions src/commands/database/db-connection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Client } from 'pg'

import type { SQLExecutor } from '@netlify/dev'
import { NetlifyDev } from '@netlify/dev'
import { NetlifyDev, type SQLExecutor } from '@netlify/dev'
import { LocalState } from '@netlify/dev-utils'

import { PgClientExecutor } from './pg-client-executor.js'
Expand Down
170 changes: 170 additions & 0 deletions src/commands/database/migration-new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { readdir, mkdir, writeFile } from 'fs/promises'
import { join } from 'path'

import inquirer from 'inquirer'

import { log, logJson } from '../../utils/command-helpers.js'
import BaseCommand from '../base-command.js'

export type NumberingScheme = 'sequential' | 'timestamp'

export interface MigrationNewOptions {
description?: string
scheme?: NumberingScheme
json?: boolean
}

export const generateSlug = (description: string): string => {
return description
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}

export const detectNumberingScheme = (existingNames: string[]): NumberingScheme | undefined => {
if (existingNames.length === 0) {
return undefined
}

const prefixes = existingNames.map((name) => name.split(/[_-]/)[0])
const allTimestamp = prefixes.every((p) => /^\d{14}$/.test(p))
if (allTimestamp) {
return 'timestamp'
}

const allSequential = prefixes.every((p) => /^\d{4}$/.test(p))
if (allSequential) {
return 'sequential'
}

return undefined
}

export const generateNextPrefix = (existingNames: string[], scheme: NumberingScheme): string => {
if (scheme === 'timestamp') {
const now = new Date()
const pad = (n: number, width = 2) => String(n).padStart(width, '0')
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('')
}

const prefixes = existingNames.map((name) => {
const match = /^(\d{4})[_-]/.exec(name)
return match ? parseInt(match[1], 10) : 0
})
const maxPrefix = prefixes.length > 0 ? Math.max(...prefixes) : 0
return String(maxPrefix + 1).padStart(4, '0')
}

const getExistingMigrationNames = async (migrationsDirectory: string): Promise<string[]> => {
try {
const entries = await readdir(migrationsDirectory, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return []
}
throw error
}
}

const DEFAULT_MIGRATIONS_PATH = 'netlify/db/migrations'

export 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 join(projectRoot, DEFAULT_MIGRATIONS_PATH)
}

export const migrationNew = async (options: MigrationNewOptions, command: BaseCommand) => {
const { json } = options

const migrationsDirectory = resolveMigrationsDirectory(command)
const existingMigrations = await getExistingMigrationNames(migrationsDirectory)
const detectedScheme = detectNumberingScheme(existingMigrations)

let description = options.description
let scheme = options.scheme

if (!description) {
const answers = await inquirer.prompt<{ description: string }>([
{
type: 'input',
name: 'description',
message: 'What is the purpose of this migration?',
validate: (input: string) => (input.trim().length > 0 ? true : 'Description cannot be empty'),
},
])
description = answers.description
}

if (!scheme) {
const answers = await inquirer.prompt<{ scheme: NumberingScheme }>([
{
type: 'list',
name: 'scheme',
message: 'Numbering scheme:',
choices: [
{ name: 'Sequential (0001, 0002, ...)', value: 'sequential' },
{ name: 'Timestamp (20260312143000)', value: 'timestamp' },
],
...(detectedScheme && { default: detectedScheme }),
},
])
scheme = answers.scheme
}

const slug = generateSlug(description)
if (!slug) {
throw new Error(
`Description "${description}" produces an empty slug. Use a description with alphanumeric characters (e.g. "add users table").`,
)
}

const prefix = generateNextPrefix(existingMigrations, scheme)
const folderName = `${prefix}_${slug}`
const folderPath = join(migrationsDirectory, folderName)
const migrationFilePath = join(folderPath, 'migration.sql')

await mkdir(folderPath, { recursive: true })
await writeFile(
migrationFilePath,
`-- Write your migration SQL here
--
-- Example:
-- CREATE TABLE IF NOT EXISTS users (
-- id SERIAL PRIMARY KEY,
-- name TEXT NOT NULL,
-- created_at TIMESTAMP DEFAULT NOW()
-- );
`,
{ flag: 'wx' },
)

if (json) {
logJson({ path: folderPath, name: folderName })
} else {
log(`Created migration: ${folderName}`)
log(` ${join(folderPath, 'migration.sql')}`)
}
}
Loading
Loading