@sanity/migrate
Version:
Tooling for running data migrations on Sanity.io projects
203 lines (173 loc) • 5.52 kB
text/typescript
import {isatty} from 'node:tty'
import {convertToTree, formatTree, maxKeyLength} from '@sanity/cli-core/tree'
import {chalk} from '@sanity/cli-core/ux'
import {type KeyedSegment} from '@sanity/types'
import {Transaction} from '../../mutations/transaction.js'
import {Mutation, NodePatch} from '../../mutations/types.js'
import {Migration} from '../../types.js'
type ItemRef = number | string
type Impact = 'destructive' | 'incremental' | 'maybeDestructive'
type Variant = 'info' | Impact
const isTty = isatty(1)
interface FormatterOptions<Subject> {
migration: Migration
subject: Subject
indentSize?: number
}
export function prettyFormat({
indentSize = 0,
migration,
subject,
}: FormatterOptions<(Mutation | Transaction)[] | Mutation | Transaction>): string {
return (Array.isArray(subject) ? subject : [subject])
.map((subjectEntry) => {
if (subjectEntry.type === 'transaction') {
return [
[
badge('transaction', 'info'),
subjectEntry.id === undefined ? null : chalk.underline(subjectEntry.id),
]
.filter(Boolean)
.join(' '),
indent(
prettyFormat({
indentSize: indentSize,
migration,
subject: subjectEntry.mutations,
}),
),
].join('\n\n')
}
return prettyFormatMutation({
indentSize,
migration,
subject: subjectEntry,
})
})
.join('\n\n')
}
function encodeItemRef(ref: KeyedSegment | number): ItemRef {
return typeof ref === 'number' ? ref : ref._key
}
function badgeStyle(variant: Variant): typeof chalk {
const styles: Record<Variant, typeof chalk> = {
destructive: chalk.bgRed.black.bold,
incremental: chalk.bgGreen.black.bold,
info: chalk.bgWhite.black,
maybeDestructive: chalk.bgYellow.black.bold,
}
return styles[variant]
}
function badge(label: string, variant: Variant): string {
if (!isTty) {
return `[${label}]`
}
return badgeStyle(variant)(` ${label} `)
}
const mutationImpact: Record<Mutation['type'], Impact> = {
create: 'incremental',
createIfNotExists: 'incremental',
createOrReplace: 'maybeDestructive',
delete: 'destructive',
patch: 'maybeDestructive',
}
function documentId(mutation: Mutation): string | undefined {
if ('id' in mutation) {
return mutation.id
}
if ('document' in mutation) {
return mutation.document._id
}
return undefined
}
const listFormatter = new Intl.ListFormat('en-US', {
type: 'disjunction',
})
function mutationHeader(mutation: Mutation, migration: Migration): string {
const mutationType = badge(mutation.type, mutationImpact[mutation.type])
const documentType =
'document' in mutation || migration.documentTypes
? badge(
'document' in mutation
? mutation.document._type
: listFormatter.format(migration.documentTypes ?? []),
'info',
)
: null
// TODO: Should we list documentType when a mutation can be yielded for any document type?
return [mutationType, documentType, chalk.underline(documentId(mutation))]
.filter(Boolean)
.join(' ')
}
function prettyFormatMutation({
indentSize = 0,
migration,
subject,
}: FormatterOptions<Mutation>): string {
const lock =
'options' in subject ? chalk.cyan(`(if revision==${subject.options?.ifRevision})`) : ''
const header = [mutationHeader(subject, migration), lock].join(' ')
const padding = ' '.repeat(indentSize)
if (
subject.type === 'create' ||
subject.type === 'createIfNotExists' ||
subject.type === 'createOrReplace'
) {
return [header, '\n', indent(JSON.stringify(subject.document, null, 2), indentSize)].join('')
}
if (subject.type === 'patch') {
const tree = convertToTree<NodePatch>(subject.patches.flat())
const paddingLength = Math.max(maxKeyLength(tree.children) + 2, 30)
return [
header,
'\n',
formatTree<NodePatch>({
getMessage: (patch: NodePatch) => formatPatchMutation(patch),
indent: padding,
node: tree.children,
paddingLength,
}),
].join('')
}
return header
}
function formatPatchMutation(patch: NodePatch): string {
const {op} = patch
const formattedType = chalk.bold(op.type)
if (op.type === 'unset') {
return `${chalk.red(formattedType)}()`
}
if (op.type === 'diffMatchPatch') {
return `${chalk.yellow(formattedType)}(${op.value})`
}
if (op.type === 'inc' || op.type === 'dec') {
return `${chalk.yellow(formattedType)}(${op.amount})`
}
if (op.type === 'set') {
return `${chalk.yellow(formattedType)}(${JSON.stringify(op.value)})`
}
if (op.type === 'setIfMissing') {
return `${chalk.green(formattedType)}(${JSON.stringify(op.value)})`
}
if (op.type === 'insert') {
return `${chalk.green(formattedType)}(${op.position}, ${encodeItemRef(
op.referenceItem,
)}, ${JSON.stringify(op.items)})`
}
if (op.type === 'replace') {
return `${chalk.yellow(formattedType)}(${encodeItemRef(op.referenceItem)}, ${JSON.stringify(
op.items,
)})`
}
if (op.type === 'truncate') {
return `${chalk.red(formattedType)}(${op.startIndex}, ${op.endIndex})`
}
throw new Error(`Invalid operation type: ${(op as {type: string}).type}`)
}
function indent(subject: string, size = 2): string {
const padding = ' '.repeat(size)
return subject
.split('\n')
.map((line) => padding + line)
.join('\n')
}