Skip to content

Commit

Permalink
Merge pull request #704 from forcedotcom/sm/more-source-member-fields
Browse files Browse the repository at this point in the history
feat: more source member fields
  • Loading branch information
shetzel authored Nov 26, 2024
2 parents 0f8092f + 6854596 commit 309271b
Show file tree
Hide file tree
Showing 17 changed files with 1,143 additions and 1,324 deletions.
1,210 changes: 319 additions & 891 deletions CHANGELOG.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/shared/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ import { ensureArray } from '@salesforce/kit';
import { RemoteChangeElement, ChangeResult, ChangeResultWithNameAndType, RemoteSyncInput } from './types';
import { ensureNameAndType } from './remoteChangeIgnoring';

const keySplit = '###';
const legacyKeySplit = '__';
export const getMetadataKey = (metadataType: string, metadataName: string): string =>
`${metadataType}__${metadataName}`;
`${metadataType}${keySplit}${metadataName}`;
export const getLegacyMetadataKey = (metadataType: string, metadataName: string): string =>
`${metadataType}${legacyKeySplit}${metadataName}`;

export const getMetadataTypeFromKey = (key: string): string => decodeURIComponent(key.split(keySplit)[0]);
export const getMetadataNameFromKey = (key: string): string => decodeURIComponent(key.split(keySplit)[1]);
export const getMetadataTypeFromLegacyKey = (key: string): string => key.split(legacyKeySplit)[0];
export const getMetadataNameFromLegacyKey = (key: string): string =>
decodeURIComponent(key.split(legacyKeySplit).slice(1).join(legacyKeySplit));

export const getKeyFromObject = (element: RemoteChangeElement | ChangeResult): string => {
if (element.type && element.name) {
Expand Down
4 changes: 2 additions & 2 deletions src/shared/metadataKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ export const getMetadataKeyFromFileResponse = (fileResponse: RemoteSyncInput): s
// Aura/LWC need to have both the bundle level and file level keys
if (fileResponse.type === 'LightningComponentBundle' && fileResponse.filePath) {
return [
`LightningComponentResource__${pathAfterFullName(fileResponse)}`,
getMetadataKey('LightningComponentResource', pathAfterFullName(fileResponse)),
getMetadataKey(fileResponse.type, fileResponse.fullName),
];
}
if (fileResponse.type === 'AuraDefinitionBundle' && fileResponse.filePath) {
return [
`AuraDefinition__${pathAfterFullName(fileResponse)}`,
getMetadataKey('AuraDefinition', pathAfterFullName(fileResponse)),
getMetadataKey(fileResponse.type, fileResponse.fullName),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*/

import { ComponentStatus } from '@salesforce/source-deploy-retrieve';
import { getMetadataKeyFromFileResponse } from './metadataKeys';
import { RemoteSyncInput } from './types';
import { getMetadataKeyFromFileResponse } from '../metadataKeys';
import { RemoteSyncInput } from '../types';

const typesToNoPollFor = [
'CustomObject',
Expand Down Expand Up @@ -46,16 +46,16 @@ const isSpecialAuraXml = (filePath?: string): boolean =>

// things that never have SourceMembers
const excludedKeys = [
'AppMenu__Salesforce1',
'Profile__Standard',
'Profile__Guest License User',
'CustomTab__standard-home',
'Profile__Minimum Access - Salesforce',
'Profile__Salesforce API Only System Integrations',
'AssignmentRules__Case',
'ListView__CollaborationGroup.All_ChatterGroups',
'CustomTab__standard-mailapp',
'ApexEmailNotifications__apexEmailNotifications',
'AppMenu###Salesforce1',
'Profile###Standard',
'Profile###Guest License User',
'CustomTab###standard-home',
'Profile###Minimum Access - Salesforce',
'Profile###Salesforce API Only System Integrations',
'AssignmentRules###Case',
'ListView###CollaborationGroup.All_ChatterGroups',
'CustomTab###standard-mailapp',
'ApexEmailNotifications###apexEmailNotifications',
];

export const calculateExpectedSourceMembers = (expectedMembers: RemoteSyncInput[]): Map<string, RemoteSyncInput> => {
Expand Down Expand Up @@ -92,7 +92,7 @@ export const calculateExpectedSourceMembers = (expectedMembers: RemoteSyncInput[
.filter(
(key) =>
// CustomObject could have been re-added by the key generator from one of its fields
!key.startsWith('CustomObject__') && !excludedKeys.includes(key)
!key.startsWith('CustomObject###') && !excludedKeys.includes(key)
)
.map((key) => outstandingSourceMembers.set(key, member));
});
Expand Down
112 changes: 112 additions & 0 deletions src/shared/remote/fileOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2023, 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 fs from 'node:fs';
import path from 'node:path';
import { parseJsonMap } from '@salesforce/kit';
import { lockInit, envVars as env, Logger } from '@salesforce/core';
import {
getLegacyMetadataKey,
getMetadataKey,
getMetadataNameFromLegacyKey,
getMetadataTypeFromLegacyKey,
} from '../functions';
import { RemoteChangeElement } from '../types';
import { ContentsV0, ContentsV1, MemberRevision, MemberRevisionLegacy } from './types';

export const FILENAME = 'maxRevision.json';

export const getFilePath = (orgId: string): string => path.join('.sf', 'orgs', orgId, FILENAME);

export const readFileContents = async (filePath: string): Promise<ContentsV1 | Record<string, never>> => {
try {
const contents = await fs.promises.readFile(filePath, 'utf8');
const parsedContents = parseJsonMap<ContentsV1 | ContentsV0>(contents, filePath);
if (parsedContents.fileVersion === 1) {
return parsedContents;
}
Logger.childFromRoot('remoteSourceTrackingService:readFileContents').debug(
`older tracking file version, found ${
parsedContents.fileVersion ?? 'undefined'
}. Upgrading file contents. Some expected data may be missing`
);
return upgradeFileContents(parsedContents);
} catch (e) {
Logger.childFromRoot('remoteSourceTrackingService:readFileContents').debug(
`Error reading or parsing file file at ${filePath}. Will treat as an empty file.`,
e
);

return {};
}
};

export const revisionToRemoteChangeElement = (memberRevision: MemberRevision): RemoteChangeElement => ({
type: memberRevision.MemberType,
name: memberRevision.MemberName,
deleted: memberRevision.IsNameObsolete,
modified: memberRevision.IsNewMember === false,
revisionCounter: memberRevision.RevisionCounter,
changedBy: memberRevision.ChangedBy,
memberIdOrName: memberRevision.MemberIdOrName,
});

export const upgradeFileContents = (contents: ContentsV0): ContentsV1 => ({
fileVersion: 1,
serverMaxRevisionCounter: contents.serverMaxRevisionCounter,
// @ts-expect-error the old file didn't store the IsNewMember field or any indication of whether the member was add/modified
sourceMembers: Object.fromEntries(
// it's the old version
Object.entries(contents.sourceMembers).map(([key, value]) => [
getMetadataKey(getMetadataTypeFromLegacyKey(key), getMetadataNameFromLegacyKey(key)),
{
MemberName: getMetadataNameFromLegacyKey(key),
MemberType: value.memberType,
IsNameObsolete: value.isNameObsolete,
RevisionCounter: value.serverRevisionCounter,
lastRetrievedFromServer: value.lastRetrievedFromServer ?? undefined,
ChangedBy: 'unknown',
MemberIdOrName: 'unknown',
},
])
),
});

export const writeTrackingFile = async ({
filePath,
maxCounter,
members,
}: {
filePath: string;
maxCounter: number;
members: Map<string, MemberRevision>;
}): Promise<void> => {
const lockResult = await lockInit(filePath);
const CURRENT_FILE_VERSION_ENV = env.getNumber('SF_SOURCE_TRACKING_FILE_VERSION') ?? 0;
const contents =
CURRENT_FILE_VERSION_ENV === 1
? ({
fileVersion: 1,
serverMaxRevisionCounter: maxCounter,
sourceMembers: Object.fromEntries(members),
} satisfies ContentsV1)
: ({
fileVersion: 0,
serverMaxRevisionCounter: maxCounter,
sourceMembers: Object.fromEntries(Array.from(members.entries()).map(toLegacyMemberRevision)),
} satisfies ContentsV0);
await lockResult.writeAndUnlock(JSON.stringify(contents, null, 4));
};

export const toLegacyMemberRevision = ([, member]: [string, MemberRevision]): [key: string, MemberRevisionLegacy] => [
getLegacyMetadataKey(member.MemberType, member.MemberName),
{
memberType: member.MemberType,
serverRevisionCounter: member.RevisionCounter,
lastRetrievedFromServer: member.lastRetrievedFromServer ?? null,
isNameObsolete: member.IsNameObsolete,
},
];
107 changes: 107 additions & 0 deletions src/shared/remote/orgQueries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2023, 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 { Connection, envVars as env, SfError, trimTo15 } from '@salesforce/core';
import { Duration } from '@salesforce/kit';
import { PinoLogger } from './remoteSourceTrackingService';
import { SOURCE_MEMBER_FIELDS, SourceMember } from './types';

export const calculateTimeout =
(logger: PinoLogger) =>
(memberCount: number): Duration => {
const overriddenTimeout = env.getNumber('SF_SOURCE_MEMBER_POLLING_TIMEOUT', 0);
if (overriddenTimeout > 0) {
logger.debug(`Overriding SourceMember polling timeout to ${overriddenTimeout}`);
return Duration.seconds(overriddenTimeout);
}

// Calculate a polling timeout for SourceMembers based on the number of
// member names being polled plus a buffer of 5 seconds. This will
// wait 50s for each 1000 components, plus 5s.
const pollingTimeout = Math.ceil(memberCount * 0.05) + 5;
logger.debug(`Computed SourceMember polling timeout of ${pollingTimeout}s`);
return Duration.seconds(pollingTimeout);
};
/** exported only for spy/mock */

export const querySourceMembersTo = async (conn: Connection, toRevision: number): Promise<SourceMember[]> => {
const query = `SELECT ${SOURCE_MEMBER_FIELDS.join(', ')} FROM SourceMember WHERE RevisionCounter <= ${toRevision}`;
return queryFn(conn, query);
};

export const querySourceMembersFrom = async ({
conn,
fromRevision,
queryCache,
userQueryCache,
logger,
}: {
conn: Connection;
fromRevision: number;
/** optional cache, used if present. Side effect: cache will be mutated */
queryCache?: Map<number, SourceMember[]>;
/** optional cache, used if present. Side effect: cache will be mutated */
userQueryCache?: Map<string, string>;
/** if you don't pass in a logger, you get no log output */
logger?: PinoLogger;
}): Promise<SourceMember[]> => {
if (queryCache) {
// Check cache first and return if found.
const cachedQueryResult = queryCache.get(fromRevision);
if (cachedQueryResult) {
logger?.debug(`Using cache for SourceMember query for revision ${fromRevision}`);
return cachedQueryResult;
}
}

// because `serverMaxRevisionCounter` is always updated, we need to select > to catch the most recent change
const query = `SELECT ${SOURCE_MEMBER_FIELDS.join(', ')} FROM SourceMember WHERE RevisionCounter > ${fromRevision}`;
logger?.debug(`Query: ${query}`);

const queryResult = await queryFn(conn, query);
if (userQueryCache) {
await updateCacheWithUnknownUsers(conn, queryResult, userQueryCache);
}
const queryResultWithResolvedUsers = queryResult.map((member) => ({
...member,
ChangedBy: userQueryCache?.get(member.ChangedBy) ?? member.ChangedBy,
}));
queryCache?.set(fromRevision, queryResultWithResolvedUsers);

return queryResultWithResolvedUsers;
};

export const queryFn = async (conn: Connection, query: string): Promise<SourceMember[]> => {
try {
return (await conn.tooling.query<SourceMember>(query, { autoFetch: true, maxFetch: 50_000 })).records.map(
sourceMemberCorrections
);
} catch (error) {
throw SfError.wrap(error);
}
};

/** A series of workarounds for server-side bugs. Each bug should be filed against a team, with a WI, so we know when these are fixed and can be removed */
const sourceMemberCorrections = (sourceMember: SourceMember): SourceMember => {
if (sourceMember.MemberType === 'QuickActionDefinition') {
return { ...sourceMember, MemberType: 'QuickAction' }; // W-15837125
}
return sourceMember;
};

const updateCacheWithUnknownUsers = async (
conn: Connection,
queryResult: SourceMember[],
userCache: Map<string, string>
): Promise<void> => {
const unknownUsers = new Set<string>(queryResult.map((member) => member.ChangedBy).filter((u) => !userCache.has(u)));
if (unknownUsers.size > 0) {
const userQuery = `SELECT Id, Name FROM User WHERE Id IN ('${Array.from(unknownUsers).join("','")}')`;
(await conn.query<{ Id: string; Name: string }>(userQuery, { autoFetch: true, maxFetch: 50_000 })).records.map(
(u) => userCache.set(trimTo15(u.Id), u.Name)
);
}
};
Loading

0 comments on commit 309271b

Please sign in to comment.