Skip to content

Commit abdd7b3

Browse files
committed
feat: sourcemember polling like toolbelt
1 parent b13fd05 commit abdd7b3

11 files changed

+142
-185
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ node_modules
4040
.DS_Store
4141
.idea
4242

43-
testProj
43+
testProj*

README.md

+3-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ You should use the class named sourceTracking.
1515

1616
## TODO
1717

18-
Push can have partial successes and needs a proper status code ex:
18+
Push can have partial successes but rolls back. Filter those out, except if they deployed, then we need to commit only the successes. It's...complicated
1919

2020
```json
2121
{
@@ -95,11 +95,9 @@ Push can have partial successes and needs a proper status code ex:
9595
}
9696
```
9797

98-
- push: ignoreWarnings logic? What is this actually doing originally?
9998
- push/pull throw proper error for conflicts (with label!)
100-
- polling for source tracking to complete (use it, w/ rewrite)
101-
- RSTS: don't use trackSourceMembers with empty sourceMembers array (equivalent of sync all)
102-
- push/pull proper table output
99+
- push/pull proper table output (exists over in plugin-source so why redo it here?)
100+
- don't use fs.promises (won't support node12)
103101

104102
- SDR sets all retrieve FileResponse as `Changed` even if it didn't exist locally. That's going to yield slightly different json output on a `pull` than toolbelt did. See `remoteChanges.nut.ts > remote changes:add > can pull the add`. Fixing in pull is less optimal than fixing in SDR (because source:retrieve is also currently reporting those as `Changed` instead of `Created`)
105103

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
},
6767
"devDependencies": {
6868
"@oclif/plugin-command-snapshot": "^2.2.2",
69-
"@salesforce/cli-plugins-testkit": "^1.2.11",
69+
"@salesforce/cli-plugins-testkit": "^1.3.7",
7070
"@salesforce/dev-config": "^2.1.2",
7171
"@salesforce/dev-scripts": "^0.9.18",
7272
"@salesforce/plugin-command-reference": "^1.3.4",

src/shared/remoteSourceTrackingService.ts

+78-66
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99

1010
import * as path from 'path';
1111
import * as fs from 'fs';
12+
import { retryDecorator } from 'ts-retry-promise';
1213
import { ConfigFile, Logger, Org, SfdxError, Messages } from '@salesforce/core';
14+
import { ComponentStatus } from '@salesforce/source-deploy-retrieve';
1315
import { Dictionary, Optional } from '@salesforce/ts-types';
14-
import { Duration, env, toNumber } from '@salesforce/kit';
15-
import { retryDecorator } from 'ts-retry-promise';
16+
import { env, toNumber } from '@salesforce/kit';
1617
import { RemoteSyncInput } from '../shared/types';
1718
import { getMetadataKeyFromFileResponse } from './metadataKeys';
1819

@@ -312,27 +313,15 @@ export class RemoteSourceTrackingService extends ConfigFile<RemoteSourceTracking
312313
}
313314
// Adds the given SourceMembers to the list of tracked MemberRevisions, optionally updating
314315
// the lastRetrievedFromServer field (sync), and persists the changes to maxRevision.json.
315-
public async trackSourceMembers(sourceMembers: SourceMember[] = [], sync = false): Promise<void> {
316+
public async trackSourceMembers(sourceMembers: SourceMember[], sync = false): Promise<void> {
317+
if (sourceMembers.length === 0) {
318+
return;
319+
}
316320
const quiet = sourceMembers.length > 100;
317321
if (quiet) {
318322
this.logger.debug(`Upserting ${sourceMembers.length} SourceMembers to maxRevision.json`);
319323
}
320324

321-
// A sync with empty sourceMembers means "update all currently tracked elements".
322-
// This is what happens during a source:pull
323-
if (!sourceMembers.length && sync) {
324-
const trackedRevisions = this.getSourceMembers();
325-
for (const key of Object.keys(trackedRevisions)) {
326-
const member = trackedRevisions[key];
327-
if (member) {
328-
member.lastRetrievedFromServer = member.serverRevisionCounter;
329-
trackedRevisions[key] = member;
330-
}
331-
}
332-
await this.write();
333-
return;
334-
}
335-
336325
let serverMaxRevisionCounter = this.getServerMaxRevision();
337326
sourceMembers.forEach((change) => {
338327
// try accessing the sourceMembers object at the index of the change's name
@@ -432,67 +421,77 @@ export class RemoteSourceTrackingService extends ConfigFile<RemoteSourceTracking
432421
* @param expectedMemberNames Array of metadata names to poll
433422
* @param pollingTimeout maximum amount of time in seconds to poll for SourceMembers
434423
*/
435-
public async pollForSourceTracking(
436-
expectedMemberNames: string[],
437-
pollingTimeout?: Duration.Unit.SECONDS
438-
): Promise<void> {
424+
public async pollForSourceTracking(expectedMembers: RemoteSyncInput[]): Promise<void> {
439425
if (env.getBoolean('SFDX_DISABLE_SOURCE_MEMBER_POLLING', false)) {
440426
this.logger.warn('Not polling for SourceMembers since SFDX_DISABLE_SOURCE_MEMBER_POLLING = true.');
441427
return;
442428
}
443429

444-
if (expectedMemberNames.length === 0) {
430+
if (expectedMembers.length === 0) {
445431
// Don't bother polling if we're not matching SourceMembers
446432
return;
447433
}
448-
449-
const overriddenTimeout = toNumber(env.getString('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', '0'));
450-
if (overriddenTimeout > 0) {
451-
this.logger.debug(`Overriding SourceMember polling timeout to ${overriddenTimeout}`);
452-
pollingTimeout = overriddenTimeout;
453-
}
454-
455-
// Calculate a polling timeout for SourceMembers based on the number of
456-
// member names being polled plus a buffer of 5 seconds. This will
457-
// wait 50s for each 1000 components, plus 5s.
458-
if (!pollingTimeout) {
459-
pollingTimeout = Math.ceil(expectedMemberNames.length * 0.05) + 5;
460-
this.logger.debug(`Computed SourceMember polling timeout of ${pollingTimeout}s`);
461-
}
462-
434+
const adjustedExpectedMembers = expectedMembers.filter(
435+
(fileResponse) =>
436+
// unchanged files will never be in the sourceMembers. Not really sure why SDR returns them.
437+
fileResponse.state !== ComponentStatus.Unchanged &&
438+
// aura meta.xml aren't tracked as SourceMembers
439+
!(
440+
fileResponse.filePath?.startsWith('AuraDefinition') &&
441+
fileResponse.filePath?.endsWith('.cmp-meta.xml') &&
442+
// if a listView is the only change inside an object, the object won't have a sourceMember change. We won't wait for those to be found
443+
fileResponse.type !== 'CustomObject'
444+
)
445+
);
463446
const fromRevision = this.getServerMaxRevision();
447+
const pollingTimeout = this.calculateTimeout(adjustedExpectedMembers.length);
464448
this.logger.debug(
465-
`Polling for ${expectedMemberNames.length} SourceMembers from revision ${fromRevision} with timeout of ${pollingTimeout}s`
449+
`Polling for ${adjustedExpectedMembers.length} SourceMembers from revision ${fromRevision} with timeout of ${pollingTimeout}s`
466450
);
467451

468-
let pollAttempts = 0;
452+
const outstandingSourceMembers = new Map();
469453

470-
// we need to keep asking the server for sourceMembers until time runs out OR we find everything in matches
471-
const expectedSourceMembers = new Set(expectedMemberNames);
472-
let sourceMembers;
473-
const poll = async (): Promise<SourceMember[]> => {
454+
// filter known Source tracking issues
455+
adjustedExpectedMembers.map((member) => {
456+
getMetadataKeyFromFileResponse(member).map((key) => outstandingSourceMembers.set(key, member));
457+
});
458+
459+
let pollAttempts = 0;
460+
const poll = async (): Promise<void> => {
474461
pollAttempts += 1; // not used to stop polling, but for debug logging
475-
sourceMembers = await this.querySourceMembersFrom({
462+
463+
// get sourceMembers since maxRevision
464+
const queriedMembers = await this.querySourceMembersFrom({
476465
fromRevision,
477466
quiet: pollAttempts !== 1,
478467
useCache: false,
479468
});
480469

481-
for (const member of sourceMembers) {
482-
expectedSourceMembers.delete(member.MemberName);
483-
}
470+
// remove anything returned from the query list
471+
queriedMembers.map((member) => {
472+
outstandingSourceMembers.delete(getMetadataKey(member.MemberType, member.MemberName));
473+
});
484474

485475
this.logger.debug(
486-
`[${pollAttempts}] Found ${expectedMemberNames.length - expectedSourceMembers.size} of ${
487-
expectedMemberNames.length
476+
`[${pollAttempts}] Found ${adjustedExpectedMembers.length - outstandingSourceMembers.size} of ${
477+
adjustedExpectedMembers.length
488478
} SourceMembers`
489479
);
490-
if (expectedSourceMembers.size === 0) {
491-
return sourceMembers;
480+
481+
// update but don't sync
482+
await this.trackSourceMembers(queriedMembers, false);
483+
484+
// exit if all have returned
485+
if (outstandingSourceMembers.size === 0) {
486+
return;
492487
}
493488

494-
if (expectedSourceMembers.size < 20) {
495-
this.logger.debug(`Still looking for SourceMembers: ${Array.from(expectedSourceMembers).join(',')}`);
489+
if (outstandingSourceMembers.size < 20) {
490+
this.logger.debug(
491+
outstandingSourceMembers.size < 20
492+
? `Still looking for SourceMembers: ${Array.from(outstandingSourceMembers.keys()).join(',')}`
493+
: `Still looking for ${outstandingSourceMembers.size} Source Members`
494+
);
496495
}
497496
throw new Error();
498497
};
@@ -506,21 +505,31 @@ export class RemoteSourceTrackingService extends ConfigFile<RemoteSourceTracking
506505
this.logger.debug(`Retrieved all SourceMember data after ${pollAttempts} attempts`);
507506
} catch {
508507
this.logger.warn(`Polling for SourceMembers timed out after ${pollAttempts} attempts`);
509-
if (expectedSourceMembers.size < 51) {
508+
if (outstandingSourceMembers.size < 51) {
510509
this.logger.debug(
511-
`Could not find ${expectedSourceMembers.size} SourceMembers: ${Array.from(expectedSourceMembers).join(',')}`
510+
`Could not find ${outstandingSourceMembers.size} SourceMembers: ${Array.from(outstandingSourceMembers).join(
511+
','
512+
)}`
512513
);
513514
} else {
514-
this.logger.debug(`Could not find SourceMembers for ${expectedSourceMembers.size} components`);
515+
this.logger.debug(`Could not find SourceMembers for ${outstandingSourceMembers.size} components`);
515516
}
516517
}
518+
}
519+
520+
private calculateTimeout(memberCount: number): number {
521+
const overriddenTimeout = toNumber(env.getString('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', '0'));
522+
if (overriddenTimeout > 0) {
523+
this.logger.debug(`Overriding SourceMember polling timeout to ${overriddenTimeout}`);
524+
return overriddenTimeout;
525+
}
517526

518-
// NOTE: we are updating tracking for every SourceMember returned by the query once we match all memberNames
519-
// passed OR polling times out. This does not update SourceMembers of *only* the memberNames passed.
520-
// This means if we ever want to support tracking on source:deploy or source:retrieve we would need
521-
// to update tracking for only the matched SourceMembers. I.e., call trackSourceMembers() passing
522-
// only the SourceMembers that match the memberNames.
523-
await this.trackSourceMembers(sourceMembers, true);
527+
// Calculate a polling timeout for SourceMembers based on the number of
528+
// member names being polled plus a buffer of 5 seconds. This will
529+
// wait 50s for each 1000 components, plus 5s.
530+
const pollingTimeout = Math.ceil(memberCount * 0.05) + 5;
531+
this.logger.debug(`Computed SourceMember polling timeout of ${pollingTimeout}s`);
532+
return pollingTimeout;
524533
}
525534

526535
private async querySourceMembersFrom({
@@ -562,8 +571,11 @@ export class RemoteSourceTrackingService extends ConfigFile<RemoteSourceTracking
562571
this.logger.debug(query);
563572
}
564573

565-
const results = await this.org.getConnection().tooling.autoFetchQuery<T>(query);
566-
567-
return results.records;
574+
try {
575+
const results = await this.org.getConnection().tooling.autoFetchQuery<T>(query);
576+
return results.records;
577+
} catch (error) {
578+
throw SfdxError.wrap(error as Error);
579+
}
568580
}
569581
}

src/shared/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77

88
import { FileResponse } from '@salesforce/source-deploy-retrieve';
99

10-
export type RemoteSyncInput = Pick<FileResponse, 'fullName' | 'filePath' | 'type'>;
10+
export type RemoteSyncInput = Pick<FileResponse, 'fullName' | 'filePath' | 'type' | 'state'>;
1111

1212
export type PushPullResponse = Pick<FileResponse, 'filePath' | 'fullName' | 'state' | 'type'>;

src/sourceTracking.ts

+16-8
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export class SourceTracking extends AsyncCreatable {
157157
files: successNonDeletes.map((fileResponse) => fileResponse.filePath) as string[],
158158
deletedFiles: successDeletes.map((fileResponse) => fileResponse.filePath) as string[],
159159
}),
160-
this.updateRemoteTracking(successes), // this includes polling for sourceMembers
160+
this.updateRemoteTracking(successes), // this should include polling for sourceMembers
161161
]);
162162

163163
return result.getFileResponses();
@@ -251,7 +251,12 @@ export class SourceTracking extends AsyncCreatable {
251251
files: successes.map((fileResponse) => fileResponse.filePath as string).filter(Boolean),
252252
}),
253253
this.updateRemoteTracking(
254-
successes.map((success) => ({ filePath: success.filePath, type: success.type, fullName: success.fullName }))
254+
successes.map((success) => ({
255+
filePath: success.filePath,
256+
type: success.type,
257+
fullName: success.fullName,
258+
state: success.state,
259+
}))
255260
),
256261
]);
257262
return [...deletesAsFileResponse, ...retrieveResult.getFileResponses()];
@@ -349,13 +354,16 @@ export class SourceTracking extends AsyncCreatable {
349354
}
350355

351356
/**
352-
* Mark remote source tracking files that we have received to the latest version
357+
* Mark remote source tracking files so say that we have received the latest version from the server
358+
* Optionall skip polling for the SourceMembers to exist on the server and be updated in local files
353359
*/
354-
public async updateRemoteTracking(fileResponses: RemoteSyncInput[]): Promise<void> {
355-
await this.ensureRemoteTracking();
356-
// TODO: poll for source tracking to be complete
357-
// to make sure we have the updates before syncing the ones from metadataKeys
358-
await this.remoteSourceTrackingService.retrieveUpdates({ cache: false });
360+
public async updateRemoteTracking(fileResponses: RemoteSyncInput[], skipPolling = false): Promise<void> {
361+
// false to explicitly NOT query until we do the polling
362+
await this.ensureRemoteTracking(false);
363+
if (!skipPolling) {
364+
// poll to make sure we have the updates before syncing the ones from metadataKeys
365+
await this.remoteSourceTrackingService.pollForSourceTracking(fileResponses);
366+
}
359367
await this.remoteSourceTrackingService.syncSpecifiedElements(fileResponses);
360368
}
361369

test/nuts/commands/basics.nut.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,25 @@ import * as path from 'path';
1111
import * as fs from 'fs';
1212

1313
import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit';
14+
import { Env } from '@salesforce/kit';
15+
import { ensureString } from '@salesforce/ts-types';
1416
import { FileResponse } from '@salesforce/source-deploy-retrieve';
1517
import { expect } from 'chai';
1618
import { StatusResult } from '../../../src/commands/force/source/status';
1719

1820
let session: TestSession;
19-
21+
let hubUsername: string;
2022
describe('end-to-end-test for tracking with an org (single packageDir)', () => {
23+
const env = new Env();
24+
2125
before(async () => {
2226
session = await TestSession.create({
2327
project: {
2428
sourceDir: path.join('test', 'nuts', 'ebikes-lwc'),
2529
},
2630
setupCommands: [`sfdx force:org:create -d 1 -s -f ${path.join('config', 'project-scratch-def.json')}`],
2731
});
32+
hubUsername = ensureString(env.getString('TESTKIT_HUB_USERNAME'));
2833
});
2934

3035
after(async () => {
@@ -105,7 +110,13 @@ describe('end-to-end-test for tracking with an org (single packageDir)', () => {
105110
});
106111

107112
describe('non-successes', () => {
108-
it('should throw an err when attempting to pull from a non scratch-org');
113+
it('should throw an err when attempting to pull from a non scratch-org', () => {
114+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
115+
const failure = execCmd(`force:source:status -u ${hubUsername} --remote --json`, {
116+
ensureExitCode: 1,
117+
}).jsonOutput;
118+
expect(failure.name).to.equal('NonSourceTrackedOrgError');
119+
});
109120
it('should not poll for SourceMembers when SFDX_DISABLE_SOURCE_MEMBER_POLLING=true');
110121

111122
describe('push partial success', () => {

0 commit comments

Comments
 (0)