UNPKG

@salesforce/source-tracking

Version:

API for tracking local and remote Salesforce metadata changes

428 lines (427 loc) 22.3 kB
"use strict"; /* * Copyright 2025, Salesforce, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.remoteChangeElementToChangeResult = exports.RemoteSourceTrackingService = void 0; const node_path_1 = __importDefault(require("node:path")); const node_fs_1 = __importDefault(require("node:fs")); const node_os_1 = require("node:os"); const ts_retry_promise_1 = require("ts-retry-promise"); const core_1 = require("@salesforce/core"); const kit_1 = require("@salesforce/kit"); const ts_types_1 = require("@salesforce/ts-types"); const metadataKeys_1 = require("../metadataKeys"); const functions_1 = require("../functions"); const expectedSourceMembers_1 = require("./expectedSourceMembers"); const fileOperations_1 = require("./fileOperations"); const orgQueries_1 = require("./orgQueries"); /* * after some results have returned, how many times should we poll for missing sourcemembers * even when there is a longer timeout remaining (because the deployment is very large) */ const POLLING_DELAY_MS = 1000; const CONSECUTIVE_EMPTY_POLLING_RESULT_LIMIT = (core_1.envVars.getNumber('SF_SOURCE_MEMBER_POLLING_TIMEOUT') ?? 120) / kit_1.Duration.milliseconds(POLLING_DELAY_MS).seconds; /** if a cached instance is older than this, it will be purged */ const MAX_INSTANCE_CACHE_TTL = 1000 * 60 * 60 * 1; // 1 hour class RemoteSourceTrackingService { /** map of constructed, init'ed instances; key is orgId. It's like a singleton at the org level */ static instanceMap = new Map(); filePath; logger; serverMaxRevisionCounter = 0; sourceMembers = new Map(); org; // A short term cache (within the same process) of query results based on a revision. // Useful for source:pull, which makes 3 of the same queries; during status, building manifests, after pull success. queryCache = new Map(); userQueryCache = new Map(); /** * Initializes the service with existing remote source tracking data, or sets * the state to begin source tracking of metadata changes in the org. */ constructor(options) { this.org = options.org; this.filePath = node_path_1.default.join(options.projectPath, '.sf', 'orgs', this.org.getOrgId(), fileOperations_1.FILENAME); } /** * Get the singleton instance for a given user. * * @param {RemoteSourceTrackingService.Options} options that contain the org * @returns {Promise<RemoteSourceTrackingService>} the remoteSourceTrackingService object for the given username */ static async getInstance(options) { const orgId = options.org.getOrgId(); const service = this.instanceMap.get(orgId)?.service ?? (await new RemoteSourceTrackingService(options).init()); this.instanceMap.set(orgId, { service, lastUsed: Date.now() }); // when we get an instance, we make sure old ones are not accumulating. Important in multitenant environments purgeOldInstances(this.instanceMap); // even if there was already an instance around, its queries might no longer be accurate (ex: missing new changes but queryFrom would return stale results) service.queryCache.clear(); service.userQueryCache.clear(); service.org = options.org; return service; } /** * Delete the RemoteSourceTracking for a given org. * * @param orgId * @returns the path of the deleted source tracking file */ static async delete(orgId) { const fileToDelete = (0, fileOperations_1.getFilePath)(orgId); // the file might not exist, in which case we don't need to delete it if (node_fs_1.default.existsSync(fileToDelete)) { await node_fs_1.default.promises.unlink(fileToDelete); } return node_path_1.default.isAbsolute(fileToDelete) ? fileToDelete : node_path_1.default.join(process.cwd(), fileToDelete); } /** * pass in a series of SDR FilResponses .\ * it sets their last retrieved revision to the current revision counter from the server. */ async syncSpecifiedElements(elements) { if (elements.length === 0) { return; } const quietLogger = elements.length > 100 ? this.logger.silent.bind(this.logger) : this.logger.debug.bind(this.logger); quietLogger(`Syncing ${elements.length} Revisions by key`); // this can be super-repetitive on a large ExperienceBundle where there is an element for each file but only one Revision for the entire bundle // any item in an aura/LWC bundle needs to represent the top (bundle) level and the file itself // so we de-dupe via a set Array.from(new Set(elements.flatMap((element) => (0, metadataKeys_1.getMetadataKeyFromFileResponse)(element)))).map((metadataKey) => { const revision = this.sourceMembers.get(metadataKey) ?? this.sourceMembers.get(decodeURI(metadataKey)); if (!revision) { this.logger.warn(`found no matching revision for ${metadataKey}`); } else if (doesNotMatchServer(revision)) { quietLogger(`Syncing ${metadataKey} revision from ${revision.lastRetrievedFromServer ?? 'null'} to ${revision.RevisionCounter}`); this.setMemberRevision(metadataKey, { ...revision, lastRetrievedFromServer: revision.RevisionCounter, }); } }); await (0, fileOperations_1.writeTrackingFile)({ filePath: this.filePath, maxCounter: this.serverMaxRevisionCounter, members: this.sourceMembers, }); } /** * Resets source tracking state by first clearing all tracked data, then * queries and synchronizes SourceMembers from the associated org. * * If a toRevision is passed, it will query for all `SourceMembers` with * a `RevisionCounter` less than or equal to the provided revision number. * * When no toRevision is passed, it will query and sync all `SourceMembers`. * * @param toRevision The `RevisionCounter` number to sync to. */ async reset(toRevision) { // Called during a source:tracking:reset this.serverMaxRevisionCounter = 0; this.sourceMembers = new Map(); const members = toRevision !== undefined && toRevision !== null ? await (0, orgQueries_1.querySourceMembersTo)(this.org.getConnection(), toRevision) : await (0, orgQueries_1.querySourceMembersFrom)({ fromRevision: 0, logger: this.logger, userQueryCache: this.userQueryCache, queryCache: this.queryCache, conn: this.org.getConnection(), }); await this.trackSourceMembers(members, true); return members.map((member) => (0, functions_1.getMetadataKey)(member.MemberType, member.MemberName)); } /** * Queries the org for any new, updated, or deleted metadata and updates * source tracking state. All `ChangeElements` not in sync with the org * are returned. */ // Internal implementation of the public `retrieveUpdates` function that adds the ability // to sync the retrieved SourceMembers; meaning it will update the lastRetrievedFromServer // field to the SourceMember's RevisionCounter, and update the serverMaxRevisionCounter // to the highest RevisionCounter. async retrieveUpdates() { // Always track new SourceMember data, or update tracking when we sync. const queriedSourceMembers = await (0, orgQueries_1.querySourceMembersFrom)({ fromRevision: this.serverMaxRevisionCounter, logger: this.logger, userQueryCache: this.userQueryCache, queryCache: this.queryCache, conn: this.org.getConnection(), }); await this.trackSourceMembers(queriedSourceMembers); // Look for any changed that haven't been synced. I.e, the lastRetrievedFromServer // does not match the serverRevisionCounter. const returnElements = Array.from(this.sourceMembers.values()) .filter(doesNotMatchServer) .map(fileOperations_1.revisionToRemoteChangeElement); this.logger.debug(returnElements.length ? `Found ${returnElements.length} elements not synced with org` : 'Remote source tracking is up to date'); return returnElements; } /** * Polls the org for SourceMember objects matching the provided metadata member names, * stopping when all members have been matched or the polling timeout is met or exceeded. * NOTE: This can be removed when the Team Dependency (TD-0085369) for W-7737094 is delivered. * * @param expectedMemberNames Array of metadata names to poll * @param pollingTimeout maximum amount of time in seconds to poll for SourceMembers */ async pollForSourceTracking(expectedMembers) { if (core_1.envVars.getBoolean('SF_DISABLE_SOURCE_MEMBER_POLLING')) { return this.logger.warn('Not polling for SourceMembers since SF_DISABLE_SOURCE_MEMBER_POLLING = true.'); } if (expectedMembers.length === 0) { return; } const outstandingSourceMembers = (0, expectedSourceMembers_1.calculateExpectedSourceMembers)(expectedMembers); const originalOutstandingSize = outstandingSourceMembers.size; // this will be the absolute timeout from the start of the poll. We can also exit early if it doesn't look like more results are coming in let highestRevisionSoFar = this.serverMaxRevisionCounter; const pollingTimeout = (0, orgQueries_1.calculateTimeout)(this.logger)(outstandingSourceMembers.size); let pollAttempts = 0; let consecutiveEmptyResults = 0; let someResultsReturned = false; /** we weren't expecting these SourceMembers, based on the deployment results */ const bonusTypes = new Set(); this.logger.debug(`Polling for ${outstandingSourceMembers.size} SourceMembers from revision ${highestRevisionSoFar} with timeout of ${pollingTimeout.seconds}s`); const poll = async () => { pollAttempts += 1; // not used to stop polling, but for debug logging // get sourceMembers added since our most recent max // use the "new highest" revision from the last poll that returned results const queriedMembers = await (0, orgQueries_1.querySourceMembersFrom)({ conn: this.org.getConnection(), fromRevision: highestRevisionSoFar, logger: pollAttempts > 1 ? undefined : this.logger, }); if (queriedMembers.length) { queriedMembers.map((member) => { // remove anything returned from the query list const metadataKey = (0, functions_1.getMetadataKey)(member.MemberType, member.MemberName); const deleted = outstandingSourceMembers.delete(metadataKey); if (!deleted) { bonusTypes.add(metadataKey); } highestRevisionSoFar = Math.max(highestRevisionSoFar, member.RevisionCounter); }); consecutiveEmptyResults = 0; // flips on the first batch of results someResultsReturned = true; } else { consecutiveEmptyResults++; } await core_1.Lifecycle.getInstance().emit('sourceMemberPollingEvent', { original: originalOutstandingSize, remaining: outstandingSourceMembers.size, attempts: pollAttempts, consecutiveEmptyResults, }); this.logger.debug(`[${pollAttempts}] Found ${originalOutstandingSize - outstandingSourceMembers.size} of ${originalOutstandingSize} expected SourceMembers`); // update but don't sync await this.trackSourceMembers(queriedMembers, false); // exit if all have returned if (outstandingSourceMembers.size === 0) { return; } if (someResultsReturned && consecutiveEmptyResults >= CONSECUTIVE_EMPTY_POLLING_RESULT_LIMIT) { throw new ts_retry_promise_1.NotRetryableError(`Polling found no results for ${consecutiveEmptyResults} consecutive attempts`); } this.logger.debug(outstandingSourceMembers.size < 20 ? `Still looking for SourceMembers: ${Array.from(outstandingSourceMembers.keys()).join(',')}` : `Still looking for ${outstandingSourceMembers.size} Source Members`); throw new Error(); }; const pollingFunction = (0, ts_retry_promise_1.retryDecorator)(poll, { timeout: pollingTimeout.milliseconds, delay: POLLING_DELAY_MS, retries: 'INFINITELY', }); const lc = core_1.Lifecycle.getInstance(); try { await pollingFunction(); this.logger.debug(`Retrieved all SourceMember data after ${pollAttempts} attempts`); // find places where the expectedSourceMembers might be too pruning too aggressively if (bonusTypes.size) { void lc.emitTelemetry({ eventName: 'sourceMemberBonusTypes', library: 'SourceTracking', deploymentSize: expectedMembers.length, bonusTypes: Array.from(bonusTypes).sort().join(','), }); } } catch { await Promise.all([ lc.emitWarning(`Polling for ${outstandingSourceMembers.size} SourceMembers timed out after ${pollAttempts} attempts (last ${consecutiveEmptyResults} were empty). Missing SourceMembers: ${formatSourceMemberWarnings(outstandingSourceMembers)}`), lc.emitTelemetry({ eventName: 'sourceMemberPollingTimeout', library: 'SourceTracking', timeoutSeconds: pollingTimeout.seconds, attempts: pollAttempts, consecutiveEmptyResults, missingQuantity: outstandingSourceMembers.size, deploymentSize: expectedMembers.length, bonusTypes: Array.from(bonusTypes).sort().join(','), types: [...new Set(Array.from(outstandingSourceMembers.values()).map((member) => member.type))] .sort() .join(','), members: Array.from(outstandingSourceMembers.keys()).join(','), }), ]); } } /** * Adds the given SourceMembers to the list of tracked MemberRevisions, optionally updating * the lastRetrievedFromServer field (sync), and persists the changes to maxRevision.json. */ async trackSourceMembers(sourceMembers, sync = false) { if (sourceMembers.length === 0) { return; } const quietLogger = sourceMembers.length > 100 ? this.logger.silent.bind(this.logger) : this.logger.debug.bind(this.logger); quietLogger(`Upserting ${sourceMembers.length} SourceMembers to maxRevision.json`); // Update the serverMaxRevisionCounter to the highest RevisionCounter this.serverMaxRevisionCounter = Math.max(this.serverMaxRevisionCounter, ...sourceMembers.map((m) => m.RevisionCounter)); this.logger.debug(`Updating serverMaxRevisionCounter to ${this.serverMaxRevisionCounter}`); sourceMembers.map((change) => { // try accessing the sourceMembers object at the index of the change's name // if it exists, we'll update the fields - if it doesn't, we'll create and insert it const key = (0, functions_1.getMetadataKey)(change.MemberType, change.MemberName); const sourceMemberFromTracking = this.getSourceMember(key); quietLogger(`${sourceMemberFromTracking ? `Updating ${key} to` : `Inserting ${key} with`} RevisionCounter: ${change.RevisionCounter}${sync ? ' and syncing' : ''}`); this.setMemberRevision(key, { ...change, // If we are syncing changes then we need to update the lastRetrievedFromServer field to // match the RevisionCounter from the SourceMember. lastRetrievedFromServer: sync ? change.RevisionCounter : sourceMemberFromTracking?.lastRetrievedFromServer, }); }); await (0, fileOperations_1.writeTrackingFile)({ filePath: this.filePath, maxCounter: this.serverMaxRevisionCounter, members: this.sourceMembers, }); } /** reads the tracking file and inits the logger and contents */ async init() { if (!(await this.org.supportsSourceTracking())) { ; const messages = new core_1.Messages('@salesforce/source-tracking', 'source', new Map([["NonSourceTrackedOrgError", "This org does not have source tracking."]])); throw new core_1.SfError(messages.getMessage('NonSourceTrackedOrgError'), 'NonSourceTrackedOrgError'); } this.logger = core_1.Logger.getRawRootLogger().child({ name: this.constructor.name }); if (node_fs_1.default.existsSync(this.filePath)) { // read the file contents and turn it into the map const rawContents = await (0, fileOperations_1.readFileContents)(this.filePath); if (rawContents.serverMaxRevisionCounter && rawContents.sourceMembers) { this.serverMaxRevisionCounter = rawContents.serverMaxRevisionCounter; this.sourceMembers = new Map(Object.entries(rawContents.sourceMembers ?? {})); } } else { // we need to init the file await (0, fileOperations_1.writeTrackingFile)({ filePath: this.filePath, maxCounter: this.serverMaxRevisionCounter, members: this.sourceMembers, }); } return this; } /** Return a tracked element as MemberRevision data.*/ getSourceMember(key) { return (this.sourceMembers.get(key) ?? this.sourceMembers.get(getDecodedKeyIfSourceMembersHas({ sourceMembers: this.sourceMembers, key, logger: this.logger }))); } setMemberRevision(key, sourceMember) { const sourceMembers = this.sourceMembers; const matchingKey = sourceMembers.get(key) ? key : getDecodedKeyIfSourceMembersHas({ sourceMembers, key, logger: this.logger }); this.sourceMembers.set(matchingKey, { ...sourceMember, MemberName: decodeURIComponent(sourceMember.MemberName) }); } } exports.RemoteSourceTrackingService = RemoteSourceTrackingService; /** * pass in an RCE, and this will return a pullable ChangeResult. * Useful for correcing bundle types where the files show change results with types but aren't resolvable */ const remoteChangeElementToChangeResult = (rce) => ({ ...rce, ...(metadataKeys_1.mappingsForSourceMemberTypesToMetadataType.has(rce.type) ? { // SNOWFLAKE: EmailTemplateFolder is treated as an alias for EmailFolder so it has a mapping. // The name must be handled differently than with bundle types. name: rce.type === 'EmailTemplateFolder' ? rce.name : rce.name.split('/')[0], type: metadataKeys_1.mappingsForSourceMemberTypesToMetadataType.get(rce.type), } : {}), origin: 'remote', // we know they're remote }); exports.remoteChangeElementToChangeResult = remoteChangeElementToChangeResult; /** * * iterate SourceMember keys and compare their decoded value with the decoded key. * if there's a match, return the matching decoded key, otherwise, return the original key */ const getDecodedKeyIfSourceMembersHas = ({ key, sourceMembers, logger, }) => { try { const originalKeyDecoded = decodeURIComponent(key); const match = Array.from(sourceMembers.keys()).find((memberKey) => decodeURIComponent(memberKey) === originalKeyDecoded); if (match) { logger.debug(`${match} matches already tracked member: ${key}`); return match; } } catch (e) { // Log the error and the key const errMsg = e instanceof Error ? e.message : (0, ts_types_1.isString)(e) ? e : 'unknown'; logger.debug(`Could not decode metadata key: ${key} due to: ${errMsg}`); } return key; }; /** organize by type and format for warning output */ const formatSourceMemberWarnings = (outstandingSourceMembers) => { // TODO: use Map.groupBy when we node22 is minimum // ex: CustomObject : [Foo__c, Bar__c] const mapByType = Array.from(outstandingSourceMembers.values()).reduce((acc, value) => { acc.set(value.type, [...(acc.get(value.type) ?? []), value.fullName]); return acc; }, new Map()); return Array.from(mapByType.entries()) .map(([type, names]) => ` - ${type}: ${names.join(', ')}`) .join(node_os_1.EOL); }; const doesNotMatchServer = (member) => member.RevisionCounter !== member.lastRetrievedFromServer; const purgeOldInstances = (instances) => { const now = Date.now(); Array.from(instances.entries()) .filter(([, { lastUsed }]) => now - lastUsed > MAX_INSTANCE_CACHE_TTL) .map(([orgId]) => { instances.delete(orgId); }); }; //# sourceMappingURL=remoteSourceTrackingService.js.map