UNPKG

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

388 lines (322 loc) 12.2 kB
import {type CliCommandContext, type CliCommandDefinition, type CliOutputter} from '@sanity/cli' import sanityImport from '@sanity/import' import {createReadStream} from 'fs' import fs from 'fs/promises' import {getIt} from 'get-it' import {promise} from 'get-it/middleware' import padStart from 'lodash/padStart' import path from 'path' import prettyMs from 'pretty-ms' import {chooseDatasetPrompt} from '../../actions/dataset/chooseDatasetPrompt' import {validateDatasetName} from '../../actions/dataset/validateDatasetName' import {debug} from '../../debug' const yellow = (str: string) => `\u001b[33m${str}\u001b[39m` const helpText = ` Options --missing On duplicate document IDs, skip importing document in question --replace On duplicate document IDs, replace existing document with imported document --allow-failing-assets Skip assets that cannot be fetched/uploaded --replace-assets Skip reuse of existing assets --skip-cross-dataset-references Skips references to other datasets Rarely used options (should generally not be used) --allow-assets-in-different-dataset Allow asset documents to reference different project/dataset --allow-system-documents Allow system documents like dataset permissions and custom retention to be imported Examples # Import "moviedb.ndjson" from the current directory to the dataset called "moviedb" sanity dataset import moviedb.ndjson moviedb # Import "moviedb.tar.gz" from the current directory to the dataset called "moviedb", # replacing any documents encountered that have the same document IDs sanity dataset import moviedb.tar.gz moviedb --replace # Import from a folder containing an ndjson file, such as an extracted tarball # retrieved through "sanity dataset export". sanity dataset import ~/some/folder moviedb # Import from a remote URL. Will download and extract the tarball to a temporary # location before importing it. sanity dataset import https://some.url/moviedb.tar.gz moviedb --replace ` interface ImportFlags { 'allow-assets-in-different-dataset'?: boolean 'allow-failing-assets'?: boolean 'asset-concurrency'?: boolean 'replace-assets'?: boolean 'skip-cross-dataset-references'?: boolean 'allow-system-documents'?: boolean replace?: boolean missing?: boolean } interface ParsedImportFlags { allowAssetsInDifferentDataset?: boolean allowFailingAssets?: boolean assetConcurrency?: boolean skipCrossDatasetReferences?: boolean allowSystemDocuments?: boolean replaceAssets?: boolean replace?: boolean missing?: boolean } interface ProgressEvent { step: string total?: number current?: number } interface ImportWarning { type?: string url?: string } function toBoolIfSet(flag: unknown): boolean | undefined { return typeof flag === 'undefined' ? undefined : Boolean(flag) } function parseFlags(rawFlags: ImportFlags): ParsedImportFlags { const allowAssetsInDifferentDataset = toBoolIfSet(rawFlags['allow-assets-in-different-dataset']) const allowFailingAssets = toBoolIfSet(rawFlags['allow-failing-assets']) const assetConcurrency = toBoolIfSet(rawFlags['asset-concurrency']) const replaceAssets = toBoolIfSet(rawFlags['replace-assets']) const skipCrossDatasetReferences = toBoolIfSet(rawFlags['skip-cross-dataset-references']) const allowSystemDocuments = toBoolIfSet(rawFlags['allow-system-documents']) const replace = toBoolIfSet(rawFlags.replace) const missing = toBoolIfSet(rawFlags.missing) return { allowAssetsInDifferentDataset, allowFailingAssets, assetConcurrency, skipCrossDatasetReferences, allowSystemDocuments, replaceAssets, replace, missing, } } const importDatasetCommand: CliCommandDefinition = { name: 'import', group: 'dataset', signature: '[FILE | FOLDER | URL] [TARGET_DATASET]', description: 'Import documents to given dataset from either an ndjson file or a gzipped tarball', helpText, // eslint-disable-next-line max-statements action: async (args, context) => { const {apiClient, output, chalk, fromInitCommand} = context const flags = parseFlags(args.extOptions) const { allowAssetsInDifferentDataset, allowFailingAssets, assetConcurrency, skipCrossDatasetReferences, allowSystemDocuments, replaceAssets, } = flags const operation = getMutationOperation(args.extOptions) const client = apiClient() const [file, target] = args.argsWithoutOptions if (!file) { throw new Error( `Source file name and target dataset must be specified ("sanity dataset import ${chalk.bold( '[file]', )} [dataset]")`, ) } const targetDataset = await determineTargetDataset(target, context) debug(`Target dataset has been set to "${targetDataset}"`) const isUrl = /^https?:\/\//i.test(file) let inputStream let assetsBase let sourceIsFolder = false if (isUrl) { debug('Input is a URL, streaming from source URL') inputStream = await getUrlStream(file) } else { const sourceFile = path.resolve(process.cwd(), file) const fileStats = await fs.stat(sourceFile).catch(() => null) if (!fileStats) { throw new Error(`${sourceFile} does not exist or is not readable`) } sourceIsFolder = fileStats.isDirectory() if (sourceIsFolder) { inputStream = sourceFile } else { assetsBase = path.dirname(sourceFile) inputStream = await createReadStream(sourceFile) } } const importClient = client.clone().config({dataset: targetDataset}) // Print information about what projectId and dataset it is being imported to const {projectId, dataset} = importClient.config() output.print('╭───────────────────────────────────────────────╮') output.print('│ │') output.print('│ Importing to: │') output.print(`│ ${chalk.bold('projectId')}: ${chalk.cyan(projectId).padEnd(44)} │`) output.print(`│ ${chalk.bold('dataset')}: ${chalk.cyan(dataset).padEnd(46)} │`) output.print('│ │') output.print('╰───────────────────────────────────────────────╯') output.print('') let currentStep: string | undefined let currentProgress: ReturnType<CliOutputter['spinner']> | undefined let stepStart: number | undefined let spinInterval: ReturnType<typeof setInterval> | null = null let percent: string | undefined function onProgress(opts: ProgressEvent) { const lengthComputable = opts.total const sameStep = opts.step == currentStep percent = getPercentage(opts) if (lengthComputable && opts.total === opts.current) { if (spinInterval) { clearInterval(spinInterval) } spinInterval = null } if (sameStep) { return } // Moved to a new step const prevStep = currentStep const prevStepStart = stepStart || Date.now() stepStart = Date.now() currentStep = opts.step if (currentProgress && currentProgress.succeed) { const timeSpent = prettyMs(Date.now() - prevStepStart, { secondsDecimalDigits: 2, }) currentProgress.text = `[100%] ${prevStep} (${timeSpent})` currentProgress.succeed() } currentProgress = output.spinner(`[0%] ${opts.step} (0.00s)`).start() if (spinInterval) { clearInterval(spinInterval) spinInterval = null } spinInterval = setInterval(() => { const timeSpent = prettyMs(Date.now() - prevStepStart, { secondsDecimalDigits: 2, }) if (currentProgress) { currentProgress.text = `${percent}${opts.step} (${timeSpent})` } }, 60) } function endTask({success}: {success: boolean}) { if (spinInterval) { clearInterval(spinInterval) } spinInterval = null if (success && stepStart && currentProgress) { const timeSpent = prettyMs(Date.now() - stepStart, { secondsDecimalDigits: 2, }) currentProgress.text = `[100%] ${currentStep} (${timeSpent})` currentProgress.succeed() } else if (currentProgress) { currentProgress.fail() } } // Start the import! try { const {numDocs, warnings} = await sanityImport(inputStream, { client: importClient, assetsBase, operation, onProgress, allowFailingAssets, allowAssetsInDifferentDataset, skipCrossDatasetReferences, allowSystemDocuments, assetConcurrency, replaceAssets, }) endTask({success: true}) output.print('Done! Imported %d documents to dataset "%s"\n', numDocs, targetDataset) printWarnings(warnings, output) } catch (err) { endTask({success: false}) const isNonRefConflict = !fromInitCommand && err.response && err.response.statusCode === 409 && err.step !== 'strengthen-references' if (!isNonRefConflict) { throw err } const message = [ err.message, '', 'You probably want either:', ' --replace (replace existing documents with same IDs)', ' --missing (only import documents that do not already exist)', '', ].join('\n') // @todo SUBCLASS ERROR? const error = new Error(message) as any error.details = err.details error.response = err.response error.responseBody = err.responseBody throw error } }, } async function determineTargetDataset(target: string, context: CliCommandContext) { const {apiClient, output, prompt} = context const client = apiClient() if (target) { const dsError = validateDatasetName(target) if (dsError) { throw new Error(dsError) } } debug('Fetching available datasets') const spinner = output.spinner('Fetching available datasets').start() const datasets = await client.datasets.list() spinner.succeed('[100%] Fetching available datasets') let targetDataset = target ? `${target}` : null if (!targetDataset) { targetDataset = await chooseDatasetPrompt(context, { message: 'Select target dataset', allowCreation: true, }) } else if (!datasets.find((dataset) => dataset.name === targetDataset)) { debug('Target dataset does not exist, prompting for creation') const shouldCreate = await prompt.single({ type: 'confirm', message: `Dataset "${targetDataset}" does not exist, would you like to create it?`, default: true, }) if (!shouldCreate) { throw new Error(`Dataset "${targetDataset}" does not exist`) } await client.datasets.create(targetDataset) } return targetDataset } function getMutationOperation(flags: ParsedImportFlags) { const {replace, missing} = flags if (replace && missing) { throw new Error('Cannot use both --replace and --missing') } if (flags.replace) { return 'createOrReplace' } if (flags.missing) { return 'createIfNotExists' } return 'create' } function getPercentage(opts: ProgressEvent) { if (!opts.total || typeof opts.current === 'undefined') { return '' } const percent = Math.floor((opts.current / opts.total) * 100) return `[${padStart(`${percent}`, 3, ' ')}%] ` } function getUrlStream(url: string) { const request = getIt([promise({onlyBody: true})]) return request({url, stream: true}) } function printWarnings(warnings: ImportWarning[], output: CliOutputter) { const assetFails = warnings.filter((warn) => warn.type === 'asset') if (!assetFails.length) { return } const warn = (output.warn || output.print).bind(output) warn(yellow('⚠ Failed to import the following %s:'), assetFails.length > 1 ? 'assets' : 'asset') warnings.forEach((warning) => { warn(` ${warning.url}`) }) } export default importDatasetCommand