Skip to content

Commit

Permalink
feat(cli): add pretty mutation formatting to migration runner (#5573)
Browse files Browse the repository at this point in the history
* feat(cli): add pretty mutation formatting to migration runner

* feat(cli): improve formatting

* feat(cli): add indent option to `prettyFormatMutation`

* feat(cli): support indentation when printing JSON

* feat(cli): support migrations for multiple document types

* feat(cli): improve dry run migration formatting

* refactor(cli): abstract tree reporter

* chore(cli): remove unused import

* fix(cli): add missing closing parenthesis

* feat(cli): add migration dry-run output

* feat(cli): underline document id when pretty formatting mutation

* feat(cli): add transaction support to pretty formatter

* feat(cli): add transaction support to pretty mutation formatter

* refactor(cli): reduce complexity

* feat(cli): pretty format mutations when committing migration
  • Loading branch information
juice49 authored and bjoerge committed Jan 30, 2024
1 parent a82df02 commit 2dba206
Show file tree
Hide file tree
Showing 6 changed files with 419 additions and 170 deletions.
43 changes: 7 additions & 36 deletions packages/@sanity/migrate/src/runner/dryRun.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import arrify from 'arrify'
import {SanityDocument} from '@sanity/types'
import {APIConfig, Migration, MigrationProgress} from '../types'
import {fromExportEndpoint, safeJsonParser} from '../sources/fromExportEndpoint'
import {streamToAsyncIterator} from '../utils/streamToAsyncIterator'
import {bufferThroughFile} from '../fs-webstream/bufferThroughFile'
import {asyncIterableToStream} from '../utils/asyncIterableToStream'
import {tap} from '../it-utils/tap'
import {parse, stringify} from '../it-utils/ndjson'
import {decodeText, toArray} from '../it-utils'
import {decodeText} from '../it-utils'
import {collectMigrationMutations} from './collectMigrationMutations'
import {getBufferFilePath} from './utils/getBufferFile'
import {createBufferFileContext} from './utils/createBufferFileContext'
Expand All @@ -18,16 +16,7 @@ interface MigrationRunnerOptions {
onProgress?: (event: MigrationProgress) => void
}

export async function dryRun(config: MigrationRunnerOptions, migration: Migration) {
const stats: MigrationProgress = {
documents: 0,
mutations: 0,
pending: 0,
queuedBatches: 0,
completedTransactions: [],
currentTransactions: [],
}

export async function* dryRun(config: MigrationRunnerOptions, migration: Migration) {
const filteredDocuments = applyFilters(
migration,
parse<SanityDocument>(
Expand All @@ -41,6 +30,7 @@ export async function dryRun(config: MigrationRunnerOptions, migration: Migratio
)

const abortController = new AbortController()

const createReader = bufferThroughFile(
asyncIterableToStream(stringify(filteredDocuments)),
getBufferFilePath(),
Expand All @@ -49,31 +39,12 @@ export async function dryRun(config: MigrationRunnerOptions, migration: Migratio

const context = createBufferFileContext(createReader)

const mutations = tap(
collectMigrationMutations(
migration,
() => parse(decodeText(streamToAsyncIterator(createReader())), {parse: safeJsonParser}),
context,
),
(muts) => {
stats.currentTransactions = arrify(muts)
config.onProgress?.({
...stats,
mutations: ++stats.mutations,
})
},
yield* collectMigrationMutations(
migration,
() => parse(decodeText(streamToAsyncIterator(createReader())), {parse: safeJsonParser}),
context,
)

for await (const mutation of await toArray(mutations)) {
config.onProgress?.({
...stats,
})
}

config.onProgress?.({
...stats,
done: true,
})
// stop buffering the export once we're done collecting all mutations
abortController.abort()
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import chalk from 'chalk'
import {ValidationMarker} from '@sanity/types'
import {Path, ValidationMarker} from '@sanity/types'
import logSymbols from 'log-symbols'
import {DocumentValidationResult, Level, isTty, levelValues} from './util'
import {pathToString} from 'sanity'

interface ValidationTree {
markers?: Pick<ValidationMarker, 'level' | 'message'>[]
children?: Record<string, ValidationTree>
import {convertToTree, formatTree, maxKeyLength, Tree} from '../../../../util/tree'
import {DocumentValidationResult, isTty, Level, levelValues} from './util'

export interface FormatDocumentValidationOptions extends DocumentValidationResult {
studioHost?: string
basePath?: string
}

interface Marker extends Pick<ValidationMarker, 'level' | 'message'> {
path: Path
}

type ValidationTree = Tree<Marker>

const levelHeaders = {
error: isTty ? chalk.bold(chalk.bgRed(chalk.black(' ERROR '))) : chalk.red('[ERROR]'),
warning: isTty ? chalk.bold(chalk.bgYellow(chalk.black(' WARN '))) : chalk.yellow('[WARN]'),
Expand All @@ -21,73 +28,19 @@ const levelHeaders = {
const link = (text: string, url: string) =>
isTty ? `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007` : chalk.underline(text)

/**
* Recursively calculates the max length of all the keys in the given validation
* tree respecting extra length due to indentation depth. Used to calculate the
* padding for the rest of the tree.
*/
const maxKeyLength = (children: Record<string, ValidationTree> = {}, depth = 0): number => {
return Object.entries(children)
.map(([key, child]) =>
Math.max(key.length + depth * 2, maxKeyLength(child.children, depth + 1)),
)
.reduce((max, next) => (next > max ? next : max), 0)
}

/**
* For sorting markers
*/
const compareLevels = <T extends {level: Level; message: string}>(a: T, b: T) =>
levelValues[a.level] - levelValues[b.level]

/**
* Recursively formats a given tree into a printed user-friendly tree structure
*/
const formatTree = (
node: Record<string, ValidationTree> = {},
paddingLength: number,
indent = '',
): string => {
const entries = Object.entries(node)

return entries
.map(([key, child], index) => {
const isLast = index === entries.length - 1
const nextIndent = `${indent}${isLast ? ' ' : '│ '}`
const nested = formatTree(child.children, paddingLength, nextIndent)

if (!child.markers?.length) {
const current = `${indent}${isLast ? '└' : '├'}${key}`
return [current, nested].filter(Boolean).join('\n')
}

const [first, ...rest] = child.markers.slice().sort(compareLevels)
const firstPadding = '.'.repeat(paddingLength - indent.length - key.length)
const elbow = isLast ? '└' : '├'
const firstBullet = logSymbols[first.level]
const subsequentPadding = ' '.repeat(paddingLength - indent.length + 2)

const firstMessage = `${indent}${elbow}${key} ${firstPadding} ${firstBullet} ${first.message}`
const subsequentMessages = rest
.map(
(marker) =>
`${nextIndent}${subsequentPadding} ${logSymbols[marker.level]} ${marker.message}`,
)
.join('\n')

const current = [firstMessage, subsequentMessages].filter(Boolean).join('\n')
return [current, nested].filter(Boolean).join('\n')
})
.join('\n')
}

/**
* Formats the markers at the root of the validation tree
*/
const formatRootErrors = (root: ValidationTree, hasChildren: boolean, paddingLength: number) => {
if (!root.markers) return ''
if (!root.nodes) return ''

const [first, ...rest] = root.markers.slice().sort(compareLevels)
const [first, ...rest] = root.nodes.slice().sort(compareLevels)
if (!first) return ''

const firstElbow = hasChildren ? '│ ' : '└─'
Expand All @@ -107,38 +60,6 @@ const formatRootErrors = (root: ValidationTree, hasChildren: boolean, paddingLen
return [firstLine, restOfLines].filter(Boolean).join('\n')
}

/**
* Converts a set of markers with paths into a tree of markers where the paths
* are embedded in the tree
*/
function convertToTree(markers: ValidationMarker[]): ValidationTree {
const root: ValidationTree = {}

// add the markers to the tree
function addMarker(marker: ValidationMarker, node: ValidationTree = root) {
// if we've traversed the whole path
if (!marker.path.length) {
if (!node.markers) node.markers = [] // ensure markers is defined

// then add the marker to the front
node.markers.push({level: marker.level, message: marker.message})
return
}

const [current, ...rest] = marker.path
const key = pathToString([current])

// ensure the current node has children and the next node
if (!node.children) node.children = {}
if (!(key in node.children)) node.children[key] = {}

addMarker({...marker, path: rest}, node.children[key])
}

for (const marker of markers) addMarker(marker)
return root
}

/**
* Formats document validation results into a user-friendly tree structure
*/
Expand All @@ -148,8 +69,8 @@ export function formatDocumentValidation({
level,
markers,
intentUrl,
}: DocumentValidationResult): string {
const tree = convertToTree(markers)
}: FormatDocumentValidationOptions): string {
const tree = convertToTree<Marker>(markers)

const documentTypeHeader = isTty
? chalk.bgWhite(chalk.black(` ${documentType} `))
Expand All @@ -160,7 +81,14 @@ export function formatDocumentValidation({
}`

const paddingLength = Math.max(maxKeyLength(tree.children) + 2, 30)
const childErrors = formatTree(tree.children, paddingLength)

const childErrors = formatTree<Marker>({
node: tree.children,
paddingLength,
getNodes: ({nodes}) => (nodes ?? []).slice().sort(compareLevels),
getMessage: (marker) => [logSymbols[marker.level], marker.message].join(' '),
})

const rootErrors = formatRootErrors(tree, childErrors.length > 0, paddingLength)

return [header, rootErrors, childErrors].filter(Boolean).join('\n')
Expand Down
Loading

0 comments on commit 2dba206

Please sign in to comment.