Skip to content

Commit cede56e

Browse files
naman-contentstackclaude
andcommitted
feat: integrate asset count API for exact pagination totals
The assets/folders list responses cap their count field at 10,000, which silently truncated exports of spaces with more than 10k items. Pagination is now driven by the dedicated count endpoint (GET /api/bff/spaces/{uid}/assets/count, is_dir toggle for folders): - paginate() takes a required caller-resolved total (single flow, no fallback to the capped response count) - getAssetsCount/getFoldersCount fail the export when the exact total is unavailable (paginating blind means silent data loss) - permanently failed pages no longer vanish: streamWorkspaceAssets returns { streamed, missing } and missing records surface as failedAssets in the export global summary (previously hardcoded 0) - spaces/fields/asset_types resolve totals via a limit=1 probe - remove dead buffered getWorkspaceAssets (zero callers, unbounded memory once the 10k cap is gone) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9e408b0 commit cede56e

9 files changed

Lines changed: 480 additions & 123 deletions

File tree

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ fileignoreconfig:
77
checksum: 2df43be75a96e1a3a00dd256628e191909aa9d7f192b672b56cb3772e074958c
88
- filename: pnpm-lock.yaml
99
checksum: eae269c31146f807a80978acb68b83b761742b6d8e82116b6af48dcc0156f085
10+
- filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts
11+
checksum: f3620fb4441b7ced00ea85831d21f6c6b8e446a0cfe36b5bfcaaaf70788b09cc
1012
version: ""

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ const ASSET_META_KEYS = ['uid', 'url', 'filename', 'file_name', 'parent_uid'];
1717

1818
type AssetRecord = { uid?: string; _uid?: string; url?: string; filename?: string; file_name?: string };
1919

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

2326
export default class ExportAssets extends CSAssetsExportAdapter {
2427
constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) {
@@ -53,11 +56,22 @@ export default class ExportAssets extends CSAssetsExportAdapter {
5356
};
5457

5558
log.debug(`Fetching folders and streaming assets for space ${workspace.space_uid}`, this.exportContext.context);
56-
const [folders] = await Promise.all([
59+
const [folders, streamResult] = await Promise.all([
5760
this.getWorkspaceFolders(workspace.space_uid, workspace.uid, this.apiPageSize, this.apiFetchConcurrency),
5861
this.streamWorkspaceAssets(workspace.space_uid, workspace.uid, onPage, this.apiPageSize, this.apiFetchConcurrency),
5962
]);
6063

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

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

8397
const folderCount = getArrayFromResponse(folders, 'folders').length;
84-
return { assets: assetsDownloaded, folders: folderCount };
98+
return { assets: downloads.ok, folders: folderCount, failedAssets: streamResult.missing + downloads.fail };
8599
}
86100

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

@@ -202,7 +220,7 @@ export default class ExportAssets extends CSAssetsExportAdapter {
202220
this.exportContext.context,
203221
);
204222

205-
return downloadOk;
223+
return { ok: downloadOk, fail: downloadFail };
206224
}
207225

208226
}

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/types/cs-assets-api.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ export type SearchAssetsParams = {
159159
limit?: number;
160160
};
161161

162+
/** Response shape of GET /api/bff/spaces/{space_uid}/assets/count. */
163+
export type AssetCountResponse = {
164+
count: number;
165+
};
166+
167+
/**
168+
* Result of streaming a workspace's assets. `missing` is the gap between the authoritative
169+
* count-API total and the records actually streamed (permanently failed pages and/or items
170+
* changed mid-export) — surfaced as failures in the export summary rather than aborting the run.
171+
*/
172+
export type StreamWorkspaceAssetsResult = {
173+
streamed: number;
174+
missing: number;
175+
};
176+
162177
/** Response shape from POST /api/search for assets. */
163178
export type SearchAssetsResponse = {
164179
count?: number;
@@ -174,14 +189,15 @@ export interface ICSAssetsAdapter {
174189
listSpaces(pageSize?: number, fetchConcurrency?: number): Promise<SpacesListResponse>;
175190
getSpace(spaceUid: string): Promise<SpaceResponse>;
176191
getWorkspaceFields(spaceUid: string, pageSize?: number, fetchConcurrency?: number): Promise<FieldsResponse>;
177-
getWorkspaceAssets(spaceUid: string, workspaceUid?: string, pageSize?: number, fetchConcurrency?: number): Promise<unknown>;
192+
getAssetsCount(spaceUid: string, workspaceUid?: string): Promise<number>;
193+
getFoldersCount(spaceUid: string, workspaceUid?: string): Promise<number>;
178194
streamWorkspaceAssets(
179195
spaceUid: string,
180196
workspaceUid: string | undefined,
181197
onPage: (items: unknown[]) => void | Promise<void>,
182198
pageSize?: number,
183199
fetchConcurrency?: number,
184-
): Promise<number>;
200+
): Promise<StreamWorkspaceAssetsResult>;
185201
getWorkspaceFolders(spaceUid: string, workspaceUid?: string, pageSize?: number, fetchConcurrency?: number): Promise<unknown>;
186202
getWorkspaceAssetTypes(spaceUid: string, pageSize?: number, fetchConcurrency?: number): Promise<AssetTypesResponse>;
187203
searchAssets(params: SearchAssetsParams): Promise<SearchAssetsResponse>;

0 commit comments

Comments
 (0)