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 = {