Skip to content

Commit db9d960

Browse files
Merge pull request #41 from contentstack/feat/DX-4976
feat: add support for AM in import module
2 parents 6368499 + 4f99b26 commit db9d960

File tree

37 files changed

+1653
-67
lines changed

37 files changed

+1653
-67
lines changed

.talismanrc

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
11
fileignoreconfig:
2-
- filename: pnpm-lock.yaml
3-
checksum: 9c19eb613068c193fac35e72327198fbc86e759968391f07cc876c56b2b1a63d
4-
- filename: packages/contentstack-export/test/unit/export/modules/base-class.test.ts
5-
checksum: bd2b28305fff90ca26bce56b2c5c61751a62225d310a2553874e9ec009ed78e8
6-
- filename: packages/contentstack-export/test/unit/export/modules/assets.test.ts
7-
checksum: 73ff01e2d19c8d1384dca2ee7087f8c19e0b1fac6b29c75a02ca523a36b7cb92
8-
- filename: packages/contentstack-export/src/types/default-config.ts
9-
checksum: bf399466aae808342ec013c0179fbc24ac2d969c77fdbef47a842b12497d507e
10-
- filename: packages/contentstack-export/src/types/index.ts
11-
checksum: fa36c236abac338b03bf307102a99f25dddac9afe75b6b34fb82e318e7759799
12-
- filename: packages/contentstack-export/src/config/index.ts
13-
checksum: 1eb407ee0bd21597d8a4c673fce99d60fafd151ac843c33dac52ffdcc73e8107
14-
- filename: packages/contentstack-export/test/unit/export/modules/stack.test.ts
15-
checksum: 79876b8f635037a2d8ba38dac055e7625bf85db6a3cf5729434e6a97e44857d6
16-
- filename: packages/contentstack-export/src/export/modules/stack.ts
17-
checksum: 375c0c5f58d43430b355050d122d3283083ca91891abe8105a4b4fd9433ece97
2+
- filename: packages/contentstack-asset-management/src/import/spaces.ts
3+
checksum: e972d66f6a889f72d72ba48112a12f9447de39285cfb568d3b99a4969cf9f5f9
184
version: '1.0'

packages/contentstack-asset-management/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
],
3030
"license": "MIT",
3131
"dependencies": {
32-
"@contentstack/cli-utilities": "~2.0.0-beta"
32+
"@contentstack/cli-utilities": "~2.0.0-beta.5"
3333
},
3434
"oclif": {
3535
"commands": "./lib/commands",

packages/contentstack-asset-management/src/constants/index.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
1-
export const BATCH_SIZE = 50;
2-
export const CHUNK_FILE_SIZE_MB = 1;
1+
/** Fallback when export/import do not pass `chunkFileSizeMb`. */
2+
export const FALLBACK_AM_CHUNK_FILE_SIZE_MB = 1;
3+
/** Fallback when import does not pass `apiConcurrency`. */
4+
export const FALLBACK_AM_API_CONCURRENCY = 5;
5+
/** @deprecated Use FALLBACK_AM_API_CONCURRENCY */
6+
export const DEFAULT_AM_API_CONCURRENCY = FALLBACK_AM_API_CONCURRENCY;
7+
8+
/** Fallback strip lists when import options omit `fieldsImportInvalidKeys` / `assetTypesImportInvalidKeys`. */
9+
export const FALLBACK_FIELDS_IMPORT_INVALID_KEYS = [
10+
'created_at',
11+
'created_by',
12+
'updated_at',
13+
'updated_by',
14+
'is_system',
15+
'asset_types_count',
16+
] as const;
17+
export const FALLBACK_ASSET_TYPES_IMPORT_INVALID_KEYS = [
18+
'created_at',
19+
'created_by',
20+
'updated_at',
21+
'updated_by',
22+
'is_system',
23+
'category',
24+
'preview_image_url',
25+
'category_detail',
26+
] as const;
27+
28+
/** @deprecated Use FALLBACK_AM_CHUNK_FILE_SIZE_MB */
29+
export const CHUNK_FILE_SIZE_MB = FALLBACK_AM_CHUNK_FILE_SIZE_MB;
330

431
/**
532
* Main process name for Asset Management 2.0 export (single progress bar).
@@ -17,10 +44,15 @@ export const PROCESS_NAMES = {
1744
AM_FIELDS: 'Fields',
1845
AM_ASSET_TYPES: 'Asset types',
1946
AM_DOWNLOADS: 'Asset downloads',
47+
// Import process names
48+
AM_IMPORT_FIELDS: 'Import fields',
49+
AM_IMPORT_ASSET_TYPES: 'Import asset types',
50+
AM_IMPORT_FOLDERS: 'Import folders',
51+
AM_IMPORT_ASSETS: 'Import assets',
2052
} as const;
2153

2254
/**
23-
* Status messages for each process (exporting, fetching, failed).
55+
* Status messages for each process (exporting, fetching, importing, failed).
2456
*/
2557
export const PROCESS_STATUS = {
2658
[PROCESS_NAMES.AM_SPACE_METADATA]: {
@@ -47,4 +79,20 @@ export const PROCESS_STATUS = {
4779
DOWNLOADING: 'Downloading asset files...',
4880
FAILED: 'Failed to download assets.',
4981
},
82+
[PROCESS_NAMES.AM_IMPORT_FIELDS]: {
83+
IMPORTING: 'Importing shared fields...',
84+
FAILED: 'Failed to import fields.',
85+
},
86+
[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES]: {
87+
IMPORTING: 'Importing shared asset types...',
88+
FAILED: 'Failed to import asset types.',
89+
},
90+
[PROCESS_NAMES.AM_IMPORT_FOLDERS]: {
91+
IMPORTING: 'Importing folders...',
92+
FAILED: 'Failed to import folders.',
93+
},
94+
[PROCESS_NAMES.AM_IMPORT_ASSETS]: {
95+
IMPORTING: 'Importing assets...',
96+
FAILED: 'Failed to import assets.',
97+
},
5098
} as const;

packages/contentstack-asset-management/src/export/assets.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ export default class ExportAssets extends AssetManagementExportAdapter {
2121
log.debug(`Fetching folders and assets for space ${workspace.space_uid}`, this.exportContext.context);
2222

2323
const [folders, assetsData] = await Promise.all([
24-
this.getWorkspaceFolders(workspace.space_uid),
25-
this.getWorkspaceAssets(workspace.space_uid),
24+
this.getWorkspaceFolders(workspace.space_uid, workspace.uid),
25+
this.getWorkspaceAssets(workspace.space_uid, workspace.uid),
2626
]);
2727

2828
await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2));

packages/contentstack-asset-management/src/export/base.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { FsUtility, log, CLIProgressManager, configHandler } from '@contentstack
55
import type { AssetManagementAPIConfig } from '../types/asset-management-api';
66
import type { ExportContext } from '../types/export-types';
77
import { AssetManagementAdapter } from '../utils/asset-management-api-adapter';
8-
import { AM_MAIN_PROCESS_NAME } from '../constants/index';
9-
import { BATCH_SIZE, CHUNK_FILE_SIZE_MB } from '../constants/index';
8+
import { AM_MAIN_PROCESS_NAME, FALLBACK_AM_CHUNK_FILE_SIZE_MB } from '../constants/index';
109

1110
export type { ExportContext };
1211

@@ -83,19 +82,17 @@ export class AssetManagementExportAdapter extends AssetManagementAdapter {
8382
await writeFile(pResolve(dir, indexFileName), '{}');
8483
return;
8584
}
85+
const chunkMb = this.exportContext.chunkFileSizeMb ?? FALLBACK_AM_CHUNK_FILE_SIZE_MB;
8686
const fs = new FsUtility({
8787
basePath: dir,
8888
indexFileName,
89-
chunkFileSize: CHUNK_FILE_SIZE_MB,
89+
chunkFileSize: chunkMb,
9090
moduleName,
9191
fileExt: 'json',
9292
metaPickKeys,
9393
keepMetadata: true,
9494
});
95-
for (let i = 0; i < items.length; i += BATCH_SIZE) {
96-
const batch = items.slice(i, i + BATCH_SIZE);
97-
fs.writeIntoFile(batch as Record<string, string>[], { mapKeyVal: true });
98-
}
95+
fs.writeIntoFile(items as Record<string, string>[], { mapKeyVal: true });
9996
fs.completeFile(true);
10097
}
10198
}

packages/contentstack-asset-management/src/export/spaces.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ export class ExportSpaces {
3535
branchName,
3636
assetManagementUrl,
3737
org_uid,
38+
apiKey,
3839
context,
3940
securedAssets,
41+
chunkFileSizeMb,
4042
} = this.options;
4143

4244
if (!linkedWorkspaces.length) {
@@ -54,7 +56,9 @@ export class ExportSpaces {
5456
const totalSteps = 2 + linkedWorkspaces.length * 4;
5557
const progress = this.createProgress();
5658
progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps);
57-
progress.startProcess(AM_MAIN_PROCESS_NAME).updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_FIELDS].FETCHING, AM_MAIN_PROCESS_NAME);
59+
progress
60+
.startProcess(AM_MAIN_PROCESS_NAME)
61+
.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_FIELDS].FETCHING, AM_MAIN_PROCESS_NAME);
5862

5963
const apiConfig: AssetManagementAPIConfig = {
6064
baseURL: assetManagementUrl,
@@ -65,6 +69,7 @@ export class ExportSpaces {
6569
spacesRootPath,
6670
context,
6771
securedAssets,
72+
chunkFileSizeMb,
6873
};
6974

7075
const sharedFieldsDir = pResolve(spacesRootPath, 'fields');
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { existsSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import omit from 'lodash/omit';
4+
import isEqual from 'lodash/isEqual';
5+
import { log } from '@contentstack/cli-utilities';
6+
7+
import type { AssetManagementAPIConfig, ImportContext } from '../types/asset-management-api';
8+
import { AssetManagementImportAdapter } from './base';
9+
import { FALLBACK_ASSET_TYPES_IMPORT_INVALID_KEYS, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index';
10+
import { runInBatches } from '../utils/concurrent-batch';
11+
import { forEachChunkedJsonStore } from '../utils/chunked-json-reader';
12+
13+
type AssetTypeToCreate = { uid: string; payload: Record<string, unknown> };
14+
15+
/**
16+
* Reads shared asset types from `spaces/asset_types/asset-types.json` and POSTs
17+
* each to the target org-level AM endpoint (`POST /api/asset_types`).
18+
*
19+
* Strategy: Fetch → Diff → Create only missing, warn on conflict
20+
* 1. Fetch asset types that already exist in the target org.
21+
* 2. Skip entries where is_system=true (platform-owned, cannot be created via API).
22+
* 3. If uid already exists and definition differs → warn and skip.
23+
* 4. If uid already exists and definition matches → silently skip.
24+
* 5. Strip read-only/computed keys from the POST body before creating new asset types.
25+
*/
26+
export default class ImportAssetTypes extends AssetManagementImportAdapter {
27+
constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) {
28+
super(apiConfig, importContext);
29+
}
30+
31+
async start(): Promise<void> {
32+
await this.init();
33+
34+
const stripKeys = this.importContext.assetTypesImportInvalidKeys ?? [...FALLBACK_ASSET_TYPES_IMPORT_INVALID_KEYS];
35+
const dir = this.getAssetTypesDir();
36+
const indexName = this.importContext.assetTypesFileName ?? 'asset-types.json';
37+
const indexPath = join(dir, indexName);
38+
39+
if (!existsSync(indexPath)) {
40+
log.debug('No shared asset types to import (index missing)', this.importContext.context);
41+
return;
42+
}
43+
44+
const existingByUid = await this.loadExistingAssetTypesMap();
45+
46+
this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].IMPORTING, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES);
47+
48+
await forEachChunkedJsonStore<Record<string, unknown>>(
49+
dir,
50+
indexName,
51+
{
52+
context: this.importContext.context,
53+
chunkReadLogLabel: 'asset-types',
54+
onOpenError: (e) =>
55+
log.debug(`Could not open chunked asset-types index: ${e}`, this.importContext.context),
56+
onEmptyIndexer: () =>
57+
log.debug('No shared asset types to import (empty indexer)', this.importContext.context),
58+
},
59+
async (records) => {
60+
const toCreate = this.buildAssetTypesToCreate(records, existingByUid, stripKeys);
61+
await this.importAssetTypesCreates(toCreate);
62+
},
63+
);
64+
}
65+
66+
/** Org-level asset types keyed by uid for diff; empty map if list API fails. */
67+
private async loadExistingAssetTypesMap(): Promise<Map<string, Record<string, unknown>>> {
68+
const existingByUid = new Map<string, Record<string, unknown>>();
69+
try {
70+
const existing = await this.getWorkspaceAssetTypes('');
71+
for (const at of existing.asset_types ?? []) {
72+
existingByUid.set(at.uid, at as Record<string, unknown>);
73+
}
74+
log.debug(`Target org has ${existingByUid.size} existing asset type(s)`, this.importContext.context);
75+
} catch (e) {
76+
log.debug(`Could not fetch existing asset types, will attempt to create all: ${e}`, this.importContext.context);
77+
}
78+
return existingByUid;
79+
}
80+
81+
private buildAssetTypesToCreate(
82+
items: Record<string, unknown>[],
83+
existingByUid: Map<string, Record<string, unknown>>,
84+
stripKeys: string[],
85+
): AssetTypeToCreate[] {
86+
const toCreate: AssetTypeToCreate[] = [];
87+
88+
for (const assetType of items) {
89+
const uid = assetType.uid as string;
90+
91+
if (assetType.is_system) {
92+
log.debug(`Skipping system asset type: ${uid}`, this.importContext.context);
93+
continue;
94+
}
95+
96+
const existing = existingByUid.get(uid);
97+
if (existing) {
98+
const exportedClean = omit(assetType, stripKeys);
99+
const existingClean = omit(existing, stripKeys);
100+
if (!isEqual(exportedClean, existingClean)) {
101+
log.warn(
102+
`Asset type "${uid}" already exists in the target org with a different definition. Skipping — to apply the exported definition, delete the asset type from the target org first.`,
103+
this.importContext.context,
104+
);
105+
} else {
106+
log.debug(`Asset type "${uid}" already exists with matching definition, skipping`, this.importContext.context);
107+
}
108+
this.tick(true, `asset-type: ${uid} (skipped, already exists)`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES);
109+
continue;
110+
}
111+
112+
toCreate.push({ uid, payload: omit(assetType, stripKeys) as Record<string, unknown> });
113+
}
114+
115+
return toCreate;
116+
}
117+
118+
private async importAssetTypesCreates(toCreate: AssetTypeToCreate[]): Promise<void> {
119+
await runInBatches(toCreate, this.apiConcurrency, async ({ uid, payload }) => {
120+
try {
121+
await this.createAssetType(payload as any);
122+
this.tick(true, `asset-type: ${uid}`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES);
123+
log.debug(`Imported asset type: ${uid}`, this.importContext.context);
124+
} catch (e) {
125+
this.tick(
126+
false,
127+
`asset-type: ${uid}`,
128+
(e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].FAILED,
129+
PROCESS_NAMES.AM_IMPORT_ASSET_TYPES,
130+
);
131+
log.debug(`Failed to import asset type ${uid}: ${e}`, this.importContext.context);
132+
}
133+
});
134+
}
135+
}

0 commit comments

Comments
 (0)