Skip to content

Commit dfe60e3

Browse files
fix(core): the import resolves a lookup cell by the target object's stored title field (#25033)
Part of #24981 Clause-②: no The import door now honours the title rule ADR-0079 already declares; no schema, export or published accept set moves, and nothing narrows. ## What this delivers (the decision-independent half) The import's lookup resolution (`resolveRef`, `packages/core/src/utils/import-runner.ts`) now asks the target object's title field after the id and the lookup's own `displayField`, and before the fallback keys. The title is resolved by ADR-0079's one rule, `resolveDisplayField` from `@objectstack/spec/data`. Only a STORED title is asked. - `Wren` (target title `displayNameField: 'nickname'`, a stored text field) now resolves. On `main` it answered `reference_not_found`, because `nickname` was never asked. - A formula title (`John Smith` against `nameField: 'full_name'`, `Acme (EMEA)` against `nameField: 'display_title'`) is not asked by `where`. The swallowed `INVALID_FIELD` query is gone, and the cell still answers `reference_not_found`. - Two records sharing a stored title answer the existing `reference_ambiguous`. Neither is linked. - The fallback key list (`name`, `title`, `label`, `full_name`, `email`, `username`) and its order are unchanged. The exposure check from #22739 still runs first and still withholds a target whose declared exposure does not serve `list`. ## What is left (with the maintainer) Q1 on the card asks how a FORMULA title should be matched (options A, B-cap, C and D in the first dev report and in the seat's analysis). Nothing is built for it here: no scan, no search door, no new refusal code. The card's second Wanted bullet ("A formula title is matched by the engine's own record-title resolution") is not delivered by this PR, so #24981 remains open after it merges. ## Mechanism (measured in this run) - **H1 holds.** `servesReferenceTarget` already read the target's `getMetaItem` item for its `enable` block. It now also returns the title field (`resolveDisplayField(item)`) and the field map from that same read. No extra metadata read is made. Pinned: two rows asking one target cost one `getMetaItem` call. - **The export reads the same seam.** `withReferenceTitleFields` (`packages/rest/src/export-format.ts`) resolves a lookup cell's title with `resolveDisplayField` over the `getMetaItem` item (`readObjectSchema` in `packages/rest/src/data-action-routes.ts`). So the import asks the field the export wrote the cell from. - **H2 holds.** "Stored" is decided by `isVirtualSearchField` (`@objectstack/spec/data`, over `SEARCH_VIRTUAL_TYPES`, which holds `formula`). It is the same predicate the engine's filter seam `assertFilterIsMaterializable` (`packages/objectql/src/filter-comparand-shape.ts`) uses to refuse a `where`, so there is no second list. `packages/core` cannot import objectql, because objectql depends on core. The spec-level predicate the engine itself reads is the one reused. - **The skip covers every candidate the target declares as a formula, not only the title rung.** The formula pin needs this: the card's formula title `full_name` is also on the fallback list, so skipping only the rung would still send `where full_name`. The skip changes no verdict. The engine refuses every `where` over a formula at that seam, which its own comment calls "the one place EVERY caller-supplied `where` passes through". The resolver's `catch` read that refusal as "field absent" and moved on. ## Pins (`packages/core/src/utils/import-runner-reference-title.test.ts`) The fake `findData` answers like the engine: equality over the stored row, and a recorded `INVALID_FIELD` / 400 refusal for a `where` over a formula field. 1. `Wren` resolves through a stored `displayNameField: 'nickname'`, and through `nameField: 'nickname'` (the alias as the registry normalizes it). The asked keys are `id`, then the lookup's `displayField` (`first_name`), then `nickname`. 2. A real (non-dry) run stores the matched id. `getMetaItem` is called once for two rows. 3. `John Smith` (formula `full_name`) and `Acme (EMEA)` (formula `display_title`) answer `reference_not_found`. The find calls never name the formula field. The asked keys are exactly `id` plus the fallback list minus the formula member, and no refusal was produced. 4. Two records sharing a stored title answer `code: 'reference_ambiguous'`. 5. For #22739: a target with `enable: { apiEnabled: false }` and a stored title answers `reference_not_found` for a match and for a miss. `findData` is never called, and `getMetaItem` is called once. ## Ablations The fix was committed first. Both mutations went through `scripts/ablation-replace.mjs`: the anchor hit 1 and fell to 0, and the blob moved. Each restore is proven by blob hash equal to HEAD and an empty `git diff HEAD`. The subject is imported by relative path from `src/`, so no `dist/` leg applies. - **Rung removed** (the line `...(target.titleField ? [target.titleField] : []),` deleted): 4 failed, 3 passed. Both Wren pins went red with `reference_not_found` for `person`. The stored-id pin went red. The ambiguity pin went red with `reference_not_found` in place of `reference_ambiguous`. Restored: blob `d9b249d961d4` equals HEAD. - **Virtual filter removed** (the `isVirtualSearchField` filter on the candidate list dropped): 2 failed, 5 passed. Both formula pins went red: "expected [ 'id', 'full_name', ... ] to not include 'full_name'", and the same for `display_title`. Restored: blob `d9b249d961d4` equals HEAD. ## Tests and gates (final head `9a67cf4ce`) - `pnpm --filter @objectstack/core typecheck`: exit 0. The test layer compiles under `tsconfig.test.json` (the new file is in that program), with 4 of 4 pinned signatures held. - `pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2`: 109 files, 2630 tests passed. - The `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derivation gave 68 commands. All 68 were run at `9a67cf4ce`, and every one exited 0. `--ran` reconciliation: "68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)". - An earlier battery at `745620fac` went red on `check:error-code-casing`. The new test spelled a field-addressed code with a shorthand `field` key, which the gate does not read as field-addressed. `9a67cf4ce` spells `field: column`. - `eslint --no-inline-config` over the two touched source files: exit 0. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Takeover.** This PR continues an interrupted dev run. The inherited uncommitted change to `import-runner.ts` (+63/-16) was read hunk by hunk and kept unchanged as the first commit. Every claim above was measured again in this run. - **The refusal wording.** For a formula-titled target, the `reference_not_found` text does not say why the cell cannot match. The first dev measured `Contact: no record matches "John Smith"`. No wording changes here. Whether the refusal should name the formula title belongs with Q1's answer; the seat's analysis lists it as a confidence gap. - **Carried from the first dev report, not filed.** When the declared title misses, the fallback key list can still link a cell by an unrelated column (`VP of Engineering` links a contact by its job `title`). This PR leaves that unchanged, by ruling. - **Carried, not filed.** `resolveRef`'s `catch` reads every `findData` throw as "field absent". Only the `INVALID_FIELD` path is measured. - **Declared to CI, not run locally.** Some consumer suites drive `runImport` through `@objectstack/core`: `packages/rest/src/import-integration.test.ts`, `packages/plugins/plugin-auth/src/admin-import-users.test.ts` and the dogfood import-reference pins. No export, type or wire shape of `@objectstack/core` changed, so CI's Test Core carries them. - **Base.** `origin/main` was merged at `73432299c` before this PR opened. Two later commits on `main` touch neither `packages/core` nor `packages/spec`. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d68aab6 commit dfe60e3

3 files changed

Lines changed: 237 additions & 16 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): the import resolves a lookup cell by the target object's stored title field
6+
7+
Clause-②: no
8+
9+
- **Before.** A lookup cell was matched against the target by its id, then by the lookup's own `displayField`, then by a fixed list of keys (`name`, `title`, `label`, `full_name`, `email`, `username`). The target object's declared title field was never asked. So a cell that named the record by its title, such as `Wren` for a target whose title is `nickname`, answered `reference_not_found`, although the list export writes lookup cells by that same title.
10+
- **Now.** After the id and the lookup's `displayField`, the import asks the target's title field, resolved by ADR-0079's one rule (`resolveDisplayField`: `nameField`, then the deprecated `displayNameField` alias, then the derived title). It is read from the same object declaration the import already reads to check the target's exposure, so no extra metadata read is made. Two records that share the title answer `reference_ambiguous`, like the import's other ambiguous matches. The cell is never linked to the first hit.
11+
- **A formula title is not matched.** A `formula` field has no stored column, and the engine refuses a filter over it. The import no longer sends that query, for the title or for any other candidate the target declares as a formula, so a cell against a formula title still answers `reference_not_found`. How a formula title should be matched is still open.
12+
- **Unchanged.** The fixed fallback keys and their order. The exposure check still runs first: a target whose declared exposure does not serve `list` answers `reference_not_found` for every cell and is never read.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24981] An import cell is resolved against a lookup TARGET's own title
5+
* field: ADR-0079's `resolveDisplayField`, the one rule the list export writes
6+
* a lookup cell by. It is asked after the id and the lookup's own
7+
* `displayField`, and before the fixed fallback keys, which are unchanged.
8+
*
9+
* Only a STORED title is asked. A title the target declares virtual (a
10+
* `formula`) has no column, and the engine's filter seam
11+
* (`assertFilterIsMaterializable`) refuses every `where` over one by the spec
12+
* predicate `isVirtualSearchField`; the resolver asks the same predicate and
13+
* never sends that query. How a formula title should match is the open half of
14+
* the card, with the maintainer, so such a cell still answers
15+
* `reference_not_found`.
16+
*
17+
* The fake's `findData` answers like the engine: equality over the stored row,
18+
* and an `INVALID_FIELD` / 400 refusal for a `where` over a formula field.
19+
*/
20+
21+
import { describe, it, expect, vi } from 'vitest';
22+
import { runImport, type ImportProtocolLike } from './import-runner';
23+
import type { ExportFieldMeta } from './import-field-meta.js';
24+
25+
type FindArgs = Parameters<ImportProtocolLike['findData']>[0];
26+
type Row = Record<string, unknown>;
27+
type Declaration = { fields: Record<string, Row>; nameField?: string; displayNameField?: string; enable?: unknown };
28+
29+
const TEXT = { type: 'text' };
30+
const FORMULA = { type: 'formula', returnType: 'text' };
31+
32+
/** The card's three targets, plus one whose stored title two records share. */
33+
const DECLARATIONS: Record<string, Declaration> = {
34+
// `Wren`: a STORED title off the fixed key list.
35+
xt_person: { displayNameField: 'nickname', fields: { first_name: TEXT, nickname: TEXT } },
36+
// `John Smith`: a FORMULA title whose name IS on the fixed key list.
37+
xt_contact: {
38+
nameField: 'full_name',
39+
fields: { first_name: TEXT, last_name: TEXT, title: TEXT, full_name: { ...FORMULA, expression: 'first_name + " " + last_name' } },
40+
},
41+
// `Acme (EMEA)`: a FORMULA title off the fixed key list.
42+
xt_account: {
43+
nameField: 'display_title',
44+
fields: { name: TEXT, region: TEXT, display_title: { ...FORMULA, expression: 'name + " (" + region + ")"' } },
45+
},
46+
xt_member: { nameField: 'nickname', fields: { nickname: TEXT } },
47+
};
48+
49+
const ROWS: Record<string, Row[]> = {
50+
xt_person: [{ id: 'p1', first_name: 'Ann', nickname: 'Wren' }],
51+
xt_contact: [{ id: 'c1', first_name: 'John', last_name: 'Smith', title: 'VP of Engineering' }],
52+
xt_account: [{ id: 'a1', name: 'Acme', region: 'EMEA' }],
53+
xt_member: [{ id: 'm1', nickname: 'Robin' }, { id: 'm2', nickname: 'Robin' }],
54+
};
55+
56+
const lookup = (name: string, reference: string, extra: Partial<ExportFieldMeta> = {}): [string, ExportFieldMeta] =>
57+
[name, { name, type: 'lookup', reference, ...extra }];
58+
59+
const metaMap = new Map<string, ExportFieldMeta>([
60+
['name', { name: 'name', type: 'text' }],
61+
// The lookup's own `displayField` is asked before the target's title.
62+
lookup('person', 'xt_person', { displayField: 'first_name' }),
63+
lookup('contact', 'xt_contact'),
64+
lookup('account', 'xt_account'),
65+
lookup('member', 'xt_member'),
66+
]);
67+
68+
function protocol(overrides: Record<string, Partial<Declaration>> = {}) {
69+
const refusals: Array<{ object: string; field: string; code: string; status: number }> = [];
70+
const findData = vi.fn(async (args: FindArgs) => {
71+
const decl = { ...DECLARATIONS[args.object], ...overrides[args.object] };
72+
const [[field, value]] = Object.entries(args.query!.where!);
73+
if (decl.fields?.[field]?.type === 'formula') {
74+
const err = Object.assign(new Error(`filters on '${field}', a virtual formula field`), { code: 'INVALID_FIELD', status: 400 });
75+
refusals.push({ object: args.object, field, code: err.code, status: err.status });
76+
throw err;
77+
}
78+
return (ROWS[args.object] ?? []).filter((r) => r[field] === value);
79+
});
80+
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) =>
81+
({ type: 'object', name, item: { name, ...DECLARATIONS[name], ...overrides[name] } }));
82+
const p: ImportProtocolLike = { findData, getMetaItem, createData: vi.fn(async (a) => ({ id: 'new', ...a.data })), updateData: vi.fn() };
83+
return { p, findData, getMetaItem, refusals };
84+
}
85+
86+
const run = (p: ImportProtocolLike, rows: Row[]) =>
87+
runImport({
88+
p, context: { userId: 'u_member', roles: ['member'] }, objectName: 'xt_source', metaMap, writeMode: 'insert',
89+
matchFields: [], dryRun: true, runAutomations: false, trimWhitespace: true, createMissingOptions: false,
90+
skipBlankMatchKey: false, rows,
91+
});
92+
93+
/** The `where` keys asked of one target, in order. */
94+
const askedOf = (findData: ReturnType<typeof protocol>['findData'], object: string) =>
95+
findData.mock.calls.filter(([a]) => a.object === object).map(([a]) => Object.keys(a.query!.where!)[0]);
96+
97+
const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field });
98+
const OK = { ok: true, code: undefined, field: undefined };
99+
100+
describe('[#24981] import resolves a lookup cell by the target object\'s stored title field', () => {
101+
for (const [label, override] of [
102+
['displayNameField (the card\'s cell, as authored)', {}],
103+
['nameField (the alias as the registry normalizes it)', { displayNameField: undefined, nameField: 'nickname' }],
104+
] as const) {
105+
it(`Wren resolves through a stored ${label}, asked after the id and the lookup's displayField`, async () => {
106+
const { p, findData, refusals } = protocol({ xt_person: override });
107+
const summary = await run(p, [{ name: 'a', person: 'Wren' }]);
108+
expect(summary.results.map(verdict)).toEqual([OK]);
109+
expect(askedOf(findData, 'xt_person')).toEqual(['id', 'first_name', 'nickname']);
110+
expect(refusals).toEqual([]);
111+
});
112+
}
113+
114+
it('the stored title stores the matched record\'s id', async () => {
115+
const { p, getMetaItem } = protocol();
116+
const createData = vi.fn(async (a: { data: Row }) => ({ id: 'new', ...a.data }));
117+
p.createData = createData;
118+
const summary = await runImport({
119+
p, context: {}, objectName: 'xt_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: false,
120+
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false,
121+
rows: [{ name: 'a', person: 'Wren' }, { name: 'b', person: 'Wren' }],
122+
});
123+
expect(summary.results.map(verdict)).toEqual([OK, OK]);
124+
expect(createData.mock.calls.map(([a]) => a.data.person)).toEqual(['p1', 'p1']);
125+
// The title comes off the declaration the exposure check already reads:
126+
// one metadata read per target per import, however many rows ask it.
127+
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['xt_person']);
128+
});
129+
130+
for (const [cell, object, column, title] of [
131+
['John Smith', 'xt_contact', 'contact', 'full_name'],
132+
['Acme (EMEA)', 'xt_account', 'account', 'display_title'],
133+
] as const) {
134+
it(`a formula title (${object}.${title}) is never asked by where: '${cell}' still answers reference_not_found`, async () => {
135+
const { p, findData, refusals } = protocol();
136+
const summary = await run(p, [{ name: 'a', [column]: cell }]);
137+
expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field: column }]);
138+
const asked = askedOf(findData, object);
139+
expect(asked).not.toContain(title);
140+
// The fixed fallback keys are unchanged; only a member the target
141+
// declares virtual is skipped, which the engine could only refuse.
142+
expect(asked).toEqual(['id', 'name', 'title', 'label', 'full_name', 'email', 'username'].filter((f) => f !== title));
143+
expect(refusals).toEqual([]);
144+
});
145+
}
146+
147+
it('two records sharing a stored title answer the import\'s ambiguity refusal, and link neither', async () => {
148+
const { p, findData } = protocol();
149+
const summary = await run(p, [{ name: 'a', member: 'Robin' }]);
150+
expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'member' }]);
151+
expect(askedOf(findData, 'xt_member')).toEqual(['id', 'nickname']);
152+
});
153+
154+
it('[#22739] a target whose exposure refuses list still answers reference_not_found, and is never read', async () => {
155+
const { p, findData, getMetaItem } = protocol({ xt_person: { enable: { apiEnabled: false } } });
156+
const summary = await run(p, [{ name: 'a', person: 'Wren' }, { name: 'b', person: 'Missing' }]);
157+
const NOT_FOUND = { ok: false, code: 'reference_not_found', field: 'person' };
158+
expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND]);
159+
expect(findData).not.toHaveBeenCalled();
160+
expect(getMetaItem).toHaveBeenCalledTimes(1);
161+
});
162+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto';
44
import { coerceRow, type RefResolver, type RefMatch } from './import-coerce.js';
55
import type { ExportFieldMeta } from './import-field-meta.js';
66
import type { ValidationMessageTranslator } from '@objectstack/spec/system';
7-
import { canServeApiOperation, type DroppedFieldsEvent, type EnableLike } from '@objectstack/spec/data';
7+
import {
8+
canServeApiOperation, isVirtualSearchField, resolveDisplayField,
9+
type DisplayNameObjectMeta, type DroppedFieldsEvent, type EnableLike, type SearchFieldMeta,
10+
} from '@objectstack/spec/data';
811
import type { ExecutionContext } from '@objectstack/spec/kernel';
912
import type { CreateDataRequest, FindDataRequest, GetMetaItemRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api';
1013
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js';
@@ -210,7 +213,8 @@ export interface ImportProtocolLike {
210213
/**
211214
* [#22739] The object-declaration read, the one the door's import template
212215
* and the connector pull already make. The reference resolution reads a
213-
* lookup TARGET's `enable` block through it ({@link servesReferenceTarget}).
216+
* lookup TARGET's `enable` block through it ({@link servesReferenceTarget}),
217+
* and, from the same read, the target's title field and field map (#24981).
214218
* A protocol without it has no declaration to judge, and none is fabricated
215219
* for it; plugin-auth's identity import, whose reads run under the system
216220
* identity, carries it like every other caller (#22800).
@@ -593,17 +597,50 @@ const REFERENCE_TARGET_OPERATION = 'list';
593597
* caller. A refused target answers as a target the caller cannot read, which
594598
* is measured: every cell, matching or not, `reference_not_found`, and nothing
595599
* is read. A declaration that cannot be read withholds too (fail-closed).
600+
*
601+
* [#24981] A withheld target answers `null`. A served one answers what the
602+
* resolution reads off the SAME declaration ({@link ServedReferenceTarget}),
603+
* so the title costs no second read: still one read per target per import.
596604
*/
597-
async function servesReferenceTarget(p: ImportProtocolLike, target: string): Promise<boolean> {
598-
if (typeof p.getMetaItem !== 'function') return true;
599-
let enable: EnableLike | null | undefined;
605+
async function servesReferenceTarget(p: ImportProtocolLike, target: string): Promise<ServedReferenceTarget | null> {
606+
if (typeof p.getMetaItem !== 'function') return {};
607+
let item: ReferenceTargetDeclaration | undefined;
600608
try {
601-
const found = (await p.getMetaItem({ type: 'object', name: target })) as { item?: { enable?: EnableLike | null } } | undefined;
602-
enable = found?.item?.enable;
609+
const found = (await p.getMetaItem({ type: 'object', name: target })) as { item?: ReferenceTargetDeclaration } | undefined;
610+
item = found?.item;
603611
} catch {
604-
return false;
612+
return null;
605613
}
606-
return canServeApiOperation(enable, REFERENCE_TARGET_OPERATION);
614+
if (!canServeApiOperation(item?.enable, REFERENCE_TARGET_OPERATION)) return null;
615+
if (!item || typeof item !== 'object') return {};
616+
return {
617+
titleField: resolveDisplayField(item),
618+
fields: item.fields as ServedReferenceTarget['fields'],
619+
};
620+
}
621+
622+
/** The slice of a lookup target's declaration the reference resolution reads. */
623+
type ReferenceTargetDeclaration = DisplayNameObjectMeta & { enable?: EnableLike | null };
624+
625+
/**
626+
* [#24981] What a served lookup target tells the reference resolution, read off
627+
* its declaration by {@link servesReferenceTarget}.
628+
*/
629+
interface ServedReferenceTarget {
630+
/**
631+
* The field that titles a record of the target: ADR-0079's
632+
* `resolveDisplayField`, the one rule the list export writes a lookup cell
633+
* by (`withReferenceTitleFields` in `@objectstack/rest`), so a cell the export
634+
* wrote is asked by the field it was written from. `undefined` when the
635+
* declaration resolves none, or no declaration was read.
636+
*/
637+
titleField?: string;
638+
/**
639+
* The target's declared field map. It is read for one question only: which
640+
* candidate fields are virtual ({@link isVirtualSearchField}), because a
641+
* `where` over a virtual field is refused by the engine's filter seam.
642+
*/
643+
fields?: Readonly<Record<string, SearchFieldMeta | undefined>>;
607644
}
608645

609646
/** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */
@@ -678,24 +715,34 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
678715
// (object, display) so a name repeated across rows costs one query. The
679716
// target's exposure is asked first, once per target per import (#22739).
680717
const refCache = new Map<string, RefMatch>();
681-
const servedTargets = new Map<string, Promise<boolean>>();
718+
const servedTargets = new Map<string, Promise<ServedReferenceTarget | null>>();
682719
const resolveRef: RefResolver = async (referenceObject, display, meta) => {
683720
let served = servedTargets.get(referenceObject);
684721
if (!served) servedTargets.set(referenceObject, served = servesReferenceTarget(p, referenceObject));
685-
if (!(await served)) return {};
722+
const target = await served;
723+
if (!target) return {};
686724
const cacheKey = `${referenceObject}::${display}`;
687725
const cached = refCache.get(cacheKey);
688726
if (cached) return cached;
689727
// Try an exact id first (authoritative + unique when the user pasted an id),
690-
// then the configured display field, then the usual human identifiers.
691-
// De-dupe so a field isn't queried twice. The first candidate field to match
692-
// wins; if that field matches >1 record we stop and report ambiguity rather
693-
// than silently linking the first.
728+
// then the configured display field, then the target's own title field
729+
// (#24981: ADR-0079's rule, the field the list export writes the cell by),
730+
// then the usual human identifiers. De-dupe so a field isn't queried twice.
731+
// The first candidate field to match wins; if that field matches >1 record
732+
// we stop and report ambiguity rather than silently linking the first.
733+
//
734+
// A candidate the target declares VIRTUAL (a `formula`) is never asked: the
735+
// engine's filter seam (`assertFilterIsMaterializable`) refuses every
736+
// `where` over one, judged by this same spec predicate, so its query could
737+
// only answer a refusal the catch below reads as "field absent". Skipping
738+
// it changes no verdict. A formula title therefore matches no cell here;
739+
// whether and how one should is the open half of #24981.
694740
const candidates = [...new Set([
695741
'id',
696742
...(meta.displayField ? [meta.displayField] : []),
743+
...(target.titleField ? [target.titleField] : []),
697744
'name', 'title', 'label', 'full_name', 'email', 'username',
698-
])];
745+
])].filter((f) => !isVirtualSearchField(target.fields?.[f]));
699746
const lookup = async (): Promise<RefMatch> => {
700747
let match: RefMatch = {};
701748
for (const f of candidates) {

0 commit comments

Comments
 (0)