diff --git a/.talismanrc b/.talismanrc index 7efa0e61f..f9c05a3d8 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,42 +1,15 @@ fileignoreconfig: -- filename: packages/contentstack-audit/test/unit/mock/am-contents-no-stack/environments/environments.json - checksum: fe82c708f5f4d0b05694a601855fb7950661048eef8d7f9f794713e3a665064e -- filename: packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json - checksum: ffaaee9269a6e833cd3dbe337ddc5c060cce948519873c7a2777754519a31b52 -- filename: pnpm-lock.yaml - checksum: 649aae748e3ae8c157439dc6c7c9ea402cc19b4dab7ca64b7e4d839d1375df53 -- filename: packages/contentstack-audit/src/types/content-types.ts - checksum: d16a65415c3184f15a807d58e5858310aeb5633794fc9075e36f89c94da636c3 -- filename: packages/contentstack-audit/src/audit-base-command.ts - checksum: b706ea114a3cb5e4c0e7e163f32ceffa3356a6b0766bf5b8fe387c3c72f44c36 -- filename: packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/chunk0-assets.json - checksum: a276dc984efd49c097b4c38a02a17cfd1d28d704bd403d65af2b7673b8e9dbe9 -- filename: packages/contentstack-audit/src/modules/entries.ts - checksum: 13072df0872f8c098d23f49b02f9e8f3e492ab2f483d67a5ea5e423ab406ad94 -- filename: packages/contentstack-audit/src/modules/assets.ts - checksum: 9cdc568453a3926d866de4d3d56e6fbc4629eafcaca1c51536c11d937a0bf126 -- filename: packages/contentstack-audit/test/unit/modules/assets.test.ts - checksum: 203bb83afa84a696b84066dc6959712289e9fc4135c8cba641f9ff634f32b3db -- filename: packages/contentstack-audit/test/unit/modules/entries.test.ts - checksum: 31d9767d87e4e9db54f641e8cb9d3360f591acdaa8b089a83eaa4aacdf0c3a35 -- filename: packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json - checksum: f02886c04ea1097ebc5965fa9a63a878adb9437d56a5b1797be1967fae35f9e4 + +- filename: packages/contentstack-bulk-operations/src/utils/backup-dir-asset-fetcher.ts + checksum: fbf1ef882bf4ea8f06a7a28b9248a07e01f5a5d2d4da98dbb9ee52bc16ce318a +- filename: packages/contentstack-bulk-operations/test/unit/utils/backup-dir-asset-fetcher.test.ts + checksum: b905524c952aff0f089fa5347e0db18479b75a438cd9ae77e2dfe1cb203d7e59 +- filename: packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts + checksum: 142e7b67a06bc13b6b4191b17f2ea8ae31a3bce20086ea35a92f3e3671eb603d +- filename: packages/contentstack-bulk-operations/src/utils/batch-helper.ts + checksum: ddcd8ef7c56d1122df88075883a2768a28813147e8fdef5b8e72a1ec118c045a - filename: packages/contentstack-import/src/import/modules/assets.ts - checksum: d213265909a1b163ce733b9e2534e3a74304a2589c2d85e059f10fa0acfdee11 -- filename: packages/contentstack-import/src/utils/import-config-handler.ts - checksum: 5b2050b283ea3d990e8ad3207b054c6932b76d2bd82a9f659b287ef11dec9f83 -- filename: packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/chunk0-assets.json - checksum: 27d5398f5d81bd3af53da9a5d10475a36c08e8fd8ad29df24e8f267494419a20 -- filename: packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json - checksum: 9fc8b266ef58ecce8d3c4182632d1f7d088dfbce81b3fcea46b087cde3129b91 -- filename: packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json - checksum: c4ac3ff80eb38b1c58eb0db21a232bd84f856d4647093542296199036945083c + checksum: 014f612c3e8db21c891da0c17a1a3b4cd340c026c58034791d86cd5f73aa9f90 - filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts - checksum: 60f33657cc6e2664c17397a2f6b4b54a3f7bf71618931d63cd2811822bd4837e -- filename: packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts - checksum: ddcf4601ac47be300eba0eb901e4d45d748c2c4eab676c56677cb9a802fe3db0 -- filename: packages/contentstack-migration/src/commands/cm/stacks/migration.ts - checksum: a64a4d396eddd936a63b799eff58c5c6660b5dcaa3a310fd8b09a027932f1789 -- filename: packages/contentstack-migration/README.md - checksum: e96006c1a948f766c88ae972b29582fa58eaf8184606bf011eebddc5a06cd7b6 -version: "" + checksum: 9392511b337c361b86c36eb2431d8fea9129a61f6b7d70dcb4a633fea339ca61 + version: "" diff --git a/packages/contentstack-bulk-operations/README.md b/packages/contentstack-bulk-operations/README.md index d428fbd29..4a51c94d1 100644 --- a/packages/contentstack-bulk-operations/README.md +++ b/packages/contentstack-bulk-operations/README.md @@ -41,11 +41,11 @@ Bulk operations for assets: publish/unpublish/cross-publish (CMS) and delete/mov ``` USAGE - $ csdx cm:stacks:bulk-assets [-a ] [-k ] [--operation publish|unpublish|delete|move] [--environments - ...] [--locales ...] [--source-env ] [--source-alias ] [--publish-mode bulk|single] - [--branch ] [-c ] [-y] [--retry-failed ] [--revert ] [--bulk-operation-file ] - [--folder-uid ] [-d ] [--dry-run] [--space-uid ] [--org-uid ] [--workspace ] - [--asset-uids-file ] [--locale ] [--target-folder-uid ] + $ csdx cm:stacks:bulk-assets [-a ] [-k ] [--operation publish|unpublish|delete|move] [--source-alias + ] [--publish-mode bulk|single] [--branch ] [-c ] [-y] [--retry-failed ] [--revert + ] [--bulk-operation-file ] [--backup-dir | --source-env | --folder-uid | + --environments ... | --locales ...] [--dry-run] [--space-uid ] [--org-uid ] [--workspace + ] [--asset-uids-file ] [--locale ] [--target-folder-uid ] FLAGS -a, --alias= Uses the name of a saved Management Token to authenticate the command. The command @@ -54,13 +54,16 @@ FLAGS -c, --config= (optional) Specifies the path to a JSON configuration file that defines the options for the command. Use this file instead of passing multiple CLI flags for a single run. - -d, --data-dir= Path to exported content folder containing asset publish details. -k, --stack-api-key= API key of the source stack. You must use either the --stack-api-key flag or the --alias flag. -y, --yes Skips interactive confirmation prompts and runs the command immediately using the provided options. Useful for automation and scripts. --asset-uids-file= Path to UTF-8 JSON file: exactly `{ "uids": ["uid1", "uid2"] }` (non-empty string array, no trimming; large lists: see docs for NODE_OPTIONS) + --backup-dir= Path to the import backup directory. Each imported asset is published only to the + environments and locales it was published to in the source stack (read from the + backup publish details and asset UID mapping), with asset-scan gating applied. + Intended for the post-import publish flow. --branch= [default: main] The name of the branch where you want to perform the bulk publish operation. If you don't mention the branch name, then by default the content from main branch will be published. @@ -114,7 +117,7 @@ EXAMPLES $ csdx cm:stacks:bulk-assets --revert ./bulk-operation -a myAlias - $ csdx cm:stacks:bulk-assets --data-dir ./content --operation publish -k blt123 + $ csdx cm:stacks:bulk-assets --backup-dir ./content --operation publish -k blt123 $ csdx cm:stacks:bulk-assets --operation delete --space-uid am123 --org-uid bltOrg --locale en-us --asset-uids-file ./assets.json diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index 3251e19ab..a38148c8f 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -27,9 +27,8 @@ import { setupStackConfig, setupBatchQueueListeners, confirmOperation as confirmOperationUtil, - getUniqueEnvironments, - getUniqueLocales, batchItems, + hasPublishTargets, handleCrossPublishOperation, logOperationInfo, validateBatch, @@ -519,9 +518,12 @@ export abstract class BaseBulkCommand extends Command { * Execute operation in BULK mode - processes items in batches */ private async executeBulkMode(items: any[], startTime: number): Promise { - const environments = getUniqueEnvironments(items); - const locales = getUniqueLocales(items); - const batches = batchItems(items, environments, locales); + const untargeted = items.filter((item) => !hasPublishTargets(item)).length; + if (untargeted > 0) { + this.logger.warn(`Skipping ${untargeted} item(s) with no publish target (missing publish_details).`); + } + + const batches = batchItems(items); batches.forEach((batch) => validateBatch(batch)); this.logger.debug(`Created ${batches.length} batches for processing`, this.loggerContext); diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts index 868f324c2..4c2e948bd 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts @@ -3,14 +3,15 @@ import * as path from 'path'; import { flags, handleAndLogError, log, FlagInput } from '@contentstack/cli-utilities'; -import { AssetPublishData, BulkOperationResult, ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces'; +import { BulkOperationResult, ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces'; import { BaseBulkCommand } from '../../../base-bulk-command'; import { $t, messages, fetchAssets, - scanDataDirStats, - BATCH_CONSTANTS, + scanBackupDirStats, + assetPublishTargets, + TargetBatcher, categorizeByScanStatus, fillMissingCsAssetsFlags, promptForOperation, @@ -20,7 +21,7 @@ import { OperationFlagMatrixError, RETRY_REVERT_CONTEXT, } from '../../../utils'; -import type { DataDirScanStats } from '../../../utils'; +import type { BackupDirScanStats } from '../../../utils'; import { AssetService } from '../../../services'; type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string }; @@ -34,7 +35,7 @@ const ALL_OPERATION_CHOICES = [ /** * Bulk operations command for assets - * Supports publish, unpublish, cross publish, and data-dir publish operations (CMS), + * Supports publish, unpublish, cross publish, and backup-dir publish operations (CMS), * plus delete and move operations (CS Assets). * * The two families use fully separate execution paths: @@ -68,8 +69,8 @@ export default class BulkAssets extends BaseBulkCommand { // Revert (unpublish) previously published assets using success log '<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias', - // Publish assets from exported content folder (e.g. after asset scanning clears) - '<%= config.bin %> <%= command.id %> --data-dir ./content --operation publish -k blt123', + // Publish imported assets to their original environments (e.g. after asset scanning clears) + '<%= config.bin %> <%= command.id %> --backup-dir ./content --operation publish -k blt123', // CS Assets bulk delete (asset UIDs from a JSON file `{ "uids": [...] }`) '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltOrg --locale en-us --asset-uids-file ./assets.json', @@ -88,9 +89,10 @@ export default class BulkAssets extends BaseBulkCommand { 'folder-uid': flags.string({ description: messages.FOLDER_UID, }), - 'data-dir': flags.string({ - char: 'd', - description: messages.DATA_DIR_FLAG_DESC, + 'backup-dir': flags.string({ + description: messages.BACKUP_DIR_FLAG_DESC, + // Environments and locales are always derived per-asset from the backup. + exclusive: ['source-env', 'folder-uid', 'environments', 'locales'], }), 'dry-run': flags.boolean({ description: messages.DRY_RUN_FLAG_DESC, @@ -196,8 +198,8 @@ export default class BulkAssets extends BaseBulkCommand { return; } - if (this.bulkOperationConfig.dataDir) { - await this.runDataDirFlow(); + if (this.bulkOperationConfig.backupDir) { + await this.runBackupDirFlow(); return; } @@ -249,22 +251,19 @@ export default class BulkAssets extends BaseBulkCommand { } } - private async runDataDirFlow(): Promise { - const { dataDir, dryRun } = this.bulkOperationConfig; - - // Capture original CLI locales/envs before pass 1 overwrites them on the config. - const cliLocales = [...(this.bulkOperationConfig.locales || [])]; - const cliEnvs = [...(this.bulkOperationConfig.environments || [])]; + private async runBackupDirFlow(): Promise { + const { backupDir, dryRun } = this.bulkOperationConfig; // Pass 1 — count-only scan: no AssetPublishData objects built, one chunk in memory at a time. - let stats: DataDirScanStats; + let stats: BackupDirScanStats; try { - stats = await scanDataDirStats(dataDir!, cliEnvs, cliLocales, this.logger); + stats = await scanBackupDirStats(backupDir!, this.logger); } catch (err: any) { - this.logger.error($t(messages.DATA_DIR_READ_ERROR, { path: dataDir!, error: err.message || String(err) })); + this.logger.error($t(messages.BACKUP_DIR_READ_ERROR, { path: backupDir!, error: err.message || String(err) })); return; } + // Unions for the dashboard and confirmation prompt only — pass 2 derives each asset's own targets. this.bulkOperationConfig.environments = stats.environments; this.bulkOperationConfig.locales = stats.locales; @@ -305,43 +304,31 @@ export default class BulkAssets extends BaseBulkCommand { } if (dryRun) { - log.info($t(messages.DATA_DIR_DRY_RUN)); + log.info($t(messages.BACKUP_DIR_DRY_RUN)); return; } // Pass 2 — stream and publish: one chunk at a time, batches of ≤50 items enqueued directly. // stats.assetUidMapper and stats.assetsIndex are reused from pass 1 — no second disk read. - const result = await this.streamAndPublish( - dataDir!, - cliLocales, - stats.totalItems, - stats.assetUidMapper, - stats.assetsIndex, - scanStatusMap - ); + const result = await this.streamAndPublish(backupDir!, stats, scanStatusMap); this.printOperationSummary(result); } /** - * Pass 2 of the data-dir flow. - * Reads chunk files one at a time, fills a working batch of ≤50 AssetPublishData items, - * and enqueues each batch directly into the queue manager without ever holding the full - * asset list in memory. Peak memory: one chunk file + one batch of ≤50 items. + * Pass 2 of the backup-dir flow. + * Reads chunk files one at a time, feeding each asset's own publish targets into a + * TargetBatcher that enqueues batches directly, without ever holding the full asset list in + * memory. Peak memory: one chunk file + one open bucket per distinct (locale, environment set). * * assetUidMapper and assetsIndex are passed in from pass 1 to avoid re-reading those files. * scanStatusMap filters out non-clean assets before enqueueing. */ private async streamAndPublish( - dataDir: string, - cliLocales: string[], - totalItemCount: number, - assetUidMapper: Record, - assetsIndex: Record, + backupDir: string, + stats: BackupDirScanStats, scanStatusMap: Map ): Promise { - // Snapshot both arrays so in-flight mutations to bulkOperationConfig can't corrupt payloads. - const environments = [...this.bulkOperationConfig.environments!]; - const locales = [...this.bulkOperationConfig.locales!]; + const { assetUidMapper, assetsIndex, environmentsMap, totalBatches } = stats; const operation = this.bulkOperationConfig.operation as OperationType; const startTime = Date.now(); @@ -352,35 +339,26 @@ export default class BulkAssets extends BaseBulkCommand { ); } - const useOverrideLocales = cliLocales.length > 0; - const BATCH_SIZE = BATCH_CONSTANTS.maxItems; - // totalItemCount comes from pass 1 using identical counting logic — used as upper bound for totalBatches. - // Scan status filtering may reduce the actual count; the invariant check below will log any mismatch. - const totalBatches = Math.ceil(totalItemCount / BATCH_SIZE); - - let workingBatch: AssetPublishData[] = []; - let batchNumber = 0; let totalSubmitted = 0; this.batchResults.clear(); - const flushBatch = (): void => { - if (workingBatch.length === 0) return; - batchNumber++; + // Batches are keyed on their publish target, so each asset reaches only its own + // environments and locales. + const batcher = new TargetBatcher((batch) => { this.queueManager.enqueue(ResourceType.ASSET, operation, { - items: [...workingBatch], - environments, - locales, - batchNumber, + items: batch.items, + environments: batch.environments, + locales: batch.locales, + batchNumber: batch.batchNumber, totalBatches, operation, }); - totalSubmitted += workingBatch.length; - workingBatch = []; - }; + totalSubmitted += batch.items.length; + }); for (const chunkFilename of Object.values(assetsIndex)) { - const chunkPath = path.join(dataDir, 'assets', chunkFilename); + const chunkPath = path.join(backupDir, 'assets', chunkFilename); const chunkData: Record = JSON.parse(fs.readFileSync(chunkPath, 'utf-8')); for (const asset of Object.values(chunkData)) { @@ -399,27 +377,32 @@ export default class BulkAssets extends BaseBulkCommand { continue; } - const assetLocales: string[] = useOverrideLocales - ? cliLocales - : [...new Set(asset.publish_details.map((pd: any) => pd.locale as string))]; + const targets = assetPublishTargets(asset, environmentsMap); - for (const locale of assetLocales) { - workingBatch.push({ type: 'asset', uid: targetUid, locale, version: asset._version }); - if (workingBatch.length >= BATCH_SIZE) { - flushBatch(); - } + for (const [locale, environments] of targets) { + batcher.add({ + type: 'asset', + uid: targetUid, + locale, + version: asset._version, + publish_details: environments.map((environment) => ({ environment, locale })), + }); } } // chunkData falls out of scope here — GC can reclaim it before the next chunk is read. } - flushBatch(); + batcher.end(); + + if (batcher.skippedCount > 0) { + this.logger.warn(`Skipped ${batcher.skippedCount} asset item(s) with no resolvable publish target.`); + } - // Invariant: pass 1 and pass 2 use identical counting logic (excluding scan status filtering). - // If batchNumber < totalBatches, scan status filtering reduced the published count — expected. - if (batchNumber !== totalBatches) { + // Invariant: pass 1 and pass 2 use identical target logic (excluding scan status filtering). + // If fewer batches were emitted, scan status filtering reduced the published count — expected. + if (batcher.emittedCount !== totalBatches) { this.logger.debug( - `Batch count: predicted ${totalBatches}, actual ${batchNumber}. Difference is expected when assets are skipped due to scan status.` + `Batch count: predicted ${totalBatches}, actual ${batcher.emittedCount}. Difference is expected when assets are skipped due to scan status.` ); } @@ -443,21 +426,21 @@ export default class BulkAssets extends BaseBulkCommand { const SEP = '─'.repeat(42); log.info(''); - log.info(` ${messages.DATA_DIR_ASSET_SCANNING_HEADER}`); + log.info(` ${messages.BACKUP_DIR_ASSET_SCANNING_HEADER}`); log.info(' ' + SEP); - log.info(` ${messages.DATA_DIR_TOTAL.padEnd(38)} ${total}`); + log.info(` ${messages.BACKUP_DIR_TOTAL.padEnd(38)} ${total}`); if (localSkipped !== undefined) { - log.warn(` ${messages.DATA_DIR_NO_PUBLISH_DETAILS.padEnd(38)} ${localSkipped}`); + log.warn(` ${messages.BACKUP_DIR_NO_PUBLISH_DETAILS.padEnd(38)} ${localSkipped}`); } if (unmapped !== undefined) { - log.warn(` ${messages.DATA_DIR_UNMAPPED.padEnd(38)} ${unmapped}`); + log.warn(` ${messages.BACKUP_DIR_UNMAPPED.padEnd(38)} ${unmapped}`); } log.info(' ' + SEP); log.info(` ${messages.SCAN_STATUS_CLEAN.padEnd(38)} ${clean}`); if (pending > 0) log.warn(` ${messages.SCAN_STATUS_PENDING.padEnd(38)} ${pending}`); if (quarantined > 0) log.warn(` ${messages.SCAN_STATUS_QUARANTINED.padEnd(38)} ${quarantined}`); log.info(' ' + SEP); - log.info(` ${messages.DATA_DIR_WILL_PUBLISH.padEnd(38)} ${clean}`); + log.info(` ${messages.BACKUP_DIR_WILL_PUBLISH.padEnd(38)} ${clean}`); log.info(''); } diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index 1474f3fa8..d9ac1ca56 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -59,7 +59,7 @@ export interface BulkOperationConfig { // Asset-specific options folderUid?: string; - dataDir?: string; + backupDir?: string; dryRun?: boolean; // Cross-publish @@ -199,7 +199,7 @@ export interface CommandFlags { // Asset-specific flags 'folder-uid'?: string; - 'data-dir'?: string; + 'backup-dir'?: string; 'dry-run'?: boolean; /** CS Assets bulk delete/move */ diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index 00b72a124..0cf6f0281 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -213,17 +213,18 @@ const bulkAssetsMsg = { SYNCED_ASSETS: 'Synced {count} assets from {sourceEnv}', ASSETS_READY_FOR_CROSS_PUBLISH: '{count} assets ready for cross-publish', - // Data-dir / scanning dashboard - DATA_DIR_ASSET_SCANNING_HEADER: 'Asset Scan Status', - DATA_DIR_TOTAL: 'Total assets found', - DATA_DIR_VALID: 'Clean (will publish)', - DATA_DIR_NO_PUBLISH_DETAILS: 'No publish details (skipped)', - DATA_DIR_UNMAPPED: 'Not imported / UID unmapped (skipped)', - DATA_DIR_WILL_PUBLISH: 'Will publish', - DATA_DIR_DRY_RUN: 'Dry run — no publish API calls will be made.', - DATA_DIR_FLAG_DESC: 'Path to exported content folder containing asset publish details.', + // Backup-dir / scanning dashboard + BACKUP_DIR_ASSET_SCANNING_HEADER: 'Asset Scan Status', + BACKUP_DIR_TOTAL: 'Total assets found', + BACKUP_DIR_VALID: 'Clean (will publish)', + BACKUP_DIR_NO_PUBLISH_DETAILS: 'No publish details (skipped)', + BACKUP_DIR_UNMAPPED: 'Not imported / UID unmapped (skipped)', + BACKUP_DIR_WILL_PUBLISH: 'Will publish', + BACKUP_DIR_DRY_RUN: 'Dry run — no publish API calls will be made.', + BACKUP_DIR_FLAG_DESC: + 'Path to the import backup directory. Each imported asset is published only to the environments and locales it was published to in the source stack (read from the backup publish details and asset UID mapping), with asset-scan gating applied. Intended for the post-import publish flow.', DRY_RUN_FLAG_DESC: 'Preview the publish plan without making any API calls.', - DATA_DIR_READ_ERROR: 'Failed to read data directory at {path}: {error}', + BACKUP_DIR_READ_ERROR: 'Failed to read backup directory at {path}: {error}', SCAN_STATUS_CLEAN: 'Clean (will publish)', SCAN_STATUS_PENDING: 'Still scanning (skipped)', SCAN_STATUS_QUARANTINED: 'Quarantined (skipped)', diff --git a/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts b/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts index de7f68ff3..30c91cfd3 100644 --- a/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts +++ b/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts @@ -1,5 +1,5 @@ import config from '../config'; -import { $t, messages, sleep } from '../utils'; +import { $t, messages, sleep, getUniqueEnvironments } from '../utils'; import { EntryPublishData, AssetPublishData, @@ -244,9 +244,9 @@ export class BulkOperationService { return entry; }); - const environments = batchEnvironments?.length - ? batchEnvironments - : items[0]?.publish_details?.map((pd) => pd.environment) || []; + // Union across all items, never items[0] — one item's environments must not apply to the + // whole batch. Batches are single-target by construction, so the union is exact. + const environments = batchEnvironments?.length ? batchEnvironments : getUniqueEnvironments(items); const locales = batchLocales?.length ? batchLocales : Array.from(new Set(items.map((item) => item.locale))); if (!environments.length) { @@ -281,9 +281,8 @@ export class BulkOperationService { return acc; }, []); - const environments = batchEnvironments?.length - ? batchEnvironments - : items[0]?.publish_details?.map((pd) => pd.environment) || []; + // Union across all items, never items[0] — see prepareEntryBulkPayload. + const environments = batchEnvironments?.length ? batchEnvironments : getUniqueEnvironments(items); const locales = batchLocales?.length ? batchLocales : Array.from(new Set(items.map((item) => item.locale))); if (!environments.length) { diff --git a/packages/contentstack-bulk-operations/src/utils/backup-dir-asset-fetcher.ts b/packages/contentstack-bulk-operations/src/utils/backup-dir-asset-fetcher.ts new file mode 100644 index 000000000..13f5d7052 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/utils/backup-dir-asset-fetcher.ts @@ -0,0 +1,170 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { chunkArray } from './helpers'; +import { BATCH_CONSTANTS } from './constants'; + +export interface BackupDirScanStats { + /** Number of assets eligible for publish (have publish_details + mapped UID). */ + eligible: number; + /** Total AssetPublishData items that will be created (eligible × locale expansions). */ + totalItems: number; + skipped: number; + unmapped: number; + /** Union across the backup, for the dashboard and confirmation prompt. Never a payload value. */ + environments: string[]; + /** Union across the backup. Display only, same as `environments`. */ + locales: string[]; + /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ + assetUidMapper: Record; + /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ + assetsIndex: Record; + /** Environment UID -> name. Reusable in pass 2 to build per-asset publish_details. */ + environmentsMap: Record; + /** Exact batch count, counted per (locale, environment set) target the same way pass 2 batches. */ + totalBatches: number; +} + +/** + * The (locale -> environment names) one backup asset should publish to: per locale, exactly the + * environments that asset had in that locale. + * + * Shared by both passes so pass 1's count and pass 2's batching cannot drift. + */ +export function assetPublishTargets(asset: any, environmentsMap: Record): Map { + const byLocale = new Map>(); + + for (const pd of asset.publish_details ?? []) { + const locale = pd.locale as string; + if (!locale) continue; + + const envName = (environmentsMap[pd.environment] || pd.environment) as string; + if (!envName) continue; + + let envs = byLocale.get(locale); + if (!envs) { + envs = new Set(); + byLocale.set(locale, envs); + } + envs.add(envName); + } + + const targets = new Map(); + for (const [locale, envs] of byLocale) { + if (envs.size > 0) targets.set(locale, [...envs].sort()); + } + + return targets; +} + +/** + * Key identifying one publish target: a single locale and a single environment set. + * Same shape TargetBatcher buckets on, so pass 1's count matches pass 2's batching. + */ +export function targetCountKey(locale: string, environments: string[]): string { + return JSON.stringify([locale, environments]); +} + +/** + * Pass 1: count-only scan of the backup directory. + * Reads chunk files one at a time, counts eligible/skipped/unmapped, and + * discovers environments and locales — without building any AssetPublishData objects. + * Memory footprint: uid mapper + env map + one chunk at a time. + * + * Returns assetUidMapper and assetsIndex so pass 2 (streamAndPublish) can reuse them + * without re-reading the same files from disk. + */ +export async function scanBackupDirStats(backupDir: string, logger?: any): Promise { + const assetsIndexPath = path.join(backupDir, 'assets', 'assets.json'); + const environmentsPath = path.join(backupDir, 'environments', 'environments.json'); + const assetUidMapperPath = path.join(backupDir, 'mapper', 'assets', 'uid-mapping.json'); + + if (!fs.existsSync(assetsIndexPath)) { + throw new Error( + `Asset index not found: ${assetsIndexPath}. Ensure --backup-dir points to the import backup directory.` + ); + } + + let assetUidMapper: Record = {}; + if (fs.existsSync(assetUidMapperPath)) { + assetUidMapper = JSON.parse(fs.readFileSync(assetUidMapperPath, 'utf-8')); + } else { + logger?.warn( + `Asset UID mapper not found: ${assetUidMapperPath}. Ensure --backup-dir points to the import backup directory.` + ); + } + + const environmentsMap: Record = {}; + if (fs.existsSync(environmentsPath)) { + const envData: Record = JSON.parse(fs.readFileSync(environmentsPath, 'utf-8')); + for (const [uid, env] of Object.entries(envData)) { + environmentsMap[uid] = (env as any).name || uid; + } + } else { + logger?.warn(`Environments file not found: ${environmentsPath}`); + } + + const assetsIndex: Record = JSON.parse(fs.readFileSync(assetsIndexPath, 'utf-8')); + + let eligible = 0; + let totalItems = 0; + let skipped = 0; + let unmapped = 0; + const allEnvs = new Set(); + const allLocales = new Set(); + /** Items per (locale, environment set) — mirrors how pass 2 buckets them, giving an exact batch count. */ + const itemsPerTarget = new Map(); + + for (const chunkFilename of Object.values(assetsIndex)) { + const chunkPath = path.join(backupDir, 'assets', chunkFilename); + const chunkData: Record = JSON.parse(fs.readFileSync(chunkPath, 'utf-8')); + + for (const asset of Object.values(chunkData)) { + if (!asset.publish_details || asset.publish_details.length === 0) { + skipped++; + continue; + } + + const targetUid = assetUidMapper[asset.uid as string]; + if (!targetUid) { + unmapped++; + continue; + } + + eligible++; + + // Same targets pass 2 batches on, so the counts stay exact. + const targets = assetPublishTargets(asset, environmentsMap); + + for (const [locale, environments] of targets) { + allLocales.add(locale); + for (const env of environments) allEnvs.add(env); + + // An environment set past the API cap becomes several single-locale batches — count each. + for (const envChunk of chunkArray(environments, BATCH_CONSTANTS.maxEnvironments)) { + const key = targetCountKey(locale, envChunk); + itemsPerTarget.set(key, (itemsPerTarget.get(key) || 0) + 1); + totalItems++; + } + } + } + // chunkData falls out of scope here — GC reclaims it + } + + const totalBatches = [...itemsPerTarget.values()].reduce( + (sum, count) => sum + Math.ceil(count / BATCH_CONSTANTS.maxItems), + 0 + ); + + return { + eligible, + totalItems, + skipped, + unmapped, + environments: [...allEnvs], + locales: [...allLocales], + assetUidMapper, + assetsIndex, + environmentsMap, + totalBatches, + }; +} diff --git a/packages/contentstack-bulk-operations/src/utils/batch-helper.ts b/packages/contentstack-bulk-operations/src/utils/batch-helper.ts index e5d718272..e98214154 100644 --- a/packages/contentstack-bulk-operations/src/utils/batch-helper.ts +++ b/packages/contentstack-bulk-operations/src/utils/batch-helper.ts @@ -17,57 +17,127 @@ export const DEFAULT_BATCH_CONFIG: BatchConfig = { maxEnvironments: BATCH_CONSTANTS.maxEnvironments, }; -export function batchItems( - items: Array, - environments: string[], - locales: string[], - config: BatchConfig = DEFAULT_BATCH_CONFIG -): BatchedItems[] { - const batches: BatchedItems[] = []; - - const itemBatches = chunkArray(items, config.maxItems); +type PublishItem = EntryPublishData | AssetPublishData; - // Combine locale + environment into publish targets - const publishTargets = locales.flatMap((locale) => environments.map((environment) => ({ locale, environment }))); +/** A batch as emitted by TargetBatcher — totalBatches is only known once batching finishes. */ +export type PendingBatch = Omit; - const targetBatchSize = config.maxLocales * config.maxEnvironments; - const targetBatches = chunkArray(publishTargets, targetBatchSize); - - let batchNumber = 0; - const totalBatches = itemBatches.length * targetBatches.length; +/** + * Group an item's own publish_details into locale -> sorted environments. + * A publish_details entry without an environment is unusable; one without a locale uses the item's. + */ +function targetsOf(item: PublishItem): Map { + const byLocale = new Map>(); + + for (const pd of item.publish_details ?? []) { + if (!pd?.environment) continue; + const locale = pd.locale || item.locale; + if (!locale) continue; + + let envs = byLocale.get(locale); + if (!envs) { + envs = new Set(); + byLocale.set(locale, envs); + } + envs.add(pd.environment); + } - for (const itemBatch of itemBatches) { - for (const targetBatch of targetBatches) { - batchNumber++; + return new Map([...byLocale].map(([locale, envs]) => [locale, [...envs].sort()])); +} - const targetSet = new Set(targetBatch.map((t) => `${t.locale}:${t.environment}`)); +/** True when an item carries at least one usable (locale, environment) publish target. */ +export function hasPublishTargets(item: PublishItem): boolean { + return targetsOf(item).size > 0; +} - const filteredItems = itemBatch - .map((item) => { - const publish_details = item.publish_details?.filter((pd) => targetSet.has(`${pd.locale}:${pd.environment}`)); +/** + * Batches items by publish target: one locale and one environment set per batch. + * + * The API expands `{ items[], environments[], locales[] }` into one record per + * (item x locale x environment), so mixing targets in a batch publishes each item to the union. + * + * Buckets are bounded by the number of distinct targets, not by item count, so this also works + * for streaming callers. + */ +export class TargetBatcher { + private readonly buckets = new Map(); + private batchCount = 0; + private skipped = 0; + + constructor( + private readonly emit: (batch: PendingBatch) => void, + private readonly config: BatchConfig = DEFAULT_BATCH_CONFIG + ) {} + + /** Items dropped for carrying no usable publish target. */ + get skippedCount(): number { + return this.skipped; + } - if (!publish_details || publish_details.length === 0) return null; + /** Batches emitted so far. */ + get emittedCount(): number { + return this.batchCount; + } - return { - ...item, - publish_details, - }; - }) - .filter(Boolean) as Array; + add(item: PublishItem): void { + const targets = targetsOf(item); + if (targets.size === 0) { + this.skipped++; + return; + } - if (filteredItems.length > 0) { - batches.push({ - items: filteredItems, - locales: [...new Set(targetBatch.map((t) => t.locale))], - environments: [...new Set(targetBatch.map((t) => t.environment))], - batchNumber, - totalBatches, + for (const [locale, environments] of targets) { + // An environment set past the API cap becomes several batches, each still single-locale. + for (const envChunk of chunkArray(environments, this.config.maxEnvironments)) { + const key = JSON.stringify([locale, envChunk]); + + let bucket = this.buckets.get(key); + if (!bucket) { + bucket = { locale, environments: envChunk, items: [] }; + this.buckets.set(key, bucket); + } + + // Narrow publish_details to this batch's target so payload building cannot re-widen it. + // `item.locale` is left alone: for entries it is the resolution hint, which can legitimately + // differ from the requested locale this batch publishes to. + bucket.items.push({ + ...item, + publish_details: envChunk.map((environment) => ({ environment, locale, version: item.version })), }); + + if (bucket.items.length >= this.config.maxItems) this.flush(key, bucket); } } } - return batches; + /** Emit every partially filled bucket. */ + end(): void { + for (const [key, bucket] of this.buckets) this.flush(key, bucket); + } + + private flush(key: string, bucket: { locale: string; environments: string[]; items: PublishItem[] }): void { + if (bucket.items.length === 0) return; + + this.batchCount++; + this.emit({ + items: bucket.items, + locales: [bucket.locale], + environments: bucket.environments, + batchNumber: this.batchCount, + }); + + this.buckets.delete(key); + } +} + +export function batchItems(items: PublishItem[], config: BatchConfig = DEFAULT_BATCH_CONFIG): BatchedItems[] { + const pending: PendingBatch[] = []; + const batcher = new TargetBatcher((batch) => pending.push(batch), config); + + for (const item of items) batcher.add(item); + batcher.end(); + + return pending.map((batch) => ({ ...batch, totalBatches: pending.length })); } /** diff --git a/packages/contentstack-bulk-operations/src/utils/config-builder.ts b/packages/contentstack-bulk-operations/src/utils/config-builder.ts index 2521b0776..1ef2ee4cc 100644 --- a/packages/contentstack-bulk-operations/src/utils/config-builder.ts +++ b/packages/contentstack-bulk-operations/src/utils/config-builder.ts @@ -69,8 +69,8 @@ function validateConfig(config: BulkOperationConfig): string[] { errors.push(`Invalid operation type: ${config.operation}. Must be 'publish' or 'unpublish'`); } - // Environments validation — skipped when assets are read from a data directory - if (!config.dataDir) { + // Environments validation — skipped when assets are read from a backup directory + if (!config.backupDir) { if ( (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && (!config.environments || config.environments.length === 0) @@ -82,9 +82,9 @@ function validateConfig(config: BulkOperationConfig): string[] { } } - // Locales validation — skipped when assets are read from a data directory + // Locales validation — skipped when assets are read from a backup directory const isNonLocalized = config.filter === FilterType.NON_LOCALIZED; - if (!config.dataDir) { + if (!config.backupDir) { if (!isNonLocalized && (!config.locales || config.locales.length === 0)) { errors.push('Locales are required'); } @@ -143,8 +143,8 @@ function validateCommandFlags(flags: CommandFlags): string[] { const operation = flags.operation as OperationType; - // Environment validation — skipped when assets are read from a data directory - if (!flags['data-dir']) { + // Environment validation — skipped when assets are read from a backup directory + if (!flags['backup-dir']) { if ( (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && (!flags.environments || flags.environments.length === 0) @@ -156,9 +156,9 @@ function validateCommandFlags(flags: CommandFlags): string[] { } } - // Locale validation — skipped when assets are read from a data directory + // Locale validation — skipped when assets are read from a backup directory const isNonLocalized = flags.filter === FilterType.NON_LOCALIZED; - if (!flags['data-dir']) { + if (!flags['backup-dir']) { if (!isNonLocalized && (!flags.locales || flags.locales.length === 0)) { errors.push('Locales are required'); } @@ -236,7 +236,7 @@ export function buildConfig(flags: CommandFlags): BulkOperationConfig { contentTypes: flags['content-types'] !== undefined ? expandFlagStringList(flags['content-types']) : undefined, includeVariants: flags['include-variants'], folderUid: flags['folder-uid'], - dataDir: flags['data-dir'], + backupDir: flags['backup-dir'], dryRun: flags['dry-run'], sourceEnv: flags['source-env'], publishMode: (flags['publish-mode'] as PublishMode) || PublishMode.BULK, diff --git a/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts b/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts deleted file mode 100644 index 71ea47bd3..000000000 --- a/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export interface DataDirScanStats { - /** Number of assets eligible for publish (have publish_details + mapped UID). */ - eligible: number; - /** Total AssetPublishData items that will be created (eligible × locale expansions). */ - totalItems: number; - skipped: number; - unmapped: number; - environments: string[]; - locales: string[]; - /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ - assetUidMapper: Record; - /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ - assetsIndex: Record; -} - -/** - * Pass 1: count-only scan of the data directory. - * Reads chunk files one at a time, counts eligible/skipped/unmapped, and - * discovers environments and locales — without building any AssetPublishData objects. - * Memory footprint: uid mapper + env map + one chunk at a time. - * - * Returns assetUidMapper and assetsIndex so pass 2 (streamAndPublish) can reuse them - * without re-reading the same files from disk. - */ -export async function scanDataDirStats( - dataDir: string, - overrideEnvs?: string[], - overrideLocales?: string[], - logger?: any -): Promise { - const assetsIndexPath = path.join(dataDir, 'assets', 'assets.json'); - const environmentsPath = path.join(dataDir, 'environments', 'environments.json'); - const assetUidMapperPath = path.join(dataDir, 'mapper', 'assets', 'uid-mapping.json'); - - if (!fs.existsSync(assetsIndexPath)) { - throw new Error( - `Asset index not found: ${assetsIndexPath}. Ensure --data-dir points to the import backup directory.` - ); - } - - let assetUidMapper: Record = {}; - if (fs.existsSync(assetUidMapperPath)) { - assetUidMapper = JSON.parse(fs.readFileSync(assetUidMapperPath, 'utf-8')); - } else { - logger?.warn( - `Asset UID mapper not found: ${assetUidMapperPath}. Ensure --data-dir points to the import backup directory.` - ); - } - - const environmentsMap: Record = {}; - if (fs.existsSync(environmentsPath)) { - const envData: Record = JSON.parse(fs.readFileSync(environmentsPath, 'utf-8')); - for (const [uid, env] of Object.entries(envData)) { - environmentsMap[uid] = (env as any).name || uid; - } - } else { - logger?.warn(`Environments file not found: ${environmentsPath}`); - } - - const assetsIndex: Record = JSON.parse(fs.readFileSync(assetsIndexPath, 'utf-8')); - - let eligible = 0; - let totalItems = 0; - let skipped = 0; - let unmapped = 0; - const allEnvs = new Set(); - const allLocales = new Set(); - - for (const chunkFilename of Object.values(assetsIndex)) { - const chunkPath = path.join(dataDir, 'assets', chunkFilename); - const chunkData: Record = JSON.parse(fs.readFileSync(chunkPath, 'utf-8')); - - for (const asset of Object.values(chunkData)) { - if (!asset.publish_details || asset.publish_details.length === 0) { - skipped++; - continue; - } - - const targetUid = assetUidMapper[asset.uid as string]; - if (!targetUid) { - unmapped++; - continue; - } - - eligible++; - - if (!overrideLocales?.length) { - for (const pd of asset.publish_details) { - if (pd.locale) allLocales.add(pd.locale as string); - } - } - if (!overrideEnvs?.length) { - for (const pd of asset.publish_details) { - const envName = environmentsMap[pd.environment] || pd.environment; - if (envName) allEnvs.add(envName as string); - } - } - - const localeCount = overrideLocales?.length - ? overrideLocales.length - : new Set(asset.publish_details.map((pd: any) => pd.locale as string)).size; - totalItems += localeCount; - } - // chunkData falls out of scope here — GC reclaims it - } - - const environments = overrideEnvs?.length ? overrideEnvs : [...allEnvs]; - const locales = overrideLocales?.length ? overrideLocales : [...allLocales]; - - return { eligible, totalItems, skipped, unmapped, environments, locales, assetUidMapper, assetsIndex }; -} diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index d22dc8a8f..7892cd776 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -25,7 +25,7 @@ import { } from './helpers'; import { setupBatchQueueListeners } from './batch-queue-handler'; import { confirmOperation } from './operation-confirmation'; -import { batchItems, validateBatch } from './batch-helper'; +import { batchItems, validateBatch, hasPublishTargets, TargetBatcher } from './batch-helper'; import { handleCrossPublishOperation } from './cross-publish-handler'; import { fetchAssets, fetchEntries, fetchTaxonomyList } from './item-fetcher'; import { @@ -60,8 +60,8 @@ import { validateAndBuildBulkDeleteItems, LoadAssetUidsError, } from './asset-uids-from-file'; -import { scanDataDirStats } from './data-dir-asset-fetcher'; -import type { DataDirScanStats } from './data-dir-asset-fetcher'; +import { scanBackupDirStats, assetPublishTargets } from './backup-dir-asset-fetcher'; +import type { BackupDirScanStats } from './backup-dir-asset-fetcher'; import { compareFieldValues, compareNonLocalizedFields, @@ -97,6 +97,8 @@ export { setupBatchQueueListeners, confirmOperation, batchItems, + hasPublishTargets, + TargetBatcher, handleCrossPublishOperation, fetchAssets, fetchEntries, @@ -137,6 +139,7 @@ export { loadBulkDeleteItemsFromFile, validateAndBuildBulkDeleteItems, LoadAssetUidsError, - scanDataDirStats, + scanBackupDirStats, + assetPublishTargets, }; -export type { DataDirScanStats }; +export type { BackupDirScanStats }; diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index f9bced56f..e0c1810c7 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -166,18 +166,18 @@ export async function fillMissingFlags(flags: any): Promise { // Track if we prompted for anything let didPrompt = false; - // The presence of --data-dir is what selects the import-backup publish flow: + // The presence of --backup-dir is what selects the import-backup publish flow: // environments and locales are then derived per-asset from the backup, so we - // neither prompt for the data-dir path nor for environments/locales here. - const hasDataDir = !!updatedFlags['data-dir']; + // neither prompt for the backup-dir path nor for environments/locales here. + const hasBackupDir = !!updatedFlags['backup-dir']; // Check if any required fields are missing const needsCredentials = !updatedFlags.alias && !updatedFlags['stack-api-key']; const needsOperation = !updatedFlags.operation; // Check if non-localized filter is used const isNonLocalized = updatedFlags.filter === FilterType.NON_LOCALIZED; - const needsEnvironments = !hasDataDir && (!updatedFlags.environments || updatedFlags.environments.length === 0); - const needsLocales = !hasDataDir && !isNonLocalized && (!updatedFlags.locales || updatedFlags.locales.length === 0); + const needsEnvironments = !hasBackupDir && (!updatedFlags.environments || updatedFlags.environments.length === 0); + const needsLocales = !hasBackupDir && !isNonLocalized && (!updatedFlags.locales || updatedFlags.locales.length === 0); // Only show interactive mode header if we need to prompt if (needsCredentials || needsOperation || needsEnvironments || needsLocales) { diff --git a/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts b/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts index c794520ce..8af31ab0f 100644 --- a/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts +++ b/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts @@ -122,6 +122,9 @@ export async function fetchEntries( // Convert to publish data format for (const entry of processedEntries) { + // `locale` on the entry is the hint the API resolves from (it may be a fallback locale + // when the entry has no document in the requested one); `publish_details` carries the + // requested locale, which is the scope the publish was asked for. const entryLocale = entry.locale || locale; const publishData: EntryPublishData = { type: 'entry', @@ -131,7 +134,7 @@ export async function fetchEntries( version: entry._version, publish_details: validEnvironments.map((env) => ({ environment: env, - locale: entryLocale, + locale, })), }; diff --git a/packages/contentstack-bulk-operations/src/utils/revert-retry-handler.ts b/packages/contentstack-bulk-operations/src/utils/revert-retry-handler.ts index f11748a91..1144a1f62 100644 --- a/packages/contentstack-bulk-operations/src/utils/revert-retry-handler.ts +++ b/packages/contentstack-bulk-operations/src/utils/revert-retry-handler.ts @@ -17,6 +17,9 @@ import { import { confirmOperation } from './operation-confirmation'; import { $t, messages } from './index'; +/** A log item paired with the environments of the log entry it came from. */ +type TargetedLogItem = { item: LogItem; environments: string[] }; + /** * Load log entries and extract configuration */ @@ -77,18 +80,25 @@ export function loadConfigFromLogFile( return null; } - // Extract items from bulk logs - const bulkItems: LogItem[] = bulkLogEntries.flatMap((entry) => entry.items); + // Pair every item with its own log entry's environments. Using the first entry's would revert + // items out of environments they were never published to. + const bulkTargets: TargetedLogItem[] = bulkLogEntries.flatMap((entry) => + entry.items.map((item) => ({ item, environments: entry.environments || [] })) + ); + const singleTargets: TargetedLogItem[] = singleLogEntries.map((entry) => ({ + item: entry.item, + environments: entry.environments || [], + })); - // Extract items from single logs - const singleItems: LogItem[] = singleLogEntries.map((entry) => entry.item); + const singleItems: LogItem[] = singleTargets.map((t) => t.item); // Get config from first available log entry const firstBulk = bulkLogEntries[0]; const firstSingle = singleLogEntries[0]; const operation = (firstBulk?.operation || firstSingle?.operation || 'publish') as OperationType; - const environments = firstBulk?.environments || firstSingle?.environments || []; + // Display/confirmation only — the per-item environments above are what get sent. + const environments = [...new Set([...bulkTargets, ...singleTargets].flatMap((t) => t.environments))]; // For single mode, extract locales from items; for bulk mode, use log entry locales const locales = firstBulk?.locales || (singleItems.length > 0 ? Array.from(new Set(singleItems.map((i) => i.locale))) : []); @@ -98,13 +108,10 @@ export function loadConfigFromLogFile( // Determine publish mode based on which log has entries const publishMode = singleLogEntries.length > 0 ? 'single' : 'bulk'; - // Combine all items - const allItems = [...bulkItems, ...singleItems]; + // Combine all items, each keeping its own environments + const allTargets = [...bulkTargets, ...singleTargets]; - const items = - resourceType === ResourceType.ENTRY - ? convertToEntryData(allItems, environments) - : convertToAssetData(allItems, environments); + const items = resourceType === ResourceType.ENTRY ? convertToEntryData(allTargets) : convertToAssetData(allTargets); return { operation, @@ -132,10 +139,10 @@ export function loadItemsFromLog( /** * Convert log items to entry publish data */ -function convertToEntryData(logItems: LogItem[], environments: string[]): EntryPublishData[] { - return logItems - .filter((item) => item.type === 'entry') - .map((item) => ({ +function convertToEntryData(targets: TargetedLogItem[]): EntryPublishData[] { + return targets + .filter(({ item }) => item.type === 'entry') + .map(({ item, environments }) => ({ type: 'entry' as const, uid: item.uid, locale: item.locale, @@ -153,10 +160,10 @@ function convertToEntryData(logItems: LogItem[], environments: string[]): EntryP /** * Convert log items to asset publish data */ -function convertToAssetData(logItems: LogItem[], environments: string[]): AssetPublishData[] { - return logItems - .filter((item) => item.type === 'asset') - .map((item) => ({ +function convertToAssetData(targets: TargetedLogItem[]): AssetPublishData[] { + return targets + .filter(({ item }) => item.type === 'asset') + .map(({ item, environments }) => ({ type: 'asset' as const, uid: item.uid, locale: item.locale, diff --git a/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts b/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts index c6dafc7fb..d9634bafb 100644 --- a/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts @@ -480,6 +480,55 @@ describe('BulkOperationService', () => { expect(payload.environments).to.deep.equal(['beta', 'beta2', 'beta3']); }); + it('should union environments across items rather than trusting the first item', () => { + // Guards the fallback only — batches are single-target by construction. + const mockItems: AssetPublishData[] = [ + { uid: 'asset1', version: 1, locale: 'en-us', publish_details: [{ environment: 'beta', locale: 'en-us' }] }, + { + uid: 'asset2', + version: 1, + locale: 'en-us', + publish_details: [ + { environment: 'beta', locale: 'en-us' }, + { environment: 'prod', locale: 'en-us' }, + ], + }, + ]; + + const payload = (bulkOperationService as any).prepareBulkPayload( + mockItems, + OperationType.PUBLISH, + ResourceType.ASSET + ); + + expect(payload.environments).to.have.members(['beta', 'prod']); + }); + + it('should prefer the batch environments over anything the items carry', () => { + const mockItems: AssetPublishData[] = [ + { + uid: 'asset1', + version: 1, + locale: 'en-us', + publish_details: [ + { environment: 'beta', locale: 'en-us' }, + { environment: 'prod', locale: 'en-us' }, + ], + }, + ]; + + const payload = (bulkOperationService as any).prepareBulkPayload( + mockItems, + OperationType.PUBLISH, + ResourceType.ASSET, + ['beta'], + ['en-us'] + ); + + expect(payload.environments).to.deep.equal(['beta']); + expect(payload.locales).to.deep.equal(['en-us']); + }); + it('should include all locales and deduplicated assets together for unpublish', () => { const mockItems: AssetPublishData[] = [ { uid: 'asset1', version: 1, locale: 'en-us', publish_details: [{ environment: 'beta', locale: 'en-us' }] }, diff --git a/packages/contentstack-bulk-operations/test/unit/utils/backup-dir-asset-fetcher.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/backup-dir-asset-fetcher.test.ts new file mode 100644 index 000000000..f3c0a4f58 --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/utils/backup-dir-asset-fetcher.test.ts @@ -0,0 +1,176 @@ +import sinon from 'sinon'; +import * as path from 'path'; +import { expect } from 'chai'; +import { assetPublishTargets, scanBackupDirStats } from '../../../src/utils/backup-dir-asset-fetcher'; +import { TargetBatcher } from '../../../src/utils/batch-helper'; +import { AssetPublishData, BatchedItems } from '../../../src/interfaces'; + +const ENVIRONMENTS_MAP = { envDev: 'dev', envProd: 'prod', envStage: 'staging' }; + +/** Asset shaped the way an import backup stores it: publish_details reference environment UIDs. */ +const devOnlyAsset = { + uid: 'a1', + _version: 2, + publish_details: [{ environment: 'envDev', locale: 'en-us' }], +}; + +const prodOnlyAsset = { + uid: 'a2', + _version: 5, + publish_details: [{ environment: 'envProd', locale: 'en-us' }], +}; + +describe('backup-dir asset fetcher', () => { + describe('assetPublishTargets', () => { + it('should map environment uids to names and keep them per locale', () => { + const targets = assetPublishTargets(devOnlyAsset, ENVIRONMENTS_MAP); + + expect([...targets.keys()]).to.deep.equal(['en-us']); + expect(targets.get('en-us')).to.deep.equal(['dev']); + }); + + it('should give each locale only the environments that locale was published to', () => { + const asset = { + uid: 'a3', + _version: 1, + publish_details: [ + { environment: 'envDev', locale: 'en-us' }, + { environment: 'envStage', locale: 'en-us' }, + { environment: 'envProd', locale: 'fr-fr' }, + ], + }; + + const targets = assetPublishTargets(asset, ENVIRONMENTS_MAP); + + expect(targets.get('en-us')).to.deep.equal(['dev', 'staging']); + expect(targets.get('fr-fr')).to.deep.equal(['prod']); + }); + + it('should fall back to the raw environment value when the uid is not in the map', () => { + const asset = { uid: 'a4', _version: 1, publish_details: [{ environment: 'unmapped-env', locale: 'en-us' }] }; + + expect(assetPublishTargets(asset, ENVIRONMENTS_MAP).get('en-us')).to.deep.equal(['unmapped-env']); + }); + + it('should skip publish_details without a locale or environment', () => { + const asset = { + uid: 'a5', + _version: 1, + publish_details: [{ environment: 'envDev' }, { locale: 'en-us' }], + }; + + expect(assetPublishTargets(asset, ENVIRONMENTS_MAP).size).to.equal(0); + }); + }); + + // The composition streamAndPublish runs: an asset must reach only its own environments, + // never the union across the backup. + describe('per-asset targets through TargetBatcher', () => { + it('should never publish an asset to another asset environments', () => { + const emitted: Array> = []; + const batcher = new TargetBatcher((batch) => emitted.push(batch)); + + for (const asset of [devOnlyAsset, prodOnlyAsset]) { + const targets = assetPublishTargets(asset, ENVIRONMENTS_MAP); + for (const [locale, environments] of targets) { + batcher.add({ + type: 'asset', + uid: `new-${asset.uid}`, + locale, + version: asset._version, + publish_details: environments.map((environment) => ({ environment, locale })), + } as AssetPublishData); + } + } + batcher.end(); + + expect(emitted).to.have.lengthOf(2); + + const devBatch = emitted.find((b) => b.items[0].uid === 'new-a1'); + const prodBatch = emitted.find((b) => b.items[0].uid === 'new-a2'); + + expect(devBatch?.environments).to.deep.equal(['dev']); + expect(devBatch?.locales).to.deep.equal(['en-us']); + expect(prodBatch?.environments).to.deep.equal(['prod']); + expect(prodBatch?.locales).to.deep.equal(['en-us']); + }); + }); + + describe('scanBackupDirStats', () => { + // The suite's init helper no-ops fs.writeFileSync outside its allowlist, so reads are + // stubbed rather than writing a real backup to disk. + const backupDir = '/backup'; + const paths = { + index: path.join(backupDir, 'assets', 'assets.json'), + chunk: path.join(backupDir, 'assets', 'assets-1.json'), + environments: path.join(backupDir, 'environments', 'environments.json'), + mapper: path.join(backupDir, 'mapper', 'assets', 'uid-mapping.json'), + }; + + let files: Record; + + beforeEach(() => { + files = { + [paths.index]: { '1': 'assets-1.json' }, + [paths.chunk]: { + a1: devOnlyAsset, + a2: prodOnlyAsset, + a3: { uid: 'a3', _version: 1, publish_details: [] }, // skipped: never published + a4: { uid: 'a4', _version: 1, publish_details: [{ environment: 'envDev', locale: 'en-us' }] }, // unmapped + }, + [paths.environments]: { envDev: { name: 'dev' }, envProd: { name: 'prod' } }, + [paths.mapper]: { a1: 'new-a1', a2: 'new-a2' }, + }; + + // require('fs') — the CJS binding is mutable; the ESM namespace import is not stubbable. + const fs = require('fs'); + sinon.stub(fs, 'existsSync').callsFake((p: any) => Object.prototype.hasOwnProperty.call(files, String(p))); + sinon.stub(fs, 'readFileSync').callsFake((p: any) => JSON.stringify(files[String(p)])); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should count eligible, skipped and unmapped assets', async () => { + const stats = await scanBackupDirStats(backupDir); + + expect(stats.eligible).to.equal(2); + expect(stats.skipped).to.equal(1); + expect(stats.unmapped).to.equal(1); + expect(stats.totalItems).to.equal(2); + }); + + it('should return the environment uid to name map for pass 2', async () => { + const stats = await scanBackupDirStats(backupDir); + + expect(stats.environmentsMap).to.deep.equal({ envDev: 'dev', envProd: 'prod' }); + }); + + it('should report the backup wide unions for display only', async () => { + const stats = await scanBackupDirStats(backupDir); + + // Correct as a summary of the backup — it is what must NOT reach the payload. + expect(stats.environments).to.have.members(['dev', 'prod']); + expect(stats.locales).to.deep.equal(['en-us']); + }); + + it('should count one batch per distinct target rather than per item chunk', async () => { + const stats = await scanBackupDirStats(backupDir); + + // Two assets, two different single-environment targets -> two batches, not one. + expect(stats.totalBatches).to.equal(2); + }); + + it('should throw when the asset index is missing', async () => { + delete files[paths.index]; + + try { + await scanBackupDirStats(backupDir); + expect.fail('expected scanBackupDirStats to throw'); + } catch (error: any) { + expect(error.message).to.contain('Asset index not found'); + } + }); + }); +}); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/batch-helper.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/batch-helper.test.ts index 1ada4b8d1..205dd683d 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/batch-helper.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/batch-helper.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { batchItems, validateBatch, DEFAULT_BATCH_CONFIG } from '../../../src/utils/batch-helper'; +import { batchItems, validateBatch, hasPublishTargets, DEFAULT_BATCH_CONFIG } from '../../../src/utils/batch-helper'; import { getUniqueEnvironments, getUniqueLocales } from '../../../src/utils/helpers'; import { EntryPublishData, AssetPublishData } from '../../../src/interfaces'; @@ -89,7 +89,7 @@ describe('Batch Helper', () => { }, ]; - const batches = batchItems(items, ['dev'], ['en-us']); + const batches = batchItems(items); expect(batches).to.have.lengthOf(1); expect(batches[0].items).to.have.lengthOf(2); expect(batches[0].environments).to.deep.equal(['dev']); @@ -104,15 +104,14 @@ describe('Batch Helper', () => { publish_details: [{ environment: 'dev', locale: 'en-us', version: 1 }], })); - const batches = batchItems(items, ['dev'], ['en-us']); + const batches = batchItems(items); expect(batches.length).to.be.greaterThan(1); expect(batches[0].items.length).to.be.at.most(50); expect(batches[0].totalBatches).to.equal(batches.length); }); - it('should split into multiple batches when locales exceed limit', () => { + it('should give every locale its own batch', () => { const locales = Array.from({ length: 15 }, (_, i) => `locale-${i}`); - // Create items for each locale const items: EntryPublishData[] = locales.map((loc) => ({ uid: `entry-${loc}`, content_type: 'blog', @@ -120,40 +119,40 @@ describe('Batch Helper', () => { publish_details: [{ environment: 'dev', locale: loc, version: 1 }], })); - const batches = batchItems(items, ['dev'], locales); - // With 15 items and maxItems=50, should fit in 1 item batch - expect(batches.length).to.be.greaterThan(0); - // Each batch should have items matching its locale set + const batches = batchItems(items); + expect(batches).to.have.lengthOf(15); + batches.forEach((batch) => { - expect(batch.items.length).to.be.at.most(50); + expect(batch.locales).to.have.lengthOf(1); batch.items.forEach((item) => { - expect(batch.locales).to.include(item.locale); + expect(batch.locales).to.deep.equal([item.locale]); }); }); }); - it('should split into multiple batches when environments exceed limit', () => { - // To force environment batching, we need more than maxLocales * maxEnvironments targets - // With 15 envs × 15 locales = 225 targets > 100 (targetBatchSize), will create multiple batches + it('should split an environment set larger than the API cap, staying single-locale', () => { const environments = Array.from({ length: 15 }, (_, i) => `env-${i}`); - const locales = Array.from({ length: 15 }, (_, i) => `locale-${i}`); const items: EntryPublishData[] = [ { uid: 'entry1', content_type: 'blog', locale: 'en-us', - publish_details: environments.flatMap((env) => - locales.map((loc) => ({ environment: env, locale: loc, version: 1 })) - ), + publish_details: environments.map((env) => ({ environment: env, locale: 'en-us', version: 1 })), }, ]; - const batches = batchItems(items, environments, locales); - // With 225 targets and targetBatchSize = 100, should create multiple batches - expect(batches.length).to.be.greaterThan(1); + const batches = batchItems(items); + // 15 environments, cap of 10 -> 2 batches, both for the one locale. + expect(batches).to.have.lengthOf(2); + batches.forEach((batch) => { + expect(batch.locales).to.deep.equal(['en-us']); + expect(batch.environments.length).to.be.at.most(DEFAULT_BATCH_CONFIG.maxEnvironments); + }); + const batched = batches.flatMap((b) => b.environments); + expect(batched).to.have.members(environments); }); - it('should include all items in a single batch when locales fit within limit', () => { + it('should never mix locales in one batch', () => { const items: EntryPublishData[] = [ { uid: 'entry1', @@ -169,23 +168,18 @@ describe('Batch Helper', () => { }, ]; - const batches = batchItems(items, ['dev'], ['en-us', 'fr-fr']); + const batches = batchItems(items); - // Should have 1 batch since locales and items are within limits - expect(batches).to.have.lengthOf(1); + // One batch per locale — a shared batch would publish entry1 in fr-fr and entry2 in en-us. + expect(batches).to.have.lengthOf(2); - // Batch should include both items - expect(batches[0].items).to.have.lengthOf(2); - expect(batches[0].locales).to.have.members(['en-us', 'fr-fr']); - - // Verify each item has correct locale - const enItem = batches[0].items.find((i) => i.locale === 'en-us'); - const frItem = batches[0].items.find((i) => i.locale === 'fr-fr'); - expect(enItem).to.exist; - expect(frItem).to.exist; + const enBatch = batches.find((b) => b.locales[0] === 'en-us'); + const frBatch = batches.find((b) => b.locales[0] === 'fr-fr'); + expect(enBatch?.items.map((i) => i.uid)).to.deep.equal(['entry1']); + expect(frBatch?.items.map((i) => i.uid)).to.deep.equal(['entry2']); }); - it('should handle assets correctly', () => { + it('should not widen an item to another item environments', () => { const items: AssetPublishData[] = [ { uid: 'asset1', @@ -202,9 +196,82 @@ describe('Batch Helper', () => { }, ]; - const batches = batchItems(items, ['dev', 'staging'], ['en-us']); + const batches = batchItems(items); + + // Different environment sets cannot share a batch. + expect(batches).to.have.lengthOf(2); + + const devOnly = batches.find((b) => b.environments.length === 1); + expect(devOnly?.environments).to.deep.equal(['dev']); + expect(devOnly?.items.map((i) => i.uid)).to.deep.equal(['asset2']); + + const both = batches.find((b) => b.environments.length === 2); + expect(both?.environments).to.deep.equal(['dev', 'staging']); + expect(both?.items.map((i) => i.uid)).to.deep.equal(['asset1']); + }); + + it('should batch a per-locale environment split separately', () => { + const items: AssetPublishData[] = [ + { + uid: 'asset1', + locale: 'en-us', + publish_details: [ + { environment: 'dev', locale: 'en-us', version: 1 }, + { environment: 'prod', locale: 'fr-fr', version: 1 }, + ], + }, + ]; + + const batches = batchItems(items); + expect(batches).to.have.lengthOf(2); + + const en = batches.find((b) => b.locales[0] === 'en-us'); + const fr = batches.find((b) => b.locales[0] === 'fr-fr'); + expect(en?.environments).to.deep.equal(['dev']); + expect(fr?.environments).to.deep.equal(['prod']); + expect(en?.items[0].publish_details).to.deep.equal([{ environment: 'dev', locale: 'en-us', version: undefined }]); + }); + + it('should group on the requested locale without rewriting the entry locale', () => { + // A non-localized entry resolves to its fallback locale, so item.locale legitimately differs + // from the locale being published to. Grouping must follow publish_details, and the entry's + // own locale must survive as the hint sent in entries[]. + const items: EntryPublishData[] = [ + { + uid: 'localized', + content_type: 'blog', + locale: 'fr-fr', + publish_details: [{ environment: 'dev', locale: 'fr-fr' }], + }, + { + uid: 'fallback', + content_type: 'blog', + locale: 'en-us', // no fr-fr document; resolved to master + publish_details: [{ environment: 'dev', locale: 'fr-fr' }], + }, + ]; + + const batches = batchItems(items); + + // Both publish to fr-fr, so both belong to the same batch despite differing item locales. expect(batches).to.have.lengthOf(1); - expect(batches[0].items).to.have.lengthOf(2); + expect(batches[0].locales).to.deep.equal(['fr-fr']); + expect(batches[0].items.map((i) => i.uid)).to.have.members(['localized', 'fallback']); + + const fallback = batches[0].items.find((i) => i.uid === 'fallback'); + expect(fallback?.locale).to.equal('en-us'); + expect(fallback?.publish_details?.[0].locale).to.equal('fr-fr'); + }); + + it('should drop items with no usable publish target', () => { + const items: EntryPublishData[] = [ + { uid: 'entry1', content_type: 'blog', locale: 'en-us', publish_details: [] }, + { uid: 'entry2', content_type: 'blog', locale: 'en-us' }, + ]; + + expect(hasPublishTargets(items[0])).to.equal(false); + expect(hasPublishTargets(items[1])).to.equal(false); + expect(batchItems(items)).to.have.lengthOf(0); }); it('should correctly set batch numbers', () => { @@ -215,7 +282,7 @@ describe('Batch Helper', () => { publish_details: [{ environment: 'dev', locale: 'en-us', version: 1 }], })); - const batches = batchItems(items, ['dev'], ['en-us']); + const batches = batchItems(items); expect(batches[0].batchNumber).to.equal(1); expect(batches[1].batchNumber).to.equal(2); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/item-fetcher.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/item-fetcher.test.ts index 0a8cb9a6e..a726281ba 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/item-fetcher.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/item-fetcher.test.ts @@ -621,7 +621,7 @@ describe('Item Fetcher Utilities', () => { expect(result[0].publish_details?.[1]).to.deep.equal({ environment: 'staging', locale: 'fr-fr' }); }); - it('should use entry locale in publish_details', async () => { + it('should keep the entry locale as the hint and publish to the requested locale', async () => { const config: BulkOperationConfig = { contentTypes: ['blog'], locales: ['en-us'], @@ -643,7 +643,7 @@ describe('Item Fetcher Utilities', () => { const result = await fetchEntries(config, mockManagementStack, mockDeliveryStack, mockLogger); expect(result[0].locale).to.equal('fr-fr'); - expect(result[0].publish_details?.[0].locale).to.equal('fr-fr'); + expect(result[0].publish_details?.[0].locale).to.equal('en-us'); }); }); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts index 3ed09244f..0db38ef18 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts @@ -102,6 +102,52 @@ describe('Revert Retry Handler', () => { expect(result[0].type).to.equal('asset'); }); + it('should keep each log entry own environments instead of the first entry', () => { + // Revert forces unpublish, so borrowing the first entry's environments would unpublish + // items from environments they were never published to. + const mockBulkLogs = [ + { + mode: 'bulk', + jobId: 'job-1', + batchNumber: 1, + operation: 'publish', + timestamp: '2024-01-09T10:00:00Z', + environments: ['prod'], + locales: ['en-us'], + items: [{ uid: 'entry1', locale: 'en-us', contentType: 'blog', version: 1, type: 'entry' }], + status: 'failed', + // deepcode ignore HardcodedNonCryptoSecret: test fixture value, not a real secret + apiKey: 'test-key', + branch: 'main', + }, + { + mode: 'bulk', + jobId: 'job-2', + batchNumber: 2, + operation: 'publish', + timestamp: '2024-01-09T10:00:00Z', + environments: ['dev'], + locales: ['en-us'], + items: [{ uid: 'entry2', locale: 'en-us', contentType: 'blog', version: 1, type: 'entry' }], + status: 'failed', + // deepcode ignore HardcodedNonCryptoSecret: test fixture value, not a real secret + apiKey: 'test-key', + branch: 'main', + }, + ]; + + readBulkFailedLogStub.returns(mockBulkLogs); + + const result = loadItemsFromLog('test-logs', true, ResourceType.ENTRY); + + expect(result).to.have.length(2); + + const entry1 = result.find((i) => i.uid === 'entry1'); + const entry2 = result.find((i) => i.uid === 'entry2'); + expect(entry1?.publish_details?.map((pd) => pd.environment)).to.deep.equal(['prod']); + expect(entry2?.publish_details?.map((pd) => pd.environment)).to.deep.equal(['dev']); + }); + it('should return empty array when no logs found', () => { readBulkFailedLogStub.returns([]); readSingleFailedLogStub.returns([]); diff --git a/packages/contentstack-import/src/commands/cm/stacks/import.ts b/packages/contentstack-import/src/commands/cm/stacks/import.ts index e2b7a0cad..4b460a56d 100644 --- a/packages/contentstack-import/src/commands/cm/stacks/import.ts +++ b/packages/contentstack-import/src/commands/cm/stacks/import.ts @@ -190,7 +190,7 @@ export default class ImportCommand extends Command { cliux.print('\nAsset Scanning is enabled — assets were not published.', { color: 'yellow' }); cliux.print(' Once scanning completes, publish your assets using:', { color: 'yellow' }); cliux.print( - ` csdx cm:stacks:bulk-assets --data-dir ${backupDir} --stack-api-key ${importConfig.apiKey} --operation publish`, + ` csdx cm:stacks:bulk-assets --backup-dir ${backupDir} --stack-api-key ${importConfig.apiKey} --operation publish`, { color: 'cyan' }, ); } diff --git a/packages/contentstack-import/src/import/modules/assets.ts b/packages/contentstack-import/src/import/modules/assets.ts index 373cfa0dd..98556e285 100644 --- a/packages/contentstack-import/src/import/modules/assets.ts +++ b/packages/contentstack-import/src/import/modules/assets.ts @@ -212,7 +212,7 @@ export default class ImportAssets extends BaseClass { log.info('Assets cannot be published immediately — scanning must complete first.', this.importConfig.context); log.info('Once scanning is done, publish your assets using:', this.importConfig.context); log.info( - 'csdx cm:stacks:bulk-assets --data-dir ./content --stack-api-key --operation publish', + 'csdx cm:stacks:bulk-assets --backup-dir ./content --stack-api-key --operation publish', this.importConfig.context, ); }