Skip to content

Commit 08d3ab9

Browse files
authored
Merge branch 'v2-dev' into fix/DX-8736
2 parents b9336dd + 6088508 commit 08d3ab9

22 files changed

Lines changed: 1277 additions & 174 deletions

File tree

.talismanrc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
fileignoreconfig:
22
- filename: pnpm-lock.yaml
3-
checksum: f149ffa2864c1e5aa5b3f3363ea0d690e288168167d103775cb1f61be81151d5
3+
checksum: 8eb82c59ad8b81af64587af55c6788d09a34d1b651d030d1ef01dce472716f2a
44
- filename: packages/contentstack-branches/test/unit/helpers/stub-auth.ts
55
checksum: 8cafd5994d3ec13ba9af74c80b330bfd14721ea4e0359b456598964a6c2913ce
66
- filename: packages/contentstack-export/src/config/index.ts
@@ -18,7 +18,7 @@ fileignoreconfig:
1818
- filename: packages/contentstack-cli-cm-regex-validate/test/utils/connect-stack.test.ts
1919
checksum: 018980aa2b919967b9ef9ab1bdf635d4867fe21593fba5890afa443f440228ff
2020
- filename: packages/contentstack-bulk-operations/src/messages/index.ts
21-
checksum: 07c2bf3f3130ad5e8b6a2971d76139e9b643c70a9ff5f7450adfb5c9bf3d7164
21+
checksum: c8acefdd18f499a82d839e3cf99e34af9dd464f63f92282739411174ae7b7c37
2222
- filename: packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts
2323
checksum: 99a3c3eb422a17f73f4c8f15088004fa4c95df3545bdf510310c1f3a20e4c2c2
2424
- filename: packages/contentstack-bulk-operations/src/base-bulk-command.ts
@@ -39,4 +39,12 @@ fileignoreconfig:
3939
checksum: 88c7e1dee308fa4ae25e7815ead0842b1aac7ed669b02859cc8b25cba879595d
4040
- filename: packages/contentstack-bulk-operations/test/unit/services/taxonomy-service.test.ts
4141
checksum: abc5ac707341760cf59d5b8b1c4e13cf2c79955e2735c33e2db3ec6bc48eddb6
42+
- filename: packages/contentstack-import/src/import/modules/assets.ts
43+
checksum: cda61a9c90bb39f27c09951b8a2623851296aefd0d3220d066032287a712d899
44+
- filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts
45+
checksum: 63c6bff4d51842d8fa3cce88545259d0a2c3cfe71df95d303d993f692cee883b
46+
- filename: packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts
47+
checksum: bc4a53f96be6a10786e00133245c7bdc43c965c8a98b753e3879e1110cf9c601
48+
- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-cs-assets.test.ts
49+
checksum: 1b6b5b0302d3efd9508e2e5d4fe3783da97a110735ca3106ce829cfe93874150
4250
version: '1.0'

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export const FALLBACK_AM_API_CONCURRENCY = 5;
66
export const DEFAULT_AM_API_CONCURRENCY = FALLBACK_AM_API_CONCURRENCY;
77
export const FALLBACK_AM_API_PAGE_SIZE = 100;
88
export const FALLBACK_AM_API_FETCH_CONCURRENCY = 5;
9+
/** Max assets/uids the CS Assets bulk delete/move endpoints accept per request. */
10+
export const CS_ASSETS_BULK_MUTATE_MAX_ITEMS = 100;
911

1012
/** Fallback strip lists when import options omit `fieldsImportInvalidKeys` / `assetTypesImportInvalidKeys`. */
1113
export const FALLBACK_FIELDS_IMPORT_INVALID_KEYS = [

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

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { resolve as pResolve } from 'node:path';
22
import { Readable } from 'node:stream';
33
import { mkdir, writeFile } from 'node:fs/promises';
44
import chunk from 'lodash/chunk';
5-
import { configHandler, log, FsUtility } from '@contentstack/cli-utilities';
5+
import { log, FsUtility } from '@contentstack/cli-utilities';
66

77
import type { CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
88
import type { ExportContext } from '../types/export-types';
99
import { CSAssetsExportAdapter } from './base';
10-
import { writeStreamToFile, getArrayFromResponse } from '../utils/export-helpers';
10+
import { writeStreamToFile, getArrayFromResponse, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
11+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1112
import { forEachChunkedJsonStore } from '../utils/chunked-json-reader';
1213
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1314
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
@@ -17,8 +18,11 @@ const ASSET_META_KEYS = ['uid', 'url', 'filename', 'file_name', 'parent_uid'];
1718

1819
type AssetRecord = { uid?: string; _uid?: string; url?: string; filename?: string; file_name?: string };
1920

20-
/** Per-space export counts surfaced to the summary (assets = downloaded binaries; folders = entities). */
21-
export type SpaceExportCounts = { assets: number; folders: number };
21+
/**
22+
* Per-space export counts surfaced to the summary (assets = downloaded binaries; folders = entities;
23+
* failedAssets = metadata records missing after permanent page failures + binary download failures).
24+
*/
25+
export type SpaceExportCounts = { assets: number; folders: number; failedAssets: number };
2226

2327
export default class ExportAssets extends CSAssetsExportAdapter {
2428
constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) {
@@ -53,11 +57,22 @@ export default class ExportAssets extends CSAssetsExportAdapter {
5357
};
5458

5559
log.debug(`Fetching folders and streaming assets for space ${workspace.space_uid}`, this.exportContext.context);
56-
const [folders] = await Promise.all([
60+
const [folders, streamResult] = await Promise.all([
5761
this.getWorkspaceFolders(workspace.space_uid, workspace.uid, this.apiPageSize, this.apiFetchConcurrency),
5862
this.streamWorkspaceAssets(workspace.space_uid, workspace.uid, onPage, this.apiPageSize, this.apiFetchConcurrency),
5963
]);
6064

65+
// Permanently failed metadata pages (or mid-export drift) — these records never reached
66+
// assets.json, so the download loop can't see them. Count them as failures in the summary;
67+
// they are recoverable via a re-export or a targeted query-export, so don't abort the run.
68+
if (streamResult.missing > 0) {
69+
log.error(
70+
`${streamResult.missing} asset metadata record(s) could not be fetched for space ${workspace.space_uid} — ` +
71+
'they are missing from this export. Re-export (or use query-export) to recover them.',
72+
this.exportContext.context,
73+
);
74+
}
75+
6176
if (fsWriter) fsWriter.completeFile(true);
6277
else await this.writeEmptyChunkedJson(assetsDir, 'assets.json');
6378
log.debug(`Wrote chunked assets metadata (${totalStreamed} item(s)) under ${assetsDir}`, this.exportContext.context);
@@ -78,22 +93,25 @@ export default class ExportAssets extends CSAssetsExportAdapter {
7893
this.tick(true, `metadata: ${workspace.space_uid} (${totalStreamed})`, null);
7994

8095
log.debug(`Starting binary downloads for space ${workspace.space_uid}`, this.exportContext.context);
81-
const assetsDownloaded = await this.downloadWorkspaceAssets(assetsDir, workspace.space_uid, downloadableCount);
96+
const downloads = await this.downloadWorkspaceAssets(assetsDir, workspace.space_uid, downloadableCount);
8297

8398
const folderCount = getArrayFromResponse(folders, 'folders').length;
84-
return { assets: assetsDownloaded, folders: folderCount };
99+
return { assets: downloads.ok, folders: folderCount, failedAssets: streamResult.missing + downloads.fail };
85100
}
86101

87102
/**
88103
* Download asset binaries by reading the just-written chunked `assets.json` back from disk
89104
* (one chunk at a time), so we never re-materialize the whole asset list in memory.
90105
*/
91-
private async downloadWorkspaceAssets(assetsDir: string, spaceUid: string, expectedDownloads: number): Promise<number> {
106+
private async downloadWorkspaceAssets(
107+
assetsDir: string,
108+
spaceUid: string,
109+
expectedDownloads: number,
110+
): Promise<{ ok: number; fail: number }> {
92111
const filesDir = pResolve(assetsDir, 'files');
93112
await mkdir(filesDir, { recursive: true });
94113

95114
const securedAssets = this.exportContext.securedAssets ?? false;
96-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
97115
log.debug(
98116
`Asset downloads: securedAssets=${securedAssets}, concurrency=${this.downloadAssetsBatchConcurrency}, expected=${expectedDownloads}`,
99117
this.exportContext.context,
@@ -102,6 +120,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
102120

103121
let downloadOk = 0;
104122
let downloadFail = 0;
123+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
124+
// entirely and abort the phase, instead of individually failing every remaining asset.
125+
let authFailure: SecuredAssetAuthError | null = null;
105126

106127
await forEachChunkedJsonStore<AssetRecord>(
107128
assetsDir,
@@ -136,25 +157,60 @@ export default class ExportAssets extends CSAssetsExportAdapter {
136157
async (records) => {
137158
const valid = records.filter((asset) => this.isDownloadable(asset));
138159
if (valid.length === 0) return;
160+
const chunkAuthFailure = authFailure;
161+
if (chunkAuthFailure) {
162+
// Auth already failed hard — count the remaining downloadables without hitting the network.
163+
for (const rec of valid) {
164+
downloadFail += 1;
165+
this.tick(false, `asset: ${rec.filename ?? rec.file_name ?? rec.uid ?? 'asset'}`, chunkAuthFailure.message);
166+
}
167+
return;
168+
}
169+
// Resolve per chunk so long download phases pick up proactively refreshed OAuth tokens.
170+
let auth: SecuredAssetAuth = securedAssets ? await getSecuredAssetAuth() : {};
139171
const apiBatches = chunk(valid, this.downloadAssetsBatchConcurrency);
140172
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
141173
const asset = apiBatches[batchIndex][index] as AssetRecord;
142174
const uid = (asset.uid ?? asset._uid) as string;
143175
const url = asset.url as string;
144176
const filename = asset.filename ?? asset.file_name ?? 'asset';
145177
if (!url || !uid) return;
178+
const knownAuthFailure = authFailure as SecuredAssetAuthError | null;
179+
if (knownAuthFailure) {
180+
downloadFail += 1;
181+
this.tick(false, `asset: ${filename}`, knownAuthFailure.message);
182+
return;
183+
}
146184
try {
147185
const separator = url.includes('?') ? '&' : '?';
148-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
186+
const doFetch = () =>
187+
fetch(
188+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
189+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
190+
);
149191
// Binary GET is idempotent — retry transient failures with backoff.
150192
const response = await withRetry(
151193
async () => {
152194
let resp: Response;
153195
try {
154-
resp = await fetch(downloadUrl);
196+
resp = await doFetch();
155197
} catch (e) {
156198
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
157199
}
200+
if (securedAssets && resp.status === 401) {
201+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
202+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
203+
auth = await getSecuredAssetAuth(true);
204+
try {
205+
resp = await doFetch();
206+
} catch (e) {
207+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
208+
}
209+
if (resp.status === 401) {
210+
authFailure = new SecuredAssetAuthError(resp.status);
211+
throw authFailure;
212+
}
213+
}
158214
if (!resp.ok) {
159215
if (isRetryableStatus(resp.status)) {
160216
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -191,6 +247,16 @@ export default class ExportAssets extends CSAssetsExportAdapter {
191247
},
192248
);
193249

250+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
251+
if (terminalAuthFailure) {
252+
// Fail the space loudly — a "completed with errors" summary would bury the real cause.
253+
log.error(
254+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
255+
this.exportContext.context,
256+
);
257+
throw terminalAuthFailure;
258+
}
259+
194260
log.info(
195261
downloadFail === 0
196262
? `Finished downloading ${downloadOk} asset file(s) for space ${spaceUid}`
@@ -202,7 +268,7 @@ export default class ExportAssets extends CSAssetsExportAdapter {
202268
this.exportContext.context,
203269
);
204270

205-
return downloadOk;
271+
return { ok: downloadOk, fail: downloadFail };
206272
}
207273

208274
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type AssetExportCounts = {
1818
folders: number;
1919
assetTypes: number;
2020
fields: number;
21+
/** Assets missing from the export: permanently failed metadata pages + failed binary downloads. */
22+
failedAssets: number;
2123
};
2224

2325
/**
@@ -53,7 +55,7 @@ export class ExportSpaces {
5355

5456
if (!linkedWorkspaces.length) {
5557
log.debug('No linked workspaces to export', context);
56-
return { assets: 0, folders: 0, assetTypes: 0, fields: 0 };
58+
return { assets: 0, folders: 0, assetTypes: 0, fields: 0, failedAssets: 0 };
5759
}
5860

5961
log.debug('Starting Contentstack Assets export process...', context);
@@ -105,6 +107,7 @@ export class ExportSpaces {
105107
// Real entity counts accumulated for the summary (Bug 3).
106108
let assetsTotal = 0;
107109
let foldersTotal = 0;
110+
let failedAssetsTotal = 0;
108111
let assetTypesCount = 0;
109112
let fieldsCount = 0;
110113
try {
@@ -140,6 +143,7 @@ export class ExportSpaces {
140143
const spaceCounts = await exportWorkspace.start(ws, spaceDir, branchName || 'main', spaceProcess);
141144
assetsTotal += spaceCounts.assets;
142145
foldersTotal += spaceCounts.folders;
146+
failedAssetsTotal += spaceCounts.failedAssets;
143147
progress.completeProcess(spaceProcess, true);
144148
log.debug(`Exported workspace structure for space ${ws.space_uid}`, context);
145149
} catch (err) {
@@ -157,14 +161,20 @@ export class ExportSpaces {
157161
}
158162

159163
log.info(
160-
anySpaceFailed
164+
anySpaceFailed || failedAssetsTotal > 0
161165
? 'Contentstack Assets export completed with errors in one or more spaces'
162166
: 'Contentstack Assets export completed successfully',
163167
context,
164168
);
165169
log.debug('Contentstack Assets export completed', context);
166170

167-
return { assets: assetsTotal, folders: foldersTotal, assetTypes: assetTypesCount, fields: fieldsCount };
171+
return {
172+
assets: assetsTotal,
173+
folders: foldersTotal,
174+
assetTypes: assetTypesCount,
175+
fields: fieldsCount,
176+
failedAssets: failedAssetsTotal,
177+
};
168178
} catch (err) {
169179
if (!bootstrapFailed) {
170180
// Mark any spaces that hadn't been processed as failed so the multibar

packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { resolve as pResolve } from 'node:path';
22
import { mkdir, writeFile } from 'node:fs/promises';
33
import { Readable } from 'node:stream';
4-
import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities';
4+
import { log, handleAndLogError } from '@contentstack/cli-utilities';
55

66
import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
77
import type { ExportContext } from '../types/export-types';
88
import ExportAssetTypes from '../export/asset-types';
99
import ExportFields from '../export/fields';
1010
import { CSAssetsExportAdapter } from '../export/base';
1111
import chunk from 'lodash/chunk';
12-
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
12+
import { getAssetItems, writeStreamToFile, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
13+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1314
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1415
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
1516

@@ -223,26 +224,64 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
223224
await mkdir(filesDir, { recursive: true });
224225

225226
const securedAssets = this.exportContext.securedAssets ?? false;
226-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
227+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
228+
// entirely and abort the phase, instead of individually failing every remaining asset.
229+
let authFailure: SecuredAssetAuthError | null = null;
230+
// OAuth → Authorization: Bearer header; basic auth → ?authtoken= query param.
231+
// Resolved lazily once per sequential batch (handlers within a batch share one resolve), so
232+
// long download runs keep picking up proactively refreshed OAuth tokens.
233+
let auth: SecuredAssetAuth = {};
234+
let authBatchIndex = -1;
235+
let authResolve: Promise<void> = Promise.resolve();
236+
const ensureAuthForBatch = (batchIndex: number): Promise<void> => {
237+
if (!securedAssets) return Promise.resolve();
238+
if (batchIndex !== authBatchIndex) {
239+
authBatchIndex = batchIndex;
240+
authResolve = getSecuredAssetAuth().then((resolved) => {
241+
auth = resolved;
242+
});
243+
}
244+
return authResolve;
245+
};
227246

228247
const apiBatches = chunk(downloadable, this.downloadAssetsBatchConcurrency);
229248
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
230249
const asset = apiBatches[batchIndex][index];
231250
const uid = String(asset.uid ?? asset._uid);
232251
const url = String(asset.url);
233252
const filename = String(asset.filename ?? asset.file_name ?? 'asset');
253+
if (authFailure) return; // auth failed hard — don't hit the network for remaining assets
234254
try {
255+
await ensureAuthForBatch(batchIndex);
235256
const separator = url.includes('?') ? '&' : '?';
236-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
257+
const doFetch = () =>
258+
fetch(
259+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
260+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
261+
);
237262
// Binary GET is idempotent — retry transient failures with backoff.
238263
const response = await withRetry(
239264
async () => {
240265
let resp: Response;
241266
try {
242-
resp = await fetch(downloadUrl);
267+
resp = await doFetch();
243268
} catch (e) {
244269
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
245270
}
271+
if (securedAssets && resp.status === 401) {
272+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
273+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
274+
auth = await getSecuredAssetAuth(true);
275+
try {
276+
resp = await doFetch();
277+
} catch (e) {
278+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
279+
}
280+
if (resp.status === 401) {
281+
authFailure = new SecuredAssetAuthError(resp.status);
282+
throw authFailure;
283+
}
284+
}
246285
if (!resp.ok) {
247286
if (isRetryableStatus(resp.status)) {
248287
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -265,5 +304,15 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
265304
};
266305

267306
await this.makeConcurrentCall({ apiBatches, module: 'asset downloads' }, promisifyHandler);
307+
308+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
309+
if (terminalAuthFailure) {
310+
// Fail the space loudly — silently skipping the rest would look like a successful export.
311+
log.error(
312+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
313+
this.exportContext.context,
314+
);
315+
throw terminalAuthFailure;
316+
}
268317
}
269318
}

0 commit comments

Comments
 (0)