Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,985 changes: 627 additions & 2,358 deletions CHANGELOG.md

Large diffs are not rendered by default.

1,768 changes: 883 additions & 885 deletions METADATA_SUPPORT.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"@salesforce/core": "^8.32.2",
"@salesforce/core": "^8.32.4",
"@salesforce/kit": "^3.2.4",
"@salesforce/ts-types": "^2.0.12",
"@salesforce/types": "^1.6.0",
Expand All @@ -41,7 +41,7 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@jsforce/jsforce-node": "^3.10.16",
"@jsforce/jsforce-node": "^3.10.19",
"@salesforce/cli-plugins-testkit": "^5.3.58",
"@salesforce/dev-scripts": "^11.0.4",
"@types/deep-equal-in-any-order": "^1.0.1",
Expand Down
57 changes: 27 additions & 30 deletions src/convert/convertContext/nonDecompositionFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* limitations under the License.
*/
import { join } from 'node:path';
import { ensureString, getString, JsonMap } from '@salesforce/ts-types';
import { ensureArray } from '@salesforce/kit';
import { ensureString, get, getString, JsonMap } from '@salesforce/ts-types';
import { SfProject } from '@salesforce/core/project';
import { getXmlElement } from '../../utils/decomposed';
import { META_XML_SUFFIX, XML_NS_KEY, XML_NS_URL } from '../../common/constants';
Expand Down Expand Up @@ -159,37 +160,33 @@ export class NonDecompositionFinalizer extends ConvertTransactionFinalizer<NonDe
}

/**
* Populate the mergeMap with all the children of all the local components
* Populate the mergeMap with all the children of all the local components.
* Parses each parent file once and extracts child entries directly rather than
* re-parsing the parent for every child.
*/
private async initMergeMap(allComponentsOfType: SourceComponent[]): Promise<void> {
// A function we can parallelize since we have to parseXml for each local file
const getMappedChildren = async (component: SourceComponent): Promise<Map<string, JsonMap>> => {
const results = await Promise.all(
component.getChildren().map(async (child): Promise<[string, JsonMap]> => {
const childXml = await child.parseXml();
return [
getString(
childXml,
ensureString(child.type.uniqueIdElement),
`No uniqueIdElement exists in the registry for ${child.type.name}`
),
childXml,
];
})
);
return new Map(results);
};

const result = await Promise.all(
allComponentsOfType.map(
async (c): Promise<[string, Map<string, JsonMap>]> => [
ensureString(c.xml, `Missing xml file for ${c.type.name}`),
await getMappedChildren(c),
]
)
);

this.mergeMap = new Map(result);
const parsedComponents = await Promise.all(allComponentsOfType.map((c) => c.parseXml()));
for (let i = 0; i < allComponentsOfType.length; i++) {
const component = allComponentsOfType[i];
const xmlPath = ensureString(component.xml, `Missing xml file for ${component.type.name}`);
const parentXml = parsedComponents[i];
const childMap = new Map<string, JsonMap>();
const xmlElement = getXmlElement(component.type);
const childrenXml = ensureArray(get(parentXml, `${component.type.name}.${xmlElement}`)) as JsonMap[];
if (!component.type.children) {
this.mergeMap.set(xmlPath, childMap);
continue;
}
const [childTypeId] = Object.keys(component.type.children.types);
const uniqueIdElement = ensureString(component.type.children.types[childTypeId].uniqueIdElement);
for (const childXml of childrenXml) {
const childName = getString(childXml, uniqueIdElement);
if (childName) {
childMap.set(childName, childXml);
}
}
this.mergeMap.set(xmlPath, childMap);
}
}
}

Expand Down
36 changes: 26 additions & 10 deletions src/convert/convertContext/recompositionFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ type ChildWithXml = {
const recompose =
(cache: XmlCache) =>
async (stateValue: RecompositionStateValueWithParent): Promise<JsonMap> => {
const childComponents = stateValue.children?.toArray() ?? [];
const childComponents = deduplicateChildren(stateValue.children?.toArray() ?? []);

// RecompositionState combines all labels metadata files into 1 component containing
// all the children. This checks for multiple parent components and gets the xml
Expand All @@ -109,15 +109,15 @@ const recompose =
await getXmlFromCache(cache)(stateValue.component);
}

const childXmls = await Promise.all(
childComponents.filter(ensureMetadataComponentWithParent).map(
async (child): Promise<ChildWithXml> => ({
cmp: child,
xmlContents: await getXmlFromCache(cache)(child),
groupName: getXmlElement(child.type),
})
)
);
const childXmls: ChildWithXml[] = [];
for (const child of childComponents.filter(ensureMetadataComponentWithParent)) {
childXmls.push({
cmp: child,
// eslint-disable-next-line no-await-in-loop
xmlContents: await getXmlFromCache(cache)(child),
groupName: getXmlElement(child.type),
});
}

const parentXmlContents = {
[XML_NS_KEY]: XML_NS_URL,
Expand Down Expand Up @@ -192,6 +192,22 @@ const toSortedGroups = (items: ChildWithXml[]): JsonMap => {
);
};

/**
* When multiple package directories contain the same nonDecomposed parent (e.g. CustomLabels),
* children from each copy get added to the same recomposition state entry. These are distinct
* SourceComponent instances (different xml paths) but represent the same logical metadata member.
* Deduplicate by type+fullName, keeping the first occurrence.
*/
const deduplicateChildren = (children: MetadataComponent[]): MetadataComponent[] => {
const seen = new Set<string>();
return children.filter((child) => {
const key = `${child.type.name}#${child.fullName}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};

/** wrapper around the xml cache. Handles the nonDecomposed "parse from parent" optimization */
const getXmlFromCache =
(xmlCache: XmlCache) =>
Expand Down
11 changes: 10 additions & 1 deletion src/convert/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,18 @@ export class ZipWriter extends ComponentWriter {
* even though it's not beneficial in the typical way a stream is.
*/
export class JsToXml extends Readable {
public constructor(private xmlObject: JsonMap) {
private xmlObject: JsonMap | undefined;

public constructor(xmlObject: JsonMap) {
super();
this.xmlObject = xmlObject;
}

public _read(): void {
if (!this.xmlObject) {
this.push(null);
return;
}
const builder = new XMLBuilder({
format: true,
indentBy: ' ',
Expand All @@ -286,6 +293,8 @@ export class JsToXml extends Readable {
commentPropName: XML_COMMENT_PROP_NAME,
});
const builtXml = String(builder.build(this.xmlObject));
// release reference so GC can reclaim the (potentially large) JSON tree
this.xmlObject = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this explicitly need to be set to undefined?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reasons: (1) guards against Node calling _read() more than once — the early-return at the top checks for undefined, and (2) releases the reference so GC can reclaim large JSON trees after they're serialized. Added a comment explaining this.

const xmlContent = correctComments(XML_DECL.concat(handleSpecialEntities(builtXml)));
this.push(xmlContent);
this.push(null);
Expand Down
105 changes: 105 additions & 0 deletions test/convert/convertContext/recomposition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,5 +285,110 @@ describe('Recomposition', () => {
expect(readFileSpy.callCount, 'readFile() should only be called twice').to.equal(0);
});
});

describe('should deduplicate children from multiple packages with the same labels', () => {
const customLabelsType = new RegistryAccess().getTypeByName('CustomLabels');
const labelsFileName = 'CustomLabels.labels-meta.xml';
const projectDir = join(process.cwd(), 'dedup-project');
const packageDir1 = join(projectDir, 'pkg1');
const packageDir2 = join(projectDir, 'pkg2');
const packageDir3 = join(projectDir, 'pkg3');
const dir1Labels = join(packageDir1, 'labels');
const dir2Labels = join(packageDir2, 'labels');
const dir3Labels = join(packageDir3, 'labels');
const parentXmlPath1 = join(dir1Labels, labelsFileName);
const parentXmlPath2 = join(dir2Labels, labelsFileName);
const parentXmlPath3 = join(dir3Labels, labelsFileName);

// Same labels in all 3 packages
const sharedLabelXmls = [
{ fullName: 'SharedLabel_1', shortDescription: 'shared 1', value: 'value 1' },
{ fullName: 'SharedLabel_2', shortDescription: 'shared 2', value: 'value 2' },
{ fullName: 'SharedLabel_3', shortDescription: 'shared 3', value: 'value 3' },
];

const labelsXml = {
[customLabelsType.name]: {
[XML_NS_KEY]: XML_NS_URL,
[customLabelsType.directoryName]: sharedLabelXmls,
},
};

const vDir: VirtualDirectory[] = [
{ dirPath: projectDir, children: ['pkg1', 'pkg2', 'pkg3'] },
{ dirPath: packageDir1, children: ['labels'] },
{ dirPath: packageDir2, children: ['labels'] },
{ dirPath: packageDir3, children: ['labels'] },
{
dirPath: dir1Labels,
children: [
{
name: labelsFileName,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
data: Buffer.from(new JsToXml(labelsXml).read().toString()),
},
],
},
{
dirPath: dir2Labels,
children: [
{
name: labelsFileName,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
data: Buffer.from(new JsToXml(labelsXml).read().toString()),
},
],
},
{
dirPath: dir3Labels,
children: [
{
name: labelsFileName,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
data: Buffer.from(new JsToXml(labelsXml).read().toString()),
},
],
},
];
const virtualTree = new VirtualTreeContainer(vDir);
const parent1 = new SourceComponent(
{ name: customLabelsType.name, type: customLabelsType, xml: parentXmlPath1 },
virtualTree
);
const parent2 = new SourceComponent(
{ name: customLabelsType.name, type: customLabelsType, xml: parentXmlPath2 },
virtualTree
);
const parent3 = new SourceComponent(
{ name: customLabelsType.name, type: customLabelsType, xml: parentXmlPath3 },
virtualTree
);

it('produces only unique labels when the same labels exist in multiple packages', async () => {
const context = new ConvertContext();
const compSet = new ComponentSet();

// Add children from all 3 packages — simulates multi-package resolution
parent1.getChildren().forEach((child) => compSet.add(child));
parent2.getChildren().forEach((child) => compSet.add(child));
parent3.getChildren().forEach((child) => compSet.add(child));

context.recomposition.transactionState.set(parent1.fullName, {
component: parent1,
children: compSet,
});

const result = await context.recomposition.finalize();

expect(result).to.have.lengthOf(1);
const output = result[0];
const xml = output.writeInfos[0].source as JsToXml;
const content = xml.read().toString();

// Should have exactly 3 labels, not 9 (3 packages × 3 labels)
const labelCount = (content.match(/<fullName>/g) ?? []).length;
expect(labelCount).to.equal(3);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomLabels xmlns="http://soap.sforce.com/2006/04/metadata">
<labels>
<fullName>OnlyInPkg1_Alpha</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Alpha label only in pkg1</shortDescription>
<value>alpha value from pkg1</value>
</labels>
<labels>
<fullName>OnlyInPkg1_Beta</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Beta label only in pkg1</shortDescription>
<value>beta value from pkg1</value>
</labels>
<labels>
<fullName>OnlyInPkg2_Delta</fullName>
<categories>pkg2only</categories>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Delta label only in pkg2</shortDescription>
<value>delta value from pkg2</value>
</labels>
<labels>
<fullName>OnlyInPkg2_Gamma</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Gamma label only in pkg2</shortDescription>
<value>gamma value from pkg2</value>
</labels>
<labels>
<fullName>OnlyInPkg3_Epsilon</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Epsilon label only in pkg3</shortDescription>
<value>epsilon value from pkg3</value>
</labels>
<labels>
<fullName>OnlyInPkg3_Zeta</fullName>
<categories>pkg3only</categories>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Zeta label only in pkg3</shortDescription>
<value>zeta value from pkg3</value>
</labels>
<labels>
<fullName>SharedLabel_One</fullName>
<categories>shared</categories>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Shared label one</shortDescription>
<value>shared one value</value>
</labels>
<labels>
<fullName>SharedLabel_Two</fullName>
<categories>shared</categories>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Shared label two</shortDescription>
<value>shared two value</value>
</labels>
</CustomLabels>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>OnlyInPkg1_Alpha</members>
<members>OnlyInPkg1_Beta</members>
<members>OnlyInPkg2_Delta</members>
<members>OnlyInPkg2_Gamma</members>
<members>OnlyInPkg3_Epsilon</members>
<members>OnlyInPkg3_Zeta</members>
<members>SharedLabel_One</members>
<members>SharedLabel_Two</members>
<name>CustomLabel</name>
</types>
<types>
<members>CustomLabels</members>
<name>CustomLabels</name>
</types>
<version>59.0</version>
</Package>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomLabels xmlns="http://soap.sforce.com/2006/04/metadata">
<labels>
<fullName>OnlyInPkg1_Alpha</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Alpha label only in pkg1</shortDescription>
<value>alpha value from pkg1</value>
</labels>
<labels>
<fullName>OnlyInPkg1_Beta</fullName>
<language>en_US</language>
<protected>true</protected>
<shortDescription>Beta label only in pkg1</shortDescription>
<value>beta value from pkg1</value>
</labels>
</CustomLabels>
Loading
Loading