diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index d8f0644f77..d029ff9d25 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -398,11 +398,14 @@ export const identifies = ( items: readonly JsonObject[], name: string, ): boolean => { - const values = items.map((item) => scalarText(item[name])); - return ( - values.every((value) => value !== undefined) && - new Set(values).size === values.length - ); + // Stop at the first missing or repeated value, so a sparse list costs one read per field. + const seen = new Set(); + for (const item of items) { + const value = scalarText(item[name]); + if (value === undefined || seen.has(value)) return false; + seen.add(value); + } + return true; }; /** Fields ending in one of these may identify listed items that no identity name identifies. */ diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 58cca153b9..cd5eb2b39f 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -17,7 +17,12 @@ import { type AnalysisViewResult, } from "./analysisView.js"; import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js"; -import { at, replaceAt } from "./environmentStateRules.js"; +import { + at, + identityCandidates, + replaceAt, + type JsonObject, +} from "./environmentStateRules.js"; type State = Extract; const ALL_RULES = [ @@ -1007,3 +1012,30 @@ it("reads only item totals and next-page markers as signs of a partial list", () ["recorded", []], ]); }); + +it("reads a sparse list once per candidate field when inferring identity", () => { + const fields = 2_000; + const wide: JsonObject = Object.fromEntries( + Array.from({ length: fields }, (_, index) => [ + `f${String(index)}Id`, + index, + ]), + ); + let reads = 0; + const sparse = Array.from( + { length: 5_000 }, + () => + new Proxy( + {}, + { + get: (target, key, receiver) => { + reads += 1; + return Reflect.get(target, key, receiver) as unknown; + }, + }, + ), + ); + expect(identityCandidates([wide, ...sparse])).toEqual([]); + // Each identity name and suffix field stops at the first sparse item. + expect(reads).toBeLessThanOrEqual(fields + 20); +});