Skip to content

Commit 2a8f7c0

Browse files
authored
feat: pull DB migrations (#8139)
1 parent c28ffa3 commit 2a8f7c0

File tree

3 files changed

+498
-1
lines changed

3 files changed

+498
-1
lines changed

src/commands/database/database.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import inquirer from 'inquirer'
33
import BaseCommand from '../base-command.js'
44
import type { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js'
55
import type { MigrationNewOptions } from './migration-new.js'
6+
import type { MigrationPullOptions } from './migration-pull.js'
67

78
export type Extension = {
89
id: string
@@ -31,7 +32,7 @@ export const createDatabaseCommand = (program: BaseCommand) => {
3132
.addExamples([
3233
'netlify db status',
3334
...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1'
34-
? ['netlify db migrations apply', 'netlify db reset', 'netlify db migrations new']
35+
? ['netlify db migrations apply', 'netlify db migrations pull', 'netlify db reset', 'netlify db migrations new']
3536
: ['netlify db init', 'netlify db init --help']),
3637
])
3738

@@ -149,5 +150,25 @@ export const createDatabaseCommand = (program: BaseCommand) => {
149150
'netlify db migrations new',
150151
'netlify db migrations new --description "add users table" --scheme sequential',
151152
])
153+
154+
migrationsCommand
155+
.command('pull')
156+
.description('Pull migrations and overwrite local migration files')
157+
.option(
158+
'-b, --branch [branch]',
159+
"Pull migrations for a specific branch (defaults to 'production'; pass --branch with no value to use local git branch)",
160+
)
161+
.option('--force', 'Skip confirmation prompt', false)
162+
.option('--json', 'Output result as JSON')
163+
.action(async (options: MigrationPullOptions, command: BaseCommand) => {
164+
const { migrationPull } = await import('./migration-pull.js')
165+
await migrationPull(options, command)
166+
})
167+
.addExamples([
168+
'netlify db migrations pull',
169+
'netlify db migrations pull --branch staging',
170+
'netlify db migrations pull --branch',
171+
'netlify db migrations pull --force',
172+
])
152173
}
153174
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { rm, mkdir, writeFile } from 'fs/promises'
2+
import { dirname, resolve, isAbsolute } from 'path'
3+
4+
import inquirer from 'inquirer'
5+
6+
import { log, logJson } from '../../utils/command-helpers.js'
7+
import execa from '../../utils/execa.js'
8+
import BaseCommand from '../base-command.js'
9+
import { resolveMigrationsDirectory } from './migration-new.js'
10+
11+
export interface MigrationPullOptions {
12+
branch?: string | true
13+
force?: boolean
14+
json?: boolean
15+
}
16+
17+
interface MigrationFile {
18+
version: number
19+
name: string
20+
path: string
21+
content: string
22+
}
23+
24+
interface ListMigrationsResponse {
25+
migrations: MigrationFile[]
26+
}
27+
28+
const getLocalGitBranch = async (): Promise<string> => {
29+
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
30+
const branch = stdout.trim()
31+
if (!branch || branch === 'HEAD') {
32+
throw new Error('Could not determine the current git branch. Are you in a detached HEAD state?')
33+
}
34+
return branch
35+
}
36+
37+
const resolveBranch = async (branchOption: string | true | undefined): Promise<string | undefined> => {
38+
if (branchOption === undefined) {
39+
return undefined
40+
}
41+
if (branchOption === true) {
42+
return getLocalGitBranch()
43+
}
44+
return branchOption
45+
}
46+
47+
const fetchMigrations = async (command: BaseCommand, branch: string | undefined): Promise<MigrationFile[]> => {
48+
const siteId = command.siteId
49+
if (!siteId) {
50+
throw new Error('The project must be linked with netlify link before pulling migrations.')
51+
}
52+
53+
const accessToken = command.netlify.api.accessToken
54+
if (!accessToken) {
55+
throw new Error('You must be logged in with netlify login to pull migrations.')
56+
}
57+
58+
const token = accessToken.replace('Bearer ', '')
59+
const basePath = command.netlify.api.basePath
60+
61+
const url = new URL(`${basePath}/sites/${encodeURIComponent(siteId)}/database/migrations`)
62+
if (branch) {
63+
url.searchParams.set('branch', branch)
64+
}
65+
66+
const response = await fetch(url, {
67+
headers: {
68+
Authorization: `Bearer ${token}`,
69+
},
70+
})
71+
72+
if (!response.ok) {
73+
const text = await response.text()
74+
throw new Error(`Failed to fetch migrations (${String(response.status)}): ${text}`)
75+
}
76+
77+
const data = (await response.json()) as ListMigrationsResponse
78+
return data.migrations
79+
}
80+
81+
export const migrationPull = async (options: MigrationPullOptions, command: BaseCommand) => {
82+
const { force, json } = options
83+
84+
const branch = await resolveBranch(options.branch)
85+
const source = branch ?? 'production'
86+
const migrations = await fetchMigrations(command, branch)
87+
88+
if (migrations.length === 0) {
89+
if (json) {
90+
logJson({ migrations_pulled: 0, branch: source })
91+
} else {
92+
log(`No migrations found for ${source}.`)
93+
}
94+
return
95+
}
96+
97+
const migrationsDirectory = resolveMigrationsDirectory(command)
98+
99+
if (!force) {
100+
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
101+
{
102+
type: 'confirm',
103+
name: 'confirmed',
104+
message: `This will overwrite all local migrations in ${migrationsDirectory} with ${String(
105+
migrations.length,
106+
)} migration${migrations.length === 1 ? '' : 's'} from ${source}. Continue?`,
107+
default: false,
108+
},
109+
])
110+
111+
if (!confirmed) {
112+
log('Pull cancelled.')
113+
return
114+
}
115+
}
116+
117+
const canonicalMigrationsDir = resolve(migrationsDirectory)
118+
119+
await rm(canonicalMigrationsDir, { recursive: true, force: true })
120+
121+
for (const migration of migrations) {
122+
if (isAbsolute(migration.path) || migration.path.split(/[/\\]/).includes('..')) {
123+
throw new Error(`Migration path "${migration.path}" contains invalid path segments.`)
124+
}
125+
126+
const filePath = resolve(canonicalMigrationsDir, migration.path)
127+
if (!filePath.startsWith(canonicalMigrationsDir)) {
128+
throw new Error(`Migration path "${migration.path}" resolves outside the migrations directory.`)
129+
}
130+
131+
await mkdir(dirname(filePath), { recursive: true })
132+
await writeFile(filePath, migration.content)
133+
}
134+
135+
if (json) {
136+
logJson({
137+
migrations_pulled: migrations.length,
138+
branch: source,
139+
migrations: migrations.map((m) => m.name),
140+
})
141+
} else {
142+
log(`Pulled ${String(migrations.length)} migration${migrations.length === 1 ? '' : 's'} from ${source}:`)
143+
for (const migration of migrations) {
144+
log(` - ${migration.name}`)
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)