Skip to content

Commit 69936c1

Browse files
CristiCanizalesclaudemshanemc
authored
fix(org-browser): deduplicate metadata list results to prevent tree registration errors (#7321)
* fix(org-browser): deduplicate metadata list results to prevent tree registration errors The Metadata API can return duplicate FileProperties for the same component (e.g. Account appearing twice in orgs with Person Account enabled). This causes the Org Browser TreeView to crash with "Element with id CustomObject:Account is already registered". Deduplicate after sorting since duplicates are adjacent. Fixes #7212 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: dedupe listMetadata via Arr.dedupeAdjacentWith + Equivalence Extract the fullName dedup into a named Effect Equivalence colocated with FilePropertiesSchema, and use Arr.dedupeAdjacentWith instead of an inline index filter. Decode now runs before sort/dedup so the Equivalence operates on decoded values. Adds unit tests covering sort, single-object coercion, dedup, and combined cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: mshanemc <shane.mclaughlin@salesforce.com>
1 parent b51e704 commit 69936c1

3 files changed

Lines changed: 110 additions & 2 deletions

File tree

packages/salesforcedx-vscode-services/src/core/metadataDescribeService.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { Connection } from '@salesforce/core';
9+
import * as Arr from 'effect/Array';
910
import * as Cache from 'effect/Cache';
1011
import * as Chunk from 'effect/Chunk';
1112
import * as Duration from 'effect/Duration';
@@ -20,7 +21,7 @@ import { ExtensionContextService } from '../vscode/extensionContextService';
2021
import { SettingsService } from '../vscode/settingsService';
2122
import { ConnectionService } from './connectionService';
2223
import { getDefaultOrgRef } from './defaultOrgRef';
23-
import { FilePropertiesSchema } from './schemas/fileProperties';
24+
import { FilePropertiesByFullName, FilePropertiesSchema } from './schemas/fileProperties';
2425
import { unknownToErrorCause } from './shared';
2526

2627
const NON_SUPPORTED_TYPES = new Set(['InstalledPackage', 'Profile', 'Scontrol']);
@@ -176,8 +177,9 @@ export class MetadataDescribeService extends Effect.Service<MetadataDescribeServ
176177
Effect.tap(result => Effect.annotateCurrentSpan({ result })),
177178
Effect.withSpan('listMetadata (API call)'),
178179
Effect.map(ensureArray),
179-
Effect.map(arr => arr.toSorted((a, b) => a.fullName.localeCompare(b.fullName))),
180180
Effect.flatMap(arr => S.decodeUnknown(S.Array(FilePropertiesSchema))(arr)),
181+
Effect.map(arr => arr.toSorted((a, b) => a.fullName.localeCompare(b.fullName))),
182+
Effect.map(Arr.dedupeAdjacentWith(FilePropertiesByFullName)),
181183
Effect.mapError(e => {
182184
const { cause } = unknownToErrorCause(e);
183185
return new ListMetadataError({

packages/salesforcedx-vscode-services/src/core/schemas/fileProperties.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7+
import * as Equivalence from 'effect/Equivalence';
78
import * as S from 'effect/Schema';
89

910
/** Schema for FileProperties (metadata.list result) */
@@ -21,3 +22,9 @@ export const FilePropertiesSchema = S.Struct({
2122
manageableState: S.optional(S.String),
2223
namespacePrefix: S.optional(S.String)
2324
});
25+
26+
/** Equivalence for FileProperties keyed on fullName — for deduping listMetadata results. */
27+
export const FilePropertiesByFullName = Equivalence.mapInput(
28+
Equivalence.string,
29+
(fp: S.Schema.Type<typeof FilePropertiesSchema>) => fp.fullName
30+
);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import type { Connection } from '@salesforce/core';
9+
import * as Effect from 'effect/Effect';
10+
import * as Layer from 'effect/Layer';
11+
import * as SubscriptionRef from 'effect/SubscriptionRef';
12+
import { ChannelService } from '../../../src/vscode/channelService';
13+
import { ConnectionService } from '../../../src/core/connectionService';
14+
import { getDefaultOrgRef } from '../../../src/core/defaultOrgRef';
15+
import { MetadataDescribeService } from '../../../src/core/metadataDescribeService';
16+
17+
type ListItem = {
18+
fullName: string;
19+
type: string;
20+
id?: string;
21+
lastModifiedDate?: string;
22+
};
23+
24+
const createMockConnectionService = (listResult: ListItem | ListItem[]): Layer.Layer<ConnectionService> =>
25+
Layer.succeed(
26+
ConnectionService,
27+
ConnectionService.make({
28+
getConnection: () =>
29+
Effect.succeed({
30+
version: '60.0',
31+
metadata: {
32+
list: jest.fn().mockResolvedValue(listResult)
33+
}
34+
} as unknown as Connection),
35+
invalidateCachedConnections: () => Effect.void
36+
})
37+
);
38+
39+
const seedDefaultOrg = Effect.gen(function* () {
40+
const ref = yield* getDefaultOrgRef();
41+
yield* SubscriptionRef.update(ref, () => ({ orgId: 'test-org' }));
42+
});
43+
44+
const runListMetadata = (listResult: ListItem | ListItem[], type = 'ApexClass') =>
45+
Effect.runPromise(
46+
Effect.gen(function* () {
47+
yield* seedDefaultOrg;
48+
const service = yield* MetadataDescribeService;
49+
return yield* service.listMetadata(type);
50+
}).pipe(
51+
Effect.provide(
52+
Layer.provide(
53+
MetadataDescribeService.DefaultWithoutDependencies,
54+
Layer.mergeAll(createMockConnectionService(listResult), ChannelService.Default)
55+
)
56+
)
57+
)
58+
);
59+
60+
describe('MetadataDescribeService.listMetadata', () => {
61+
it('sorts an out-of-order array by fullName', async () => {
62+
const result = await runListMetadata([
63+
{ fullName: 'Charlie', type: 'ApexClass' },
64+
{ fullName: 'Alpha', type: 'ApexClass' },
65+
{ fullName: 'Bravo', type: 'ApexClass' }
66+
]);
67+
68+
expect(result.map(r => r.fullName)).toEqual(['Alpha', 'Bravo', 'Charlie']);
69+
});
70+
71+
it('wraps a non-array (single object) response into an array of one', async () => {
72+
const result = await runListMetadata({ fullName: 'Solo', type: 'ApexClass' });
73+
74+
expect(result).toHaveLength(1);
75+
expect(result[0].fullName).toBe('Solo');
76+
});
77+
78+
it('deduplicates entries with the same fullName', async () => {
79+
const result = await runListMetadata([
80+
{ fullName: 'Dup', type: 'ApexClass', id: '1' },
81+
{ fullName: 'Dup', type: 'ApexClass', id: '2' },
82+
{ fullName: 'Unique', type: 'ApexClass' }
83+
]);
84+
85+
expect(result.map(r => r.fullName)).toEqual(['Dup', 'Unique']);
86+
});
87+
88+
it('handles out-of-order, duplicate, and single-fullName entries together', async () => {
89+
const result = await runListMetadata([
90+
{ fullName: 'Charlie', type: 'ApexClass', id: 'c1' },
91+
{ fullName: 'Alpha', type: 'ApexClass', id: 'a1' },
92+
{ fullName: 'Charlie', type: 'ApexClass', id: 'c2' },
93+
{ fullName: 'Bravo', type: 'ApexClass' },
94+
{ fullName: 'Alpha', type: 'ApexClass', id: 'a2' }
95+
]);
96+
97+
expect(result.map(r => r.fullName)).toEqual(['Alpha', 'Bravo', 'Charlie']);
98+
});
99+
});

0 commit comments

Comments
 (0)