Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/24087-search-title-namefield-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/spec': patch
---

Clause-②: no

fix(metadata-protocol): the global search palette titles a hit by its `nameField` before the deprecated `titleFormat`

- **What changed.** `searchAll` (`GET /api/v1/search`) now resolves a hit's `title` in this order: the primary-title pointer (`nameField`, or the deprecated alias `displayNameField`), then the `titleFormat` template, then the conventional name fields, then first/last name, then the record id. Before, `titleFormat` came first. ADR-0079 D3 and the `titleFormat` schema description both say an explicit `nameField` takes precedence.
- **What it means for a deployment.** An object that declares both keys is now titled by its `nameField` value. For example, an approval request shows `Discount Sign-off · opp_45` instead of `flow:deal_desk · opp_45`. The registry also designates a `nameField` for any object that has a title-eligible field (a text-like field, or a formula returning text). So an object that declares only `titleFormat` is now titled by that designated field too. The template still titles a row when the object has no pointer, or when the pointer's value is empty on that row.
- **Fix for an author who wants the composite title back:** add a `formula` field with `returnType: 'text'` that builds the composite, set `nameField` to it, and drop `titleFormat`. This is the migration ADR-0079 prescribes (`os explain title-format-retired`).
- **`@objectstack/spec`:** the description of `SearchAllHitSchema.title` now states the same order. No schema shape changed.
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2796,7 +2796,7 @@ Installed package with runtime lifecycle state
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Name of the object the hit belongs to. |
| **id** | `string` | ✅ | ID of the matched record. |
| **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. |
| **title** | `string` | ✅ | Display title for the hit, resolved in order: the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the object's deprecated `titleFormat` template → conventional name fields → first/last name → the record ID as a string. |
| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. |
| **record** | `Record<string, any>` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". |

Expand Down Expand Up @@ -2837,7 +2837,7 @@ Installed package with runtime lifecycle state
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Name of the object the hit belongs to. |
| **id** | `string` | ✅ | ID of the matched record. |
| **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. |
| **title** | `string` | ✅ | Display title for the hit, resolved in order: the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the object's deprecated `titleFormat` template → conventional name fields → first/last name → the record ID as a string. |
| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. |
| **record** | `Record<string, any>` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". |

Expand Down
120 changes: 106 additions & 14 deletions packages/metadata-protocol/src/protocol.search-title-namefield.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
// titling and nothing else.

import { describe, it, expect, vi } from 'vitest';
import { provisionPrimary } from '@objectstack/spec/data';
import { ObjectStackProtocolImplementation } from './protocol.js';

type ObjectMeta = Record<string, unknown> & { name: string };
Expand Down Expand Up @@ -174,24 +175,115 @@ describe('[#8786] searchAll titles a hit from the canonical nameField', () => {
expect(hits).toHaveLength(1);
expect(hits[0].title).toBe('TK-77');
});
});

it('titleFormat still outranks the pointer', async () => {
// Ordering above the changed line is untouched: `titleFormat` is
// resolved before the candidate list is built, so a canonical
// `nameField` does not overtake an explicit format.
const account: ObjectMeta = {
name: 'account',
titleFormat: '{company_name} ({region})',
nameField: 'company_name',
fields: { company_name: text('company_name'), region: text('region') },
};
const p = makeProtocol([account], {
account: [{ id: 'acc_3', company_name: 'Acme Industrial', region: 'EMEA' }],
describe('[#24087] searchAll titles nameField before the deprecated titleFormat', () => {
// [ADR-0079 D3] `titleFormat` is retired in favour of `nameField`, and the
// spec's own describe says "an explicit nameField now takes precedence".
// `renderTitle` used to render the template FIRST and return it whenever
// every placeholder resolved, so an object declaring both keys
// (`sys_approval_request`: `titleFormat: '{process_name} · {record_id}'`
// beside a `display_title` formula designated as `nameField`) was titled
// in the palette by its machine source.
//
// The fixtures below are passed through `provisionPrimary` — the
// designate-only stamp the SchemaRegistry runs on every object at
// registration — so each one is the body `registry.getAllObjects()`
// serves, not the body as authored. That stamp is why a titleFormat-only
// object with a title-eligible field is titled by the DERIVED pointer
// (pinned below): authorship is not recoverable after it, so the reader
// cannot tell an authored `nameField` from a stamped one.
const served = (o: ObjectMeta): ObjectMeta => provisionPrimary(o, { synthesize: false });

it('THE PIN — an object declaring both keys is titled by its nameField value', async () => {
const request = served({
name: 'approval_request',
titleFormat: '{process_name} · {record_id}',
nameField: 'display_title',
fields: {
display_title: { name: 'display_title', type: 'formula', returnType: 'text' },
process_name: text('process_name'),
record_id: text('record_id'),
},
});
const p = makeProtocol([request], {
approval_request: [{
id: 'apr_1',
display_title: 'Discount Sign-off · opp_45',
process_name: 'flow:deal_desk',
record_id: 'opp_45',
}],
});

const { hits } = await p.searchAll({ q: 'acme', perObject: 5 });
const { hits } = await p.searchAll({ q: 'discount', perObject: 5 });

expect(hits).toHaveLength(1);
expect(hits[0].title).toBe('Discount Sign-off · opp_45');
// The defect's signature, named so a refactor of the value above
// cannot quietly re-admit it.
expect(hits[0].title).not.toBe('flow:deal_desk · opp_45');
});

it('CONTROL — the template still titles an object the registry designates no pointer for', async () => {
// No title-eligible field, so `provisionPrimary` stamps nothing and
// the template is the best title there is: the reorder must demote
// `titleFormat`, not drop it.
const order = served({
name: 'order',
titleFormat: 'Order {order_no}',
fields: { order_no: { name: 'order_no', type: 'number' } },
// A number is not in the auto-default search set; declaring it
// keeps the object scannable so the sweep reaches its title.
searchableFields: ['order_no'],
});
expect(order.nameField).toBeUndefined();
const p = makeProtocol([order], { order: [{ id: 'ord_1', order_no: 42 }] });

const { hits } = await p.searchAll({ q: '42', perObject: 5 });

expect(hits).toHaveLength(1);
expect(hits[0].title).toBe('Order 42');
});

it('CONTROL — an empty pointer value falls through to the template, not to the id', async () => {
const request = served({
name: 'approval_request',
titleFormat: '{process_name} · {record_id}',
nameField: 'display_title',
fields: {
display_title: { name: 'display_title', type: 'formula', returnType: 'text' },
process_name: text('process_name'),
record_id: text('record_id'),
},
});
const p = makeProtocol([request], {
approval_request: [{ id: 'apr_2', display_title: '', process_name: 'flow:deal_desk', record_id: 'opp_46' }],
});

const { hits } = await p.searchAll({ q: 'deal', perObject: 5 });

expect(hits).toHaveLength(1);
expect(hits[0].title).toBe('flow:deal_desk · opp_46');
});

it('a titleFormat-only object with a title-eligible field is titled by the STAMPED pointer', async () => {
// The measured consequence of mirroring `resolveDisplayField`: the
// registry designates `ref_code` (first title-eligible field), and the
// served body cannot say whether an author wrote it, so it outranks
// the deprecated template exactly as an authored one does.
const ticket = served({
name: 'ticket',
titleFormat: '[{ref_code}] {summary_text}',
fields: { ref_code: text('ref_code'), summary_text: text('summary_text') },
});
expect(ticket.nameField).toBe('ref_code');
const p = makeProtocol([ticket], {
ticket: [{ id: 'tk_9', ref_code: 'TK-9', summary_text: 'Printer on fire' }],
});

const { hits } = await p.searchAll({ q: 'tk', perObject: 5 });

expect(hits).toHaveLength(1);
expect(hits[0].title).toBe('Acme Industrial (EMEA)');
expect(hits[0].title).toBe('TK-9');
});
});
46 changes: 28 additions & 18 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15559,14 +15559,39 @@ export class ObjectStackProtocolImplementation implements
: []);
const fieldByName = new Map(fields.map(f => [f.name, f]));
const hasField = (n: string) => fieldByName.has(n);
// Resolve title for a record using titleFormat → the declared
// primary-title pointer → common conventional fields → id.
// Resolve title for a record: the declared primary-title pointer →
// titleFormat → common conventional fields → id. [ADR-0079 D3]
// `nameField` takes precedence over the deprecated `titleFormat`
// (the spec's own describe says so), the same order as
// `resolveDisplayField`, which never consults `titleFormat` at all.
// The registry's `provisionPrimary` stamps a derived `nameField`
// at registration, and authorship is not recoverable after that
// stamp (see `ObjectTitleCompleteness.status`), so a stamped
// pointer outranks the template too: `titleFormat` titles a row
// only when the object has no pointer, or its value is empty.
// titleFormat supports simple `{field}` placeholders (the
// `template` dialect); unresolved placeholders fall through to the
// next strategy.
const titleFormatSource = (obj.titleFormat && (obj.titleFormat.source || obj.titleFormat))
|| undefined;
// [ADR-0079] `nameField` is the canonical primary-title pointer;
// `displayNameField` is the deprecated alias (still honored).
// Reading the alias ALONE made this the one consumer a canonical
// designation could not reach: `provisionPrimary` — the
// designation seat the registry runs on every object at
// registration — stamps `nameField` only
// (`spec/src/data/display-name.ts`), so an object that declares its
// title canonically and does not also carry the alias fell through
// to `String(row.id)` and the palette showed a raw id. Same
// precedence as `resolveDisplayField`, the #4254 ingress gate, and
// this function's own search-field resolution below — a fourth
// spelling here would re-split what those merged.
const titlePointer = obj.nameField ?? obj.displayNameField;
const renderTitle = (row: any): string => {
if (typeof titlePointer === 'string' && hasField(titlePointer)) {
const v = row[titlePointer];
if (v != null && String(v).trim()) return String(v);
}
if (typeof titleFormatSource === 'string') {
let allResolved = true;
const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
Expand All @@ -15578,23 +15603,8 @@ export class ObjectStackProtocolImplementation implements
if (rendered) return rendered.replace(/\s+-\s+$/, '').replace(/^\s+-\s+/, '').trim() || row.id;
}
const candidates = [
// [ADR-0079] `nameField` is the canonical primary-title
// pointer; `displayNameField` is the deprecated alias
// (still honored). Reading the alias ALONE made this the
// one consumer a canonical designation could not reach:
// `provisionPrimary` — the designation seat the registry
// runs on every object at registration — stamps
// `nameField` only (`spec/src/data/display-name.ts`), so
// an object that declares its title canonically and does
// not also carry the alias fell through this list to
// `String(row.id)` and the palette showed a raw id. Same
// precedence as `resolveDisplayField`, the #4254 ingress
// gate, and this function's own search-field resolution 44
// lines below — a fourth spelling here would re-split what
// those merged.
obj.nameField ?? obj.displayNameField,
'name', 'full_name', 'title', 'subject', 'label', 'company',
].filter((c): c is string => typeof c === 'string' && hasField(c));
].filter(hasField);
for (const c of candidates) {
const v = row[c];
if (v != null && String(v).trim()) return String(v);
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/src/api/protocol.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2570,10 +2570,10 @@ export const SearchAllHitSchema = lazySchema(() => z.object({
object: z.string().describe('Name of the object the hit belongs to.'),
id: z.string().describe('ID of the matched record.'),
title: z.string().describe(
'Display title for the hit, resolved in order: the object\'s `titleFormat` template → '
+ 'the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias '
+ '`displayNameField` still honored) → conventional name fields → first/last name → '
+ 'the record ID as a string.'
'Display title for the hit, resolved in order: the declared primary-title pointer '
+ '(`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the '
+ 'object\'s deprecated `titleFormat` template → conventional name fields → first/last '
+ 'name → the record ID as a string.'
),
snippet: z.string().optional().describe(
'Excerpt cut around the first matched term in a searchable text column, ellipsized at '
Expand Down
Loading