Skip to content

Commit add2d19

Browse files
hotlongclaude
andauthored
fix(metadata-protocol): title ⌘K hits from the canonical nameField (#8786) (#8809)
`searchAll`'s `renderTitle` opened its candidate list with `obj.displayNameField` alone — the ADR-0079 DEPRECATED alias. The canonical pointer is `nameField`, and `provisionPrimary` (the designation seat the SchemaRegistry runs on every object at registration) stamps `nameField` only, never the alias. So an object that declared its primary title canonically fell through the candidate list to `String(row.id)` and the palette showed a raw id. Reads `obj.nameField ?? obj.displayNameField` instead — the spelling `resolveDisplayField`, the #4254 ingress gate, and this same function's search-field resolution 44 lines below already use. Presentation only: #7643's recall half is untouched. Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75e66fc commit add2d19

3 files changed

Lines changed: 250 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): global search titles a hit from the canonical `nameField`, not only the deprecated `displayNameField` alias (#8786)
6+
7+
`searchAll` — the global-search (⌘K) palette — resolved a hit's title from a
8+
candidate list that opened with `obj.displayNameField` **alone**. Under
9+
ADR-0079 `nameField` is the canonical primary-title pointer and
10+
`displayNameField` is the deprecated alias, so this was the one consumer a
11+
canonical designation could not reach.
12+
13+
It is reachable rather than theoretical because `provisionPrimary` — the
14+
ADR-0079 designation seat the SchemaRegistry runs on every object at
15+
registration — stamps `nameField` **only** and never the alias. An object that
16+
declares its primary title canonically, without also carrying the deprecated
17+
alias, produced `undefined` for that entry, the entry was filtered out of the
18+
candidate list, and the title fell through to `String(row.id)`: the palette
19+
showed a raw record id where the object's own declared, populated title
20+
existed.
21+
22+
Impact was bounded to objects whose primary title is **outside**
23+
`name` / `full_name` / `title` / `subject` / `label` / `company` — anything in
24+
that conventional list already resolved through the later entries, which is why
25+
this stayed invisible. An object declaring `nameField: 'company_name'` now
26+
titles its hits `Acme Industrial` instead of `acc_1`.
27+
28+
The fix reads the precedence the rest of the platform already spells —
29+
`obj.nameField ?? obj.displayNameField` — matching `resolveDisplayField`
30+
(`@objectstack/spec`), the #4254 ingress gate, and this same function's
31+
search-field resolution 44 lines below. The deprecated alias is still honored
32+
on its own; only objects that carry **both** pointers naming **different**
33+
fields see a precedence change, and no such object exists in this repo (every
34+
one that carries both spells them identically).
35+
36+
Presentation only: which rows come back is untouched.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#8786] `searchAll`'s TITLE rendering reads the canonical primary-title
4+
// pointer, not only the deprecated alias.
5+
//
6+
// ## The defect
7+
//
8+
// `renderTitle`'s candidate list opened with `obj.displayNameField` alone.
9+
// Under ADR-0079 `nameField` is the CANONICAL pointer and `displayNameField`
10+
// is the deprecated alias — and `provisionPrimary`, the designation seat the
11+
// SchemaRegistry runs on every object at registration, stamps `nameField`
12+
// ONLY (`packages/spec/src/data/display-name.ts:301,314`). So an object whose
13+
// primary title is designated canonically, without the deprecated alias
14+
// alongside it, produced `undefined` for that entry, the entry was filtered
15+
// out, and the title fell through the conventional-name list to
16+
// `String(row.id)` — the ⌘K palette showing a raw record id where the
17+
// object's own declared, populated title existed.
18+
//
19+
// ## Why the fixtures look the way they do
20+
//
21+
// The impact is bounded to objects whose primary title is OUTSIDE
22+
// `name` / `full_name` / `title` / `subject` / `label` / `company`: anything
23+
// in that list resolves through the conventional entries and titles
24+
// identically before and after the fix. Every pin here that must discriminate
25+
// therefore uses a NON-conventional title field (`company_name`, `ref_no`,
26+
// `ref_code` — note `company_name` is not `company`).
27+
//
28+
// ## Recall is NOT what this file pins
29+
//
30+
// #7643 fixed which rows come back and delegated recall to the engine; that
31+
// contract is pinned in `protocol.search-case-fold.test.ts`. This file is
32+
// PRESENTATION — what the returned row is called. Same function, different
33+
// contract. The double below is deliberately a boundary stand-in with no
34+
// filtering: it returns its rows verbatim so the assertions are about
35+
// titling and nothing else.
36+
37+
import { describe, it, expect, vi } from 'vitest';
38+
import { ObjectStackProtocolImplementation } from './protocol.js';
39+
40+
type ObjectMeta = Record<string, unknown> & { name: string };
41+
42+
/**
43+
* A protocol over a fixed object set. `rowsByObject` is served verbatim —
44+
* no filtering, no `$search` expansion — so a hit's `title` is the only
45+
* thing under test.
46+
*/
47+
function makeProtocol(objects: ObjectMeta[], rowsByObject: Record<string, unknown[]>) {
48+
const find = vi.fn(async (object: string) => rowsByObject[object] ?? []);
49+
const engine = {
50+
registry: {
51+
getObject: (n: string) => objects.find(o => o.name === n),
52+
getAllObjects: () => objects,
53+
},
54+
find,
55+
};
56+
return new ObjectStackProtocolImplementation(engine as never);
57+
}
58+
59+
const text = (name: string) => ({ name, type: 'text' });
60+
61+
describe('[#8786] searchAll titles a hit from the canonical nameField', () => {
62+
it('titles from a canonically-designated nameField with no displayNameField', async () => {
63+
// THE PIN. `account` declares its primary title the way
64+
// `provisionPrimary` designates it — `nameField` only, no deprecated
65+
// alias — and `company_name` is outside the conventional list, so
66+
// before the fix this hit came back titled `acc_1`.
67+
const account: ObjectMeta = {
68+
name: 'account',
69+
nameField: 'company_name',
70+
fields: { company_name: text('company_name') },
71+
};
72+
const p = makeProtocol([account], {
73+
account: [{ id: 'acc_1', company_name: 'Acme Industrial' }],
74+
});
75+
76+
const { hits } = await p.searchAll({ q: 'acme', perObject: 5 });
77+
78+
expect(hits).toHaveLength(1);
79+
expect(hits[0].title).toBe('Acme Industrial');
80+
// Stated separately: the regression's signature is the raw id, and an
81+
// assertion naming it survives a future refactor of the value above.
82+
expect(hits[0].title).not.toBe('acc_1');
83+
});
84+
85+
it('CONTROL — an object using a conventional `name` still titles correctly', async () => {
86+
// The discriminating control. Without it the pin above cannot tell
87+
// "the candidate list now reads the canonical pointer" from "the
88+
// candidate list was bypassed": a short-circuit on the pointer would
89+
// pass the pin and break this, since `contact` declares no pointer at
90+
// all and depends entirely on the conventional entries.
91+
const contact: ObjectMeta = {
92+
name: 'contact',
93+
fields: { name: text('name') },
94+
};
95+
const p = makeProtocol([contact], {
96+
contact: [{ id: 'con_1', name: 'Ada Lovelace' }],
97+
});
98+
99+
const { hits } = await p.searchAll({ q: 'ada', perObject: 5 });
100+
101+
expect(hits).toHaveLength(1);
102+
expect(hits[0].title).toBe('Ada Lovelace');
103+
});
104+
105+
it('CONTROL — the pointer is a CANDIDATE, not a short-circuit: an empty value falls through', async () => {
106+
// The second half of the same discrimination. `nameField` names a
107+
// field that exists but is blank on this row; the ordered list must
108+
// continue to the conventional `name` rather than returning empty or
109+
// dropping to the id. A `return row[pointer]` rewrite passes both
110+
// tests above and fails here.
111+
const account: ObjectMeta = {
112+
name: 'account',
113+
nameField: 'company_name',
114+
fields: { company_name: text('company_name'), name: text('name') },
115+
};
116+
const p = makeProtocol([account], {
117+
account: [{ id: 'acc_2', company_name: ' ', name: 'Fallback Co' }],
118+
});
119+
120+
const { hits } = await p.searchAll({ q: 'fallback', perObject: 5 });
121+
122+
expect(hits).toHaveLength(1);
123+
expect(hits[0].title).toBe('Fallback Co');
124+
});
125+
126+
it('still honors the deprecated displayNameField alias on its own', async () => {
127+
// Back-compat: the alias is DEPRECATED, not withdrawn. An object
128+
// carrying only the alias titled correctly before this change and
129+
// must keep doing so — the fix adds a canonical read in front of the
130+
// alias, it does not replace it.
131+
const legacy: ObjectMeta = {
132+
name: 'legacy_doc',
133+
displayNameField: 'ref_code',
134+
fields: { ref_code: text('ref_code') },
135+
};
136+
const p = makeProtocol([legacy], {
137+
legacy_doc: [{ id: 'leg_1', ref_code: 'DOC-2291' }],
138+
});
139+
140+
const { hits } = await p.searchAll({ q: 'doc', perObject: 5 });
141+
142+
expect(hits).toHaveLength(1);
143+
expect(hits[0].title).toBe('DOC-2291');
144+
});
145+
146+
it('prefers nameField when an object carries BOTH pointers naming different fields', async () => {
147+
// The precedence this change lands on, pinned rather than assumed.
148+
// It is the platform's existing answer, not a new one:
149+
// `resolveDisplayField` spells `nameField ?? displayNameField`
150+
// (`spec/src/data/display-name.ts`) and is pinned to prefer
151+
// `nameField` in `display-name.test.ts`; `searchAll`'s own
152+
// search-field resolution and the #4254 ingress gate spell it the same
153+
// way. A fifth answer here would re-split what those merged.
154+
//
155+
// No object in the repo is actually in this state — every real object
156+
// carrying both spells them identically (measured across the repo for
157+
// #8786) — so this pins the rule for the first object that isn't,
158+
// rather than changing any object's behaviour today.
159+
const ticket: ObjectMeta = {
160+
name: 'ticket',
161+
nameField: 'ref_no',
162+
displayNameField: 'legacy_ref',
163+
fields: { ref_no: text('ref_no'), legacy_ref: text('legacy_ref') },
164+
};
165+
const p = makeProtocol([ticket], {
166+
ticket: [{ id: 'tk_1', ref_no: 'TK-77', legacy_ref: 'OLD-11' }],
167+
});
168+
169+
const { hits } = await p.searchAll({ q: 'tk', perObject: 5 });
170+
171+
expect(hits).toHaveLength(1);
172+
expect(hits[0].title).toBe('TK-77');
173+
});
174+
175+
it('titleFormat still outranks the pointer', async () => {
176+
// Ordering above the changed line is untouched: `titleFormat` is
177+
// resolved before the candidate list is built, so a canonical
178+
// `nameField` does not overtake an explicit format.
179+
const account: ObjectMeta = {
180+
name: 'account',
181+
titleFormat: '{company_name} ({region})',
182+
nameField: 'company_name',
183+
fields: { company_name: text('company_name'), region: text('region') },
184+
};
185+
const p = makeProtocol([account], {
186+
account: [{ id: 'acc_3', company_name: 'Acme Industrial', region: 'EMEA' }],
187+
});
188+
189+
const { hits } = await p.searchAll({ q: 'acme', perObject: 5 });
190+
191+
expect(hits).toHaveLength(1);
192+
expect(hits[0].title).toBe('Acme Industrial (EMEA)');
193+
});
194+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8670,10 +8670,11 @@ export class ObjectStackProtocolImplementation implements
86708670
: []);
86718671
const fieldByName = new Map(fields.map(f => [f.name, f]));
86728672
const hasField = (n: string) => fieldByName.has(n);
8673-
// Resolve title for a record using titleFormat → displayNameField →
8674-
// common conventional fields → id. titleFormat supports simple
8675-
// `{field}` placeholders (the `template` dialect); unresolved
8676-
// placeholders fall through to the next strategy.
8673+
// Resolve title for a record using titleFormat → the declared
8674+
// primary-title pointer → common conventional fields → id.
8675+
// titleFormat supports simple `{field}` placeholders (the
8676+
// `template` dialect); unresolved placeholders fall through to the
8677+
// next strategy.
86778678
const titleFormatSource = (obj.titleFormat && (obj.titleFormat.source || obj.titleFormat))
86788679
|| undefined;
86798680
const renderTitle = (row: any): string => {
@@ -8688,7 +8689,21 @@ export class ObjectStackProtocolImplementation implements
86888689
if (rendered) return rendered.replace(/\s+-\s+$/, '').replace(/^\s+-\s+/, '').trim() || row.id;
86898690
}
86908691
const candidates = [
8691-
obj.displayNameField,
8692+
// [ADR-0079] `nameField` is the canonical primary-title
8693+
// pointer; `displayNameField` is the deprecated alias
8694+
// (still honored). Reading the alias ALONE made this the
8695+
// one consumer a canonical designation could not reach:
8696+
// `provisionPrimary` — the designation seat the registry
8697+
// runs on every object at registration — stamps
8698+
// `nameField` only (`spec/src/data/display-name.ts`), so
8699+
// an object that declares its title canonically and does
8700+
// not also carry the alias fell through this list to
8701+
// `String(row.id)` and the palette showed a raw id. Same
8702+
// precedence as `resolveDisplayField`, the #4254 ingress
8703+
// gate, and this function's own search-field resolution 44
8704+
// lines below — a fourth spelling here would re-split what
8705+
// those merged.
8706+
obj.nameField ?? obj.displayNameField,
86928707
'name', 'full_name', 'title', 'subject', 'label', 'company',
86938708
].filter((c): c is string => typeof c === 'string' && hasField(c));
86948709
for (const c of candidates) {

0 commit comments

Comments
 (0)