UNPKG

@strapi/strapi

Version:

An open source headless CMS solution to create and manage your own API. It provides a powerful dashboard and features to make your life easier. Databases supported: MySQL, MariaDB, PostgreSQL, SQLite

562 lines (559 loc) 24 kB
import chalk from 'chalk'; import path from 'node:path'; import CLITable from 'cli-table3'; import { Option } from 'commander'; import { createLogger, configs, formats } from '@strapi/logger'; import { compileStrapi, createStrapi } from '@strapi/core'; import ora from 'ora'; import { merge } from 'lodash/fp'; import { engine } from '@strapi/data-transfer'; import { exitWith, readableBytes, formatElapsedAndMaybeRemainingLabel, TRANSFER_PROGRESS_FIELD_SEP } from './helpers.mjs'; import { parseInteger, getParseListWithChoices, parseList, confirmMessage } from './commander.mjs'; const { errors: { TransferEngineInitializationError } } = engine; const exitMessageText = (process1, error = false)=>{ const processCapitalized = process1[0].toUpperCase() + process1.slice(1); if (!error) { return chalk.bold(chalk.green(`${processCapitalized} process has been completed successfully!`)); } return chalk.bold(chalk.red(`${processCapitalized} process failed.`)); }; const pad = (n)=>{ return (n < 10 ? '0' : '') + String(n); }; const yyyymmddHHMMSS = ()=>{ const date = new Date(); return date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate()) + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()); }; const getDefaultExportName = ()=>{ return `export_${yyyymmddHHMMSS()}`; }; const buildTransferTable = (resultData)=>{ if (!resultData) { return; } // Build pretty table const table = new CLITable({ head: [ 'Type', 'Count', 'Size' ].map((text)=>chalk.bold.blue(text)) }); let totalBytes = 0; let totalItems = 0; Object.keys(resultData).forEach((stage)=>{ const item = resultData[stage]; if (!item) { return; } table.push([ { hAlign: 'left', content: chalk.bold(stage) }, { hAlign: 'right', content: item.count }, { hAlign: 'right', content: `${readableBytes(item.bytes, 1, 11)} ` } ]); totalBytes += item.bytes; totalItems += item.count; if (item.aggregates) { Object.keys(item.aggregates).sort().forEach((subkey)=>{ if (!item.aggregates) { return; } const subitem = item.aggregates[subkey]; table.push([ { hAlign: 'left', content: `-- ${chalk.bold.grey(subkey)}` }, { hAlign: 'right', content: chalk.grey(subitem.count) }, { hAlign: 'right', content: chalk.grey(`(${readableBytes(subitem.bytes, 1, 11)})`) } ]); }); } }); table.push([ { hAlign: 'left', content: chalk.bold.green('Total') }, { hAlign: 'right', content: chalk.bold.green(totalItems) }, { hAlign: 'right', content: `${chalk.bold.green(readableBytes(totalBytes, 1, 11))} ` } ]); return table; }; const IGNORED_CONTENT_TYPE_PREFIXES = [ 'admin::' ]; const IGNORED_CONTENT_TYPES = [ 'plugin::content-releases.release', 'plugin::content-releases.release-action' ]; /** Media library content types — common target for `--exclude-content-types` (see issue #25008). */ const UPLOAD_CONTENT_TYPE_UIDS = [ 'plugin::upload.file', 'plugin::upload.folder' ]; const isIgnoredContentType = (type)=>IGNORED_CONTENT_TYPE_PREFIXES.some((prefix)=>type.startsWith(prefix)) || IGNORED_CONTENT_TYPES.includes(type); const abortTransfer = async ({ engine, strapi: strapi1 })=>{ try { await engine.abortTransfer(); await strapi1.destroy(); } catch { // ignore because there's not much else we can do return false; } return true; }; const setSignalHandler = async (handler, signals = [ 'SIGINT', 'SIGTERM', 'SIGQUIT' ])=>{ signals.forEach((signal)=>{ // We specifically remove ALL listeners because we have to clear the one added in Strapi bootstrap that has a process.exit // TODO: Ideally Strapi bootstrap would not add that listener, and then this could be more flexible and add/remove only what it needs to process.removeAllListeners(signal); process.on(signal, handler); }); }; const createStrapiInstance = async (opts = {})=>{ try { const appContext = await compileStrapi(); const app = createStrapi({ ...opts, ...appContext }); app.log.level = opts.logLevel || 'error'; return await app.load(); } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ECONNREFUSED') { throw new Error('Process failed. Check the database connection with your Strapi project.'); } throw error; } }; const transferDataTypes = Object.keys(engine.TransferGroupPresets); const MEDIA_LIBRARY_PRESET = 'media-library'; const TRANSFER_FILTER_PRESET_DESCRIPTIONS = { content: 'entities and links (incl. media library DB records)', files: 'upload binaries in public/uploads (not media library DB records)', config: 'core store and webhooks', [MEDIA_LIBRARY_PRESET]: 'upload binaries and media library DB records (files + plugin::upload.file, plugin::upload.folder)' }; const transferExcludePresetChoices = [ ...transferDataTypes, MEDIA_LIBRARY_PRESET ]; const formatTransferPresetHelp = (types)=>types.map((type)=>`${type} (${TRANSFER_FILTER_PRESET_DESCRIPTIONS[type]})`).join('; '); const transferExcludePresetsHelp = formatTransferPresetHelp(transferExcludePresetChoices); const transferOnlyPresetsHelp = formatTransferPresetHelp(transferDataTypes); const throttleOption = new Option('--throttle <delay after each entity>', `Add a delay in milliseconds between each transferred entity`).argParser(parseInteger).hideHelp(); // This option is not publicly documented const excludeOption = new Option('--exclude <comma-separated data types>', `Exclude data: ${transferExcludePresetsHelp}`).argParser(getParseListWithChoices(transferExcludePresetChoices, 'Invalid options for "exclude"')); const onlyOption = new Option('--only <command-separated data types>', `Include only these types (plus schemas): ${transferOnlyPresetsHelp}`).argParser(getParseListWithChoices(transferDataTypes, 'Invalid options for "only"')); const excludeContentTypesOption = new Option('--exclude-content-types <comma-separated UIDs>', `Exclude content types from entities and links (e.g. ${UPLOAD_CONTENT_TYPE_UIDS.join(',')} to omit the media library; or use --exclude media-library to skip binaries and upload records — see issue #25008)`).argParser(parseList); const onlyContentTypesOption = new Option('--only-content-types <comma-separated UIDs>', 'Transfer only these content types in entities and links (e.g. api::article.article)').argParser(parseList); const validateExcludeOnly = (command)=>{ const { exclude, only } = command.opts(); if (!only || !exclude) { return; } const choicesInBoth = only.filter((n)=>{ return exclude.indexOf(n) !== -1; }); if (choicesInBoth.length > 0) { exitWith(1, `Data types may not be used in both "exclude" and "only" in the same command. Found in both: ${choicesInBoth.join(',')}`); } }; const validateContentTypeTransferOptions = (command)=>{ const { excludeContentTypes, onlyContentTypes } = command.opts(); if (!excludeContentTypes?.length || !onlyContentTypes?.length) { return; } const overlap = excludeContentTypes.filter((uid)=>onlyContentTypes.includes(uid)); if (overlap.length > 0) { exitWith(1, `Content types may not be used in both "--exclude-content-types" and "--only-content-types". Found in both: ${overlap.join(',')}`); } }; const assertKnownContentTypes = (uids, strapi1, flag)=>{ const known = new Set(Object.keys(strapi1.contentTypes)); const unknown = uids.filter((uid)=>!known.has(uid)); if (unknown.length > 0) { exitWith(1, `Unknown content type(s) for ${flag}: ${unknown.join(', ')}`); } }; const validateContentTypeTransferOptionsForStrapi = (opts, strapi1)=>{ if (opts.excludeContentTypes?.length) { assertKnownContentTypes(opts.excludeContentTypes, strapi1, '--exclude-content-types'); } if (opts.onlyContentTypes?.length) { assertKnownContentTypes(opts.onlyContentTypes, strapi1, '--only-content-types'); } }; const shouldIncludeContentTypeInTransfer = (uid, opts)=>{ if (isIgnoredContentType(uid)) { return false; } if (opts.excludeContentTypes?.includes(uid)) { return false; } if (opts.onlyContentTypes?.length) { return opts.onlyContentTypes.includes(uid); } return true; }; const createEntityFilter = (opts)=>{ return (entity)=>shouldIncludeContentTypeInTransfer(entity.type, opts); }; const createLinkFilter = (opts)=>{ return (link)=>shouldIncludeContentTypeInTransfer(link.left.type, opts) && shouldIncludeContentTypeInTransfer(link.right.type, opts); }; const buildTransferTransforms = (opts)=>({ links: [ { filter: createLinkFilter(opts) } ], entities: [ { filter: createEntityFilter(opts) } ] }); const errorColors = { fatal: chalk.red, error: chalk.red, silly: chalk.yellow }; const formatDiagnostic = (operation, verbose)=>{ let logger; let logFileBasename; const getLogger = ()=>{ if (!logger) { logFileBasename = `${operation}_${Date.now()}.log`; const absoluteLogPath = path.resolve(process.cwd(), logFileBasename); logger = createLogger(configs.createOutputFileConfiguration(logFileBasename, { level: 'info', format: formats?.detailedLogs }, { consoleLevel: verbose ? 'info' : 'warn' })); logger.info(`[${operation}] Diagnostic log file: ${absoluteLogPath} (info-level messages are written here even without --verbose)`); } return logger; }; return ({ details, kind })=>{ try { if (kind === 'error') { const { message, severity = 'fatal' } = details; const colorizeError = errorColors[severity]; const errorMessage = colorizeError(`[${severity.toUpperCase()}] ${message}`); getLogger().error(errorMessage); } if (kind === 'info') { const { message, params, origin } = details; const msg = `[${origin ?? 'transfer'}] ${message}\n${params ? JSON.stringify(params, null, 2) : ''}`; getLogger().info(msg); } if (kind === 'warning') { const { origin, message } = details; getLogger().warn(`(${origin ?? 'transfer'}) ${message}`); } } catch (err) { getLogger().error(err); } }; }; /** Stages where throughput is dominated by DB work; items/s is more meaningful than JSON byte rate. */ const STAGES_WITH_ITEM_THROUGHPUT = new Set([ 'entities', 'links' ]); const MAX_ETA_MS = 86400000; /** * Linear ETA from completed amount vs total, using average rate so far (done / elapsedMs). * Returns null when progress or totals are not usable yet. */ const estimateEtaMs = (elapsedMs, done, total)=>{ if (elapsedMs < 500 || done <= 0 || total <= 0 || done >= total) { return null; } const ratePerMs = done / elapsedMs; const remaining = total - done; const etaMs = remaining / ratePerMs; if (!Number.isFinite(etaMs) || etaMs <= 0 || etaMs >= MAX_ETA_MS) { return null; } return etaMs; }; const loadersFactory = (defaultLoaders = {})=>{ const loaders = defaultLoaders; const updateLoader = (stage, data)=>{ if (!(stage in loaders)) { createLoader(stage); } const stageData = data[stage]; const elapsedTime = stageData?.startTime ? (stageData?.endTime || Date.now()) - stageData.startTime : 0; const bytes = stageData?.bytes ?? 0; const count = stageData?.count ?? 0; const totalBytes = stageData?.totalBytes; const totalCount = stageData?.totalCount; const countLabel = totalCount != null && totalCount > 0 ? `${count} / ${totalCount}` : String(count); const sizeCompact = totalBytes != null && totalBytes > 0 ? `${readableBytes(bytes)} / ${readableBytes(totalBytes)}` : readableBytes(bytes); const parts = [ `${stage}: ${countLabel} transferred`, sizeCompact ]; if (elapsedTime > 0 && !stageData?.endTime) { if (STAGES_WITH_ITEM_THROUGHPUT.has(stage)) { const itemsPerSec = count * 1000 / elapsedTime; parts.push(`${itemsPerSec.toFixed(1)} items/s`); } else { parts.push(`${readableBytes(bytes * 1000 / elapsedTime)}/s`); } } let etaMs = null; if (!stageData?.endTime) { if (STAGES_WITH_ITEM_THROUGHPUT.has(stage) && totalCount != null) { etaMs = estimateEtaMs(elapsedTime, count, totalCount); } else if (totalBytes != null) { etaMs = estimateEtaMs(elapsedTime, bytes, totalBytes); } } parts.push(formatElapsedAndMaybeRemainingLabel(elapsedTime ?? 0, etaMs)); loaders[stage].text = parts.join(TRANSFER_PROGRESS_FIELD_SEP); return loaders[stage]; }; const createLoader = (stage)=>{ Object.assign(loaders, { [stage]: ora() }); return loaders[stage]; }; const getLoader = (stage)=>{ return loaders[stage]; }; return { updateLoader, createLoader, getLoader }; }; /** * Get the telemetry data to be sent for a didDEITSProcess* event from an initialized transfer engine object */ const getTransferTelemetryPayload = (engine)=>{ return { eventProperties: { source: engine?.sourceProvider?.name, destination: engine?.destinationProvider?.name } }; }; /** * Get a transfer engine schema diff handler that confirms with the user before bypassing a schema check */ const getDiffHandler = (engine, { force, action })=>{ return async (context, next)=>{ // if we abort here, we need to actually exit the process because of conflict with inquirer prompt setSignalHandler(async ()=>{ await abortTransfer({ engine, strapi: strapi }); exitWith(1, exitMessageText(action, true)); }); let workflowsStatus; const source = 'Schema Integrity'; Object.entries(context.diffs).forEach(([uid, diffs])=>{ for (const diff of diffs){ const path = [ uid ].concat(diff.path).join('.'); const endPath = diff.path[diff.path.length - 1]; // Catch known features if (uid === 'plugin::review-workflows.workflow' || uid === 'plugin::review-workflows.workflow-stage' || endPath?.startsWith('strapi_stage') || endPath?.startsWith('strapi_assignee')) { workflowsStatus = diff.kind; } else if (diff.kind === 'added') { engine.reportWarning(chalk.red(`${chalk.bold(path)} does not exist on source`), source); } else if (diff.kind === 'deleted') { engine.reportWarning(chalk.red(`${chalk.bold(path)} does not exist on destination`), source); } else if (diff.kind === 'modified') { engine.reportWarning(chalk.red(`${chalk.bold(path)} has a different data type`), source); } } }); // output the known feature warnings if (workflowsStatus === 'added') { engine.reportWarning(chalk.red(`Review workflows feature does not exist on source`), source); } else if (workflowsStatus === 'deleted') { engine.reportWarning(chalk.red(`Review workflows feature does not exist on destination`), source); } else if (workflowsStatus === 'modified') { engine.panic(new TransferEngineInitializationError('Unresolved differences in schema [review workflows]')); } const confirmed = await confirmMessage('There are differences in schema between the source and destination, and the data listed above will be lost. Are you sure you want to continue?', { force }); // reset handler back to normal setSignalHandler(()=>abortTransfer({ engine, strapi: strapi })); if (confirmed) { context.ignoredDiffs = merge(context.diffs, context.ignoredDiffs); } return next(context); }; }; const getAssetsBackupHandler = (engine, { force, action })=>{ return async (context, next)=>{ // if we abort here, we need to actually exit the process because of conflict with inquirer prompt setSignalHandler(async ()=>{ await abortTransfer({ engine, strapi: strapi }); exitWith(1, exitMessageText(action, true)); }); console.warn('The backup for the assets could not be created inside the public directory. Ensure Strapi has write permissions on the public directory.'); const confirmed = await confirmMessage('Do you want to continue without backing up your public/uploads files?', { force }); if (confirmed) { context.ignore = true; } // reset handler back to normal setSignalHandler(()=>abortTransfer({ engine, strapi: strapi })); return next(context); }; }; const shouldSkipStage = (opts, dataKind)=>{ if (opts.exclude?.includes(dataKind)) { return true; } if (opts.only) { return !opts.only.includes(dataKind); } return false; }; const areUploadContentTypesInTransferScope = (opts)=>UPLOAD_CONTENT_TYPE_UIDS.every((uid)=>shouldIncludeContentTypeInTransfer(uid, opts)); const areAllUploadContentTypesOutOfTransferScope = (opts)=>UPLOAD_CONTENT_TYPE_UIDS.every((uid)=>!shouldIncludeContentTypeInTransfer(uid, opts)); const isContentStageActive = (opts)=>!shouldSkipStage(opts, 'content'); const expandMediaLibraryPreset = (opts)=>{ if (!opts.exclude?.includes(MEDIA_LIBRARY_PRESET)) { return; } const exclude = opts.exclude.filter((item)=>item !== MEDIA_LIBRARY_PRESET); opts.exclude = exclude; if (!opts.exclude.includes('files')) { opts.exclude.push('files'); } const excludeContentTypes = new Set(opts.excludeContentTypes ?? []); for (const uid of UPLOAD_CONTENT_TYPE_UIDS){ excludeContentTypes.add(uid); } opts.excludeContentTypes = [ ...excludeContentTypes ]; }; const autoExcludeFilesWhenUploadTypesOutOfScope = (opts)=>{ if (opts.filesAutoExcluded || !isContentStageActive(opts) || opts.only?.includes('files') || shouldSkipStage(opts, 'files') || !areAllUploadContentTypesOutOfTransferScope(opts)) { return; } opts.exclude = [ ...opts.exclude ?? [], 'files' ]; opts.filesAutoExcluded = true; }; const normalizeTransferFilterOptions = (opts)=>{ expandMediaLibraryPreset(opts); autoExcludeFilesWhenUploadTypesOutOfScope(opts); return opts; }; const normalizeTransferFilterOptionsHook = (command)=>{ normalizeTransferFilterOptions(command.opts()); }; const logTransferFilterSummary = (opts)=>{ const { exclude, only, excludeContentTypes, onlyContentTypes } = opts; if (!exclude?.length && !only?.length && !excludeContentTypes?.length && !onlyContentTypes?.length) { return; } const parts = []; if (exclude?.length) { parts.push(`excluding ${exclude.join(', ')}`); } if (only?.length) { parts.push(`only ${only.join(', ')}`); } if (parts.length) { console.log(chalk.dim(`Transfer filters: ${parts.join('; ')}.`)); } const contentTypeParts = []; if (excludeContentTypes?.length) { contentTypeParts.push(`excluding ${excludeContentTypes.join(', ')}`); } if (onlyContentTypes?.length) { contentTypeParts.push(`only ${onlyContentTypes.join(', ')}`); } if (contentTypeParts.length) { console.log(chalk.dim(`Content type filters: ${contentTypeParts.join('; ')}.`)); } if (opts.filesAutoExcluded) { console.log(chalk.dim('Skipping files stage: upload content types are not in transfer scope (plugin::upload.file, plugin::upload.folder).')); } if (shouldSkipStage(opts, 'files') && !shouldSkipStage(opts, 'content') && areUploadContentTypesInTransferScope(opts) && !opts.filesAutoExcluded) { console.log(chalk.dim('Note: Media library records (plugin::upload.file, plugin::upload.folder) are still transferred with the rest of your content (the entities stage). Sync upload binaries separately (e.g. rsync public/uploads).')); } }; // Based on exclude/only from options, create the restore object to match const parseRestoreFromOptions = (opts, strapi1)=>{ const entitiesOptions = { exclude: [ ...Object.keys(strapi1.contentTypes).filter(isIgnoredContentType), ...IGNORED_CONTENT_TYPES, ...opts.excludeContentTypes ?? [] ], include: undefined }; const contentInScope = !(opts.only && !opts.only.includes('content') || opts.exclude?.includes('content')); if (!contentInScope) { // Nothing from the entities stage is transferred; do not delete any records beforehand. entitiesOptions.include = []; } else if (opts.onlyContentTypes?.length) { // Only wipe content types that are being replaced by this transfer. entitiesOptions.include = opts.onlyContentTypes; } else if (shouldSkipStage(opts, 'config')) { // When config is excluded, scope pre-transfer deletion to user content types only. // Internal models (e.g. strapi::core-store) must not be wiped via the entities path. entitiesOptions.include = Object.keys(strapi1.contentTypes).filter((uid)=>!isIgnoredContentType(uid) && !opts.excludeContentTypes?.includes(uid)); } const restoreConfig = { entities: entitiesOptions, assets: !shouldSkipStage(opts, 'files'), configuration: { webhook: !shouldSkipStage(opts, 'config'), coreStore: !shouldSkipStage(opts, 'config') } }; return restoreConfig; }; export { UPLOAD_CONTENT_TYPE_UIDS, abortTransfer, areAllUploadContentTypesOutOfTransferScope, areUploadContentTypesInTransferScope, buildTransferTable, buildTransferTransforms, createEntityFilter, createLinkFilter, createStrapiInstance, excludeContentTypesOption, excludeOption, exitMessageText, formatDiagnostic, getAssetsBackupHandler, getDefaultExportName, getDiffHandler, getTransferTelemetryPayload, isIgnoredContentType, loadersFactory, logTransferFilterSummary, normalizeTransferFilterOptions, normalizeTransferFilterOptionsHook, onlyContentTypesOption, onlyOption, parseRestoreFromOptions, setSignalHandler, shouldSkipStage, throttleOption, validateContentTypeTransferOptions, validateContentTypeTransferOptionsForStrapi, validateExcludeOnly }; //# sourceMappingURL=data-transfer.mjs.map