diff --git a/package.json b/package.json index e9c7c546..e83e4c2e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@salesforce/core": "^9.1.4", + "@salesforce/core": "^9.1.6", "@salesforce/kit": "^4.0.0", "@salesforce/source-deploy-retrieve": "^13.2.0", "@salesforce/ts-types": "^3.0.1", diff --git a/src/shared/remote/fileOperations.ts b/src/shared/remote/fileOperations.ts index 5c75d3b0..dfa4fdc9 100644 --- a/src/shared/remote/fileOperations.ts +++ b/src/shared/remote/fileOperations.ts @@ -86,6 +86,31 @@ export const upgradeFileContents = (contents: ContentsV0): ContentsV1 => ({ ), }); +/** + * Prevents cross-process stale writes by merging in-memory state with on-disk state. + * For each member present in both, takes the higher lastRetrievedFromServer (never regresses). + */ +const mergeWithDiskState = async ( + filePath: string, + members: Map, + maxCounter: number +): Promise => { + const diskContents = await readFileContents(filePath); + if (!('serverMaxRevisionCounter' in diskContents) || !diskContents.sourceMembers) { + return maxCounter; + } + + for (const [key, diskMember] of Object.entries(diskContents.sourceMembers)) { + const memMember = members.get(key); + if (memMember && (diskMember.lastRetrievedFromServer ?? -1) > (memMember.lastRetrievedFromServer ?? -1)) { + members.set(key, { ...memMember, lastRetrievedFromServer: diskMember.lastRetrievedFromServer }); + } + } + + const diskMax = diskContents.serverMaxRevisionCounter; + return Number.isFinite(diskMax) ? Math.max(maxCounter, diskMax) : maxCounter; +}; + export const writeTrackingFile = async ({ filePath, maxCounter, @@ -96,6 +121,11 @@ export const writeTrackingFile = async ({ members: Map; }): Promise => { const lockResult = await lockInit(filePath); + + // Merge with on-disk state to prevent cross-process stale overwrites. + // readFileContents returns {} for missing/unreadable files, which mergeWithDiskState handles. + maxCounter = await mergeWithDiskState(filePath, members, maxCounter); + const CURRENT_FILE_VERSION_ENV = env.getNumber('SF_SOURCE_TRACKING_FILE_VERSION') ?? 0; const contents = CURRENT_FILE_VERSION_ENV === 1 diff --git a/test/unit/remote/fileOperations.test.ts b/test/unit/remote/fileOperations.test.ts index 0f3228b0..41f64e2c 100644 --- a/test/unit/remote/fileOperations.test.ts +++ b/test/unit/remote/fileOperations.test.ts @@ -78,6 +78,171 @@ describe('writing file version based on env', () => { }); }); +describe('cross-process merge on write', () => { + const fakeOrgId = '00DFakeFakeFakeMrg'; + const filePath = getFilePath(fakeOrgId); + + afterEach(async () => { + delete process.env.SF_SOURCE_TRACKING_FILE_VERSION; + try { + await fs.promises.unlink(filePath); + } catch { + // may not exist + } + }); + + it('preserves higher lastRetrievedFromServer from disk when in-memory is stale', async () => { + const diskMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 50, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 50, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 50, members: diskMembers }); + + const staleMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 30, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 50, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 50, members: staleMembers }); + + const contents = await fs.promises.readFile(filePath, 'utf8'); + const parsed = parseJsonMap(contents, filePath); + expect(parsed.sourceMembers['ApexClass__MyClass'].lastRetrievedFromServer).to.equal(50); + }); + + it('preserves higher lastRetrievedFromServer from memory when disk is stale', async () => { + const diskMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 30, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 50, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 30, members: diskMembers }); + + const freshMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 50, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 50, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 50, members: freshMembers }); + + const contents = await fs.promises.readFile(filePath, 'utf8'); + const parsed = parseJsonMap(contents, filePath); + expect(parsed.sourceMembers['ApexClass__MyClass'].lastRetrievedFromServer).to.equal(50); + }); + + it('does not regress null over a valid lastRetrievedFromServer on disk', async () => { + const diskMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 26, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 26, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 26, members: diskMembers }); + + const staleMembers = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: undefined, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 26, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 26, members: staleMembers }); + + const contents = await fs.promises.readFile(filePath, 'utf8'); + const parsed = parseJsonMap(contents, filePath); + expect(parsed.sourceMembers['ApexClass__MyClass'].lastRetrievedFromServer).to.equal(26); + }); + + it('takes the max serverMaxRevisionCounter between memory and disk', async () => { + const members = new Map([ + [ + 'ApexClass###MyClass', + { + MemberIdOrName: '01p000000000001', + ChangedBy: 'user1', + lastRetrievedFromServer: 10, + MemberType: 'ApexClass', + IsNameObsolete: false, + RevisionCounter: 10, + MemberName: 'MyClass', + IsNewMember: false, + LastModifiedDate: '2026-01-01', + }, + ], + ]); + await writeTrackingFile({ filePath, maxCounter: 100, members }); + await writeTrackingFile({ filePath, maxCounter: 80, members }); + + const contents = await fs.promises.readFile(filePath, 'utf8'); + const parsed = parseJsonMap(contents, filePath); + expect(parsed.serverMaxRevisionCounter).to.equal(100); + }); +}); + describe('upgrading undefined file version to v1 file', () => { it('returns new file version even if file is not versioned', () => { const oldFile = { diff --git a/yarn.lock b/yarn.lock index 0bbabe9a..96e3942f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -817,6 +817,31 @@ ts-retry-promise "^0.8.1" zod "^4.1.12" +"@salesforce/core@^9.1.6": + version "9.1.6" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.1.6.tgz#cdc951ab573b92f482d0371d29687f9170e4a9d9" + integrity sha512-suF9uvuTwuer8zAlN4mRr3Dvd5Z+rM58r//P3JiYEgY4e1cMdiq7LOKA0PLZpbtNJL3rcZZDFTvqD6ypQAU1gA== + dependencies: + "@jsforce/jsforce-node" "^3.10.22" + "@salesforce/kit" "^4.0.0" + "@salesforce/ts-types" "^3.0.0" + ajv "^8.18.0" + change-case "^4.1.2" + fast-levenshtein "^3.0.0" + faye "^1.4.1" + form-data "^4.0.5" + js2xmlparser "^4.0.1" + jsonwebtoken "9.0.3" + jszip "3.10.1" + memfs "4.38.1" + pino "^9.7.0" + pino-abstract-transport "^1.2.0" + pino-pretty "^11.3.0" + proper-lockfile "^4.1.2" + semver "^7.8.0" + ts-retry-promise "^0.8.1" + zod "^4.1.12" + "@salesforce/dev-config@^4.3.3": version "4.3.3" resolved "https://registry.yarnpkg.com/@salesforce/dev-config/-/dev-config-4.3.3.tgz#65650087baa542a905ad4e8b38a5c5302f7f009a"