From 91c3f6b3ff5fa4eef10703075db58e20ae34457c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:13:07 +0000 Subject: [PATCH] fix(plugin-dashboard): converge the two private reference-bearing field tables onto core's published family (#5692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/plugin-dashboard` held two hand-maintained copies of the reference-bearing field family — `LOOKUP_TYPES` in `recordFields.tsx` and an inline disjunction inside `computeLookupExpand` in `ObjectDataTable.tsx`. Neither derived from nor pinned against `@object-ui/core`'s published `EXPANDABLE_FIELD_TYPES`, and both predate objectui#5312, whose docblock recorded `paramToField` as the LAST private copy of the rule. Both now read core's object and carry the identity pin the earlier conversions carry, so a member-identical private copy fails rather than quietly re-forking the table. The copies were not in a subset relation with the shared family in either direction, so converging changes behaviour twice: - `tree` is GAINED — a self-referencing hierarchy column is now `$expand`-ed on the dashboard road, the treatment the form and grid roads already gave it. - `reference` is DROPPED, measured rather than preferred: the spelling is absent from `@objectstack/spec`'s closed `FieldType` vocabulary and refused by `FieldSchema.safeParse` — probed with `lookup` / `master_detail` / `user` / `tree` as live controls and the retired `owner` plus a nonsense spelling as dead ones — so no spec-compliant object schema can declare a field whose stored type is `reference`. The measurement is carried forward as an executable pin, so the day the spec adds the spelling this goes red and the membership question reopens. `EXPANDABLE_FIELD_TYPES` itself is unchanged: the measurement did not license widening a published shared set. Core's docblock ledger is corrected — its hand-kept "four consumers" count was already stale by two, and objectui#5312's "LAST private copy" claim was false by two more. objectui#4914's retirement gate is untouched and still runs ahead of both predicates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m --- ...oard-expandable-family-convergence-5692.md | 26 +++ packages/core/src/utils/expand-fields.ts | 47 ++++- .../plugin-dashboard/src/ObjectDataTable.tsx | 35 +++- .../expandableFamily.identity-5692.test.ts | 197 ++++++++++++++++++ .../lookupTypes.retiredFieldType.test.ts | 36 +++- .../plugin-dashboard/src/recordFields.tsx | 54 +++-- 6 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 .changeset/dashboard-expandable-family-convergence-5692.md create mode 100644 packages/plugin-dashboard/src/__tests__/expandableFamily.identity-5692.test.ts diff --git a/.changeset/dashboard-expandable-family-convergence-5692.md b/.changeset/dashboard-expandable-family-convergence-5692.md new file mode 100644 index 0000000000..b7fcc7a8cf --- /dev/null +++ b/.changeset/dashboard-expandable-family-convergence-5692.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +`plugin-dashboard`'s two private copies of the reference-bearing field family now +read `@object-ui/core`'s published `EXPANDABLE_FIELD_TYPES` instead of restating +it — `LOOKUP_TYPES` in `recordFields.tsx` and the inline disjunction inside +`computeLookupExpand` in `ObjectDataTable.tsx`. Both carry the identity pin the +already-converged consumers carry, so a member-identical private copy fails +rather than quietly re-forking the table. + +Two behaviour changes on the dashboard table's `$expand`, in opposite directions: + +- **A `tree` column is now expanded.** A self-referencing hierarchy field is + reference-bearing and a member of the shared family, so its cell renders the + parent record's display name instead of a bare id — the same treatment the form + and grid roads already gave it. +- **A `reference`-typed column is no longer expanded.** Measured before removing + it: `reference` is absent from `@objectstack/spec`'s closed `FieldType` + vocabulary and is refused by `FieldSchema.safeParse`, so no spec-compliant + object schema can declare a field whose stored type is `reference`. Dropping it + is a no-op on real data; the spelling is a legacy dialect alias on the + action-param surface, folded to `lookup` before any field-type data is read. + +`EXPANDABLE_FIELD_TYPES` itself is unchanged — the measurement did not license +widening a published shared set. diff --git a/packages/core/src/utils/expand-fields.ts b/packages/core/src/utils/expand-fields.ts index a934118a49..22c82bd705 100644 --- a/packages/core/src/utils/expand-fields.ts +++ b/packages/core/src/utils/expand-fields.ts @@ -23,10 +23,19 @@ import { columnIdentity } from './column-identity.js'; * belongs in this set; whether the backend materialises the expanded object * for it is a server concern — requesting it is harmless and forward-compatible. * - * ## One family, four consumers + * ## One family, many consumers — and NO reliable count of them + * + * This section used to open "One family, four consumers" and enumerate four. + * That count was hand-kept, and hand-kept counts of this table are exactly what + * keeps going wrong: it was already stale by two when objectui#5692 measured it + * (`paramToField` in `app-shell` had joined with objectui#5312, and + * `ListView`'s relational-sort rule reads the set directly), and objectui#5312's + * claim to have converted "the LAST private copy" was false by two MORE — see + * the falsification note at the end. Read the list below as the lineage of the + * conversions, not as a census; the mechanical fact is the identity pins. * * This is the reference-bearing FAMILY, not the `$expand` builder's private - * list, and four concerns already read it under three different words: + * list, and these concerns read it under several different words: * * - `$expand` construction — `buildExpandFields` below ("expandable"); * - predicate-record projection — `predicate-record.ts` ("relational"); @@ -37,7 +46,16 @@ import { columnIdentity } from './column-identity.js'; * - the grid's bulk-action dialog — `widgetNeedsDataSource` in * `packages/plugin-grid/src/components/bulkParamToField.ts`, which decides * which param widget is handed the grid's `DataSource` and which param field - * shape carries `reference_to` / `display_field`. + * shape carries `reference_to` / `display_field`; + * - the action-param dialog — `paramToField` in + * `packages/app-shell/src/utils/paramToField.ts`, which decides which param + * carries a reference target (objectui#5312); + * - the list view's relational-sort rule — `ListView.tsx`, which will not offer + * a server-side sort on a field whose stored value is a foreign key; + * - the dashboard table's `$expand` whitelist — `computeLookupExpand` in + * `packages/plugin-dashboard/src/ObjectDataTable.tsx` (objectui#5692); + * - the dashboard's relation/link test — `isLookupType` in + * `packages/plugin-dashboard/src/recordFields.tsx` (objectui#5692). * * The third one used to be a second hand-maintained copy, and this comment used * to claim the set "mirrors the form layer's `DATA_SOURCE_FIELD_TYPES` @@ -69,6 +87,29 @@ import { columnIdentity } from './column-identity.js'; * carry an identity pin (a spy on this `has`) so a member-identical private copy * fails rather than quietly re-forking the table. * + * ## The "LAST private copy" claim was false — objectui#5692 + * + * objectui#5312 converted `paramToField` (`app-shell`) and recorded it as the + * fourth and LAST private copy of this rule. `packages/plugin-dashboard` held + * two more the whole time — `LOOKUP_TYPES` in `recordFields.tsx` and an inline + * disjunction inside `computeLookupExpand` in `ObjectDataTable.tsx` — which + * predate that sweep and were outside its file surface, so nothing contradicted + * the claim. Both now derive from this set with NO extension and carry the same + * identity pin. + * + * Those two were NOT member-identical to this set, in either direction: they + * lacked `tree` and carried a fifth spelling, `reference`. Converging them was + * therefore a behaviour change in two directions, and the direction that could + * have widened THIS set was settled by measurement rather than by preference: + * `reference` is absent from `@objectstack/spec`'s closed `FieldType` + * vocabulary and is refused by `FieldSchema.safeParse` — measured with `lookup` + * / `master_detail` / `user` / `tree` as live controls and the retired `owner` + * plus a nonsense spelling as dead ones — so no spec-compliant object schema can + * declare a field whose stored type is `reference`, and the dashboard's copies + * were carrying a dead spelling, not a member this set was missing. This set is + * unchanged by objectui#5692; the two dashboard faces simply stopped answering + * for a type no producer can emit. + * * Stated so it reads as a decision rather than a surprise: **adding a member * here also grants that type the form's data-source wiring.** That is the * intended coupling — a type whose stored value is a foreign key needs a diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index e28542bb98..ac36072848 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -17,6 +17,9 @@ import { // re-exports the same function object; read here from its home. isRetiredFieldType, reportRetiredFieldType, + // The reference-bearing field family (objectui#5692). Read, never copied — + // see the convergence note on `computeLookupExpand`. + EXPANDABLE_FIELD_TYPES, } from '@object-ui/core'; import type { DrillDownConfig } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; @@ -173,8 +176,9 @@ export function normalizeColumns(columns: (string | Record)[]): Nor /** * Compute the list of lookup-typed accessors that should be expanded when * fetching rows. Returns column accessors whose object schema field type is - * a relation (lookup/reference/master_detail/user). Used by the - * dashboard table widget to ask the data adapter to populate referenced + * a relation. Which types those are is NOT restated here: it is + * {@link EXPANDABLE_FIELD_TYPES}, the family `@object-ui/core` publishes. Used + * by the dashboard table widget to ask the data adapter to populate referenced * records (e.g. `account: { id, name }`) so cells don't show raw FK ids. * * THE GATE (objectui#4914, ruling B) runs ahead of the relation test. Measured @@ -185,6 +189,28 @@ export function normalizeColumns(columns: (string | Record)[]): Nor * show once the spelling stopped being a relation. That the author is TOLD is * the whole difference between this and the mechanical deletion the * measurement rejected. + * + * ## The relation test is core's object, not a private copy (objectui#5692) + * + * It used to be the inline literal + * `t === 'lookup' || t === 'reference' || t === 'master_detail' || t === 'user'` + * — one of TWO copies this package held (the other `LOOKUP_TYPES` in + * `recordFields.tsx`), neither deriving from nor pinned against the family core + * publishes. objectui#5312's claim to have converted "the LAST private copy" + * was false by these two; they predate that sweep and were outside its file + * surface. + * + * This is the LIVE half of that convergence — `computeLookupExpand` drives a + * real `$expand` on every dashboard table fetch — so both membership deltas are + * observable here, and both were decided by measurement (see `isLookupType` in + * `recordFields.tsx` for the full record): + * + * - a `tree` column now GETS `$expand`-ed, the same treatment the form / grid + * road already gives it; + * - a `reference` column no longer does, and that is a no-op on spec-compliant + * data: the spelling is absent from `@objectstack/spec`'s closed `FieldType` + * and refused by `FieldSchema.safeParse`, so no object schema can declare a + * field whose stored type is `reference`. */ export function computeLookupExpand( schema: { columns?: any[]; objectName?: string }, @@ -202,7 +228,10 @@ export function computeLookupExpand( reportRetiredFieldType(t); return false; } - return t === 'lookup' || t === 'reference' || t === 'master_detail' || t === 'user'; + // Never `new Set([...EXPANDABLE_FIELD_TYPES, …])` and never a re-listing of + // its members: a copy re-forks the table, which is the defect this removed, + // and the identity pin fails on it by design. + return EXPANDABLE_FIELD_TYPES.has(t as string); }; const cols = Array.isArray(schema.columns) ? schema.columns : []; diff --git a/packages/plugin-dashboard/src/__tests__/expandableFamily.identity-5692.test.ts b/packages/plugin-dashboard/src/__tests__/expandableFamily.identity-5692.test.ts new file mode 100644 index 0000000000..78c0da40e3 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/expandableFamily.identity-5692.test.ts @@ -0,0 +1,197 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#5692 — this package's TWO private copies of the reference-bearing + * field family converge onto `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`. + * + * The copies were `LOOKUP_TYPES` in `recordFields.tsx` and an inline + * disjunction inside `computeLookupExpand` in `ObjectDataTable.tsx`. Neither + * derived from nor pinned against the shared set, and objectui#5312 had recorded + * `paramToField` as the LAST private copy — false by these two, which predate + * that sweep. + * + * ## Why the load-bearing pin is IDENTITY, not membership + * + * Every membership assertion below is satisfied by a private + * `new Set(['lookup', 'master_detail', 'tree', 'user'])` holding the same + * strings — i.e. by a re-fork of exactly the kind this change removed. So the + * pins that decide the convergence spy on the `has` of the object core exports: + * a call is recorded only if the face under test consulted THAT object, so a + * member-identical copy leaves the spy empty and fails here, where a value check + * would pass ON the defect. Same shape as objectui#4770 / #4790 / #4815 / #5312. + * + * ## The two membership deltas, and how each was decided + * + * The private copies were not in a subset relation with the shared set in either + * direction: they lacked `tree` and carried a fifth spelling, `reference`. + * + * - `tree` GAINED (accepted): a member of the spec's closed `FieldType` that + * the form / grid road already expands. + * - `reference` DROPPED (measured, not preferred): it is not a declarable field + * type at all, so no producer can emit a field whose stored type is + * `reference`. `describe('the reference drop is a no-op...')` below carries + * that measurement — with live and dead controls — so the day the spec adds + * the spelling, this file goes RED and the membership question reopens + * instead of the drop staying silently correct-by-accident. + * + * Ablation direction, predicted before running: restore either private copy and + * that face's identity pin goes RED (the spy records no call) while its `tree` + * pin goes red too and its `reference` pin flips; the ordinary-relation + * regression controls stay GREEN in both directions, which is what makes them + * controls rather than duplicates of the pins. + */ +import { describe, it, expect, vi } from 'vitest'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; +import { FieldType } from '@objectstack/spec/data'; +import { isLookupType } from '../recordFields'; +import { computeLookupExpand } from '../ObjectDataTable'; + +const SPEC_FIELD_TYPES: readonly string[] = [ + ...(FieldType as unknown as { options: readonly string[] }).options, +]; + +/** The relations an ordinary dashboard table shows — the regression control. */ +const ORDINARY_RELATIONS = ['lookup', 'master_detail', 'user'] as const; + +const objectSchema = () => ({ + fields: { + id: { type: 'text' }, + title: { type: 'text' }, + account: { type: 'lookup', reference: 'accounts' }, + parent_case: { type: 'master_detail', reference: 'cases' }, + assignee: { type: 'user' }, + parent_node: { type: 'tree', reference: 'nodes' }, + legacy_ref: { type: 'reference', reference: 'accounts' }, + }, +}); + +const ALL_COLUMNS = [ + 'title', + 'account', + 'parent_case', + 'assignee', + 'parent_node', + 'legacy_ref', +]; + +describe("the dashboard's relation rule is core's object, not a copy (objectui#5692)", () => { + it('`isLookupType` asks `@object-ui/core` EXPANDABLE_FIELD_TYPES', () => { + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + expect(isLookupType('lookup')).toBe(true); + expect(spy.mock.calls.map(([k]) => k)).toContain('lookup'); + } finally { + spy.mockRestore(); + } + }); + + it('`computeLookupExpand` asks it too — in BOTH column modes', () => { + // The explicit-whitelist mode and the auto-derive mode are two separate + // code paths through the predicate, so a convergence that reconnected one + // would leave the other forked. Each is spied separately. + const modes: [string, () => unknown][] = [ + ['explicit whitelist', () => + computeLookupExpand({ columns: ALL_COLUMNS }, objectSchema())], + ['auto-derive', () => computeLookupExpand({}, objectSchema())], + ]; + for (const [label, exercise] of modes) { + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + exercise(); + expect( + spy.mock.calls.map(([k]) => k), + `${label} never consulted the shared set`, + ).toContain('lookup'); + } finally { + spy.mockRestore(); + } + } + }); +}); + +describe('the ordinary relations are untouched — regression control', () => { + // These must stay green through BOTH ablation legs. If they move, the + // convergence took the whole whitelist with it and the pins above are + // reporting on rubble rather than on a re-homed rule. + it('`isLookupType` still answers true for every ordinary relation', () => { + for (const type of ORDINARY_RELATIONS) { + expect(isLookupType(type), type).toBe(true); + } + expect(isLookupType('text')).toBe(false); + }); + + it('`$expand` still carries the ordinary relation columns, in both modes', () => { + const explicit = computeLookupExpand({ columns: ALL_COLUMNS }, objectSchema()); + const auto = computeLookupExpand({}, objectSchema()); + for (const expanded of [explicit, auto]) { + expect(expanded).toEqual( + expect.arrayContaining(['account', 'parent_case', 'assignee']), + ); + expect(expanded).not.toContain('title'); + expect(expanded).not.toContain('id'); + } + }); +}); + +describe('`tree` gains expansion on the dashboard road — the accepted direction', () => { + // A self-referencing hierarchy column is reference-bearing, so the form and + // grid roads already `$expand` it. The dashboard's private copies did not, + // which is the divergence this convergence closes; the column's cell shows + // the parent record's display name instead of a bare id. + it('is a member of the shared family', () => { + expect(EXPANDABLE_FIELD_TYPES.has('tree')).toBe(true); + }); + + it('`isLookupType` now answers true for it', () => { + expect(isLookupType('tree')).toBe(true); + }); + + it('a `tree` column is now requested for `$expand`, in both modes', () => { + expect( + computeLookupExpand({ columns: ALL_COLUMNS }, objectSchema()), + ).toContain('parent_node'); + expect(computeLookupExpand({}, objectSchema())).toContain('parent_node'); + }); +}); + +describe('the `reference` drop is a no-op on real data — the measured direction', () => { + /** + * The measurement, kept as an executable pin rather than as prose in a PR. + * Controls run on the same read as the subject, so a probe that had lost hold + * of the vocabulary (an empty list, the wrong export) fails as a broken probe + * instead of reporting the subject absent. + */ + it('every LIVE control IS a spec `FieldType`, and every DEAD one is not', () => { + // Live controls: the four members of the shared family. + for (const type of EXPANDABLE_FIELD_TYPES) { + expect(SPEC_FIELD_TYPES, `'${type}' is not a spec FieldType`).toContain(type); + } + // Dead controls: a spelling this renderer retired, and pure nonsense. + // If either turns up "present", the read is broken and the subject reading + // below means nothing. + expect(SPEC_FIELD_TYPES).not.toContain('owner'); + expect(SPEC_FIELD_TYPES).not.toContain('zzz_not_a_field_type'); + }); + + it('SUBJECT — `reference` is not a declarable field type', () => { + // The whole licence for dropping it. If the spec ever adds the spelling, + // this goes red and the "should the shared family gain `reference`?" + // question reopens — deliberately, rather than the drop remaining correct + // only by accident. + expect(SPEC_FIELD_TYPES).not.toContain('reference'); + }); + + it('so the dashboard no longer answers for it', () => { + expect(isLookupType('reference')).toBe(false); + expect( + computeLookupExpand({ columns: ALL_COLUMNS }, objectSchema()), + ).not.toContain('legacy_ref'); + expect(computeLookupExpand({}, objectSchema())).not.toContain('legacy_ref'); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/lookupTypes.retiredFieldType.test.ts b/packages/plugin-dashboard/src/__tests__/lookupTypes.retiredFieldType.test.ts index e67f7cbd2a..f65f9faad4 100644 --- a/packages/plugin-dashboard/src/__tests__/lookupTypes.retiredFieldType.test.ts +++ b/packages/plugin-dashboard/src/__tests__/lookupTypes.retiredFieldType.test.ts @@ -40,12 +40,21 @@ import { computeLookupExpand } from '../ObjectDataTable'; const RETIRED = Object.keys(RETIRED_FIELD_TYPES)[0]; /** - * Relations that were never retired. `reference` is the load-bearing one: it is - * as absent from the spec's closed `FieldType` as the retired spelling is, and - * it is a deliberate compatibility member — the measurement's reductio, here as - * a control. + * Relations that were never retired — the non-vacuity control for the gate. + * + * This list used to read `['lookup', 'reference', 'master_detail', 'user']`, and + * called `reference` "the load-bearing one: as absent from the spec's closed + * `FieldType` as the retired spelling is … a deliberate compatibility member — + * the measurement's reductio". objectui#5692 acted on that reductio: this face + * now reads `EXPANDABLE_FIELD_TYPES` from `@object-ui/core` instead of a private + * table, so `reference` is no longer a relation here and `tree` is. Rewritten + * rather than re-spelled, because the old entry pinned the very branch that + * change deleted — it would have kept passing only while the fork survived. + * + * The gate itself (objectui#4914, ruling B) is untouched: it runs AHEAD of the + * membership test either way, and every assertion about it below is unchanged. */ -const LIVE_RELATIONS = ['lookup', 'reference', 'master_detail', 'user'] as const; +const LIVE_RELATIONS = ['lookup', 'master_detail', 'tree', 'user'] as const; const objectSchema = (ownerType: string) => ({ fields: { @@ -71,9 +80,12 @@ afterEach(() => { describe('`isLookupType` — item 9', () => { it('still answers true for every live relation, silently', () => { - // NON-VACUITY CONTROL. `reference` in particular: if the gate had been - // written as "not in the spec enum" rather than "in the retirement table", - // this line is what goes red. + // NON-VACUITY CONTROL: the gate must refuse the RETIRED spelling without + // taking any live relation with it. `reference` used to sit here for a + // sharper reason — it is as absent from the spec's closed `FieldType` as the + // retired spelling, so it caught a gate mistakenly written as "not in the + // spec enum". objectui#5692 removed it from this face for exactly that + // absence, and `tree` took its place in the list. for (const live of LIVE_RELATIONS) { expect(isLookupType(live), live).toBe(true); } @@ -83,8 +95,12 @@ describe('`isLookupType` — item 9', () => { it('answers false for a retired spelling, where it used to answer true', () => { expect(isLookupType(RETIRED)).toBe(false); - // The comparison the measurement made: it sat at `reference`'s level. - expect(isLookupType(RETIRED)).not.toBe(isLookupType('reference')); + // The comparison the measurement made was against `reference`, which sat at + // the retired spelling's level. objectui#5692 removed that spelling from + // this face, so the comparison is now made against a relation that is still + // live — it says the same thing (the retired spelling does NOT rank with a + // real relation) without depending on the branch that was deleted. + expect(isLookupType(RETIRED)).not.toBe(isLookupType('lookup')); }); it('tolerates the non-string inputs its signature accepts', () => { diff --git a/packages/plugin-dashboard/src/recordFields.tsx b/packages/plugin-dashboard/src/recordFields.tsx index baa38803cf..a0e454a7d5 100644 --- a/packages/plugin-dashboard/src/recordFields.tsx +++ b/packages/plugin-dashboard/src/recordFields.tsx @@ -35,15 +35,11 @@ import { isRetiredFieldType, reportRetiredFieldType, } from '@object-ui/fields'; - -/** - * Field types treated as relations (rendered as links to the related record). - * - * `owner` left this set with objectui#4914; {@link isLookupType} refuses every - * retired spelling ahead of the membership test, so the deletion is lockstep - * hygiene rather than the behavioural half. - */ -const LOOKUP_TYPES = new Set(['lookup', 'reference', 'master_detail', 'user']); +// The reference-bearing field family, read as the object `@object-ui/core` +// publishes rather than restated here (objectui#5692). Imported from its home +// package, the way the other converged consumers do, so the identity pin has a +// single object to spy on. +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; /** * Framework / system audit fields hidden from auto-derived columns and the @@ -241,14 +237,46 @@ export function renderFieldValue( * Whether a field is a relation/lookup (used to drive `$expand`). * * THE GATE (objectui#4914, ruling B) runs ahead of the membership test. - * Measured before the ruling: `isLookupType('owner')` was `true`, at exactly - * the same level as `isLookupType('reference')` — a retired spelling holding - * first-class relation status. It answers `false` now, and says why once. + * Measured before the ruling: `isLookupType('owner')` was `true` — a retired + * spelling holding first-class relation status. It answers `false` now, and + * says why once. + * + * The membership half is no longer a private table (objectui#5692). It used to + * be `new Set(['lookup', 'reference', 'master_detail', 'user'])`, one of TWO + * copies this package held — the other inline in `computeLookupExpand` — and + * neither derived from nor pinned against {@link EXPANDABLE_FIELD_TYPES}, the + * family `@object-ui/core` publishes for exactly this question. objectui#5312's + * claim to have converted "the LAST private copy" was false by these two: they + * predate that sweep and were outside its file surface. + * + * The convergence moved membership in two directions, each decided by + * measurement rather than by preference (objectui#5692): + * + * - `tree` is GAINED. A self-referencing hierarchy field is reference-bearing, + * it is a member of the spec's closed `FieldType`, and the form / grid road + * already `$expand`s it. The dashboard road giving it the same treatment is + * family behaviour restored, not a new decision. + * - `reference` is DROPPED, and dropping it is a no-op on spec-compliant data. + * The spelling is absent from `@objectstack/spec`'s closed `FieldType` + * vocabulary and is refused by `FieldSchema.safeParse` — measured with + * `lookup` / `master_detail` / `user` / `tree` as live controls and the + * retired `owner` plus a nonsense spelling as dead ones — so no + * spec-compliant object schema can declare a field whose stored type is + * `reference`. Where the spelling IS live it is a legacy DIALECT alias on the + * action-param surface, folded to `lookup` before any field-type data is read + * (`PARAM_TYPE_ALIASES` in `app-shell/src/utils/paramToField.ts`). Keeping it + * here would be a lenient renderer-side alias for off-spec metadata, which is + * what AGENTS.md #0.1 bans. + * + * Extending this surface later: OR in a second, surface-local set, the way the + * object form's `needsDataSourceWiring` does. Never + * `new Set([...EXPANDABLE_FIELD_TYPES, …])` — a copy re-forks the table, which + * is the defect this change removed, and the identity pin fails on it by design. */ export function isLookupType(t: unknown): boolean { if (typeof t === 'string' && isRetiredFieldType(t)) { reportRetiredFieldType(t); return false; } - return LOOKUP_TYPES.has(t as string); + return EXPANDABLE_FIELD_TYPES.has(t as string); }