sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
267 lines (224 loc) • 9.3 kB
text/typescript
import path from 'node:path'
import {type CliCommandDefinition} from '@sanity/cli'
import {
DEFAULT_MUTATION_CONCURRENCY,
dryRun,
MAX_MUTATION_CONCURRENCY,
type Migration,
type MigrationProgress,
run,
} from '@sanity/migrate'
import {Table} from 'console-table-printer'
import {register} from 'esbuild-register/dist/node'
import {hideBin} from 'yargs/helpers'
import yargs from 'yargs/yargs'
import {debug} from '../../debug'
import {MIGRATIONS_DIRECTORY} from './constants'
import {resolveMigrations} from './listMigrationsCommand'
import {prettyFormat} from './prettyMutationFormatter'
import {isLoadableMigrationScript, resolveMigrationScript} from './utils/resolveMigrationScript'
const helpText = `
Options
--no-dry-run By default the migration runs in dry mode. Pass this option to migrate dataset.
--concurrency <concurrent> How many mutation requests to run in parallel. Must be between 1 and ${MAX_MUTATION_CONCURRENCY}. Default: ${DEFAULT_MUTATION_CONCURRENCY}.
--no-progress Don't output progress. Useful if you want debug your migration script and see the output of console.log() statements.
--dataset <dataset> Dataset to migrate. Defaults to the dataset configured in your Sanity CLI config.
--project <project id> Project ID of the dataset to migrate. Defaults to the projectId configured in your Sanity CLI config.
--no-confirm Skip the confirmation prompt before running the migration. Make sure you know what you're doing before using this flag.
--from-export <export.tar.gz> Use a local dataset export as source for migration instead of calling the Sanity API. Note: this is only supported for dry runs.
Examples
# dry run the migration
sanity migration run <id>
# execute the migration against a dataset
sanity migration run <id> --no-dry-run --project xyz --dataset staging
# execute the migration using a dataset export as the source
sanity migration run <id> --from-export=production.tar.gz --no-dry-run --projectId xyz --dataset staging
`
interface CreateFlags {
['dry-run']?: boolean
concurrency?: number
['from-export']?: string
progress?: boolean
dataset?: string
project?: string
confirm?: boolean
}
function parseCliFlags(args: {argv?: string[]}) {
return yargs(hideBin(args.argv || process.argv).slice(2))
.options('dry-run', {type: 'boolean', default: true})
.options('concurrency', {type: 'number', default: DEFAULT_MUTATION_CONCURRENCY})
.options('progress', {type: 'boolean', default: true})
.options('dataset', {type: 'string'})
.options('from-export', {type: 'string'})
.options('project', {type: 'string'})
.options('confirm', {type: 'boolean', default: true}).argv
}
const runMigrationCommand: CliCommandDefinition<CreateFlags> = {
name: 'run',
group: 'migration',
signature: 'ID',
helpText,
description: 'Run a migration against a dataset',
// eslint-disable-next-line max-statements
action: async (args, context) => {
const {apiClient, output, prompt, chalk, workDir} = context
const [id] = args.argsWithoutOptions
const migrationsDirectoryPath = path.join(workDir, MIGRATIONS_DIRECTORY)
const flags = await parseCliFlags(args)
const fromExport = flags.fromExport
const dry = flags.dryRun
const dataset = flags.dataset
const project = flags.project
if ((dataset && !project) || (project && !dataset)) {
throw new Error('If either --dataset or --project is provided, both must be provided')
}
if (!id) {
output.error(chalk.red('Error: Migration ID must be provided'))
const migrations = await resolveMigrations(workDir)
const table = new Table({
title: `Migrations found in project`,
columns: [
{name: 'id', title: 'ID', alignment: 'left'},
{name: 'title', title: 'Title', alignment: 'left'},
],
})
migrations.forEach((definedMigration) => {
table.addRow({id: definedMigration.id, title: definedMigration.migration.title})
})
table.printTable()
output.print('\nRun `sanity migration run <ID>` to run a migration')
return
}
if (!__DEV__) {
register({
target: `node${process.version.slice(1)}`,
})
}
const candidates = resolveMigrationScript(workDir, id)
const resolvedScripts = candidates.filter(isLoadableMigrationScript)
if (resolvedScripts.length > 1) {
// todo: consider prompt user about which one to run? note: it's likely a mistake if multiple files resolve to the same name
throw new Error(
`Found multiple migrations for "${id}" in ${chalk.cyan(migrationsDirectoryPath)}: \n - ${candidates
.map((candidate) => path.relative(migrationsDirectoryPath, candidate.absolutePath))
.join('\n - ')}`,
)
}
const script = resolvedScripts[0]
if (!script) {
throw new Error(
`No migration found for "${id}" in ${chalk.cyan(chalk.cyan(migrationsDirectoryPath))}. Make sure that the migration file exists and exports a valid migration as its default export.\n
Tried the following files:\n - ${candidates
.map((candidate) => path.relative(migrationsDirectoryPath, candidate.absolutePath))
.join('\n - ')}`,
)
}
const mod = script.mod
if ('up' in mod || 'down' in mod) {
// todo: consider adding support for up/down as separate named exports
// For now, make sure we reserve the names for future use
throw new Error(
'Only "up" migrations are supported at this time, please use a default export',
)
}
const migration: Migration = mod.default
if (fromExport && !dry) {
throw new Error('Can only dry run migrations from a dataset export file')
}
const concurrency = flags.concurrency
if (concurrency !== undefined) {
if (concurrency > MAX_MUTATION_CONCURRENCY) {
throw new Error(
`Concurrency exceeds the maximum allowed value of ${MAX_MUTATION_CONCURRENCY}`,
)
}
if (concurrency === 0) {
throw new Error(`Concurrency must be a positive number, got ${concurrency}`)
}
}
const projectConfig = apiClient({
requireUser: true,
requireProject: true,
}).config()
const apiConfig = {
dataset: dataset ?? projectConfig.dataset!,
projectId: project ?? projectConfig.projectId!,
apiHost: projectConfig.apiHost!,
token: projectConfig.token!,
apiVersion: 'v2024-01-29',
} as const
if (dry) {
dryRunHandler()
return
}
const response =
flags.confirm &&
(await prompt.single<boolean>({
message: `This migration will run on the ${chalk.yellow(
chalk.bold(apiConfig.dataset),
)} dataset in ${chalk.yellow(chalk.bold(apiConfig.projectId))} project. Are you sure?`,
type: 'confirm',
}))
if (response === false) {
debug('User aborted migration')
return
}
const spinner = output.spinner(`Running migration "${id}"`).start()
await run({api: apiConfig, concurrency, onProgress: createProgress(spinner)}, migration)
spinner.stop()
function createProgress(progressSpinner: ReturnType<typeof output.spinner>) {
return function onProgress(progress: MigrationProgress) {
if (!flags.progress) {
progressSpinner.stop()
return
}
if (progress.done) {
progressSpinner.text = `Migration "${id}" completed.
Project id: ${chalk.bold(apiConfig.projectId)}
Dataset: ${chalk.bold(apiConfig.dataset)}
${progress.documents} documents processed.
${progress.mutations} mutations generated.
${chalk.green(progress.completedTransactions.length)} transactions committed.`
progressSpinner.stopAndPersist({symbol: chalk.green('✔')})
return
}
;[null, ...progress.currentTransactions].forEach((transaction) => {
progressSpinner.text = `Running migration "${id}" ${dry ? 'in dry mode...' : '...'}
Project id: ${chalk.bold(apiConfig.projectId)}
Dataset: ${chalk.bold(apiConfig.dataset)}
Document type: ${chalk.bold(migration.documentTypes?.join(','))}
${progress.documents} documents processed…
${progress.mutations} mutations generated…
${chalk.blue(progress.pending)} requests pending…
${chalk.green(progress.completedTransactions.length)} transactions committed.
${
transaction && !progress.done
? `» ${prettyFormat({chalk, subject: transaction, migration, indentSize: 2})}`
: ''
}`
})
}
}
async function dryRunHandler() {
output.print(`Running migration "${id}" in dry mode`)
if (fromExport) {
output.print(`Using export ${chalk.cyan(fromExport)}`)
}
output.print()
output.print(`Project id: ${chalk.bold(apiConfig.projectId)}`)
output.print(`Dataset: ${chalk.bold(apiConfig.dataset)}`)
for await (const mutation of dryRun({api: apiConfig, exportPath: fromExport}, migration)) {
if (!mutation) continue
output.print()
output.print(
prettyFormat({
chalk,
subject: mutation,
migration,
}),
)
}
}
},
}
export default runMigrationCommand