Skip to content

Commit 154a34d

Browse files
authored
Merge branch 'v2-dev' into feat/DX-9314
2 parents 7c4e504 + 27dcc22 commit 154a34d

10 files changed

Lines changed: 370 additions & 26 deletions

File tree

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,6 @@ fileignoreconfig:
4141
checksum: abc5ac707341760cf59d5b8b1c4e13cf2c79955e2735c33e2db3ec6bc48eddb6
4242
- filename: packages/contentstack-import/src/import/modules/assets.ts
4343
checksum: cda61a9c90bb39f27c09951b8a2623851296aefd0d3220d066032287a712d899
44+
- filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts
45+
checksum: 63c6bff4d51842d8fa3cce88545259d0a2c3cfe71df95d303d993f692cee883b
4446
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/types/cs-assets-api.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,45 @@ export type BulkDeleteAssetItem = { uid: string; locale: string };
127127

128128
export type BulkDeleteAssetsPayload = { assets: BulkDeleteAssetItem[] };
129129

130+
/** One failed batch when a bulk mutate is split across multiple ≤100-item requests. */
131+
export type BulkMutateFailure = {
132+
batchIndex: number;
133+
count: number;
134+
status?: number;
135+
error: string;
136+
/** Asset uids in the failed batch, so callers can re-run just the failures. */
137+
uids: string[];
138+
};
139+
140+
/** Raw response of a single bulk-mutate request (one ≤100-item POST) as returned by the API. */
141+
export type CsAssetsMutateBatchResponse = { notice?: string; job_id?: string };
142+
143+
/** Aggregate result of a bulk delete, combining every dispatched ≤100-item batch. */
130144
export type BulkDeleteAssetsResponse = {
145+
/** First notice returned; a human-facing message, safe to surface as the summary line. */
131146
notice?: string;
132-
job_id?: string;
147+
/** One submitted job id, for a short summary line; `job_ids` holds all of them (batches
148+
* run concurrently, so this is whichever completed first — not a stable "first batch"). */
149+
primaryJobId?: string;
150+
notices?: string[];
151+
job_ids?: string[];
152+
failures?: BulkMutateFailure[];
153+
batchesTotal?: number;
154+
batchesSucceeded?: number;
133155
};
134156

135157
export type BulkMoveAssetsPayload = {
136158
asset_uids: string[];
137159
target_folder_uid: string;
138160
};
139161

162+
/** Aggregate result of a bulk move (sync; no job ids). */
140163
export type BulkMoveAssetsResponse = {
141164
notice?: string;
165+
notices?: string[];
166+
failures?: BulkMutateFailure[];
167+
batchesTotal?: number;
168+
batchesSucceeded?: number;
142169
};
143170

144171
/**

packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import chunk from 'lodash/chunk';
44
import { HttpClient, log, authenticationHandler, handleAndLogError } from '@contentstack/cli-utilities';
55

66
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from './retry';
7-
import { FALLBACK_AM_API_FETCH_CONCURRENCY, FALLBACK_AM_API_PAGE_SIZE } from '../constants/index';
7+
import {
8+
CS_ASSETS_BULK_MUTATE_MAX_ITEMS,
9+
FALLBACK_AM_API_CONCURRENCY,
10+
FALLBACK_AM_API_FETCH_CONCURRENCY,
11+
FALLBACK_AM_API_PAGE_SIZE,
12+
} from '../constants/index';
813

914
import type {
1015
CSAssetsAPIConfig,
@@ -14,6 +19,8 @@ import type {
1419
BulkDeleteAssetsResponse,
1520
BulkMoveAssetsPayload,
1621
BulkMoveAssetsResponse,
22+
BulkMutateFailure,
23+
CsAssetsMutateBatchResponse,
1724
CreateAssetMetadata,
1825
CreateAssetTypePayload,
1926
CreateFieldPayload,
@@ -93,6 +100,21 @@ export type CustomPromiseHandlerInput = {
93100

94101
export type CustomPromiseHandler = (input: CustomPromiseHandlerInput) => Promise<any>;
95102

103+
/**
104+
* Error thrown by {@link CSAssetsAdapter.postJson} for a failed POST. Carries the HTTP
105+
* `status` (undefined for network/transport failures) so callers can classify failures
106+
* without parsing the message string.
107+
*/
108+
export class CsAssetsPostError extends Error {
109+
constructor(
110+
message: string,
111+
public readonly status?: number,
112+
) {
113+
super(message);
114+
this.name = 'CsAssetsPostError';
115+
}
116+
}
117+
96118
export class CSAssetsAdapter implements ICSAssetsAdapter {
97119
private readonly config: CSAssetsAPIConfig;
98120
private readonly apiClient: HttpClient;
@@ -649,10 +671,11 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
649671
}
650672
const text = await response.text().catch(() => '');
651673
const bodySnippet = this.formatResponseBodyForError(text);
652-
throw new Error(
674+
throw new CsAssetsPostError(
653675
`CS Assets API POST failed: status ${response.status} path ${path}${
654676
bodySnippet ? `\nResponse: ${bodySnippet}` : ''
655677
}`,
678+
response.status,
656679
);
657680
}
658681
return response.json() as Promise<T>;
@@ -669,12 +692,17 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
669692
: await doPost();
670693
} catch (error) {
671694
if (error instanceof RetryableHttpError) {
672-
throw new Error(`CS Assets API POST failed: path ${path} (status ${error.status ?? 'network'}) - ${error.message}`);
695+
throw new CsAssetsPostError(
696+
`CS Assets API POST failed: path ${path} (status ${error.status ?? 'network'}) - ${error.message}`,
697+
error.status,
698+
);
673699
}
674-
if (error instanceof Error && error.message.includes('CS Assets API POST failed')) {
700+
if (error instanceof CsAssetsPostError) {
675701
throw error;
676702
}
677-
throw new Error(`CS Assets API POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`);
703+
throw new CsAssetsPostError(
704+
`CS Assets API POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`,
705+
);
678706
}
679707
}
680708

@@ -779,18 +807,76 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
779807
payload: BulkDeleteAssetsPayload,
780808
): Promise<BulkDeleteAssetsResponse> {
781809
const path = `/api/spaces/${encodeURIComponent(spaceUid)}/assets/bulk/delete?workspace=${encodeURIComponent(workspaceUid)}`;
782-
return this.postJson<BulkDeleteAssetsResponse>(path, payload, { space_key: spaceUid });
810+
const bodies = chunk(payload.assets, CS_ASSETS_BULK_MUTATE_MAX_ITEMS).map((assets) => ({ assets }));
811+
const { notices, jobIds, failures, batchesTotal } = await this.dispatchBulkMutateBatches(spaceUid, path, bodies);
812+
return {
813+
notice: notices[0],
814+
primaryJobId: jobIds[0],
815+
notices,
816+
job_ids: jobIds,
817+
failures,
818+
batchesTotal,
819+
batchesSucceeded: batchesTotal - failures.length,
820+
};
783821
}
784822

785823
/**
786824
* POST /api/spaces/{spaceUid}/assets/bulk-move — move assets into a folder.
825+
* Split into ≤{@link CS_ASSETS_BULK_MUTATE_MAX_ITEMS}-item requests (same cap as delete).
787826
*/
788827
async bulkMoveAssets(
789828
spaceUid: string,
790829
workspaceUid: string = 'main',
791830
payload: BulkMoveAssetsPayload,
792831
): Promise<BulkMoveAssetsResponse> {
793832
const path = `/api/spaces/${encodeURIComponent(spaceUid)}/assets/bulk-move?workspace=${encodeURIComponent(workspaceUid)}`;
794-
return this.postJson<BulkMoveAssetsResponse>(path, payload, { space_key: spaceUid });
833+
const bodies = chunk(payload.asset_uids, CS_ASSETS_BULK_MUTATE_MAX_ITEMS).map((asset_uids) => ({
834+
asset_uids,
835+
target_folder_uid: payload.target_folder_uid,
836+
}));
837+
const { notices, failures, batchesTotal } = await this.dispatchBulkMutateBatches(spaceUid, path, bodies);
838+
return {
839+
notice: notices[0],
840+
notices,
841+
failures,
842+
batchesTotal,
843+
batchesSucceeded: batchesTotal - failures.length,
844+
};
845+
}
846+
847+
/**
848+
* Dispatch pre-chunked bulk-mutate request bodies (each already ≤100 items) through
849+
* {@link postJson} with bounded concurrency via {@link makeConcurrentCall}. A batch
850+
* failure is collected — never rethrown — because the CS Assets bulk endpoints commit
851+
* each request independently, so earlier batches are already applied server-side and
852+
* callers must be able to report partial outcomes. `postJson` retries transient
853+
* (429/5xx) failures; 4xx like the 422 item-cap are not retried.
854+
*/
855+
private async dispatchBulkMutateBatches(
856+
spaceUid: string,
857+
path: string,
858+
bodies: unknown[],
859+
): Promise<{ notices: string[]; jobIds: string[]; failures: BulkMutateFailure[]; batchesTotal: number }> {
860+
const notices: string[] = [];
861+
const jobIds: string[] = [];
862+
const failures: BulkMutateFailure[] = [];
863+
const apiBatches = chunk(bodies, FALLBACK_AM_API_CONCURRENCY);
864+
865+
await this.makeConcurrentCall({ module: `bulk-mutate ${path}`, apiBatches }, async ({ element, batchIndex, index }) => {
866+
const globalIndex = batchIndex * FALLBACK_AM_API_CONCURRENCY + index;
867+
const body = element as { assets?: { uid: string }[]; asset_uids?: string[] };
868+
const uids = body.assets ? body.assets.map((a) => a.uid) : (body.asset_uids ?? []);
869+
try {
870+
const r = await this.postJson<CsAssetsMutateBatchResponse>(path, body, { space_key: spaceUid }, { retry: true });
871+
if (typeof r.notice === 'string') notices.push(r.notice);
872+
if (typeof r.job_id === 'string') jobIds.push(r.job_id);
873+
} catch (e) {
874+
const status = e instanceof CsAssetsPostError ? e.status : undefined;
875+
const message = e instanceof Error ? e.message : String(e);
876+
failures.push({ batchIndex: globalIndex, count: uids.length, status, error: message, uids });
877+
}
878+
});
879+
880+
return { notices, jobIds, failures, batchesTotal: bodies.length };
795881
}
796882
}

packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -791,39 +791,94 @@ describe('CSAssetsAdapter', () => {
791791
});
792792
});
793793

794+
const deleteItems = (n: number) =>
795+
Array.from({ length: n }, (_, i) => ({ uid: `a${i}`, locale: 'en-us' }));
796+
794797
describe('bulkDeleteAssets', () => {
795798
it('POSTs to the bulk delete endpoint with workspace query param', async () => {
796-
fetchStub.resolves(okJsonResponse({ deleted: 2 }));
799+
fetchStub.resolves(okJsonResponse({ notice: 'ok', job_id: 'j1' }));
797800
const adapter = new CSAssetsAdapter(baseConfig);
798-
await adapter.bulkDeleteAssets('sp-1', 'ws-main', { asset_uids: ['a1', 'a2'] } as any);
801+
await adapter.bulkDeleteAssets('sp-1', 'ws-main', { assets: deleteItems(2) });
799802

800803
const [url, opts] = fetchStub.firstCall.args;
801804
expect(url).to.include('/api/spaces/sp-1/assets/bulk/delete');
802805
expect(url).to.include('workspace=ws-main');
803806
expect(opts.headers['space_key']).to.equal('sp-1');
807+
expect(JSON.parse(opts.body).assets).to.have.length(2);
804808
});
805809

806810
it('uses "main" as default workspace uid', async () => {
807811
fetchStub.resolves(okJsonResponse({}));
808812
const adapter = new CSAssetsAdapter(baseConfig);
809-
await adapter.bulkDeleteAssets('sp-1', undefined as any, {} as any);
813+
await adapter.bulkDeleteAssets('sp-1', undefined as any, { assets: deleteItems(1) });
810814

811815
const [url] = fetchStub.firstCall.args;
812816
expect(url).to.include('workspace=main');
813817
});
818+
819+
it('splits >100 assets into ≤100-item batches and aggregates job ids', async () => {
820+
fetchStub.callsFake(async () => okJsonResponse({ notice: 'batch ok', job_id: 'job-x' }));
821+
const adapter = new CSAssetsAdapter(baseConfig);
822+
const result = await adapter.bulkDeleteAssets('sp-1', 'main', { assets: deleteItems(250) });
823+
824+
expect(fetchStub.callCount).to.equal(3); // 100 + 100 + 50
825+
for (const call of fetchStub.getCalls()) {
826+
expect(JSON.parse(call.args[1].body).assets.length).to.be.at.most(100);
827+
}
828+
expect(result.batchesTotal).to.equal(3);
829+
expect(result.batchesSucceeded).to.equal(3);
830+
expect(result.job_ids).to.have.length(3);
831+
expect(result.failures).to.have.length(0);
832+
});
833+
834+
it('keeps succeeded batches and records failures when one batch fails (partial)', async () => {
835+
// 3 batches; the 2nd (second fetch) returns 422, others succeed.
836+
let n = 0;
837+
fetchStub.callsFake(async () => {
838+
n += 1;
839+
return n === 2 ? failResponse(422, 'Assets cannot exceed the max limit of 100.') : okJsonResponse({ job_id: `j${n}` });
840+
});
841+
const adapter = new CSAssetsAdapter(baseConfig);
842+
const result = await adapter.bulkDeleteAssets('sp-1', 'main', { assets: deleteItems(250) });
843+
844+
expect(result.batchesTotal).to.equal(3);
845+
expect(result.batchesSucceeded).to.equal(2);
846+
expect(result.failures).to.have.length(1);
847+
expect(result.failures![0].status).to.equal(422);
848+
expect(result.failures![0].error).to.include('422');
849+
// Failed batch carries its uids so callers can re-run just the failures.
850+
expect(result.failures![0].uids).to.have.length(100);
851+
expect(result.failures![0].uids.every((u) => u.startsWith('a'))).to.equal(true);
852+
});
814853
});
815854

816855
describe('bulkMoveAssets', () => {
817856
it('POSTs to the bulk-move endpoint with workspace query param', async () => {
818857
fetchStub.resolves(okJsonResponse({ moved: 1 }));
819858
const adapter = new CSAssetsAdapter(baseConfig);
820-
await adapter.bulkMoveAssets('sp-1', 'ws-main', { asset_uids: ['a1'], folder_uid: 'f1' } as any);
859+
await adapter.bulkMoveAssets('sp-1', 'ws-main', { asset_uids: ['a1'], target_folder_uid: 'f1' });
821860

822861
const [url, opts] = fetchStub.firstCall.args;
823862
expect(url).to.include('/api/spaces/sp-1/assets/bulk-move');
824863
expect(url).to.include('workspace=ws-main');
825864
expect(opts.headers['space_key']).to.equal('sp-1');
826865
});
866+
867+
it('splits >100 uids into ≤100-item batches, re-attaching target_folder_uid', async () => {
868+
fetchStub.callsFake(async () => okJsonResponse({ notice: 'moved' }));
869+
const adapter = new CSAssetsAdapter(baseConfig);
870+
const uids = Array.from({ length: 150 }, (_, i) => `u${i}`);
871+
const result = await adapter.bulkMoveAssets('sp-1', 'main', { asset_uids: uids, target_folder_uid: 'f1' });
872+
873+
expect(fetchStub.callCount).to.equal(2); // 100 + 50
874+
for (const call of fetchStub.getCalls()) {
875+
const body = JSON.parse(call.args[1].body);
876+
expect(body.asset_uids.length).to.be.at.most(100);
877+
expect(body.target_folder_uid).to.equal('f1');
878+
}
879+
expect(result.batchesTotal).to.equal(2);
880+
expect(result.batchesSucceeded).to.equal(2);
881+
});
827882
});
828883

829884
describe('postJson error handling', () => {

packages/contentstack-bulk-operations/src/interfaces/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ export interface CsAssetsBulkOperationResult {
267267
notice?: string;
268268
jobId?: string;
269269
error?: string;
270+
/** Aggregate across the ≤100-item batches a single delete/move is split into. */
271+
jobIds?: string[];
272+
batchesTotal?: number;
273+
batchesSucceeded?: number;
274+
batchesFailed?: number;
275+
failures?: { batchIndex: number; count: number; error: string; uids: string[] }[];
270276
}
271277

272278
/** Typed flags for CS Assets delete/move operations (cm:stacks:bulk-assets). */

packages/contentstack-bulk-operations/src/messages/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,20 @@ const csAssetsBulkMsg = {
247247
CS_ASSETS_INVALID_OPERATION: 'Invalid operation: {operation}. Must be delete or move',
248248
CS_ASSETS_CONFIRM_SUMMARY: 'Proceed with CS Assets {operation} on {count} item(s)?',
249249
CS_ASSETS_DELETE_SUCCESS: 'CS Assets bulk delete job submitted successfully!',
250+
CS_ASSETS_DELETE_JOBS_SUBMITTED:
251+
'{count} bulk delete job(s) submitted. Deletion runs asynchronously — this confirms submission, not completion. Verify at the status URL below:',
250252
CS_ASSETS_DELETE_JOB_ID: 'Job ID: {jobId}',
251253
CS_ASSETS_DELETE_ASYNC_NOTE: 'The job runs asynchronously — check the bulk task queue for status:',
252254
CS_ASSETS_MOVE_SUCCESS: 'CS Assets bulk move completed successfully!',
253255
CS_ASSETS_MOVE_ASSETS_COUNT: '{count} asset(s) moved to folder: {folderUid}',
254256
CS_ASSETS_OPERATION_FAILED: 'CS Assets {operation} failed.',
257+
CS_ASSETS_BATCH_SUMMARY: 'Dispatched in {batchesTotal} batch(es) of up to 100 — {batchesSucceeded} succeeded.',
258+
CS_ASSETS_PARTIAL_FAILURE: 'CS Assets {operation} partially failed: {batchesFailed} of {batchesTotal} batch(es) failed.',
259+
CS_ASSETS_FAILED_BATCH: 'Batch {batchIndex} ({count} item(s)) failed: {error}',
260+
CS_ASSETS_FAILED_UIDS_WRITTEN:
261+
'Uids whose {operation} request did not confirm success written to: {path} (these requests failed to return success — the server may or may not have applied them).',
262+
CS_ASSETS_RETRY_HINT:
263+
'Re-run just these with: --operation {operation} --asset-uids-file {path} (plus the same --space-uid/--org-uid, and --locale for delete). Safe to re-run — the operation is idempotent, so assets already applied are a no-op.',
255264

256265
// Merged-command flag matrix validation
257266
FLAG_NOT_ALLOWED_FOR_OPERATION: '{flag} is not valid for operation "{operation}".{hint}',

packages/contentstack-bulk-operations/src/services/am-asset-service.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ export class CsAssetsService {
2424
const response = await this.adapter.bulkDeleteAssets(spaceUid, workspaceUid ?? 'main', {
2525
assets: items,
2626
});
27+
const failures = response.failures ?? [];
2728
return {
28-
success: true,
29+
success: failures.length === 0,
2930
notice: typeof response.notice === 'string' ? response.notice : undefined,
30-
jobId: typeof response.job_id === 'string' ? response.job_id : undefined,
31+
jobId: typeof response.primaryJobId === 'string' ? response.primaryJobId : undefined,
32+
jobIds: response.job_ids,
33+
batchesTotal: response.batchesTotal,
34+
batchesSucceeded: response.batchesSucceeded,
35+
batchesFailed: failures.length,
36+
failures: failures.map((f) => ({ batchIndex: f.batchIndex, count: f.count, error: f.error, uids: f.uids })),
37+
error: failures.length > 0 ? failures.map((f) => f.error).join('; ') : undefined,
3138
};
3239
} catch (e: unknown) {
3340
return {
@@ -48,9 +55,15 @@ export class CsAssetsService {
4855
asset_uids: assetUids,
4956
target_folder_uid: targetFolderUid,
5057
});
58+
const failures = response.failures ?? [];
5159
return {
52-
success: true,
60+
success: failures.length === 0,
5361
notice: typeof response.notice === 'string' ? response.notice : undefined,
62+
batchesTotal: response.batchesTotal,
63+
batchesSucceeded: response.batchesSucceeded,
64+
batchesFailed: failures.length,
65+
failures: failures.map((f) => ({ batchIndex: f.batchIndex, count: f.count, error: f.error, uids: f.uids })),
66+
error: failures.length > 0 ? failures.map((f) => f.error).join('; ') : undefined,
5467
};
5568
} catch (e: unknown) {
5669
return {

0 commit comments

Comments
 (0)