Skip to content

Commit

Permalink
feat: option to use SDR events
Browse files Browse the repository at this point in the history
  • Loading branch information
mshanemc committed May 23, 2022
1 parent 577b1ac commit d86f698
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 280 deletions.
26 changes: 0 additions & 26 deletions README.md
Expand Up @@ -2,8 +2,6 @@

JavaScript library for tracking local and remote Salesforce metadata changes.

**_ UNDER DEVELOPMENT _**

You should use the class named SourceTracking.

Start like this:
Expand All @@ -14,7 +12,6 @@ import { SourceTracking } from '@salesforce/source-tracking';
const tracking = await SourceTracking.create({
org: this.org, // Org from sfdx-core
project: this.project, // Project from sfdx-core
apiVersion: this.flags.apiversion as string, // can be undefined, will figure it out if you don't allow users to override
});
```

Expand Down Expand Up @@ -87,26 +84,3 @@ await tracking.updateRemoteTracking(
false
);
```

## TODO

NUT for tracking file compatibility check logic
pollSourceMembers should better handle aggregated types. ex:

```txt
DEBUG Could not find 2 SourceMembers (using ebikes): AuraDefinition__pageTemplate_2_7_3/pageTemplate_2_7_3.cmp-meta.xml,[object Object],CustomObject__Account,[object Object]
```

### Enhancements

- for updating ST after deploy/retrieve, we need a quick way for those commands to ask, "is this an ST org?" OR a graceful "ifSupported" wrapper for those methods.
- ensureRemoteTracking could have 2 options in an object
1. `ensureQueryHasReturned` which will make sure the query has run at least once
2. `forceQuery` will re-query even if the query already ran (cache-buster typically)

### Cleanup

- review commented code
- review public methods for whether they should be public
- organize any shared types
- export top-level stuff
88 changes: 88 additions & 0 deletions src/shared/conflicts.ts
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { resolve } from 'path';
import { SfError } from '@salesforce/core';
import { SourceComponent, ComponentSet, ForceIgnore } from '@salesforce/source-deploy-retrieve';
import { ConflictResponse, ChangeResult } from './types';
import { getMetadataKey } from './functions';
import { populateTypesAndNames } from './populateTypesAndNames';
export const throwIfConflicts = (conflicts: ConflictResponse[]): void => {
if (conflicts.length > 0) {
const conflictError = new SfError('Conflict detected');
conflictError.setData(conflicts);
}
};

export const findConflictsInComponentSet = (
components: SourceComponent[],
conflicts: ChangeResult[]
): ConflictResponse[] => {
// map do dedupe by name-type-filename
const conflictMap = new Map<string, ConflictResponse>();
const cs = new ComponentSet(components);
conflicts
.filter((cr) => cr.name && cr.type && cs.has({ fullName: cr.name, type: cr.type }))
.forEach((c) => {
c.filenames?.forEach((f) => {
conflictMap.set(`${c.name}#${c.type}#${f}`, {
state: 'Conflict',
// the following 2 type assertions are valid because of previous filter statement
// they can be removed once TS is smarter about filtering
fullName: c.name as string,
type: c.type as string,
filePath: resolve(f),
});
});
});
const reformattedConflicts = Array.from(conflictMap.values());
return reformattedConflicts;
};

export const dedupeConflictChangeResults = ({
localChanges = [],
remoteChanges = [],
projectPath,
forceIgnore,
}: {
localChanges: ChangeResult[];
remoteChanges: ChangeResult[];
projectPath: string;
forceIgnore: ForceIgnore;
}): ChangeResult[] => {
// index the remoteChanges by filename
const fileNameIndex = new Map<string, ChangeResult>();
const metadataKeyIndex = new Map<string, ChangeResult>();
remoteChanges.map((change) => {
if (change.name && change.type) {
metadataKeyIndex.set(getMetadataKey(change.name, change.type), change);
}
change.filenames?.map((filename) => {
fileNameIndex.set(filename, change);
});
});

const conflicts = new Set<ChangeResult>();

populateTypesAndNames({ elements: localChanges, excludeUnresolvable: true, projectPath, forceIgnore }).map(
(change) => {
const metadataKey = getMetadataKey(change.name as string, change.type as string);
// option 1: name and type match
if (metadataKeyIndex.has(metadataKey)) {
conflicts.add({ ...(metadataKeyIndex.get(metadataKey) as ChangeResult) });
} else {
// option 2: some of the filenames match
change.filenames?.map((filename) => {
if (fileNameIndex.has(filename)) {
conflicts.add({ ...(fileNameIndex.get(filename) as ChangeResult) });
}
});
}
}
);
// deeply de-dupe
return Array.from(conflicts);
};
5 changes: 4 additions & 1 deletion src/shared/functions.ts
Expand Up @@ -5,7 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { sep, normalize } from 'path';
import { sep, normalize, isAbsolute, relative } from 'path';
import { isString } from '@salesforce/ts-types';
import { SourceComponent } from '@salesforce/source-deploy-retrieve';
import { RemoteChangeElement, ChangeResult } from './types';
Expand Down Expand Up @@ -42,3 +42,6 @@ const nonEmptyStringFilter = (value: string): boolean => {
// adapted for TS from https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/chunk.md
export const chunkArray = <T>(arr: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => arr.slice(i * size, i * size + size));

export const ensureRelative = (filePath: string, projectPath: string): string =>
isAbsolute(filePath) ? relative(projectPath, filePath) : filePath;
15 changes: 15 additions & 0 deletions src/shared/metadataKeys.ts
Expand Up @@ -6,6 +6,7 @@
*/
import { basename, dirname, join, normalize, sep } from 'path';
import { ComponentSet, RegistryAccess } from '@salesforce/source-deploy-retrieve';
import { Lifecycle } from '@salesforce/core';
import { RemoteSyncInput } from './types';
import { getMetadataKey } from './functions';

Expand Down Expand Up @@ -76,3 +77,17 @@ export const mappingsForSourceMemberTypesToMetadataType = new Map<string, string
['AuraDefinition', 'AuraDefinitionBundle'],
['LightningComponentResource', 'LightningComponentBundle'],
]);

export const registrySupportsType = (type: string): boolean => {
try {
if (mappingsForSourceMemberTypesToMetadataType.has(type)) {
return true;
}
// this must use getTypeByName because findType doesn't support addressable child types (ex: customField!)
registry.getTypeByName(type);
return true;
} catch (e) {
void Lifecycle.getInstance().emitWarning(`Unable to find type ${type} in registry`);
return false;
}
};
96 changes: 96 additions & 0 deletions src/shared/populateFilePaths.ts
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { EOL } from 'os';
import { Logger } from '@salesforce/core';
import { ComponentSet } from '@salesforce/source-deploy-retrieve';
import { ChangeResult } from './types';
import { metadataMemberGuard } from './guards';
import { getKeyFromObject, getMetadataKey } from './functions';

const logger = Logger.childFromRoot('SourceTracking.PopulateFilePaths');

/**
* Will build a component set, crawling your local directory, to get paths for remote changes
*
* @param elements ChangeResults that may or may not have filepaths in their filenames parameters
* @param packageDirPaths Array of paths from PackageDirectories
* @returns
*/
export const populateFilePaths = (elements: ChangeResult[], packageDirPaths: string[]): ChangeResult[] => {
if (elements.length === 0) {
return [];
}

logger.debug('populateFilePaths for change elements', elements);
// component set generated from an array of MetadataMember from all the remote changes
// but exclude the ones that aren't in the registry
const remoteChangesAsMetadataMember = elements
.map((element) => {
if (typeof element.type === 'string' && typeof element.name === 'string') {
return {
type: element.type,
fullName: element.name,
};
}
})
.filter(metadataMemberGuard);

const remoteChangesAsComponentSet = new ComponentSet(remoteChangesAsMetadataMember);

logger.debug(` the generated component set has ${remoteChangesAsComponentSet.size.toString()} items`);
if (remoteChangesAsComponentSet.size < elements.length) {
// there *could* be something missing
// some types (ex: LWC) show up as multiple files in the remote changes, but only one in the component set
// iterate the elements to see which ones didn't make it into the component set
const missingComponents = elements.filter(
(element) =>
!remoteChangesAsComponentSet.has({ type: element?.type as string, fullName: element?.name as string })
);
// Throw if anything was actually missing
if (missingComponents.length > 0) {
throw new Error(
`unable to generate complete component set for ${elements
.map((element) => `${element.name} (${element.type})`)
.join(EOL)}`
);
}
}

const matchingLocalSourceComponentsSet = ComponentSet.fromSource({
fsPaths: packageDirPaths,
include: remoteChangesAsComponentSet,
});
logger.debug(
` local source-backed component set has ${matchingLocalSourceComponentsSet.size.toString()} items from remote`
);

// make it simpler to find things later
const elementMap = new Map<string, ChangeResult>();
elements.map((element) => {
elementMap.set(getKeyFromObject(element), element);
});

// iterates the local components and sets their filenames
for (const matchingComponent of matchingLocalSourceComponentsSet.getSourceComponents().toArray()) {
if (matchingComponent.fullName && matchingComponent.type.name) {
logger.debug(
`${matchingComponent.fullName}|${matchingComponent.type.name} matches ${
matchingComponent.xml
} and maybe ${matchingComponent.walkContent().toString()}`
);
const key = getMetadataKey(matchingComponent.type.name, matchingComponent.fullName);
elementMap.set(key, {
...elementMap.get(key),
modified: true,
origin: 'remote',
filenames: [matchingComponent.xml as string, ...matchingComponent.walkContent()].filter((filename) => filename),
});
}
}

return Array.from(elementMap.values());
};
96 changes: 96 additions & 0 deletions src/shared/populateTypesAndNames.ts
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { Logger } from '@salesforce/core';
import { isString } from '@salesforce/ts-types';
import { MetadataResolver, VirtualTreeContainer, ForceIgnore } from '@salesforce/source-deploy-retrieve';
import { ChangeResult } from './types';
import { sourceComponentGuard } from './guards';
import { ensureRelative } from './functions';
const logger = Logger.childFromRoot('SourceTracking.PopulateTypesAndNames');

/**
* uses SDR to translate remote metadata records into local file paths (which only typically have the filename).
*
* @input elements: ChangeResult[]
* @input projectPath
* @input forceIgnore: ForceIgnore. If provided, result will indicate whether the file is ignored
* @input excludeUnresolvable: boolean Filter out components where you can't get the name and type (that is, it's probably not a valid source component)
* @input resolveDeleted: constructs a virtualTree instead of the actual filesystem--useful when the files no longer exist
*/
export const populateTypesAndNames = ({
elements,
projectPath,
forceIgnore,
excludeUnresolvable = false,
resolveDeleted = false,
}: {
elements: ChangeResult[];
projectPath: string;
forceIgnore?: ForceIgnore;
excludeUnresolvable?: boolean;
resolveDeleted?: boolean;
}): ChangeResult[] => {
if (elements.length === 0) {
return [];
}

logger.debug(`populateTypesAndNames for ${elements.length} change elements`);
const filenames = elements.flatMap((element) => element.filenames).filter(isString);

// component set generated from the filenames on all local changes
const resolver = new MetadataResolver(
undefined,
resolveDeleted ? VirtualTreeContainer.fromFilePaths(filenames) : undefined,
!!forceIgnore
);
const sourceComponents = filenames
.flatMap((filename) => {
try {
return resolver.getComponentsFromPath(filename);
} catch (e) {
logger.warn(`unable to resolve ${filename}`);
return undefined;
}
})
.filter(sourceComponentGuard);

logger.debug(` matching SourceComponents have ${sourceComponents.length} items from local`);

// make it simpler to find things later
const elementMap = new Map<string, ChangeResult>();
elements.map((element) => {
element.filenames?.map((filename) => {
elementMap.set(ensureRelative(filename, projectPath), element);
});
});

// iterates the local components and sets their filenames
sourceComponents.map((matchingComponent) => {
if (matchingComponent?.fullName && matchingComponent?.type.name) {
const filenamesFromMatchingComponent = [matchingComponent.xml, ...matchingComponent.walkContent()];
const ignored = filenamesFromMatchingComponent
.filter(isString)
.filter((filename) => !filename.includes('__tests__'))
.some((filename) => forceIgnore?.denies(filename));
filenamesFromMatchingComponent.map((filename) => {
if (filename && elementMap.has(filename)) {
// add the type/name from the componentSet onto the element
elementMap.set(filename, {
origin: 'remote',
...elementMap.get(filename),
type: matchingComponent.type.name,
name: matchingComponent.fullName,
ignored,
});
}
});
}
});
return excludeUnresolvable
? Array.from(new Set(elementMap.values())).filter((changeResult) => changeResult.name && changeResult.type)
: Array.from(new Set(elementMap.values()));
};
7 changes: 7 additions & 0 deletions src/shared/types.ts
Expand Up @@ -62,4 +62,11 @@ export interface ConflictError {
conflicts: ChangeResult[];
}

export interface ConflictResponse {
state: 'Conflict';
fullName: string;
type: string;
filePath: string;
}

export type ChangeOptionType = ChangeResult | SourceComponent | string;

0 comments on commit d86f698

Please sign in to comment.