Skip to content

Commit 07049cf

Browse files
Merge pull request #552 from forcedotcom/sm/custom-registry-support
custom registry support
2 parents 70302da + 63faa2a commit 07049cf

25 files changed

+800
-1041
lines changed

CHANGELOG.md

+220-591
Large diffs are not rendered by default.

package.json

+6-6
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,24 @@
4444
"node": ">=18.0.0"
4545
},
4646
"dependencies": {
47-
"@oclif/core": "^3.25.2",
48-
"@salesforce/core": "^6.7.1",
49-
"@salesforce/kit": "^3.0.15",
50-
"@salesforce/source-deploy-retrieve": "^10.5.3",
47+
"@oclif/core": "^3.26.0",
48+
"@salesforce/core": "^6.7.3",
49+
"@salesforce/kit": "^3.1.0",
50+
"@salesforce/source-deploy-retrieve": "^10.6.1",
5151
"@salesforce/ts-types": "^2.0.9",
5252
"fast-xml-parser": "^4.2.5",
5353
"graceful-fs": "^4.2.11",
5454
"isomorphic-git": "1.23.0",
5555
"ts-retry-promise": "^0.8.0"
5656
},
5757
"devDependencies": {
58-
"@salesforce/cli-plugins-testkit": "^5.1.10",
58+
"@salesforce/cli-plugins-testkit": "^5.1.12",
5959
"@salesforce/dev-scripts": "^8.4.2",
6060
"@types/graceful-fs": "^4.1.9",
6161
"eslint-plugin-sf-plugin": "^1.17.4",
6262
"ts-node": "^10.9.2",
6363
"ts-patch": "^3.1.2",
64-
"typescript": "^5.4.2"
64+
"typescript": "^5.4.3"
6565
},
6666
"config": {},
6767
"publishConfig": {

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
export * from './sourceTracking';
8+
export { SourceTracking, SourceTrackingOptions } from './sourceTracking';
99
export {
1010
RemoteSyncInput,
1111
ChangeOptionType,

src/shared/conflicts.ts

+28-37
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { resolve } from 'node:path';
8-
import { ComponentSet, ForceIgnore } from '@salesforce/source-deploy-retrieve';
8+
import { ComponentSet, ForceIgnore, RegistryAccess } from '@salesforce/source-deploy-retrieve';
99
import { ConflictResponse, ChangeResult, SourceConflictError } from './types';
1010
import { getMetadataKey } from './functions';
1111
import { populateTypesAndNames } from './populateTypesAndNames';
12+
import { isChangeResultWithNameAndType } from './guards';
1213

1314
export const throwIfConflicts = (conflicts: ConflictResponse[]): void => {
1415
if (conflicts.length > 0) {
@@ -26,15 +27,14 @@ export const findConflictsInComponentSet = (cs: ComponentSet, conflicts: ChangeR
2627
// map do dedupe by name-type-filename
2728
const conflictMap = new Map<string, ConflictResponse>();
2829
conflicts
29-
.filter((cr) => cr.name && cr.type && cs.has({ fullName: cr.name, type: cr.type }))
30+
.filter(isChangeResultWithNameAndType)
31+
.filter((cr) => cs.has({ fullName: cr.name, type: cr.type }))
3032
.forEach((cr) => {
3133
cr.filenames?.forEach((f) => {
3234
conflictMap.set(`${cr.name}#${cr.type}#${f}`, {
3335
state: 'Conflict',
34-
// the following 2 type assertions are valid because of previous filter statement
35-
// they can be removed once TS is smarter about filtering
36-
fullName: cr.name as string,
37-
type: cr.type as string,
36+
fullName: cr.name,
37+
type: cr.type,
3838
filePath: resolve(f),
3939
});
4040
});
@@ -48,42 +48,33 @@ export const getDedupedConflictsFromChanges = ({
4848
remoteChanges = [],
4949
projectPath,
5050
forceIgnore,
51+
registry,
5152
}: {
5253
localChanges: ChangeResult[];
5354
remoteChanges: ChangeResult[];
5455
projectPath: string;
5556
forceIgnore: ForceIgnore;
57+
registry: RegistryAccess;
5658
}): ChangeResult[] => {
57-
// index the remoteChanges by filename
58-
const fileNameIndex = new Map<string, ChangeResult>();
59-
const metadataKeyIndex = new Map<string, ChangeResult>();
60-
remoteChanges.map((change) => {
61-
if (change.name && change.type) {
62-
metadataKeyIndex.set(getMetadataKey(change.name, change.type), change);
63-
}
64-
change.filenames?.map((filename) => {
65-
fileNameIndex.set(filename, change);
66-
});
67-
});
68-
69-
const conflicts = new Set<ChangeResult>();
70-
71-
populateTypesAndNames({ elements: localChanges, excludeUnresolvable: true, projectPath, forceIgnore }).map(
72-
(change) => {
73-
const metadataKey = getMetadataKey(change.name as string, change.type as string);
74-
// option 1: name and type match
75-
if (metadataKeyIndex.has(metadataKey)) {
76-
conflicts.add({ ...(metadataKeyIndex.get(metadataKey) as ChangeResult) });
77-
} else {
78-
// option 2: some of the filenames match
79-
change.filenames?.map((filename) => {
80-
if (fileNameIndex.has(filename)) {
81-
conflicts.add({ ...(fileNameIndex.get(filename) as ChangeResult) });
82-
}
83-
});
84-
}
85-
}
59+
const metadataKeyIndex = new Map(
60+
remoteChanges
61+
.filter(isChangeResultWithNameAndType)
62+
.map((change) => [getMetadataKey(change.name, change.type), change])
8663
);
87-
// deeply de-dupe
88-
return Array.from(conflicts);
64+
const fileNameIndex = new Map(
65+
remoteChanges.flatMap((change) => (change.filenames ?? []).map((filename) => [filename, change]))
66+
);
67+
68+
return populateTypesAndNames({ excludeUnresolvable: true, projectPath, forceIgnore, registry })(localChanges)
69+
.filter(isChangeResultWithNameAndType)
70+
.flatMap((change) => {
71+
const metadataKey = getMetadataKey(change.name, change.type);
72+
return metadataKeyIndex.has(metadataKey)
73+
? // option 1: name and type match
74+
[metadataKeyIndex.get(metadataKey)!]
75+
: // option 2: some of the filenames match
76+
(change.filenames ?? [])
77+
.filter((filename) => fileNameIndex.has(filename))
78+
.map((filename) => fileNameIndex.get(filename)!);
79+
});
8980
};

src/shared/functions.ts

+68-18
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
import { sep, normalize, isAbsolute, relative } from 'node:path';
99
import * as fs from 'node:fs';
1010
import { isString } from '@salesforce/ts-types';
11-
import { SourceComponent } from '@salesforce/source-deploy-retrieve';
11+
import {
12+
FileResponseSuccess,
13+
ForceIgnore,
14+
MetadataComponent,
15+
MetadataMember,
16+
RegistryAccess,
17+
SourceComponent,
18+
} from '@salesforce/source-deploy-retrieve';
1219
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
1320
import { ensureArray } from '@salesforce/kit';
14-
import { RemoteChangeElement, ChangeResult } from './types';
21+
import { RemoteChangeElement, ChangeResult, ChangeResultWithNameAndType, RemoteSyncInput } from './types';
22+
import { ensureNameAndType } from './remoteChangeIgnoring';
1523

1624
export const getMetadataKey = (metadataType: string, metadataName: string): string =>
1725
`${metadataType}__${metadataName}`;
@@ -25,29 +33,39 @@ export const getKeyFromObject = (element: RemoteChangeElement | ChangeResult): s
2533

2634
export const supportsPartialDelete = (cmp: SourceComponent): boolean => !!cmp.type.supportsPartialDelete;
2735

28-
export const isLwcLocalOnlyTest = (filePath: string): boolean =>
29-
filePath.includes('__utam__') || filePath.includes('__tests__');
36+
export const excludeLwcLocalOnlyTest = (filePath: string): boolean =>
37+
!(filePath.includes('__utam__') || filePath.includes('__tests__'));
3038

3139
/**
3240
* Verify that a filepath starts exactly with a complete parent path
3341
* ex: '/foo/bar-extra/baz'.startsWith('foo/bar') would be true, but this function understands that they are not in the same folder
3442
*/
35-
export const pathIsInFolder = (filePath: string, folder: string): boolean => {
36-
const biggerStringParts = normalize(filePath).split(sep).filter(nonEmptyStringFilter);
37-
return normalize(folder)
38-
.split(sep)
39-
.filter(nonEmptyStringFilter)
40-
.every((part, index) => part === biggerStringParts[index]);
41-
};
43+
export const pathIsInFolder =
44+
(folder: string) =>
45+
(filePath: string): boolean => {
46+
const biggerStringParts = normalize(filePath).split(sep).filter(nonEmptyStringFilter);
47+
return normalize(folder)
48+
.split(sep)
49+
.filter(nonEmptyStringFilter)
50+
.every((part, index) => part === biggerStringParts[index]);
51+
};
52+
53+
/** just like pathIsInFolder but with the parameter order reversed for iterating a single file against an array of folders */
54+
export const folderContainsPath =
55+
(filePath: string) =>
56+
(folder: string): boolean =>
57+
pathIsInFolder(folder)(filePath);
4258

4359
const nonEmptyStringFilter = (value: string): boolean => isString(value) && value.length > 0;
4460

4561
// adapted for TS from https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/chunk.md
4662
export const chunkArray = <T>(arr: T[], size: number): T[][] =>
4763
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => arr.slice(i * size, i * size + size));
4864

49-
export const ensureRelative = (filePath: string, projectPath: string): string =>
50-
isAbsolute(filePath) ? relative(projectPath, filePath) : filePath;
65+
export const ensureRelative =
66+
(projectPath: string) =>
67+
(filePath: string): string =>
68+
isAbsolute(filePath) ? relative(projectPath, filePath) : filePath;
5169

5270
export type ParsedCustomLabels = {
5371
CustomLabels: { labels: Array<{ fullName: string }> };
@@ -64,12 +82,12 @@ export const deleteCustomLabels = async (
6482
filename: string,
6583
customLabels: SourceComponent[]
6684
): Promise<ParsedCustomLabels | undefined> => {
67-
const customLabelsToDelete = customLabels
68-
.filter((label) => label.type.id === 'customlabel')
69-
.map((change) => change.fullName);
85+
const customLabelsToDelete = new Set(
86+
customLabels.filter(sourceComponentIsCustomLabel).map((change) => change.fullName)
87+
);
7088

7189
// if we don't have custom labels, we don't need to do anything
72-
if (!customLabelsToDelete.length) {
90+
if (!customLabelsToDelete.size) {
7391
return undefined;
7492
}
7593
// for custom labels, we need to remove the individual label from the xml file
@@ -83,7 +101,7 @@ export const deleteCustomLabels = async (
83101

84102
// delete the labels from the json based on their fullName's
85103
cls.CustomLabels.labels = ensureArray(cls.CustomLabels.labels).filter(
86-
(label) => !customLabelsToDelete.includes(label.fullName)
104+
(label) => !customLabelsToDelete.has(label.fullName)
87105
);
88106

89107
if (cls.CustomLabels.labels.length === 0) {
@@ -104,3 +122,35 @@ export const deleteCustomLabels = async (
104122
return cls;
105123
}
106124
};
125+
126+
/** returns true if forceIgnore denies a path OR if there is no forceIgnore provided */
127+
export const forceIgnoreDenies =
128+
(forceIgnore?: ForceIgnore) =>
129+
(filePath: string): boolean =>
130+
forceIgnore?.denies(filePath) ?? false;
131+
132+
export const sourceComponentIsCustomLabel = (input: SourceComponent): boolean => input.type.name === 'CustomLabel';
133+
134+
export const sourceComponentHasFullNameAndType = (input: SourceComponent): boolean =>
135+
typeof input.fullName === 'string' && typeof input.type.name === 'string';
136+
137+
export const getAllFiles = (sc: SourceComponent): string[] => [sc.xml, ...sc.walkContent()].filter(isString);
138+
139+
export const remoteChangeToMetadataMember = (cr: ChangeResult): MetadataMember => {
140+
const checked = ensureNameAndType(cr);
141+
142+
return {
143+
fullName: checked.name,
144+
type: checked.type,
145+
};
146+
};
147+
148+
// weird, right? This is for oclif.table which allows types but not interfaces. In this case, they are equivalent
149+
export const FileResponseSuccessToRemoteSyncInput = (fr: FileResponseSuccess): RemoteSyncInput => fr;
150+
151+
export const changeResultToMetadataComponent =
152+
(registry: RegistryAccess = new RegistryAccess()) =>
153+
(cr: ChangeResultWithNameAndType): MetadataComponent => ({
154+
fullName: cr.name,
155+
type: registry.getTypeByName(cr.type),
156+
});

src/shared/guards.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import { SourceComponent, MetadataMember } from '@salesforce/source-deploy-retrieve';
7+
import {
8+
SourceComponent,
9+
MetadataMember,
10+
FileResponse,
11+
ComponentStatus,
12+
FileResponseFailure,
13+
FileResponseSuccess,
14+
} from '@salesforce/source-deploy-retrieve';
15+
import { ChangeResult } from './types';
16+
import { ChangeResultWithNameAndType } from './types';
817

918
export const sourceComponentGuard = (input: SourceComponent | undefined): input is SourceComponent =>
1019
input instanceof SourceComponent;
@@ -13,3 +22,23 @@ export const metadataMemberGuard = (
1322
input: MetadataMember | undefined | Partial<MetadataMember>
1423
): input is MetadataMember =>
1524
input !== undefined && typeof input.fullName === 'string' && typeof input.type === 'string';
25+
26+
export const isSdrFailure = (fileResponse: FileResponse): fileResponse is FileResponseFailure =>
27+
fileResponse.state === ComponentStatus.Failed;
28+
29+
export const isSdrSuccess = (fileResponse: FileResponse): fileResponse is FileResponseSuccess =>
30+
fileResponse.state !== ComponentStatus.Failed;
31+
32+
export const FileResponseIsDeleted = (fileResponse: FileResponse): boolean =>
33+
fileResponse.state === ComponentStatus.Deleted;
34+
35+
export const FileResponseIsNotDeleted = (fileResponse: FileResponse): boolean =>
36+
fileResponse.state !== ComponentStatus.Deleted;
37+
38+
export const FileResponseHasPath = (
39+
fileResponse: FileResponseSuccess
40+
): fileResponse is FileResponseSuccess & Required<Pick<FileResponseSuccess, 'filePath'>> =>
41+
fileResponse.filePath !== undefined;
42+
43+
export const isChangeResultWithNameAndType = (cr?: ChangeResult): cr is ChangeResultWithNameAndType =>
44+
typeof cr === 'object' && typeof cr.name === 'string' && typeof cr.type === 'string';

src/shared/localComponentSetArray.ts

+15-6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
MetadataResolver,
1313
VirtualTreeContainer,
1414
DestructiveChangesType,
15+
RegistryAccess,
1516
} from '@salesforce/source-deploy-retrieve';
1617
import { sourceComponentGuard } from './guards';
1718
import { supportsPartialDelete, pathIsInFolder } from './functions';
@@ -35,8 +36,8 @@ export const getGroupedFiles = (input: GroupedFileInput, byPackageDir = false):
3536
const getSequential = ({ packageDirs, nonDeletes, deletes }: GroupedFileInput): GroupedFile[] =>
3637
packageDirs.map((pkgDir) => ({
3738
path: pkgDir.name,
38-
nonDeletes: nonDeletes.filter((f) => pathIsInFolder(f, pkgDir.name)),
39-
deletes: deletes.filter((f) => pathIsInFolder(f, pkgDir.name)),
39+
nonDeletes: nonDeletes.filter(pathIsInFolder(pkgDir.name)),
40+
deletes: deletes.filter(pathIsInFolder(pkgDir.name)),
4041
}));
4142

4243
const getNonSequential = ({
@@ -51,26 +52,34 @@ const getNonSequential = ({
5152
},
5253
];
5354

54-
export const getComponentSets = (groupings: GroupedFile[], sourceApiVersion?: string): ComponentSet[] => {
55+
export const getComponentSets = ({
56+
groupings,
57+
sourceApiVersion,
58+
registry = new RegistryAccess(),
59+
}: {
60+
groupings: GroupedFile[];
61+
sourceApiVersion?: string;
62+
registry: RegistryAccess;
63+
}): ComponentSet[] => {
5564
const logger = Logger.childFromRoot('localComponentSetArray');
5665

5766
// optimistic resolution...some files may not be possible to resolve
58-
const resolverForNonDeletes = new MetadataResolver();
67+
const resolverForNonDeletes = new MetadataResolver(registry);
5968

6069
return groupings
6170
.map((grouping) => {
6271
logger.debug(
6372
`building componentSet for ${grouping.path} (deletes: ${grouping.deletes.length} nonDeletes: ${grouping.nonDeletes.length})`
6473
);
6574

66-
const componentSet = new ComponentSet();
75+
const componentSet = new ComponentSet(undefined, registry);
6776
if (sourceApiVersion) {
6877
componentSet.sourceApiVersion = sourceApiVersion;
6978
}
7079

7180
// we need virtual components for the deletes.
7281
// TODO: could we use the same for the non-deletes?
73-
const resolverForDeletes = new MetadataResolver(undefined, VirtualTreeContainer.fromFilePaths(grouping.deletes));
82+
const resolverForDeletes = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(grouping.deletes));
7483

7584
grouping.deletes
7685
.flatMap((filename) => resolverForDeletes.getComponentsFromPath(filename))

0 commit comments

Comments
 (0)