From 1972d01f67f02acd7986c047b2e7fe5f74e7896d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:26:31 +0000 Subject: [PATCH 1/5] feat(types,core,fields)!: reference is the only relational-target spelling objectui writes or reads Round 4 of objectui#11070 (the reference_to round), under the objectui#6837 ruling. The ingestion pass folds a foreign reference_to / referenceTo onto reference and no longer stamps reference_to; the in-repo emitters write reference; LookupField, UserField and the lookup / user read cells read reference only; LookupFieldMetadata, MasterDetailFieldMetadata and DetailViewField (with its zod mirror) declare reference. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- content/docs/fields/lookup.mdx | 20 +++-- content/docs/guide/record-edit-modes.md | 2 +- .../providers/MetadataProvider.merge.test.ts | 16 +++- .../src/providers/MetadataProvider.tsx | 15 ++-- ...taProvider.itemReferenceKeys-7650.test.tsx | 29 ++++--- .../__tests__/auditHistoryDisplay.test.ts | 13 +-- .../src/utils/auditHistoryDisplay.ts | 24 +++--- .../src/utils/decisionOutputParams.test.ts | 2 +- .../app-shell/src/utils/paramToField.test.ts | 13 +-- packages/app-shell/src/utils/paramToField.ts | 4 +- .../src/utils/resolveActionParams.test.ts | 14 ++-- .../src/utils/resolveActionParams.ts | 5 +- ...alog.fieldBackedPickerReach-10129.test.tsx | 2 +- ...etailView.approvalDeclaredActions.test.tsx | 2 +- ...builtinActionSessionUserPredicate.test.tsx | 2 +- .../RecordDetailView.expandFls-7230.test.tsx | 6 +- ...lView.headerActionLookupPredicate.test.tsx | 2 +- .../app-shell/src/views/RecordDetailView.tsx | 2 +- ...rdDetailView.userActionPredicates.test.tsx | 2 +- .../FlowReferenceField.lookup.test.tsx | 4 +- .../inspectors/FlowReferenceField.tsx | 2 +- .../inspectors/useDatasetFields.test.ts | 5 +- .../inspectors/useDatasetFields.ts | 13 +-- .../PagePreview.expandFls-7429.test.tsx | 6 +- .../previews/object-fields-io.ts | 6 +- ...tle.emptyNameLookupFallback-10117.test.tsx | 2 +- ...eader-title.oneInterpolator-10447.test.tsx | 2 +- packages/components/src/custom/field.tsx | 2 +- .../__tests__/form-depends-on-labels.test.tsx | 2 +- packages/core/src/actions/ActionRunner.ts | 2 +- .../reference-keys.legacyWarning-6837.test.ts | 32 ++++--- ...eference-keys.measuredSurface-8938.test.ts | 2 +- ...reference-keys.retiredDialect-7650.test.ts | 2 +- .../utils/__tests__/reference-keys.test.ts | 39 ++++++--- packages/core/src/utils/chart-series.ts | 5 +- packages/core/src/utils/expand-fields.ts | 2 +- packages/core/src/utils/reference-keys.ts | 84 +++++++++++-------- .../src/getObjectSchema.test.ts | 19 +++-- packages/data-objectstack/src/index.ts | 11 +-- ...FieldEditWidget.hostPlumbing-7008.test.tsx | 2 +- .../src/__tests__/cell-truncation.test.tsx | 2 +- .../cellRenderers.emptyArray-8481.test.tsx | 2 +- .../inline-widget-aria-invalid-7126.test.tsx | 2 +- .../lookupCell.contractEnvelope-6917.test.tsx | 2 +- .../lookupCell.readGate-10501.test.tsx | 2 +- ...okupCell.unresolvedReference-8695.test.tsx | 16 ++-- .../__tests__/lookupCellDisplayName.test.tsx | 10 +-- .../src/__tests__/lookupCellOverflow.test.tsx | 2 +- .../__tests__/lookupCellRecordLink.test.tsx | 2 +- .../__tests__/lookupCellReferenceKey.test.tsx | 35 ++++++-- ...ctFirst-gate-joiner-locale-parity.test.tsx | 2 +- .../__tests__/textCellJsonText-7246.test.tsx | 2 +- .../userCell.readGate-10535.test.tsx | 42 ++++++---- packages/fields/src/complex-widgets.test.tsx | 37 ++++++-- packages/fields/src/index.tsx | 23 +++-- ...LookupField.candidateExpand-10223.test.tsx | 10 +-- .../LookupField.chipRemoveGate-10120.test.tsx | 8 +- ...okupField.contextTailRetired-7206.test.tsx | 2 +- .../widgets/LookupField.dependsOn.test.tsx | 2 +- ...ookupField.dependsOnDeclared-6153.test.tsx | 2 +- ...LookupField.dependsOnRetired-7357.test.tsx | 2 +- .../LookupField.displayFls-10373.test.tsx | 4 +- .../LookupField.gateHintLabel.test.tsx | 2 +- .../LookupField.hydratedLabel-10487.test.tsx | 2 +- .../widgets/LookupField.hydration.test.tsx | 10 +-- .../src/widgets/LookupField.idField.test.tsx | 4 +- .../LookupField.labelOrder-10343.test.tsx | 2 +- .../LookupField.optionDescription.test.tsx | 2 +- .../LookupField.pickedLabel-10559.test.tsx | 2 +- .../LookupField.pickerAgreement.test.tsx | 4 +- ...okupField.pickerColumnOrder-10486.test.tsx | 2 +- .../LookupField.recentsFilters.test.tsx | 4 +- packages/fields/src/widgets/LookupField.tsx | 13 +-- ...cordPickerDialog.displayFls-10373.test.tsx | 2 +- .../RecordPickerDialog.filterOptions.test.tsx | 2 +- .../fields/src/widgets/RecordPickerDialog.tsx | 2 +- packages/fields/src/widgets/UserField.tsx | 5 +- .../src/widgets/lookupColumnDisplay.tsx | 2 +- .../ObjectCalendar.expandFls-7230.test.tsx | 6 +- .../ObjectCalendar.expandGate-6453.test.tsx | 6 +- .../plugin-dashboard/src/ObjectDataTable.tsx | 47 ++++++++--- .../ObjectDataTable.columnIdentity.test.tsx | 2 +- ...ObjectDataTable.emitBoundary-6373.test.tsx | 19 +++++ ...jectDataTable.overrideSource-6425.test.tsx | 22 +++++ .../lookupRelationalMeta-6694.test.tsx | 12 +-- .../plugin-dashboard/src/recordFields.tsx | 43 ++++++---- .../plugin-detail/src/HeaderHighlight.tsx | 4 +- .../plugin-detail/src/InlineFieldInput.tsx | 6 +- .../plugin-detail/src/RecordDetailPanel.tsx | 22 ++--- .../plugin-detail/src/RecordMetaFooter.tsx | 4 +- packages/plugin-detail/src/RelatedList.tsx | 12 +-- ...tailSection.copyObjectValues-8395.test.tsx | 2 +- .../DetailSection.inlineEdit.test.tsx | 4 +- .../DetailView.expandFls-7230.test.tsx | 6 +- .../src/__tests__/DetailView.test.tsx | 4 +- .../__tests__/DetailView.title2688.test.tsx | 2 +- .../DetailView.userActionPredicates.test.tsx | 2 +- .../HeaderHighlight.editable.test.tsx | 24 +++--- ...eFieldInput.autonumberRefFallback.test.tsx | 12 +-- .../InlineFieldInput.delegation.test.tsx | 2 +- .../src/__tests__/InlineFieldInput.test.tsx | 4 +- ...rdDetailDrawer.referenceArms-6837.test.tsx | 52 ++++++------ .../RelatedList.addPickerFilter.test.tsx | 2 +- .../RelatedList.addPickerLabelField.test.tsx | 2 +- .../__tests__/RelatedList.headerSort.test.tsx | 4 +- .../RelatedList.sortabilitySignal.test.tsx | 2 +- .../expandableFamily.identity-5874.test.tsx | 4 +- .../src/__tests__/fieldEnrichment.test.ts | 4 +- .../__tests__/inlineEditTypeCoverage.test.tsx | 8 +- .../referenceArms-6837.divergent.test.ts | 42 ++++++---- packages/plugin-detail/src/fieldEnrichment.ts | 14 ++-- ....titleFormatOneInterpolator-10447.test.tsx | 2 +- .../src/synth/buildDefaultPageSchema.ts | 10 +-- packages/plugin-form/README.md | 2 +- .../parameterTypesNameable-7324.test.ts | 12 ++- .../src/currentUserDefault.test.tsx | 4 +- .../src/fieldSecurityPayload.test.tsx | 2 +- packages/plugin-form/src/schemaDefaults.ts | 20 ++--- .../src/sectionFields.spec-parity.test.ts | 3 +- .../plugin-form/src/sectionFields.test.ts | 22 ++--- packages/plugin-form/src/sectionFields.ts | 12 ++- .../src/systemManagedPayload.test.tsx | 2 +- .../ObjectGantt.emptyHostData-7333.test.tsx | 2 +- .../src/ObjectGantt.expandFls-7230.test.tsx | 6 +- .../src/ObjectGantt.fetchGate-7225.test.tsx | 2 +- .../src/ObjectGantt.quickfilter.test.tsx | 2 +- .../ObjectGantt.referenceArms-6837.test.tsx | 37 ++++---- packages/plugin-gantt/src/ObjectGantt.tsx | 2 +- .../ObjectGrid.headerMenuSort-10727.test.tsx | 2 +- .../ObjectGrid.linkCellAnchor.test.tsx | 2 +- .../bulkLookupDependsOnReach-8755.test.tsx | 2 +- .../src/__tests__/bulkParamToField.test.ts | 8 +- .../hostFetchedDefaultColumns-6677.test.tsx | 6 +- .../src/__tests__/inlineEditExitMode.test.tsx | 2 +- .../__tests__/inlineEditLookupRepro.test.tsx | 2 +- .../lookupDisplayFieldSpelling-6875.test.tsx | 5 +- .../mobileCardLookupDisplay.test.tsx | 2 +- .../relationalMetaCopySet-6711.test.tsx | 14 +++- .../relationalMetaCopySet-6874.test.tsx | 17 +++- .../relationalMetaCopySet.derivation.test.ts | 7 +- .../src/__tests__/serverSorting.test.tsx | 2 +- .../src/components/bulkParamToField.ts | 6 +- .../plugin-grid/src/relationalMetaKeys.ts | 34 +++++--- .../ObjectKanban.expandFls-7429.test.tsx | 6 +- .../fetchGate.objectDef-6271.test.tsx | 2 +- .../resolveDisplay.opaqueId-6063.test.tsx | 2 +- packages/plugin-list/src/ObjectGallery.tsx | 3 +- .../ListView.mapLocationSelect-10370.test.tsx | 2 +- .../ListView.relationalSort.test.tsx | 6 +- .../ListView.sortFieldFallback.test.tsx | 2 +- .../ListView.sortabilitySignal.test.tsx | 2 +- ...bjectMap.descriptionDisplay-10456.test.tsx | 2 +- .../src/ObjectMap.expandFls-7429.test.tsx | 6 +- .../ObjectTimeline.expandFls-7429.test.tsx | 6 +- .../ObjectTimeline.fetchGate-7895.test.tsx | 2 +- ...bjectTimeline.lookupDisplay-10530.test.tsx | 2 +- .../ObjectTree.referenceArms-6837.test.tsx | 37 ++++---- .../ObjectView.expandFls-7429.test.tsx | 6 +- .../__tests__/ObjectView.expandGate.test.tsx | 6 +- ...maRenderer.objectFieldsStrip-8818.test.tsx | 2 +- ...eSettledSchema.equalPayload-10106.test.tsx | 4 +- ...-metadata-depends-on-declared-6153.test.ts | 4 +- ...adata-rows-option-description-6140.test.ts | 2 +- .../__tests__/form-field-zod-coverage.test.ts | 2 +- .../strict-face-read-keys-11070.test.ts | 48 +++++++++-- packages/types/src/field-types.ts | 21 ++++- packages/types/src/form.ts | 36 ++++---- packages/types/src/views.ts | 6 +- packages/types/src/zod/views.zod.ts | 2 +- 169 files changed, 934 insertions(+), 629 deletions(-) diff --git a/content/docs/fields/lookup.mdx b/content/docs/fields/lookup.mdx index 5be26e3872..5d7f26232a 100644 --- a/content/docs/fields/lookup.mdx +++ b/content/docs/fields/lookup.mdx @@ -30,7 +30,7 @@ const accountId: LookupFieldMetadata = { label: 'Account', placeholder: 'Search accounts…', required: true, - reference_to: 'accounts', + reference: 'accounts', reference_field: 'name', descriptionField: 'industry', idField: '_id', @@ -44,6 +44,14 @@ const accountId: LookupFieldMetadata = { }; ``` +The target object is `reference` — the spelling `@objectstack/spec`'s `FieldSchema` +declares, and the only one the lookup and user widgets and their read cells read. +[objectui#11070](https://github.com/objectstack-ai/objectui/issues/11070) retired +objectui's snake_case twin `reference_to`: the spec refuses it by name, and nothing +reads it. A stored object definition that still spells it is folded onto `reference` +when it is served through the ObjectStack adapter; a definition handed to the widget +any other way names no target — rename the key to `reference`. + When no data source is available the field falls back to a static option list: ```ts @@ -53,7 +61,7 @@ const priority: LookupFieldMetadata = { type: 'lookup', name: 'priority', label: 'Priority', - reference_to: 'priorities', + reference: 'priorities', options: [ { label: 'High', value: 'high' }, { label: 'Normal', value: 'normal' }, @@ -93,7 +101,7 @@ const contact: LookupFieldMetadata = { type: 'lookup', name: 'contact', label: 'Contact', - reference_to: 'contacts', + reference: 'contacts', // Filter `contacts` by `account_id` equal to the form's current `account`. dependsOn: [{ field: 'account', param: 'account_id' }], }; @@ -120,14 +128,14 @@ When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or type: 'lookup', name: 'customer', label: 'Customer', - reference_to: 'customers', + reference: 'customers', reference_field: 'name', // Display field (default: 'name') descriptionField: 'industry', // Optional secondary field } ``` The popup will: -1. Fetch records via `dataSource.find(reference_to, { $top: 50 })` on open +1. Fetch records via `dataSource.find(reference, { $top: 50 })` on open 2. Send `$search` queries with 300ms debounce as the user types 3. Show loading spinner, error state with retry, and empty state 4. Display "Showing X of Y" when more records exist than the page size @@ -153,7 +161,7 @@ The full **RecordPickerDialog** can be opened in two ways: type: 'lookup', name: 'order', label: 'Order', - reference_to: 'orders', + reference: 'orders', reference_field: 'order_number', descriptionField: 'customer_name', lookup_columns: [ diff --git a/content/docs/guide/record-edit-modes.md b/content/docs/guide/record-edit-modes.md index aaef57262f..94e404f15b 100644 --- a/content/docs/guide/record-edit-modes.md +++ b/content/docs/guide/record-edit-modes.md @@ -32,7 +32,7 @@ Set `editMode` on the object metadata: "fields": { "name": { "type": "text", "label": "Name", "required": true }, "industry": { "type": "picklist", "label": "Industry" }, - "owner": { "type": "lookup", "label": "Owner", "reference_to": "user" } + "owner": { "type": "lookup", "label": "Owner", "reference": "user" } } } ``` diff --git a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts index 08827ffd40..64f422ded9 100644 --- a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts +++ b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts @@ -208,20 +208,30 @@ describe('attachInlineSubforms — relationship-level inlineEdit', () => { expect(attachInlineSubforms(plain)).toBe(plain); }); - it('resolves the parent from a reference_to-keyed field (ObjectUI convention)', () => { - // Served schemas use `reference`; ObjectUI-authored / normalized defs use - // `reference_to` — the parent resolution must accept either key. + it('resolves the parent from `reference` alone — a raw `reference_to` def names none (objectui#11070 round 4)', () => { + // `reference` is the only target spelling ObjectUI reads. Every object + // item reaches this function through the provider's ingestion pass, which + // folds a legacy `reference_to` onto `reference` first, so a RAW + // `reference_to`-only def (the second child here) is one no served object + // can be, and it resolves no parent. const out = attachInlineSubforms([ { name: 'order', fields: { number: { type: 'text' } } }, { name: 'order_line', fields: { qty: { type: 'number' }, + order: { type: 'master_detail', reference: 'order', inlineEdit: true }, + }, + }, + { + name: 'order_note', + fields: { order: { type: 'master_detail', reference_to: 'order', inlineEdit: true }, }, }, ]); const order = out.find((o) => o.name === 'order')!; + expect(order.form?.subforms).toHaveLength(1); expect(order.form?.subforms?.[0]).toMatchObject({ childObject: 'order_line', relationshipField: 'order', diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index d9a75c607c..e017e84787 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -513,8 +513,10 @@ export function attachInlineSubforms(objects: any[]): any[] { const d: any = fdef; if (!fname || !d?.inlineEdit) continue; if (d.type !== 'master_detail' && d.type !== 'lookup') continue; - // Served schemas use `reference`; ObjectUI-authored defs use `reference_to`. - const parent = d.reference ?? d.reference_to; + // `reference` is the only target spelling read (objectui#11070 round 4); + // a legacy def was folded onto it at ingestion (`ensureType` runs + // `normalizeSchemaReferenceKeys` over every `object` item it stores). + const parent = d.reference; if (!parent) continue; (inlineByParent[parent] ||= []).push({ childObject: child.name, @@ -698,10 +700,11 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: const promise = fetchItems .then((res: unknown) => { const items = extractItems(res); - // Canonicalize `reference` ↔ `reference_to` on object field defs at - // ingestion (the store-side choke point, mirroring the adapter's - // getObjectSchema pass) so `useMetadata().objects` consumers can - // read either key (#2407 / PR #2587). Idempotent, in place. + // Fold a legacy `reference_to` / `referenceTo` onto `reference` on + // object field defs at ingestion (the store-side choke point, + // mirroring the adapter's getObjectSchema pass), so + // `useMetadata().objects` consumers read `reference` alone + // (objectui#11070 round 4). Idempotent, in place. if (type === 'object') { for (const it of items) normalizeSchemaReferenceKeys(it); } diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.itemReferenceKeys-7650.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.itemReferenceKeys-7650.test.tsx index 9c7deddf1c..37278464d8 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.itemReferenceKeys-7650.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.itemReferenceKeys-7650.test.tsx @@ -30,6 +30,12 @@ * The negative pins are the load-bearing half: a normalizer that fired for * every metadata type, or that overwrote a key the producer had already set, * would pass every positive assertion here. + * + * ⚠️ objectui#11070 round 4 retired the `reference_to` STAMP: the pass now only + * FOLDS a legacy spelling onto `reference`. A `reference`-only def therefore + * comes back unchanged and cannot show that the pass ran, so every positive pin + * below feeds a LEGACY spelling and reads `reference` back. The first pin used + * to assert the stamp and now asserts its absence. */ import { describe, it, expect, vi } from 'vitest'; @@ -83,7 +89,7 @@ async function withProvider( } describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)', () => { - it('stamps `reference_to` on a by-name object def that spells only `reference`', async () => { + it('serves a by-name object def that spells only `reference` unchanged — no `reference_to` stamped', async () => { const ctx = await withProvider({ 'object/account': { name: 'account', @@ -93,11 +99,10 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' const item = await ctx.getItem('object', 'account'); - expect(item.fields.owner.reference).toBe('user'); - expect(item.fields.owner.reference_to).toBe('user'); + expect(item.fields.owner).toEqual({ type: 'lookup', reference: 'user' }); }); - it('stamps `reference` on a by-name object def that spells only the legacy `reference_to`', async () => { + it('folds `reference` onto a by-name object def that spells only the legacy `reference_to`', async () => { const ctx = await withProvider({ 'object/contact': { name: 'contact', @@ -115,13 +120,13 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' const ctx = await withProvider({ 'object/lead': { name: 'lead', - fields: [{ name: 'owner', type: 'lookup', reference: 'user' }], + fields: [{ name: 'owner', type: 'lookup', reference_to: 'user' }], }, }); const item = await ctx.getItem('object', 'lead'); - expect(item.fields[0].reference_to).toBe('user'); + expect(item.fields[0].reference).toBe('user'); }); it('NEGATIVE — leaves a non-`object` metadata type untouched', async () => { @@ -131,14 +136,14 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' // provider inventing a convention the contract does not declare. 'view/account_list': { name: 'account_list', - fields: { owner: { type: 'lookup', reference: 'user' } }, + fields: { owner: { type: 'lookup', reference_to: 'user' } }, }, }); const item = await ctx.getItem('view', 'account_list'); - expect(item.fields.owner.reference).toBe('user'); - expect(item.fields.owner.reference_to).toBeUndefined(); + expect(item.fields.owner.reference_to).toBe('user'); + expect(item.fields.owner.reference).toBeUndefined(); }); it('NEGATIVE — never overwrites a spelling the producer already set', async () => { @@ -146,7 +151,7 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' 'object/opportunity': { name: 'opportunity', fields: { - // Deliberately inconsistent: if the stamp overwrote rather than + // Deliberately inconsistent: if the fold overwrote rather than // filled, one of these two values would change. owner: { type: 'lookup', reference: 'user', reference_to: 'legacy_user' }, }, @@ -165,7 +170,7 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' { 'object/account': { name: 'account', - fields: { owner: { type: 'lookup', reference: 'user' } }, + fields: { owner: { type: 'lookup', reference_to: 'user' } }, }, }, itemCalls, @@ -177,6 +182,6 @@ describe('MetadataProvider.getItem canonicalizes object schemas (objectui#7650)' // The by-name cache answered the second read (so the normalization must // have stuck to the cached object, not to a throwaway copy). expect(itemCalls.filter((c) => c === 'object/account')).toHaveLength(1); - expect(second.fields.owner.reference_to).toBe('user'); + expect(second.fields.owner.reference).toBe('user'); }); }); diff --git a/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts b/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts index 39fb60b8f1..836dda7b01 100644 --- a/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts +++ b/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts @@ -125,9 +125,9 @@ describe('collectLookupIds', () => { * WHY REFUSAL IS THE POINT. `lookupTarget`'s only caller chain is * `RecordDetailView`'s History effect, which feeds it `objectDef.fields` from * `useMetadata().objects` — the metadata cache for type `'object'`, i.e. OBJECT - * METADATA DOCUMENTS, never ObjectUI's own view/field contract (that contract's - * `reference_to` lives on `DetailViewFieldSchema`, which `plugin-detail` - * translates INTO from `reference`). So every spelling and carrier below is one + * METADATA DOCUMENTS, never ObjectUI's own view/field contract (that contract + * is `DetailViewFieldSchema`, keyed `reference_to` until objectui#11070 round 4 + * and `reference` since). So every spelling and carrier below is one * `ObjectSchema.safeParse` (spec 17.2.0) refuses on the documents that actually * arrive here, and resolving one could only re-hide the producer that emitted * it (AGENTS.md #0.1). @@ -181,9 +181,10 @@ describe('relationship target (objectui#6719)', () => { /** * SPELLING axis. `reference_to ?? reference` read the LEGACY key first, ahead * of the canonical one. `ObjectSchema.safeParse` refuses all three below BY - * NAME ("Did you mean `reference_to` → `reference`?"); `reference_to` is a - * live key only on ObjectUI's own view/field contract, a different contract - * this reader is never handed. Restoring the `reference_to` arm turns the + * NAME ("Did you mean `reference_to` → `reference`?"); `reference_to` was + * then live only on ObjectUI's own view/field contract, a different contract + * this reader is never handed, and objectui#11070 round 4 retired it there + * too. Restoring the `reference_to` arm turns the * first case and the partial-migration case below RED; the other two * spellings were never read here and are pinned so the chain cannot grow back * a second time. diff --git a/packages/app-shell/src/utils/auditHistoryDisplay.ts b/packages/app-shell/src/utils/auditHistoryDisplay.ts index fdb1866f07..0fa58c9646 100644 --- a/packages/app-shell/src/utils/auditHistoryDisplay.ts +++ b/packages/app-shell/src/utils/auditHistoryDisplay.ts @@ -33,9 +33,10 @@ export interface AuditFieldDef { /** * Relationship target object. `string`, and only `string` — see * {@link lookupTarget} for the census behind both the carrier and the - * spelling. `reference_to` is deliberately NOT declared here: it is a key on - * ObjectUI's own view/field contract, not on an object metadata document, - * and this interface only ever describes the latter. + * spelling. `reference_to` is deliberately NOT declared here: no contract + * declares it — `FieldSchema` refuses it by name, and objectui#11070 round 4 + * retired it from ObjectUI's own view/field contract too — and this interface + * only ever describes an object metadata document. */ reference?: string; [k: string]: unknown; @@ -140,10 +141,11 @@ export function collectAuditChanges( * `objectDef` being an entry of `useMetadata().objects`, i.e. the metadata * cache for type `'object'` (`MetadataProvider`'s `TYPE_BY_STATE_KEY.objects`). * That is an OBJECT METADATA DOCUMENT, never ObjectUI's own view/field - * contract. `plugin-detail` does hold defs keyed `reference_to` - * (`DetailViewFieldSchema` in `@object-ui/types` `views.zod.ts`), but it - * TRANSLATES INTO that contract from `reference` (`RecordDetailDrawer`, - * `RecordMetaFooter`) and none of it flows back into `objectDef.fields`. + * contract. `plugin-detail` holds defs of that contract + * (`DetailViewFieldSchema` in `@object-ui/types` `views.zod.ts`, keyed + * `reference` since objectui#11070 round 4 and `reference_to` before it), built + * from the object def (`RecordDetailDrawer`, `RecordMetaFooter`), and none of it + * flows back into `objectDef.fields`. * * spelling: `reference` is what `ObjectSchema.safeParse` (spec 17.2.0) * ACCEPTS — the positive control every zero below is measured @@ -165,10 +167,10 @@ export function collectAuditChanges( * Dropping the `reference_to` arm loses nothing even for a def that arrives * spelling only the legacy key: `normalizeSchemaReferenceKeys` * (`@object-ui/core`) runs over every `'object'` item at the app-shell - * ingestion choke point and stamps BOTH snake_case keys from whichever - * spelling arrived. Its own docs give the reason this reader must not keep a - * second copy of that tolerance — the choke point exists "so per-consumer - * dual-key fallbacks can't drift" (AGENTS.md #0.1). + * ingestion choke point and folds whichever legacy spelling arrived onto + * `reference`. Its own docs give the reason this reader must not keep a + * second copy of that tolerance — the choke point exists so that no consumer + * needs a dual-key fallback of its own (AGENTS.md #0.1). */ function lookupTarget(def: AuditFieldDef | undefined): string | null { const target = def?.reference; diff --git a/packages/app-shell/src/utils/decisionOutputParams.test.ts b/packages/app-shell/src/utils/decisionOutputParams.test.ts index 27d4398621..005e875af0 100644 --- a/packages/app-shell/src/utils/decisionOutputParams.test.ts +++ b/packages/app-shell/src/utils/decisionOutputParams.test.ts @@ -136,7 +136,7 @@ describe('decisionOutputParams — the params survive resolution (objectui#2955) expect(param.referenceTo).toBeUndefined(); expect(widgetFor(param)).toMatchObject({ type: 'lookup', - reference_to: referenceTo, + reference: referenceTo, multiple: true, }); }); diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts index 20c1ec1b44..5862c1d44a 100644 --- a/packages/app-shell/src/utils/paramToField.test.ts +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -121,10 +121,11 @@ describe('paramToField', () => { // Both readings are kept because the order matters — the emit had to move // BEFORE the supply existed, and it is what made the wiring a one-line // change. Pinned in `views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx`. - // The remaining snake members below (`reference_to`, `title_format`, - // `lookup_columns`, `lookup_page_size`) were outside both rulings and are - // unchanged — this mixed shape is deliberate, and asserting it keeps the two - // halves visibly separate. + // The remaining snake members below (`title_format`, `lookup_columns`, + // `lookup_page_size`) were outside both rulings and are unchanged — this + // mixed shape is deliberate, and asserting it keeps the two halves visibly + // separate. The target left that group in objectui#11070 round 4: the + // widgets read the spec's `reference` only, so that is what this emits. it('maps the full lookup picker config to the field metadata the widgets read', () => { const field = paramToField(p({ type: 'lookup', @@ -141,7 +142,7 @@ describe('paramToField', () => { })); expect(field).toMatchObject({ type: 'lookup', - reference_to: 'space_users', + reference: 'space_users', displayField: 'name', idField: 'id', descriptionField: 'email', @@ -248,7 +249,7 @@ describe("the reference-bearing rule is core's object, not a copy (objectui#5312 expect( paramToField(p({ type, referenceTo: 'accounts', displayField: 'name' })), `'${type}' lost its reference config in the convergence`, - ).toMatchObject({ type, reference_to: 'accounts', displayField: 'name' }); + ).toMatchObject({ type, reference: 'accounts', displayField: 'name' }); } expect(RETIRED_INLINE_MEMBERS.filter((t) => !EXPANDABLE_FIELD_TYPES.has(t))).toEqual([]); }); diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index 8a2649964b..c6e140d9ab 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -174,7 +174,9 @@ export function paramToField(param: ActionParamDef): Record { // the identity pin fails on it by design. if (EXPANDABLE_FIELD_TYPES.has(type)) { Object.assign(field, { - reference_to: param.referenceTo, + // The widget field's target key is the spec's `reference`, the only + // spelling `LookupField` / `UserField` read (objectui#11070 round 4). + reference: param.referenceTo, displayField: param.displayField, idField: param.idField, descriptionField: param.descriptionField, diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index 04663bb3dd..3292db57dc 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -215,10 +215,10 @@ describe('resolveActionParams — inline lookup reference target (#3405)', () => * * So these tests author through the PUBLIC type and follow one param all the * way to the field the widgets consume: `ActionParam` → `resolveActionParams()` - * → `ActionParamDef` → `paramToField()` → `reference_to`. The internal pipeline + * → `ActionParamDef` → `paramToField()` → `reference`. The internal pipeline * keeps its two spellings (authoring `reference`, resolved `referenceTo`) — the * point is not that they match, it is that the public entry and the public exit - * do. + * do (the exit was `reference_to` until objectui#11070 round 4). */ describe('resolveActionParams — authored through the public ActionParam type (objectui#3174)', () => { const authoringCtx = () => @@ -244,7 +244,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( const authorToField = (authored: ActionParam) => paramToField(resolveActionParams([authored], authoringCtx())[0]); - it('an inline `reference` reaches the picker as `reference_to`', () => { + it('an inline `reference` reaches the picker as `reference`', () => { const authored: ActionParam = { name: 'account_id', label: 'Account', @@ -254,7 +254,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( expect(authorToField(authored)).toMatchObject({ name: 'account_id', type: 'lookup', - reference_to: 'account', + reference: 'account', }); }); @@ -262,7 +262,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( const authored: ActionParam = { field: 'inspector' }; expect(authorToField(authored)).toMatchObject({ type: 'lookup', - reference_to: 'sys_user', + reference: 'sys_user', // objectui#7155 — the spec spelling, emitted by `paramToField`. displayField: 'name', idField: 'id', @@ -273,7 +273,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( const authored: ActionParam = { name: 'parent_id', type: 'master_detail', reference: 'account' }; expect(authorToField(authored)).toMatchObject({ type: 'master_detail', - reference_to: 'account', + reference: 'account', }); }); @@ -308,7 +308,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( // Still degrades — the metadata is genuinely unusable — but loudly, and // the downstream warning now prescribes a key the author can write. - expect(field.reference_to).toBeUndefined(); + expect(field.reference).toBeUndefined(); expect(field.type).toBe('text'); expect(messages.some((m) => m.includes('degrades to a plain record-id text input'))).toBe(true); } finally { diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index ab27a056a8..17aa514a05 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -342,7 +342,10 @@ interface RuntimeField { // author could legally write. That is not a producer leg worth keeping behind // the declared one — it is the inverse of one, and keeping it would be the // consumer-side tolerance AGENTS.md #0.1 bans. - reference_to?: string; + // + // ⭐ objectui#11070 round 4 removed the `reference_to` member: nothing here + // read it (the target is read as `reference` below), and ObjectUI no longer + // writes or reads that spelling anywhere. reference?: string; displayField?: string; display_field?: string; diff --git a/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx b/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx index b77dc8397f..fb4d869c68 100644 --- a/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx @@ -379,7 +379,7 @@ describe('objectui#10129 leg D — the console runtime resolves a field-backed p id="contract_type" value={null} onChange={() => {}} - field={{ name: 'contract_type', ...CLM_CONTRACT.fields.contract_type, reference_to: 'clm_contract_type' } as never} + field={{ name: 'contract_type', ...CLM_CONTRACT.fields.contract_type, reference: 'clm_contract_type' } as never} dataSource={ds as never} /> diff --git a/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx b/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx index a8b3d25d3c..9519ae70f7 100644 --- a/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx @@ -135,7 +135,7 @@ const SYS_APPROVAL_REQUEST_DEF = { fields: { id: { type: 'text', label: 'Id' }, status: { type: 'text', label: 'Status' }, - submitter_id: { type: 'lookup', label: 'Submitter', reference_to: 'sys_user' }, + submitter_id: { type: 'lookup', label: 'Submitter', reference: 'sys_user' }, }, actions: [ { diff --git a/packages/app-shell/src/views/RecordDetailView.builtinActionSessionUserPredicate.test.tsx b/packages/app-shell/src/views/RecordDetailView.builtinActionSessionUserPredicate.test.tsx index abd9d7ca0b..55d0042668 100644 --- a/packages/app-shell/src/views/RecordDetailView.builtinActionSessionUserPredicate.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.builtinActionSessionUserPredicate.test.tsx @@ -115,7 +115,7 @@ const FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' }, - executor: { type: 'lookup', label: 'Executor', reference_to: 'sys_user' }, + executor: { type: 'lookup', label: 'Executor', reference: 'sys_user' }, }; /** The record as the detail fetch delivers it when it did NOT expand `executor`. */ diff --git a/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx b/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx index 35b19b4ff8..ad2b64b395 100644 --- a/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx @@ -130,9 +130,9 @@ const objectDef = { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' }, stage: { label: 'Stage', type: 'text' }, - account: { label: 'Account', type: 'lookup', reference_to: 'accounts' }, - secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' }, - owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' }, + account: { label: 'Account', type: 'lookup', reference: 'accounts' }, + secret_account: { label: 'Secret Account', type: 'lookup', reference: 'accounts' }, + owner_dept: { label: 'Dept', type: 'master_detail', reference: 'departments' }, }, }; diff --git a/packages/app-shell/src/views/RecordDetailView.headerActionLookupPredicate.test.tsx b/packages/app-shell/src/views/RecordDetailView.headerActionLookupPredicate.test.tsx index 6e95f679d8..268b4fac31 100644 --- a/packages/app-shell/src/views/RecordDetailView.headerActionLookupPredicate.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.headerActionLookupPredicate.test.tsx @@ -114,7 +114,7 @@ const CANONICAL = 'os.user.id == record.manager'; const FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' }, - manager: { type: 'lookup', label: 'Manager', reference_to: 'sys_user' }, + manager: { type: 'lookup', label: 'Manager', reference: 'sys_user' }, }; /** The record as the detail fetch delivers it when it did NOT expand `manager`. */ diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 8e27779b82..80e2edb3c9 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -2214,7 +2214,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri label: fieldDef.label || key, type: fieldDef.type || 'text', ...(fieldDef.options && { options: fieldDef.options }), - ...(refTarget && { reference_to: refTarget }), + ...(refTarget && { reference: refTarget }), ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }), ...(fieldDef.currency && { currency: fieldDef.currency }), }; diff --git a/packages/app-shell/src/views/RecordDetailView.userActionPredicates.test.tsx b/packages/app-shell/src/views/RecordDetailView.userActionPredicates.test.tsx index fc0e583db5..7260971cda 100644 --- a/packages/app-shell/src/views/RecordDetailView.userActionPredicates.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.userActionPredicates.test.tsx @@ -113,7 +113,7 @@ const FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' }, - owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, }; function objectDef(userActions?: unknown) { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx index 8f64afe1e6..005ceda190 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx @@ -52,10 +52,10 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ })); vi.mock('@object-ui/fields', async (importOriginal) => ({ ...(await importOriginal()), - LookupField: (props: { field?: { reference_to?: string; idField?: string; multiple?: boolean } }) => ( + LookupField: (props: { field?: { reference?: string; idField?: string; multiple?: boolean } }) => (
diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index 943bc83774..15c637d1fd 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -578,7 +578,7 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, // and the lookup keys below are rejected. type: 'lookup', name: 'value', - reference_to: binding.object, + reference: binding.object, displayField: binding.displayField, // `position` commits the machine name, the rest the row id. idField: binding.valueField, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts index 44c25bdde1..a6e87be479 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts @@ -64,8 +64,9 @@ describe('resolveReferenceTo', () => { * none: `ObjectSchema.safeParse` (spec 17.2.0) REFUSES all three BY NAME while * ACCEPTING `reference` (the positive control asserted above), `referenceTo`'s * producers were retired by objectui#6041 and are stripped by the read door - * (objectui#6519), `reference_to` is live only on ObjectUI's own view/field - * schema — a different contract — and `reference_to_object` never had a + * (objectui#6519), `reference_to` was then live only on ObjectUI's own + * view/field schema — a different contract, moved to `reference` by + * objectui#11070 round 4 — and `reference_to_object` never had a * producer at all. * * A def that reaches here spelling the target any of these ways is a PRODUCER diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts index c3985b3f2f..93671523bd 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts @@ -90,12 +90,13 @@ export function resolveLabel(label: unknown, fallback: string): string { * this way. This is the positive control every zero * below is measured against. * reference_to REFUSED BY NAME — "Did you mean `reference_to` -> - * `reference`?". Zero producers on THIS surface. It is - * a live key only on ObjectUI's own view/field schema - * (`@object-ui/types` `views.zod.ts`), a different - * contract that `plugin-detail` translates INTO from - * `reference`; an object metadata document never - * carries it. + * `reference`?". Zero producers on THIS surface. It was + * then a live key only on ObjectUI's own view/field + * schema (`@object-ui/types` `views.zod.ts`), a + * different contract that `plugin-detail` translated + * INTO from `reference` (objectui#11070 round 4 moved + * that schema to `reference` too); an object metadata + * document never carries it. * referenceTo REFUSED BY NAME. Its two historical producers were * retired at the producer by objectui#6041, and * objectui#6519 added it to `RETIRED_FIELD_KEYS` so the diff --git a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.expandFls-7429.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.expandFls-7429.test.tsx index 18ce51ed4f..aec65b2ecc 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.expandFls-7429.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.expandFls-7429.test.tsx @@ -109,9 +109,9 @@ const OBJECT = 'showcase_account'; */ const OBJECT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const draft = { diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index ff988eb7c4..0db979f41b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -138,9 +138,9 @@ function isUsableTarget(value: unknown): value is string { * * Recovering HERE is that ruling's own shape rather than an exception to it: * the door normalizes, the readers stay canonical. `reference_to` is the - * ingestion choke points' business (`normalizeSchemaReferenceKeys`, which also - * stamps a second key `FieldSchema` refuses) and is deliberately not touched by - * this door, which serves a WRITE path. + * ingestion choke points' business (`normalizeSchemaReferenceKeys`, which folds + * it onto `reference`) and is deliberately not touched by this door, which + * serves a WRITE path. * * ## Three things this does not do * diff --git a/packages/components/src/__tests__/page-header-title.emptyNameLookupFallback-10117.test.tsx b/packages/components/src/__tests__/page-header-title.emptyNameLookupFallback-10117.test.tsx index 347468c991..14309fee2b 100644 --- a/packages/components/src/__tests__/page-header-title.emptyNameLookupFallback-10117.test.tsx +++ b/packages/components/src/__tests__/page-header-title.emptyNameLookupFallback-10117.test.tsx @@ -73,7 +73,7 @@ const KPI_ENTRY_SHEET = { nameField: 'name', fields: { name: { type: 'text', label: 'Name', readonly: true }, - subject: { type: 'lookup', label: 'Subject', reference_to: 'sys_business_unit', required: true }, + subject: { type: 'lookup', label: 'Subject', reference: 'sys_business_unit', required: true }, subject_type: { type: 'text', label: 'Subject Type' }, }, }; diff --git a/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx index e6d18b6761..bc203fd655 100644 --- a/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx +++ b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx @@ -81,7 +81,7 @@ afterEach(() => { /** Lookup-typed and number-typed fields: the derivation takes neither. */ const dealFields = { - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, deal_no: { type: 'autonumber', label: 'Deal No' }, }; diff --git a/packages/components/src/custom/field.tsx b/packages/components/src/custom/field.tsx index 813b85a8bd..fa36b810e6 100644 --- a/packages/components/src/custom/field.tsx +++ b/packages/components/src/custom/field.tsx @@ -35,7 +35,7 @@ export interface FieldProps * A labelled form-control wrapper: label, slotted control, description, error. * * Named `FieldContainer`, not `Field` (objectstack#4115): `@objectstack/spec/data` - * exports `Field` — an object FIELD's metadata (type, reference_to, options, + * exports `Field` — an object FIELD's metadata (type, reference, options, * permissions, …) and its builder namespace — which has nothing to do with this * `
`. Two unrelated things under one name is the defect that issue exists * to remove; `@object-ui/app-shell` already renamed the same kind of collision diff --git a/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx b/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx index 39c0725cc0..e3b1cf18e4 100644 --- a/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx +++ b/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx @@ -62,7 +62,7 @@ const fields = [ name: 'contact', label: 'Contact', type: 'lookup', - field: { name: 'contact', reference_to: 'crm_contact', dependsOn: ['crm_account'] }, + field: { name: 'contact', reference: 'crm_contact', dependsOn: ['crm_account'] }, }, // A widget outside the data-source family, to pin the strip half. { name: 'topics', label: 'Topics', type: 'tags' }, diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 6c345d1e09..60230cc45c 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -764,7 +764,7 @@ export interface ActionParamDef { // to a `lookup` or `reference` field. Forwarded to `` inside // `ActionParamDialog` so the user gets a real record picker (popover + // RecordPickerDialog) instead of a plain text input. - /** Object name the lookup picker queries (`reference_to` on the field). */ + /** Object name the lookup picker queries (`reference` on the field). */ referenceTo?: string; /** Field on the referenced record used as the human label (default `name`). */ displayField?: string; diff --git a/packages/core/src/utils/__tests__/reference-keys.legacyWarning-6837.test.ts b/packages/core/src/utils/__tests__/reference-keys.legacyWarning-6837.test.ts index 3bba179598..a597c6f965 100644 --- a/packages/core/src/utils/__tests__/reference-keys.legacyWarning-6837.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.legacyWarning-6837.test.ts @@ -16,8 +16,8 @@ * > 外的键不解析,但不无声。 * * Half 2 deleted the per-reader `reference_to` fallback arms. What makes that - * deletion survivable for a BYO host is the ingestion choke point, which stamps - * `reference` from whichever spelling arrived. But a choke point that absorbs a + * deletion survivable for a served legacy def is the ingestion choke point, + * which folds whichever legacy spelling arrived onto `reference`. But a choke point that absorbs a * producer's bug SILENTLY is the AGENTS.md #0.1 failure mode by another name — * so it now says so, once, in dev. * @@ -31,14 +31,22 @@ * 3. does NOT fire twice — the flood control * 4. names the field and the ruling — the message is the deliverable, * not the call count - * 5. the STAMP is unchanged — the guard that this file is + * 5. the FOLD still runs — the guard that this file is * testing an addition, not a * behaviour change * * ⚠️ Case 5 is the one that would go missing. The ruling asked for an audible * warning, NOT for the normalizer to start refusing anything: the def must - * still come out carrying both snake_case keys exactly as before. A warning - * that also dropped the key would pass 1-4 and be a regression. + * still come out carrying `reference`, with the legacy key it arrived with + * left in place. A warning that also dropped the key, or skipped the fold, + * would pass 1-4 and be a regression. + * + * ⚠️ Case 5 used to read "the STAMP is unchanged" and assert that BOTH + * snake_case keys came out of a legacy-only def. objectui#11070 round 4 + * retired the `reference_to` stamp (ObjectUI writes and reads `reference` + * only), so the `referenceTo` pin below now asserts the fold to `reference` + * and NO `reference_to`; the `reference_to` pin keeps its input key because + * the pass never drops one. * * ## Warn-once GRANULARITY — the memo key, stated exactly, and pinned * @@ -66,7 +74,7 @@ * * It fires only where `normalizeSchemaReferenceKeys` runs, which in production * is exactly the three ingestion choke points (objectui#7650 added the third, - * `MetadataProvider.getItem`) — all of which also STAMP the def, so it fires + * `MetadataProvider.getItem`) — all of which also FOLD the def, so it fires * where nothing is broken. A hand-written schema served through any * other `DataSource` reaches a reader raw and warns nothing. The reader-side * diagnostic that would cover that is still open on objectui#6837. @@ -230,23 +238,23 @@ describe('the choke point warns, in dev, when a def spells ONLY a legacy target }); }); - describe('⛔ the STAMP is unchanged — this slice adds a warning, it does not change behaviour', () => { - it('still stamps BOTH snake_case keys from a `reference_to`-only def', () => { + describe('⛔ the FOLD still runs — the warning is an addition, not a behaviour change', () => { + it('folds a `reference_to`-only def onto `reference`, and keeps the key it arrived with', () => { const def: Record = { type: 'lookup', reference_to: 'crm_account' }; normalizeFieldReferenceKeys(def, 'account'); expect(def.reference).toBe('crm_account'); expect(def.reference_to).toBe('crm_account'); }); - it('still stamps BOTH snake_case keys from a `referenceTo`-only def', () => { + it('folds a `referenceTo`-only def onto `reference`, and stamps no `reference_to` (objectui#11070 round 4)', () => { const def: Record = { type: 'lookup', referenceTo: 'crm_account' }; normalizeFieldReferenceKeys(def, 'account'); expect(def.reference).toBe('crm_account'); - expect(def.reference_to).toBe('crm_account'); + expect('reference_to' in def).toBe(false); }); - it('is silent AND still stamps under NODE_ENV=production', () => { - // The warning is a dev affordance; the stamp is the contract. A + it('is silent AND still folds under NODE_ENV=production', () => { + // The warning is a dev affordance; the fold is the contract. A // production build must keep the second and lose only the first. vi.stubEnv('NODE_ENV', 'production'); try { diff --git a/packages/core/src/utils/__tests__/reference-keys.measuredSurface-8938.test.ts b/packages/core/src/utils/__tests__/reference-keys.measuredSurface-8938.test.ts index 45a1d46e57..187dbcc487 100644 --- a/packages/core/src/utils/__tests__/reference-keys.measuredSurface-8938.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.measuredSurface-8938.test.ts @@ -267,7 +267,7 @@ describe('the two silences the diagnostic must KEEP (objectui#8938)', () => { it('⛔ does NOT fire for `reference_to` / `referenceTo` — the reference arm owns those', () => { // Both probe onto no declared key, so without the exclusion each would be - // reported as unfoldable in the same breath as the reference arm stamping it. + // reported as unfoldable in the same breath as the reference arm folding it. const f: Record = { type: 'lookup', reference_to: 'crm_account' }; normalizeFieldReferenceKeys(f, 'owner', 'account'); expect(f.reference).toBe('crm_account'); diff --git a/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts b/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts index e5fa486f54..431c2e69a2 100644 --- a/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts @@ -429,7 +429,7 @@ describe('derives the fold from the contract, not from a table (objectui#7650)', it('the reference pair stays OUT of the derived arm — it has its own', () => { // `referenceTo` is not a declared key, so `reference_to` probes onto - // nothing and the derived arm ignores it. The reference stamp below is the + // nothing and the derived arm ignores it. The reference fold below is the // separate, older mechanism, and this pin keeps the two from double-handling. expect(declared.map(probe)).not.toContain(probe('reference_to')); const f: Record = { type: 'lookup', reference_to: 'user' }; diff --git a/packages/core/src/utils/__tests__/reference-keys.test.ts b/packages/core/src/utils/__tests__/reference-keys.test.ts index e99d692fc4..305e050eeb 100644 --- a/packages/core/src/utils/__tests__/reference-keys.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.test.ts @@ -13,30 +13,38 @@ import { } from '../reference-keys'; describe('normalizeFieldReferenceKeys', () => { - it('stamps reference_to from the ObjectStack-convention `reference` key', () => { + // objectui#11070 round 4: `reference` is the only spelling ObjectUI writes or + // reads, so this pass FOLDS a foreign spelling onto it and never stamps + // `reference_to`. Two pins here used to assert that stamp (on a + // `reference` def and on a `referenceTo` def) and now assert its absence; + // the idempotence and map / array pins below flipped for the same reason. + it('does NOT stamp reference_to onto a def that carries the spec `reference` key', () => { // Exactly what the showcase backend serves for showcase_project.account. const field = { type: 'lookup', reference: 'showcase_account' } as any; normalizeFieldReferenceKeys(field); - expect(field.reference_to).toBe('showcase_account'); expect(field.reference).toBe('showcase_account'); + expect('reference_to' in field).toBe(false); }); - it('stamps `reference` from a reference_to-keyed (ObjectUI-authored) field', () => { + it('folds a foreign reference_to onto `reference`, leaving the foreign key where it was', () => { const field = { type: 'master_detail', reference_to: 'showcase_project' } as any; normalizeFieldReferenceKeys(field); expect(field.reference).toBe('showcase_project'); + // The leave arm: nothing here drops a key. + expect(field.reference_to).toBe('showcase_project'); }); - it('accepts legacy camelCase referenceTo as a source', () => { + it('folds legacy camelCase referenceTo onto `reference`, and stamps no reference_to', () => { const field = { type: 'lookup', referenceTo: 'accounts' } as any; normalizeFieldReferenceKeys(field); - expect(field.reference_to).toBe('accounts'); expect(field.reference).toBe('accounts'); + expect('reference_to' in field).toBe(false); }); - it('never overwrites keys that are already set', () => { - // Divergent keys are broken metadata; first-wins (reference_to) but the - // existing `reference` value must not be clobbered. + it('never overwrites a `reference` that is already set', () => { + // Divergent keys are broken metadata. The producer's `reference` stands — + // it is the one spelling every reader reads — and the foreign value stays + // on the def unread. const field = { reference_to: 'a', reference: 'b' } as any; normalizeFieldReferenceKeys(field); expect(field.reference_to).toBe('a'); @@ -60,7 +68,11 @@ describe('normalizeFieldReferenceKeys', () => { it('is idempotent', () => { const field = { type: 'user', reference: 'sys_user' } as any; normalizeFieldReferenceKeys(normalizeFieldReferenceKeys(field)); - expect(field).toEqual({ type: 'user', reference: 'sys_user', reference_to: 'sys_user' }); + expect(field).toEqual({ type: 'user', reference: 'sys_user' }); + + const legacy = { type: 'lookup', reference_to: 'accounts' } as any; + normalizeFieldReferenceKeys(normalizeFieldReferenceKeys(legacy)); + expect(legacy).toEqual({ type: 'lookup', reference_to: 'accounts', reference: 'accounts' }); }); }); @@ -76,20 +88,21 @@ describe('normalizeSchemaReferenceKeys', () => { } as any; const out = normalizeSchemaReferenceKeys(schema); expect(out).toBe(schema); // mutates the cached object, not a copy - expect(schema.fields.account.reference_to).toBe('showcase_account'); - expect(schema.fields.team_members.reference_to).toBe('sys_user'); + expect(schema.fields.account).toEqual({ type: 'lookup', reference: 'showcase_account' }); + expect(schema.fields.team_members).toEqual({ type: 'user', reference: 'sys_user', multiple: true }); expect('reference_to' in schema.fields.name).toBe(false); }); it('normalizes array-shaped field containers', () => { const schema = { fields: [ - { name: 'project', type: 'master_detail', reference: 'showcase_project' }, + { name: 'project', type: 'master_detail', reference_to: 'showcase_project' }, { name: 'title', type: 'text' }, ], } as any; normalizeSchemaReferenceKeys(schema); - expect(schema.fields[0].reference_to).toBe('showcase_project'); + expect(schema.fields[0].reference).toBe('showcase_project'); + expect('reference' in schema.fields[1]).toBe(false); }); it('tolerates schemas without fields and non-object input', () => { diff --git a/packages/core/src/utils/chart-series.ts b/packages/core/src/utils/chart-series.ts index bcae7ca080..7b4d1209bd 100644 --- a/packages/core/src/utils/chart-series.ts +++ b/packages/core/src/utils/chart-series.ts @@ -1055,8 +1055,9 @@ function fieldDefsOf(schema: unknown): Record | null { * itself as the positive control (445 of 565 lookup / master_detail defs in the * framework tree; ACCEPTED by `ObjectSchema.safeParse` on spec 17.2.0, which * REFUSES all three others BY NAME). No producer emits any of them onto an - * object metadata document: `reference_to` is live only on ObjectUI's own - * view/field schema (a different contract, translated INTO from `reference`), + * object metadata document: `reference_to` was then live only on ObjectUI's own + * view/field schema (a different contract, translated INTO from `reference`; + * objectui#11070 round 4 moved that contract to `reference` as well), * `referenceTo`'s two producers were retired by objectui#6041, and * `reference_to_object` occurs nowhere in either tree outside this chain and the * test that called it. diff --git a/packages/core/src/utils/expand-fields.ts b/packages/core/src/utils/expand-fields.ts index d720e61223..9cfa4da255 100644 --- a/packages/core/src/utils/expand-fields.ts +++ b/packages/core/src/utils/expand-fields.ts @@ -90,7 +90,7 @@ 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` / `displayField`; * - the action-param dialog — `paramToField` in * `packages/app-shell/src/utils/paramToField.ts`, which decides which param * carries a reference target (objectui#5312); diff --git a/packages/core/src/utils/reference-keys.ts b/packages/core/src/utils/reference-keys.ts index 5cc832c5b3..a78124a7f8 100644 --- a/packages/core/src/utils/reference-keys.ts +++ b/packages/core/src/utils/reference-keys.ts @@ -19,19 +19,25 @@ import { FIELD_KEY_GUIDANCE, FieldSchema } from '@objectstack/spec/data'; /** * Backend object schemas follow the ObjectStack convention and name a * relational field's target object `reference` - * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`), while ObjectUI's - * types — and most in-repo consumers — historically read `reference_to` - * (some legacy configs also carry camelCase `referenceTo`). A consumer that - * reads only one key silently loses the relation under the other convention: - * the exact bug HeaderHighlight had (#2407 / PR #2587), where a served - * `reference`-keyed lookup rendered a raw id. - * - * `normalizeFieldReferenceKeys` stamps BOTH snake_case keys onto the field - * definition whenever any of the three spellings is present, so downstream - * reads work regardless of which single key they check. It mutates the field - * in place — the ObjectStack adapter caches the schema object and re-serves - * it, so the one pass must stick — and is idempotent. Keys that are already - * set are never overwritten. + * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`) — the only + * spelling `@objectstack/spec`'s `FieldSchema` declares. Since objectui#11070 + * round 4 it is also the only spelling ObjectUI writes or reads: its types + * (`LookupFieldMetadata`, `MasterDetailFieldMetadata`, `DetailViewField`), its + * in-repo emitters, and its readers (`LookupField`, `UserField`, the lookup + * and user read cells) all spell it `reference`. + * + * `normalizeFieldReferenceKeys` FOLDS a foreign `reference_to` (or camelCase + * `referenceTo`) onto `reference` when — and only when — `reference` is + * absent, so a stored legacy def still reaches every reader. It never writes + * `reference_to`. Until round 4 it stamped BOTH snake_case keys onto every + * relational def, and that stamp was the producer that kept a second spelling + * alive inside ObjectUI: the readers read `reference_to` because the stamp and + * the emitters wrote it, and the emitters wrote it because the readers read it. + * It mutates the field in place — the ObjectStack adapter caches the schema + * object and re-serves it, so the one pass must stick — and is idempotent. A + * `reference` that is already set is never overwritten, and the foreign key is + * left on the def exactly as served: nothing here drops a key (see "Leave arm" + * below). * * ## ⭐ Why this file also WARNS — objectui#6837 half 2 * @@ -66,8 +72,9 @@ import { FIELD_KEY_GUIDANCE, FieldSchema } from '@objectstack/spec/data'; * packages/app-shell/src/providers/MetadataProvider.tsx (`getItem`, metadata type `object`) * packages/data-objectstack/src/index.ts (ObjectStackAdapter.getObjectSchema) * - * All three STAMP the def, so a def that triggers this warning is also a def - * that still resolves. ⇒ The warning fires precisely where nothing is broken. + * All three FOLD the def onto `reference`, so a def that triggers this warning + * is also a def that still resolves. ⇒ The warning fires precisely where + * nothing is broken. * * ⚠️ The third of those is new in objectui#7650 and the count above used to * read TWO. The old count was true about where this file RAN and false about @@ -82,15 +89,22 @@ import { FIELD_KEY_GUIDANCE, FieldSchema } from '@objectstack/spec/data'; * required member of the published `DataSource` interface and the readers call * it on the generic `dataSource`, so a hand-written schema served through ANY * other `DataSource` reaches a reader RAW — it neither passes through here nor - * warns. On that path the failure is exactly as silent as it was before. - * Reader-side or shared-resolver diagnostics, which would cover it, remain an - * open question on objectui#6837 (options B and C of its table §5). + * warns. Since objectui#11070 round 4 no reader carries a `reference_to` leg, + * so on that path a def that spells ONLY a legacy key resolves no target: the + * picker has no object to query and the read cell names nothing. That round's + * changeset states the break for such a host. Reader-side or shared-resolver + * diagnostics, which would make it audible, remain an open question on + * objectui#6837 (options B and C of its table §5). * * ⛔ Do not describe this pin as making the BYO break audible. It does not. * - * ⛔ The stamping itself is deliberately UNCHANGED. It is the only thing - * standing between a BYO `DataSource` and the break, and retiring it is a - * separate decision with its own weight — not this card's. + * ⭐ The `reference_to` STAMP is retired (objectui#11070 round 4, the seat's + * answer A to that card's Q1 under the objectui#6837 ruling). An earlier + * revision of this header kept it as "the only thing standing between a BYO + * `DataSource` and the break". It stood there only because the readers still + * read `reference_to`, and they read it because this stamp and ObjectUI's own + * emitters wrote it; with both ends moved to `reference` it protected nothing. + * The FOLD onto `reference` is what a served legacy def needs, and it stays. */ /** @@ -318,7 +332,7 @@ type UnfoldableReason = 'no-declared-twin' | 'ambiguous-probe' | 'canonical-occu * exactly as loudly as one that does not. * - `reference_to` / `referenceTo`, which probe onto no declared key and * would otherwise report as `no-declared-twin` while the reference arm was - * in the middle of stamping them. They have their own arm and their own + * in the middle of folding them onto `reference`. They have their own arm and their own * diagnostic ({@link warnOnLegacyOnlyReference}, objectui#6837). * * Dev-only and memoised, the discipline both existing warnings already use: the @@ -379,7 +393,7 @@ function canonicalizeRetiredFieldKeys( if (value === undefined) continue; // The reference arm's own keys are handled (and warned about) below; they // probe onto no declared key, so without this they would report here as an - // unfoldable spelling while that arm was about to stamp them. + // unfoldable spelling while that arm was about to fold them. if (REFERENCE_ARM_KEYS.has(key)) continue; const probe = aliasProbe(key); const canonical = folds.get(probe); @@ -486,7 +500,7 @@ const isDev = (): boolean => (globalThis as { process?: { env?: Record } }).process?.env?.NODE_ENV !== 'production'; -/** The two spellings no contract declares, in the order the stamp prefers them. */ +/** The two spellings no contract declares, in the order the fold prefers them. */ const LEGACY_REFERENCE_KEYS = ['reference_to', 'referenceTo'] as const; /** @@ -500,9 +514,9 @@ const REFERENCE_ARM_KEYS: ReadonlySet = new Set(LEGACY_REFERENCE * Dev-mode only: say out loud that a def arrived spelling ONLY a legacy key. * * Non-breaking by construction: changes no types, rejects nothing, drops no - * key, and is a no-op under `NODE_ENV=production`. The stamp still runs, so the - * def renders exactly as it did before — this only makes the producer's bug - * visible instead of absorbing it silently. + * key, and is a no-op under `NODE_ENV=production`. The fold still runs, so the + * def still resolves its target — this only makes the producer's bug visible + * instead of absorbing it silently. */ function warnOnLegacyOnlyReference( f: Record, @@ -547,24 +561,28 @@ export function normalizeFieldReferenceKeys( // the reference arm's early return below would skip almost every def that // needs it (objectui#7650). canonicalizeRetiredFieldKeys(f, fieldName, objectName); - const target = f.reference_to ?? f.reference ?? f.referenceTo; - if (target == null || target === '') return fieldDef; + // The reference arm is a FOLD, never a stamp (objectui#11070 round 4): a + // producer's `reference` stands, and only a def that lacks it gains one from + // a foreign spelling. `reference_to` is never written, and the foreign key is + // left where it was. + if (f.reference !== undefined) return fieldDef; + const legacy = f.reference_to ?? f.referenceTo; + if (legacy == null || legacy === '') return fieldDef; warnOnLegacyOnlyReference(f, fieldName, objectName); - if (f.reference_to === undefined) f.reference_to = target; - if (f.reference === undefined) f.reference = target; + f.reference = legacy; return fieldDef; } /** * Apply {@link normalizeFieldReferenceKeys} to every field of an object - * schema — BOTH arms: the `reference` pair and the retired dialect. Accepts + * schema — BOTH arms: the `reference` fold and the retired dialect. Accepts * both field-container shapes the metadata API serves — a `name → def` map or * an array of defs — and tolerates anything else by returning the input * untouched. Mutates in place; idempotent. * * This is meant to run at the choke point where object schemas enter the * client (`ObjectStackAdapter.getObjectSchema`, the app-shell metadata - * provider) so per-consumer dual-key fallbacks can't drift. + * provider), so no consumer needs a dual-key fallback of its own. * * Field NAMES and the OBJECT name are forwarded so the dev-mode warning above * can name the whole producer site rather than half of it: the map form keys diff --git a/packages/data-objectstack/src/getObjectSchema.test.ts b/packages/data-objectstack/src/getObjectSchema.test.ts index 3cc69cc58f..7166694fbc 100644 --- a/packages/data-objectstack/src/getObjectSchema.test.ts +++ b/packages/data-objectstack/src/getObjectSchema.test.ts @@ -75,12 +75,13 @@ describe('ObjectStackAdapter.getObjectSchema', () => { expect(Object.keys(schema.fields)).toEqual(['x']); }); - it('canonicalizes the ObjectStack-convention `reference` key onto `reference_to` (and back)', async () => { + it('folds a legacy `reference_to` onto `reference`, and stamps no `reference_to` (objectui#11070 round 4)', async () => { // The server names a relational field's target `reference` // (showcase_project.account → { type: 'lookup', reference: 'showcase_account' }), - // while most consumers read `reference_to` (#2407 / PR #2587). getObjectSchema - // is the choke point every schema read goes through, so both keys must come - // back stamped — regardless of which convention the served schema used. + // the only spelling ObjectUI's readers read. getObjectSchema is the choke + // point every schema read goes through, so a stored def that still spells + // the legacy key comes back carrying `reference` — and a `reference` def + // comes back exactly as served, with no second spelling stamped on it. const { fetchImpl } = makeFetch({ name: 'showcase_project', fields: { @@ -98,13 +99,15 @@ describe('ObjectStackAdapter.getObjectSchema', () => { const schema: any = await adapter.getObjectSchema('showcase_project'); - expect(schema.fields.account.reference_to).toBe('showcase_account'); - expect(schema.fields.team_members.reference_to).toBe('sys_user'); - // Mirror direction: `.reference`-only readers (e.g. attachInlineSubforms) - // must also see the target on a reference_to-authored schema. + expect(schema.fields.account).toEqual({ type: 'lookup', reference: 'showcase_account' }); + expect(schema.fields.team_members).toEqual({ type: 'user', reference: 'sys_user', multiple: true }); + // The fold: every reader reads `reference`, so a legacy-only def must + // reach them carrying it. The key it arrived with is left in place. expect(schema.fields.legacy.reference).toBe('showcase_order'); + expect(schema.fields.legacy.reference_to).toBe('showcase_order'); // Non-relational fields stay untouched. expect('reference_to' in schema.fields.name).toBe(false); + expect('reference' in schema.fields.name).toBe(false); }); it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => { diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index e1d9480ed2..afacc4a6d7 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -5039,11 +5039,12 @@ export class ObjectStackAdapter implements DataSource { this.fetchObjectSchemaFresh(objectName), ); - // Canonicalize the relational-target key: the server names it - // `reference` (ObjectStack convention) while most consumers read - // `reference_to` (#2407 / PR #2587). Stamping both here — the choke - // point every schema read goes through — means no per-consumer - // dual-key fallback can drift. Idempotent on the cached object. + // Canonicalize the relational-target key onto `reference`, the only + // spelling the spec declares and ObjectUI's readers read (objectui#11070 + // round 4): a stored def that still spells a legacy `reference_to` / + // `referenceTo` is folded here — the choke point every schema read goes + // through — so no consumer needs a dual-key fallback. `reference_to` is + // never stamped. Idempotent on the cached object. normalizeSchemaReferenceKeys(schema); // ADR-0056 P2 (epic #2398): stamp structured-widget hints onto specific diff --git a/packages/fields/src/__tests__/FieldEditWidget.hostPlumbing-7008.test.tsx b/packages/fields/src/__tests__/FieldEditWidget.hostPlumbing-7008.test.tsx index 574d8c18d6..51c37e3122 100644 --- a/packages/fields/src/__tests__/FieldEditWidget.hostPlumbing-7008.test.tsx +++ b/packages/fields/src/__tests__/FieldEditWidget.hostPlumbing-7008.test.tsx @@ -190,7 +190,7 @@ describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', // wrapper field > SchemaRendererContext > none"; the factory is a conduit // and adds no second authority. This pins that the delivered prop is what // the widget ends up querying. - const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never; + const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference: 'accounts' } as never; const makeSource = () => ({ find: vi.fn().mockResolvedValue([]), getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }), diff --git a/packages/fields/src/__tests__/cell-truncation.test.tsx b/packages/fields/src/__tests__/cell-truncation.test.tsx index 4e5b0603d0..bc001c99de 100644 --- a/packages/fields/src/__tests__/cell-truncation.test.tsx +++ b/packages/fields/src/__tests__/cell-truncation.test.tsx @@ -47,7 +47,7 @@ describe('cell renderers truncate for real and expose the full text (issue #3466 , ); diff --git a/packages/fields/src/__tests__/cellRenderers.emptyArray-8481.test.tsx b/packages/fields/src/__tests__/cellRenderers.emptyArray-8481.test.tsx index 926ddd623e..6332f22ac3 100644 --- a/packages/fields/src/__tests__/cellRenderers.emptyArray-8481.test.tsx +++ b/packages/fields/src/__tests__/cellRenderers.emptyArray-8481.test.tsx @@ -128,7 +128,7 @@ describe('objectui#8481 — an empty array is not a cell value', () => { }); it('NON-REGRESSION — a POPULATED lookup still renders one chip per referenced record', () => { - const { container } = renderCell('lookup', ['alpha', 'beta'], { reference_to: 'other' }); + const { container } = renderCell('lookup', ['alpha', 'beta'], { reference: 'other' }); expect( within(container).queryByText('alpha'), diff --git a/packages/fields/src/__tests__/inline-widget-aria-invalid-7126.test.tsx b/packages/fields/src/__tests__/inline-widget-aria-invalid-7126.test.tsx index e9c214a541..731f481f66 100644 --- a/packages/fields/src/__tests__/inline-widget-aria-invalid-7126.test.tsx +++ b/packages/fields/src/__tests__/inline-widget-aria-invalid-7126.test.tsx @@ -183,7 +183,7 @@ describe('inline field widgets announce a delivered `error` (objectui#7126)', () // the next reader does not "fix" a widget that was never broken (and so a // future refactor that flattens the delegation cannot silently drop it). const container = renderInline( - { name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' }, + { name: 'owner_id', type: 'user', label: 'Owner', reference: 'sys_user' }, 'Required', ); diff --git a/packages/fields/src/__tests__/lookupCell.contractEnvelope-6917.test.tsx b/packages/fields/src/__tests__/lookupCell.contractEnvelope-6917.test.tsx index bcdcd242f9..d5516eacf3 100644 --- a/packages/fields/src/__tests__/lookupCell.contractEnvelope-6917.test.tsx +++ b/packages/fields/src/__tests__/lookupCell.contractEnvelope-6917.test.tsx @@ -88,7 +88,7 @@ async function renderChip(objectName: string, id: string, answer: unknown) { , ); diff --git a/packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsx b/packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsx index 54079ec8d4..a4003b230b 100644 --- a/packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsx +++ b/packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsx @@ -106,7 +106,7 @@ function Cell({ const cell = (
- +
); diff --git a/packages/fields/src/__tests__/lookupCell.unresolvedReference-8695.test.tsx b/packages/fields/src/__tests__/lookupCell.unresolvedReference-8695.test.tsx index 4d0046f0b7..c0bb9fb8e2 100644 --- a/packages/fields/src/__tests__/lookupCell.unresolvedReference-8695.test.tsx +++ b/packages/fields/src/__tests__/lookupCell.unresolvedReference-8695.test.tsx @@ -39,7 +39,7 @@ * * ── ⭐ How many states hide behind "unresolved" — measured, not assumed ──── * Six causes reach this arm and the renderer distinguishes NONE of them: - * never fetched (no dataSource / no `reference_to`), IN FLIGHT, the resolver + * never fetched (no dataSource / no `reference`), IN FLIGHT, the resolver * threw, the resolver answered with no record, it answered with a record no * display field could name, and — for array entries after the first — never * asked at all (`primaryPrimitiveId`). `useLookupName` returns @@ -95,7 +95,7 @@ const SHAPES: ReadonlyArray = [ ['opaque, the shape `isLikelyOpaqueId` used to mute away', '01HQZX9K2M4N6P8R'], ]; -const REF = { reference_to: 'sys_user' }; +const REF = { reference: 'sys_user' }; /** Every family that routes through `LookupCellRenderer` — a bigger surface than `user`. */ const FAMILIES = ['lookup', 'master_detail', 'tree'] as const; @@ -186,7 +186,7 @@ describe('objectui#8695 — ADDITIVE, NOT SUBTRACTIVE: the sentence that graded const { container } = renderCell( 'lookup', ['Ada Lovelace', '01HQZX9K2M4N6P8R', { id: 'a_1', name: 'Globex' }], - { reference_to: 'account' }, + { reference: 'account' }, ); expect(marks(container), 'both unresolved chips say so').toHaveLength(2); @@ -201,7 +201,7 @@ describe('objectui#8695 — ADDITIVE, NOT SUBTRACTIVE: the sentence that graded const { container } = renderCell( 'lookup', ['a', 'b', 'c', 'Ada Lovelace', '01HQZX9K2M4N6P8R'], - { reference_to: 'account' }, + { reference: 'account' }, ); const overflow = screen.getByText('+2'); expect( @@ -231,7 +231,7 @@ describe('objectui#8695 — THE SENTENCE: epistemic, and about a RECORD', () => // pointing at any other object — and two existing tests pin that wording // byte-for-byte, so it could not be widened in place either. it('the lookup sentence is a SIBLING key, and never claims the target is a user', () => { - const { container } = renderCell('lookup', 'Ada Lovelace', { reference_to: 'mtc_work_order' }); + const { container } = renderCell('lookup', 'Ada Lovelace', { reference: 'mtc_work_order' }); const stated = marks(container)[0]!.getAttribute('title') ?? ''; expect(stated, 'a work order is not a user').not.toMatch(/\buser\b/i); @@ -257,7 +257,7 @@ describe('objectui#8695 — POSITIVE CONTROLS: a resolved reference is untouched // today, unmarked — otherwise this repair is indistinguishable from // "every lookup now looks broken". it('an EXPANDED record renders its name, unmarked, exactly as before', () => { - const { container } = renderCell('lookup', { id: 'a_1', name: 'Globex' }, { reference_to: 'account' }); + const { container } = renderCell('lookup', { id: 'a_1', name: 'Globex' }, { reference: 'account' }); expect(marks(container), 'a resolved reference is not unresolved').toHaveLength(0); expect(container.innerHTML).toBe( 'Globex', @@ -282,7 +282,7 @@ describe('objectui#8695 — POSITIVE CONTROLS: a resolved reference is untouched , ); @@ -316,7 +316,7 @@ describe('objectui#8695 — POSITIVE CONTROLS: a resolved reference is untouched , ); diff --git a/packages/fields/src/__tests__/lookupCellDisplayName.test.tsx b/packages/fields/src/__tests__/lookupCellDisplayName.test.tsx index 158fc10c6d..b3a91ebb04 100644 --- a/packages/fields/src/__tests__/lookupCellDisplayName.test.tsx +++ b/packages/fields/src/__tests__/lookupCellDisplayName.test.tsx @@ -43,7 +43,7 @@ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => , ); @@ -59,7 +59,7 @@ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => , ); @@ -74,7 +74,7 @@ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => , ); @@ -93,7 +93,7 @@ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => , ); @@ -110,7 +110,7 @@ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => , ); diff --git a/packages/fields/src/__tests__/lookupCellOverflow.test.tsx b/packages/fields/src/__tests__/lookupCellOverflow.test.tsx index a3716a1f47..8bc806d9b4 100644 --- a/packages/fields/src/__tests__/lookupCellOverflow.test.tsx +++ b/packages/fields/src/__tests__/lookupCellOverflow.test.tsx @@ -24,7 +24,7 @@ import React from 'react'; import { LookupCellRenderer } from '../index'; -const FIELD = { type: 'lookup', reference_to: 'mtc_work_object' } as any; +const FIELD = { type: 'lookup', reference: 'mtc_work_object' } as any; const manyRecords = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `obj-${i + 1}`, name: `ZTLW-A.${i + 1}` })); diff --git a/packages/fields/src/__tests__/lookupCellRecordLink.test.tsx b/packages/fields/src/__tests__/lookupCellRecordLink.test.tsx index 4b8936943b..70b26b4d20 100644 --- a/packages/fields/src/__tests__/lookupCellRecordLink.test.tsx +++ b/packages/fields/src/__tests__/lookupCellRecordLink.test.tsx @@ -33,7 +33,7 @@ import { RelatedRecordActionsProvider } from '@object-ui/react'; /** The reporter's shape: `$expand`-ed lookup value (display name + record id). */ const WORK_ORDER = { id: 'wo-1', name: 'TYG1WX20260812001001' }; -const FIELD = { type: 'lookup', reference_to: 'mtc_work_order' } as any; +const FIELD = { type: 'lookup', reference: 'mtc_work_order' } as any; /** A host that can route to records, mirroring the console bridge's shape. */ function makeHost(overrides: Record = {}) { diff --git a/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx b/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx index f7713b56d1..a540fbdc9a 100644 --- a/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx +++ b/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx @@ -1,15 +1,18 @@ /** * Regression: LookupCellRenderer must resolve a bare foreign-key id to a * display name when the field metadata carries the ObjectStack `reference` - * key (as produced by `Field.lookup('...')` in @objectstack/spec) — not only - * the objectui `reference_to` alias. + * key (as produced by `Field.lookup('...')` in @objectstack/spec). * * Real-world symptom (framework app-showcase): inline-edit a lookup cell, pick * a record, click another row → the cell showed a muted "—" forever because - * the just-picked opaque id could not be resolved. Every other reader in the - * codebase (LookupField, UserField, DetailSection, RelatedList, …) already - * accepts `reference_to || reference`; this read cell used to read only - * `reference_to`. + * the just-picked opaque id could not be resolved. This read cell used to read + * only the objectui `reference_to` alias. + * + * Since objectui#11070 round 4 `reference` is the ONLY spelling it reads — the + * spelling every other reader reads too — and the second pin below holds the + * other half: a def that spells only the retired `reference_to` resolves no + * target here. A served legacy def reaches this cell already folded onto + * `reference` by the ingestion choke point (`normalizeSchemaReferenceKeys`). */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; @@ -34,7 +37,7 @@ function makeDataSource() { } describe('LookupCellRenderer — reference key resolution', () => { - it('resolves an opaque id to a name via the `reference` key (not just reference_to)', async () => { + it('resolves an opaque id to a name via the `reference` key', async () => { const ds = makeDataSource(); render( @@ -54,4 +57,22 @@ describe('LookupCellRenderer — reference key resolution', () => { expect(screen.queryByText('—')).not.toBeInTheDocument(); expect(ds.findOne).toHaveBeenCalledWith('showcase_account', OPAQUE_ID); }); + + it('reads no target from a retired `reference_to` (objectui#11070 round 4)', async () => { + const ds = makeDataSource(); + render( + + + , + ); + // The raw id stays visible beside the unresolved-reference marker… + expect(screen.getByText(OPAQUE_ID)).toBeInTheDocument(); + // …because nothing named an object to resolve it through. + await new Promise((r) => setTimeout(r, 20)); + expect(ds.findOne).not.toHaveBeenCalled(); + expect(screen.queryByText('Globex')).not.toBeInTheDocument(); + }); }); diff --git a/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx index 9e7cc44791..eb765b3f03 100644 --- a/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx +++ b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx @@ -118,7 +118,7 @@ function lookupGateHint(language: string): string { { name: 'contact', label: 'Contact', - reference_to: 'crm_contact', + reference: 'crm_contact', reference_field: 'name', dependsOn: [PARENT_A.name, PARENT_B.name], } as any diff --git a/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx b/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx index 649d4759c1..ff461dcdf0 100644 --- a/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx +++ b/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx @@ -100,7 +100,7 @@ describe('text-like cells display JSON-shaped text verbatim (objectui#7246)', () const text = renderedText( , ); expect(text).toContain('Website Relaunch'); diff --git a/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx b/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx index 1613e00979..587621ffc9 100644 --- a/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx +++ b/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx @@ -25,8 +25,9 @@ * * - single and multi: a denied name and a denied image are not drawn; the * cell reads exactly as a stripping backend's row makes it read; - * - the person's object is the field's `reference_to` (or `reference`), and - * `sys_user` when it names none — the answer `UserField` gives the picker; + * - the person's object is the field's `reference`, and `sys_user` when it + * names none — the answer `UserField` gives the picker; a retired + * `reference_to` names nothing (objectui#11070 round 4); * - a policy that changes after mount relabels the SAME mounted cell; * - lit controls: fields the policy does NOT deny still print, and with no * policy loaded (no provider: `isLoaded` is false) the row is drawn as @@ -150,21 +151,28 @@ describe('UserCellRenderer — the name and the avatar are drawn from the fields expect(gated.html).toBe(served.html); }); - it("the person's object is the field's `reference_to` / `reference`, and `sys_user` when it names none", () => { - // `reference` is the spec's spelling, the one a grid column forwards; the - // objectui types declare only `reference_to`, hence the cast. - for (const field of [ - { type: 'user', reference_to: 'crm_member' }, - { type: 'user', reference: 'crm_member' }, - ] as unknown as FieldMetadata[]) { - const gated = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') }); - for (const secret of SECRETS) expect(gated.html).not.toContain(secret); - - // A policy on `sys_user` says nothing about a field that points elsewhere. - const elsewhere = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') }); - expect(elsewhere.text).toContain('Amy Lin'); - expect(elsewhere.imgs).toEqual(['http://x/amy.png']); - } + it("the person's object is the field's `reference`, and `sys_user` when it names none", () => { + // `reference` is the spec's spelling and, since objectui#11070 round 4, the + // only one read. `UserFieldMetadata` does not declare it, hence the cast. + const field = { type: 'user', reference: 'crm_member' } as unknown as FieldMetadata; + const gated = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') }); + for (const secret of SECRETS) expect(gated.html).not.toContain(secret); + + // A policy on `sys_user` says nothing about a field that points elsewhere. + const elsewhere = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') }); + expect(elsewhere.text).toContain('Amy Lin'); + expect(elsewhere.imgs).toEqual(['http://x/amy.png']); + }); + + it('a retired `reference_to` names no person object — the cell reads `sys_user` (objectui#11070 round 4)', () => { + const field = { type: 'user', reference_to: 'crm_member' } as unknown as FieldMetadata; + // The retired spelling is read by nothing, so a policy on `crm_member` + // does not reach this field… + const unreached = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') }); + expect(unreached.text).toContain('Amy Lin'); + // …and the cell answers `sys_user`, as for a field that names none. + const fallback = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') }); + for (const secret of SECRETS) expect(fallback.html).not.toContain(secret); }); it('a policy that changes after mount relabels the same mounted cell', () => { diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index fc7fcdd73e..91f553ca70 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -75,7 +75,7 @@ describe('Complex & Relationship Widgets', () => { const dynamicField = { ...mockField, label: 'Customer', - reference_to: 'customers', + reference: 'customers', reference_field: 'name', } as any; @@ -356,7 +356,7 @@ describe('Complex & Relationship Widgets', () => { }); it('defaults inline-create ON for a user-facing relation (no allow_create needed)', async () => { - // dynamicField.reference_to === 'customers' (user-facing) with NO + // dynamicField.reference === 'customers' (user-facing) with NO // allow_create flag → the capability is ON by default (standard). mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); mockDataSource.create.mockResolvedValue({ id: 'new-2', name: 'Zeta' }); @@ -378,7 +378,7 @@ describe('Complex & Relationship Widgets', () => { // A picker into the platform user directory must not offer "+ create" // (those rows are org members, not inline-created from a field). mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); - const field = { ...dynamicField, reference_to: 'sys_user' } as any; + const field = { ...dynamicField, reference: 'sys_user' } as any; render(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Select/i })); }); @@ -503,11 +503,11 @@ describe('Complex & Relationship Widgets', () => { expect(onChange).toHaveBeenCalledWith('2'); }); - it('resolves reference_to from nested field.field (form metadata slot)', async () => { + it('resolves reference from nested field.field (form metadata slot)', async () => { // Simulates the `field.field` nesting the FORM renderer hands widgets: // `renderFieldComponent` passes `field: field.field || field`, where // `.field` is the declared metadata slot (objectui#3090) holding the - // real metadata (reference_to, reference_field, …). The test used to + // real metadata (reference, reference_field, …). The test used to // credit this shape to the docs-demo `createFieldRenderer` wrapper, // which never produced it and no longer exists (objectui#3910); the // live producer is the form path, so the coverage stands. @@ -527,7 +527,7 @@ describe('Complex & Relationship Widgets', () => { field: { name: 'order', type: 'lookup', - reference_to: 'orders', + reference: 'orders', reference_field: 'name', }, // dataSource lands at the wrapper level @@ -557,8 +557,29 @@ describe('Complex & Relationship Widgets', () => { }); }); - it('supports ObjectStack "reference" convention (not just "reference_to")', async () => { - // ObjectStack backend uses `reference` instead of `reference_to` + it('reads no target from a retired "reference_to" (objectui#11070 round 4)', async () => { + // `reference` is the only spelling the widget reads. A def that + // spells only the retired `reference_to` names no object, so + // opening the picker queries nothing. + mockDataSource.find.mockResolvedValue({ data: [{ id: 'a1', name: 'Acme Corp' }], total: 1 }); + render( + + ); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + expect(mockDataSource.find).not.toHaveBeenCalled(); + expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument(); + }); + + it('supports ObjectStack "reference" convention', async () => { + // ObjectStack backend uses `reference`, the spec's spelling const onChange = vi.fn(); mockDataSource.find.mockResolvedValue({ data: [ diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 5d7a549f18..13209d81e1 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2452,7 +2452,7 @@ const MAX_LOOKUP_CELL_CHIPS = 3; * Display order: * 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name * 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label - * 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to` + * 3. Fetch-on-demand: when the value is a primitive ID and `field.reference` * is known, resolve via dataSource and show the related record's display name. * 4. Nothing named it → the unresolved-reference affordance (objectui#8695): * the raw value, kept visible, beside a stated epistemic marker. This arm @@ -2467,14 +2467,12 @@ const MAX_LOOKUP_CELL_CHIPS = 3; * (objectui#10501). */ export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement { - // ObjectStack object metadata uses `reference` for the lookup target while the - // objectui types call it `reference_to`. Every other reader (LookupField, - // UserField, DetailSection, RelatedList, …) accepts both; this read cell must - // too, or a picked/opaque id never resolves to a name and the cell shows the - // muted "—" placeholder forever (e.g. after inline-editing a lookup). - const referenceTo = - (field as { reference_to?: string }).reference_to || - (field as { reference?: string }).reference; + // `reference` — the spelling `@objectstack/spec`'s `FieldSchema` declares — + // is the only target spelling read, as every other reader (LookupField, + // UserField, DetailSection, RelatedList, …) reads it (objectui#11070 round + // 4). A served def that still spells a legacy key was folded onto it at + // ingestion (`normalizeSchemaReferenceKeys`). + const referenceTo = (field as { reference?: string }).reference; // Explicit author-chosen display field on the lookup — beats every resolver. // ObjectGrid forwards `displayField` on the column meta (RELATIONAL_META_KEYS) @@ -2806,7 +2804,7 @@ function UnresolvedUserReference({ * `pending` / `err` / `ok` discriminator its own cache stores is dropped * before any caller sees it: * - * 1. never fetched — no `dataSource`, or no `reference_to` on the field; + * 1. never fetched — no `dataSource`, or no `reference` on the field; * 2. IN FLIGHT — the first paint of every successful resolve passes through * here (measured: the settled paint replaces it); * 3. the resolver threw (`state: 'err'`); @@ -2913,15 +2911,14 @@ function UnresolvedReferenceMark({ * denies on the person's object are removed first ({@link withoutDeniedFields}, * `id` kept), and everything below reads that row — exactly as it reads the * row a stripping backend serves. The person's object is the field's - * `reference_to` (or `reference`), and `sys_user` when it names none: the - * object `UserField` points the picker at. + * `reference`, and `sys_user` when it names none: the object `UserField` + * points the picker at. */ export function UserCellRenderer({ value, field }: CellRendererProps): React.ReactElement { // Called before any early return (rules of hooks). A policy that loads or // changes re-renders this cell through the context, and the drawing follows. const perms = usePermissions(); const personObject = - (field as { reference_to?: string } | undefined)?.reference_to || (field as { reference?: string } | undefined)?.reference || 'sys_user'; diff --git a/packages/fields/src/widgets/LookupField.candidateExpand-10223.test.tsx b/packages/fields/src/widgets/LookupField.candidateExpand-10223.test.tsx index 9c6354d9b1..12ee55e868 100644 --- a/packages/fields/src/widgets/LookupField.candidateExpand-10223.test.tsx +++ b/packages/fields/src/widgets/LookupField.candidateExpand-10223.test.tsx @@ -60,9 +60,9 @@ const CANDIDATES = 50; const TASK_VERSION_FIELDS: Record = { name: { type: 'text', label: 'Name' }, code: { type: 'text', label: 'Code' }, - task: { type: 'master_detail', label: 'Task', reference_to: 'task' }, + task: { type: 'master_detail', label: 'Task', reference: 'task' }, version: { type: 'number', label: 'Version' }, - owner: { type: 'user', label: 'Owner', reference_to: 'sys_user' }, + owner: { type: 'user', label: 'Owner', reference: 'sys_user' }, }; interface BackendOptions { @@ -182,7 +182,7 @@ async function mountLookup( value={undefined} onChange={() => {}} dataSource={backend.dataSource} - field={{ reference_to: 'task_version' } as never} + field={{ reference: 'task_version' } as never} {...(extra as object)} /> , @@ -296,8 +296,8 @@ describe('LookupField — no displayed reference column, no `$expand` (objectui# */ const WITH_AUDIT_RELATIONS: Record = { ...TASK_VERSION_FIELDS, - created_by: { type: 'user', label: 'Created By', reference_to: 'sys_user' }, - owner_id: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + created_by: { type: 'user', label: 'Created By', reference: 'sys_user' }, + owner_id: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, }; it('dropdown and recents rail: `highlightFields` naming only the display field send no `$expand` key', async () => { diff --git a/packages/fields/src/widgets/LookupField.chipRemoveGate-10120.test.tsx b/packages/fields/src/widgets/LookupField.chipRemoveGate-10120.test.tsx index b18ea1c622..c9f11898d3 100644 --- a/packages/fields/src/widgets/LookupField.chipRemoveGate-10120.test.tsx +++ b/packages/fields/src/widgets/LookupField.chipRemoveGate-10120.test.tsx @@ -59,7 +59,7 @@ describe('LookupField — the chip remove control follows the field\'s interacti onChange={onChange} disabled dataSource={makeDataSource() as never} - field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet' } as never} + field={{ reference: 'kpi_entry_sheet', label: 'Sheet' } as never} />, ); // The value is still READ — a disabled field is not a hidden one. @@ -75,7 +75,7 @@ describe('LookupField — the chip remove control follows the field\'s interacti onChange={() => {}} disabled dataSource={makeDataSource() as never} - field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never} + field={{ reference: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never} />, ); await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy()); @@ -89,7 +89,7 @@ describe('LookupField — the chip remove control follows the field\'s interacti value={['SHEET1', 'SHEET2']} onChange={() => {}} dataSource={makeDataSource() as never} - field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never} + field={{ reference: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never} />, ); await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy()); @@ -105,7 +105,7 @@ describe('LookupField — the chip remove control follows the field\'s interacti onChange={() => {}} readonly dataSource={makeDataSource() as never} - field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet' } as never} + field={{ reference: 'kpi_entry_sheet', label: 'Sheet' } as never} />, ); await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy()); diff --git a/packages/fields/src/widgets/LookupField.contextTailRetired-7206.test.tsx b/packages/fields/src/widgets/LookupField.contextTailRetired-7206.test.tsx index 16fd2b4181..f788b2dfab 100644 --- a/packages/fields/src/widgets/LookupField.contextTailRetired-7206.test.tsx +++ b/packages/fields/src/widgets/LookupField.contextTailRetired-7206.test.tsx @@ -75,7 +75,7 @@ function makeDataSource() { const lookupField = { name: 'contact', label: 'Contact', - reference_to: 'contacts', + reference: 'contacts', reference_field: 'name', dependsOn: ['account'], } as any; diff --git a/packages/fields/src/widgets/LookupField.dependsOn.test.tsx b/packages/fields/src/widgets/LookupField.dependsOn.test.tsx index 659dfa11c8..e2df412cc0 100644 --- a/packages/fields/src/widgets/LookupField.dependsOn.test.tsx +++ b/packages/fields/src/widgets/LookupField.dependsOn.test.tsx @@ -68,7 +68,7 @@ describe('LookupField — dependsOn gating and cascade filter (#2215)', () => { const dependentField = { name: 'contact', label: 'Contact', - reference_to: 'contacts', + reference: 'contacts', reference_field: 'name', dependsOn: ['account'], } as any; diff --git a/packages/fields/src/widgets/LookupField.dependsOnDeclared-6153.test.tsx b/packages/fields/src/widgets/LookupField.dependsOnDeclared-6153.test.tsx index f2120b1f01..100aaa9eed 100644 --- a/packages/fields/src/widgets/LookupField.dependsOnDeclared-6153.test.tsx +++ b/packages/fields/src/widgets/LookupField.dependsOnDeclared-6153.test.tsx @@ -55,7 +55,7 @@ const contactField: LookupFieldMetadata = { type: 'lookup', name: 'contact', label: 'Contact', - reference_to: 'contacts', + reference: 'contacts', reference_field: 'name', dependsOn: [{ field: 'account', param: 'account_id' }], }; diff --git a/packages/fields/src/widgets/LookupField.dependsOnRetired-7357.test.tsx b/packages/fields/src/widgets/LookupField.dependsOnRetired-7357.test.tsx index e5eb4cefa7..29ebb93d42 100644 --- a/packages/fields/src/widgets/LookupField.dependsOnRetired-7357.test.tsx +++ b/packages/fields/src/widgets/LookupField.dependsOnRetired-7357.test.tsx @@ -54,7 +54,7 @@ function makeDataSource() { const baseLookup = { name: 'contact', label: 'Contact', - reference_to: 'contacts', + reference: 'contacts', reference_field: 'name', }; diff --git a/packages/fields/src/widgets/LookupField.displayFls-10373.test.tsx b/packages/fields/src/widgets/LookupField.displayFls-10373.test.tsx index c3c987bae7..847ab2e98e 100644 --- a/packages/fields/src/widgets/LookupField.displayFls-10373.test.tsx +++ b/packages/fields/src/widgets/LookupField.displayFls-10373.test.tsx @@ -51,7 +51,7 @@ const ACCOUNT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, code: { type: 'text', label: 'Code' }, secret: { type: 'text', label: 'Secret' }, - region: { type: 'lookup', label: 'Region', reference_to: 'region' }, + region: { type: 'lookup', label: 'Region', reference: 'region' }, }; interface BackendOptions { @@ -156,7 +156,7 @@ async function openDropdown( value={undefined} onChange={onChange} dataSource={ds} - field={{ reference_to: 'account' } as never} + field={{ reference: 'account' } as never} {...(extra as object)} /> , diff --git a/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx b/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx index 07ac24f50b..63db853049 100644 --- a/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx +++ b/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx @@ -38,7 +38,7 @@ import { LookupField } from './LookupField'; const gatedField = { name: 'contact', label: 'Contact', - reference_to: 'crm_contact', + reference: 'crm_contact', reference_field: 'name', dependsOn: ['crm_account'], } as any; diff --git a/packages/fields/src/widgets/LookupField.hydratedLabel-10487.test.tsx b/packages/fields/src/widgets/LookupField.hydratedLabel-10487.test.tsx index a746347cf2..dc1ae689d7 100644 --- a/packages/fields/src/widgets/LookupField.hydratedLabel-10487.test.tsx +++ b/packages/fields/src/widgets/LookupField.hydratedLabel-10487.test.tsx @@ -103,7 +103,7 @@ function Host({ ds, initial, multiple = false }: { ds: Backend; initial: unknown value={value} onChange={setValue} dataSource={ds as never} - field={{ reference_to: 'contract', multiple } as never} + field={{ reference: 'contract', multiple } as never} /> ); diff --git a/packages/fields/src/widgets/LookupField.hydration.test.tsx b/packages/fields/src/widgets/LookupField.hydration.test.tsx index c1c84a89be..6e6272a93b 100644 --- a/packages/fields/src/widgets/LookupField.hydration.test.tsx +++ b/packages/fields/src/widgets/LookupField.hydration.test.tsx @@ -40,7 +40,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108 value={['u1', 'u2', 'u3']} onChange={() => {}} dataSource={{ find, findOne } as never} - field={{ reference_to: 'sys_user', multiple: true } as never} + field={{ reference: 'sys_user', multiple: true } as never} />, ); @@ -65,7 +65,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108 value={['u1', 'u2', 'u3']} onChange={() => {}} dataSource={{ find } as never} - field={{ reference_to: 'sys_user', multiple: true } as never} + field={{ reference: 'sys_user', multiple: true } as never} />, ); @@ -89,7 +89,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108 value={['u1', 'u2']} onChange={() => {}} dataSource={{ find } as never} - field={{ reference_to: 'sys_user', multiple: true } as never} + field={{ reference: 'sys_user', multiple: true } as never} />, ); @@ -110,7 +110,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108 onChange={() => {}} readonly dataSource={{ find } as never} - field={{ reference_to: 'sys_user', multiple: true } as never} + field={{ reference: 'sys_user', multiple: true } as never} />, ); @@ -128,7 +128,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108 value={['u1']} onChange={() => {}} dataSource={{ find, findOne } as never} - field={{ reference_to: 'sys_user', multiple: true } as never} + field={{ reference: 'sys_user', multiple: true } as never} />, ); await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1')); diff --git a/packages/fields/src/widgets/LookupField.idField.test.tsx b/packages/fields/src/widgets/LookupField.idField.test.tsx index 0ba4588e6e..0101eb4112 100644 --- a/packages/fields/src/widgets/LookupField.idField.test.tsx +++ b/packages/fields/src/widgets/LookupField.idField.test.tsx @@ -28,7 +28,7 @@ describe('LookupField — idField hydration (#3508)', () => { onChange={() => {}} dataSource={{ find, findOne } as never} field={{ - reference_to: 'sys_position', + reference: 'sys_position', idField: 'name', displayField: 'label', multiple: false, @@ -53,7 +53,7 @@ describe('LookupField — idField hydration (#3508)', () => { value="u1" onChange={() => {}} dataSource={{ find, findOne } as never} - field={{ reference_to: 'sys_user', multiple: false } as never} + field={{ reference: 'sys_user', multiple: false } as never} />, ); await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1')); diff --git a/packages/fields/src/widgets/LookupField.labelOrder-10343.test.tsx b/packages/fields/src/widgets/LookupField.labelOrder-10343.test.tsx index 7df9c6cb6f..cf6dc2dabf 100644 --- a/packages/fields/src/widgets/LookupField.labelOrder-10343.test.tsx +++ b/packages/fields/src/widgets/LookupField.labelOrder-10343.test.tsx @@ -86,7 +86,7 @@ function renderLookup(ds: Backend, field: Record, value: unknow value={value} onChange={() => {}} dataSource={ds} - field={{ reference_to: 'contract', ...field } as never} + field={{ reference: 'contract', ...field } as never} /> , ); diff --git a/packages/fields/src/widgets/LookupField.optionDescription.test.tsx b/packages/fields/src/widgets/LookupField.optionDescription.test.tsx index f562f9bed8..9dfec44edf 100644 --- a/packages/fields/src/widgets/LookupField.optionDescription.test.tsx +++ b/packages/fields/src/widgets/LookupField.optionDescription.test.tsx @@ -38,7 +38,7 @@ const priorityField: LookupFieldMetadata = { type: 'lookup', name: 'priority', label: 'Priority', - reference_to: 'priorities', + reference: 'priorities', options: [ { label: 'High', value: 'high', description: 'Blocks the release' }, { label: 'Normal', value: 'normal' }, diff --git a/packages/fields/src/widgets/LookupField.pickedLabel-10559.test.tsx b/packages/fields/src/widgets/LookupField.pickedLabel-10559.test.tsx index 9648c6ee33..c04fe1eada 100644 --- a/packages/fields/src/widgets/LookupField.pickedLabel-10559.test.tsx +++ b/packages/fields/src/widgets/LookupField.pickedLabel-10559.test.tsx @@ -96,7 +96,7 @@ function Host({ ds }: { ds: Backend }) { value={value} onChange={setValue} dataSource={ds as never} - field={{ reference_to: 'contract' } as never} + field={{ reference: 'contract' } as never} /> ); diff --git a/packages/fields/src/widgets/LookupField.pickerAgreement.test.tsx b/packages/fields/src/widgets/LookupField.pickerAgreement.test.tsx index 8a4bbbdbe3..624a52bf32 100644 --- a/packages/fields/src/widgets/LookupField.pickerAgreement.test.tsx +++ b/packages/fields/src/widgets/LookupField.pickerAgreement.test.tsx @@ -70,7 +70,7 @@ const WORK_STEPS: Record = { */ const SCHEDULE_FIELDS: Record = { name: { type: 'text', label: 'Name' }, - work_step: { type: 'lookup', label: 'Work Step', reference_to: 'work_steps' }, + work_step: { type: 'lookup', label: 'Work Step', reference: 'work_steps' }, planned_on: { type: 'date', label: 'Planned On', format: 'medium' }, status: { type: 'select', @@ -167,7 +167,7 @@ async function readInlineDropdown(stepId: string): Promise {}} dataSource={dataSource} field={{ - reference_to: 'work_schedules', + reference: 'work_schedules', display_field: 'name', lookup_columns: LOOKUP_COLUMNS, } as never} diff --git a/packages/fields/src/widgets/LookupField.pickerColumnOrder-10486.test.tsx b/packages/fields/src/widgets/LookupField.pickerColumnOrder-10486.test.tsx index b4ec5b51a1..c296ee3dcb 100644 --- a/packages/fields/src/widgets/LookupField.pickerColumnOrder-10486.test.tsx +++ b/packages/fields/src/widgets/LookupField.pickerColumnOrder-10486.test.tsx @@ -89,7 +89,7 @@ async function readBoth(ds: any, field: Record = {}): Promise {}} dataSource={ds} - field={{ reference_to: 'contract', ...field } as never} + field={{ reference: 'contract', ...field } as never} /> , ); diff --git a/packages/fields/src/widgets/LookupField.recentsFilters.test.tsx b/packages/fields/src/widgets/LookupField.recentsFilters.test.tsx index 77d989283b..c30ec7fe60 100644 --- a/packages/fields/src/widgets/LookupField.recentsFilters.test.tsx +++ b/packages/fields/src/widgets/LookupField.recentsFilters.test.tsx @@ -169,7 +169,7 @@ beforeEach(() => { const staticFilterField = { name: 'product', label: 'Product', - reference_to: 'product', + reference: 'product', reference_field: 'name', lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }], } as any; @@ -177,7 +177,7 @@ const staticFilterField = { const cascadeField = { name: 'product', label: 'Product', - reference_to: 'product', + reference: 'product', reference_field: 'name', dependsOn: [{ field: 'project', param: 'project' }], } as any; diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 6c6da89ea1..b45a87aec8 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -299,15 +299,13 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // The form renderer passes `field: field.field || field` — `.field` is the // declared metadata slot (objectui#3090) — so the actual objectSchema field - // metadata (reference_to, displayField, etc.) can arrive nested at + // metadata (reference, displayField, etc.) can arrive nested at // `lookupField.field`. Unwrap it so lookup-specific properties resolve // correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper, // which never produced the nesting and was removed in objectui#3910; the form // path is the real producer.) - // ObjectStack convention uses `reference` while the types use `reference_to`, - // so we check for both property names. const innerField = lookupField?.field; - const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField)) + const fieldMeta = (innerField && typeof innerField === 'object' && ('reference' in innerField || 'type' in innerField)) ? innerField : lookupField; @@ -321,8 +319,11 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro const displayField = declaredDisplayField || DEFAULT_DISPLAY_FIELD; const descriptionField: string | undefined = fieldMeta?.descriptionField; const idField = fieldMeta?.idField || 'id'; - // ObjectStack convention uses `reference`; types define `reference_to` — support both - const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference; + // `reference` — `@objectstack/spec`'s `FieldSchema.reference` — is the only + // target spelling read (objectui#11070 round 4). A served def that still + // spells a legacy key was folded onto it at ingestion + // (`normalizeSchemaReferenceKeys`); no second spelling is read here. + const referenceTo: string | undefined = fieldMeta?.reference; // Inline quick-create — a STANDARD capability, default ON for user-facing // relations: an empty/zero-result picker offers to create the referenced // record (opening its create form; see handleCreateNew) so the first related diff --git a/packages/fields/src/widgets/RecordPickerDialog.displayFls-10373.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.displayFls-10373.test.tsx index dd723b212c..c5f547aed4 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.displayFls-10373.test.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.displayFls-10373.test.tsx @@ -53,7 +53,7 @@ const ACCOUNT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, code: { type: 'text', label: 'Code' }, secret: { type: 'text', label: 'Secret' }, - region: { type: 'lookup', label: 'Region', reference_to: 'region' }, + region: { type: 'lookup', label: 'Region', reference: 'region' }, }; /** diff --git a/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx index 7ee4d6667a..5168bcbb2c 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx @@ -583,7 +583,7 @@ describe('LookupField → picker filter panel wiring (#3336)', () => { name: 'project', label: 'Project', type: 'lookup', - reference_to: 'projects', + reference: 'projects', reference_field: 'name', } as any; diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index c40ea61f1e..6a06ea3247 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -386,7 +386,7 @@ export interface RecordPickerDialogProps { * The referenced object's schema `fields` map (field name → field * definition). When provided, cell renderers receive the FULL field * metadata — `options`, `currency`, `scale`, `precision`, `format`, - * `reference_to`, … — exactly like the list view enriches its columns from + * `reference`, … — exactly like the list view enriches its columns from * the object schema. Without it a `select` column falls back to * title-casing the raw stored value instead of resolving the option label * (#3333: `manufacturing` rendered as "Manufacturing" instead of the diff --git a/packages/fields/src/widgets/UserField.tsx b/packages/fields/src/widgets/UserField.tsx index 019158e2ba..5367632200 100644 --- a/packages/fields/src/widgets/UserField.tsx +++ b/packages/fields/src/widgets/UserField.tsx @@ -37,15 +37,16 @@ export function UserField(props: FieldWidgetComponentProps) { // Mirror the unwrap LookupField itself performs. (Formerly attributed to the // docs-demo `createFieldRenderer` wrapper, removed in objectui#3910.) const metaIsNested = raw?.field && typeof raw.field === 'object' - && ('reference' in raw.field || 'reference_to' in raw.field || 'type' in raw.field); + && ('reference' in raw.field || 'type' in raw.field); const meta = metaIsNested ? raw.field : raw; // Ensure the picker always targets sys_user (even if the author omitted an // explicit reference), presents user names by default, and defaults to the // search-first PeoplePicker with sensible person display + candidate hygiene. + // `reference` is the only target spelling read (objectui#11070 round 4). const normalized = { ...(meta || {}), - reference: meta?.reference || meta?.reference_to || 'sys_user', + reference: meta?.reference || 'sys_user', displayField: meta?.displayField || meta?.reference_field || 'name', picker: meta?.picker ?? 'search', subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'], diff --git a/packages/fields/src/widgets/lookupColumnDisplay.tsx b/packages/fields/src/widgets/lookupColumnDisplay.tsx index a9845d7e4a..25181fbe05 100644 --- a/packages/fields/src/widgets/lookupColumnDisplay.tsx +++ b/packages/fields/src/widgets/lookupColumnDisplay.tsx @@ -116,7 +116,7 @@ export function resolveSchemaOptions( * referenced object's schema (`fieldsMeta`) the same way the list view enriches * its columns. This is what lets a `select` column resolve its option label * (options + i18n) instead of title-casing the raw value (#3333), and what - * carries `reference` / `reference_to` through to the lookup cell renderer so + * carries `reference` through to the lookup cell renderer so * an unresolved foreign-key id resolves to a name (#5492). * * Columns whose def carries no `type` inherit the schema field's type, so diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx index 80b625b1ad..67f94979d6 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx @@ -106,9 +106,9 @@ const OBJECT = 'visit'; const VISIT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, starts_at: { type: 'datetime', label: 'Start' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const today = new Date(); diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx index 1f57feb838..f9669d8775 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx @@ -135,12 +135,12 @@ import { ObjectCalendar } from '../ObjectCalendar'; * field added here with an expandable type must show up in the query or the * test fails. */ -const VISIT_FIELDS: Record = { +const VISIT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, starts_at: { type: 'datetime', label: 'Start' }, amount: { type: 'currency', label: 'Amount' }, owner: { type: 'user', label: 'Owner' }, - account: { type: 'lookup', label: 'Account', reference_to: 'account' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, // No target key. It carried the retired snake_case spelling, which // `FieldSchema` refuses BY NAME, so the line annotated nothing and the // `$expand` expectation is derived from the declared TYPE. On a `tree` the @@ -148,7 +148,7 @@ const VISIT_FIELDS: Record]?: never } & ObjectDataTableRetiredDecimalsTombstone - & ObjectDataTableRetiredReferenceToTombstone; + & ObjectDataTableRetiredReferenceToTombstone + & ObjectDataTableRetiredReferenceToSnakeTombstone; /** * What this widget's column producer is allowed to READ off the AUTHORED @@ -355,11 +377,13 @@ export interface ObjectDataTableColumnHolds {} * * The pool shrank, then grew again. What THIS band refuses is `name` and * `label` — both of them `FieldMeta` members with answers this seam already - * has (see the docblock above) — plus, since objectui#6694, the three - * relational members that card added: `reference_to`, `reference` and - * `display_field`. + * has (see the docblock above) — plus, since objectui#6694, the relational + * members that card added: `reference` and `display_field`. It added + * `reference_to` too; objectui#11070 round 4 retired that member from + * `FieldMeta`, so it left this band's POOL and + * {@link ObjectDataTableRetiredReferenceToSnakeTombstone} carries its refusal. * - * ⭐ Those three are the derivation working as designed, and their verdict is + * ⭐ Those members are the derivation working as designed, and their verdict is * the one objectui#6597 already reached for `referenceTo`: an AUTHORED column * may not source a lookup's reference target. They are refused HERE while * `buildFieldMeta` writes them freely, because that write's source is the @@ -388,7 +412,10 @@ export type AuthoredColumnOverrides = /** RETIRED, objectui#6597's verdict (enforce-or-remove, withdraw) — * re-stated by hand for the same reason: the key left `keyof FieldMeta`, * so the derived band can no longer reach it. See the tombstone's docblock. */ - & ObjectDataTableRetiredReferenceToTombstone; + & ObjectDataTableRetiredReferenceToTombstone + /** RETIRED from `FieldMeta` by objectui#11070 round 4 — re-stated by hand for + * the same reason. See the tombstone's docblock. */ + & ObjectDataTableRetiredReferenceToSnakeTombstone; /** * Shared empty fallback for the resolved row list (objectui#4629). @@ -910,8 +937,8 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // // `referenceTo` is NOT read here any more either — RETIRED by // objectui#6597 (enforce-or-remove, withdraw): `LookupCellRenderer` - // resolves its target from `reference_to` / `reference`, never this - // spelling, so an authored `referenceTo` never reached anything on this + // resolves its target from `reference`, never this spelling, so an + // authored `referenceTo` never reached anything on this // path (measured, `ObjectDataTable.overrideSource-6425.test.tsx`). const fieldMeta = buildFieldMeta({ accessorKey: col.accessorKey, diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnIdentity.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnIdentity.test.tsx index 00e28873c2..eabfbefaa1 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnIdentity.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnIdentity.test.tsx @@ -134,7 +134,7 @@ describe('ObjectDataTable — normalizeColumns resolves identity at the producer describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => { const objectSchema = { fields: { - account: { type: 'lookup', reference_to: 'accounts' }, + account: { type: 'lookup', reference: 'accounts' }, stage: { type: 'text' }, }, }; diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx index 636bcf1d4b..5a274bc648 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx @@ -370,6 +370,25 @@ describe("the emit type can FAIL — otherwise the annotation is decoration (#63 // @ts-expect-error objectui#6373/#6597 — `referenceTo` refused by the explicit retired-key tombstone. const referenceToWrittenRefused: EnrichedColumn = carriesReferenceTo; expect(referenceToWrittenRefused.accessorKey).toBe('a'); + + // objectui#11070 round 4's sibling pin, same shape: `reference_to` was a + // `FieldMeta` member (objectui#6694), so the DERIVED tombstone band + // refused it; the round retired the member, and the hand-written + // `ObjectDataTableRetiredReferenceToSnakeTombstone` carries it now. + const carriesReferenceToSnake: { header: string; accessorKey: string; reference_to?: unknown } = + { header: 'h', accessorKey: 'a', reference_to: 'account' }; + // @ts-expect-error objectui#6694/#11070 — `reference_to` refused by the explicit retired-key tombstone. + const referenceToSnakeWrittenRefused: EnrichedColumn = carriesReferenceToSnake; + expect(referenceToSnakeWrittenRefused.accessorKey).toBe('a'); + }); + + it('the TOMBSTONE is what refuses the retired `reference_to` at this emit (objectui#11070 round 4)', () => { + // Counter-control for the pin above: `EnrichedColumn` minus that tombstone + // and nothing else ACCEPTS the source the directive refuses. + const carriesReferenceToSnake: { header: string; accessorKey: string; reference_to?: unknown } = + { header: 'h', accessorKey: 'a', reference_to: 'account' }; + const untombstoned: Omit = carriesReferenceToSnake; + expect(untombstoned.accessorKey).toBe('a'); }); it('the TOMBSTONE is what refuses the retired `decimals` at this emit', () => { diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx index ac776aad87..359aa5386c 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx @@ -303,6 +303,28 @@ describe('the override reads are typed, and the band can FAIL (#6425)', () => { // @ts-expect-error objectui#6425/#6597 — `referenceTo` refused by the explicit retired-key tombstone. const referenceToRefused: AuthoredColumnOverrides = carriesReferenceTo; expect(referenceToRefused.accessorKey).toBe('amount'); + + // `reference_to` was a `FieldMeta` member from objectui#6694, so the + // DERIVED band refused it by membership. objectui#11070 round 4 retired the + // member (the cell reads `reference` alone), so — the same mechanism again + // — the key left the band's POOL and + // `ObjectDataTableRetiredReferenceToSnakeTombstone` carries its refusal. + const carriesReferenceToSnake: { accessorKey: string; reference_to?: unknown } = { accessorKey: 'amount' }; + // @ts-expect-error objectui#6694/#11070 — `reference_to` refused by the explicit retired-key tombstone. + const referenceToSnakeRefused: AuthoredColumnOverrides = carriesReferenceToSnake; + expect(referenceToSnakeRefused.accessorKey).toBe('amount'); + }); + + it('the TOMBSTONE is what refuses `reference_to` — not the band (objectui#11070 round 4)', () => { + // The counter-control, built like the two below it: `Omit<…, 'reference_to'>` + // is `AuthoredColumnOverrides` minus that tombstone and nothing else, and it + // ACCEPTS the source the directive above refuses — so the refusal is the + // tombstone's, not the derived band's (which no longer reaches the key), + // not the excess-property check's (the source is a variable) and not + // weak-type detection's (`accessorKey` is in common). + const carriesReferenceToSnake: { accessorKey: string; reference_to?: unknown } = { accessorKey: 'amount' }; + const untombstoned: Omit = carriesReferenceToSnake; + expect(untombstoned.accessorKey).toBe('amount'); }); it('the TOMBSTONE is what refuses `decimals` — not the band, not freshness', () => { diff --git a/packages/plugin-dashboard/src/__tests__/lookupRelationalMeta-6694.test.tsx b/packages/plugin-dashboard/src/__tests__/lookupRelationalMeta-6694.test.tsx index 65bca934f4..34fba0fcee 100644 --- a/packages/plugin-dashboard/src/__tests__/lookupRelationalMeta-6694.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/lookupRelationalMeta-6694.test.tsx @@ -97,7 +97,7 @@ function makeDataSource(opts: { const ownerSchema = { name: opts.ownerObject, fields: { - project: { type: 'lookup', label: 'Project', reference_to: opts.refObject }, + project: { type: 'lookup', label: 'Project', reference: opts.refObject }, }, }; const refSchema = { @@ -176,7 +176,7 @@ describe('objectui#6694 — ObjectDataTable lookup cells carry their reference t ); // `navigable` is `!!objectName && recordId != null`. The id was always - // there; the object name is what the missing `reference_to` withheld, so + // there; the object name is what the missing target key withheld, so // this anchor is the whole of consequence 2. const link = await waitFor(() => screen.getByRole('link'), { timeout: 3000 }); expect(link).toHaveAttribute('href', '/app/project_6694_b/view/p-2'); @@ -257,8 +257,10 @@ describe('objectui#6694 — RecordDetailDrawer lookup rows carry their reference describe('objectui#6694 — buildFieldMeta copies the cell-read relational keys and no others', () => { const def = { type: 'lookup', - reference_to: 'project', reference: 'project', + // The snake twin objectui#11070 round 4 RETIRED — the cell reads + // `reference` alone. Kept on the fixture so its absence below is a reading. + reference_to: 'project', display_field: 'project_code', // The SPEC spelling of the same pointer (objectui#6875). `FieldSchema` // declares `displayField` and none of the snake twins, so this is the leg a @@ -281,10 +283,10 @@ describe('objectui#6694 — buildFieldMeta copies the cell-read relational keys titleFormat: '{project_code}', }; - it('copies reference_to / reference / display_field / displayField', () => { + it('copies reference / display_field / displayField — and not the retired reference_to', () => { const meta = buildFieldMeta({ accessorKey: 'project', label: 'Project', def }) as any; - expect(meta.reference_to).toBe('project'); expect(meta.reference).toBe('project'); + expect(meta).not.toHaveProperty('reference_to'); expect(meta.display_field).toBe('project_code'); // objectui#6875 — the spec-declared spelling, previously dropped here and in // `ObjectGrid` at the same time. diff --git a/packages/plugin-dashboard/src/recordFields.tsx b/packages/plugin-dashboard/src/recordFields.tsx index 81964aba2f..6862e24a3d 100644 --- a/packages/plugin-dashboard/src/recordFields.tsx +++ b/packages/plugin-dashboard/src/recordFields.tsx @@ -139,8 +139,14 @@ const DATE_PATTERN_FIELD_TYPES = new Set(['date', 'datetime']); * the module `getCellRenderer` dispatches into — the complete set of relational * keys read off a cell's `field` prop is: * - * - `reference_to`, `reference`, `display_field`, `displayField` — read by - * `LookupCellRenderer` itself. ✅ COPIED. + * - `reference`, `display_field`, `displayField` — read by + * `LookupCellRenderer` itself when this was measured. ✅ COPIED. + * + * ⭐ `reference_to` LEFT this list with objectui#11070 round 4. The cell + * read `reference_to || reference` until then; it reads `reference` alone + * now, the ingestion choke point no longer stamps `reference_to`, and no + * in-repo producer writes it. Copying it would write a member no reader + * consults — the shape objectui#6711 and objectui#6874 retired. * * ⭐ `displayField` ARRIVED with objectui#6875. The enumeration above used * to name three keys, because it was written from the FIRST leg of each @@ -169,9 +175,9 @@ const DATE_PATTERN_FIELD_TYPES = new Set(['date', 'datetime']); * - `titleFormat` — never read off a FIELD meta at all; every reader takes it * off the OBJECT schema (`getRecordDisplayName` in `@object-ui/core`, * `containers.tsx`). On this path that object schema arrives through - * `useRefObjectSchema(reference_to)` — so copying `reference_to` is what - * makes `titleFormat` work, and copying `titleFormat` here would reach - * nothing. ⛔ NOT copied. ⭐ The grid has since retired it too + * `useRefObjectSchema(reference)` — so copying the target (`reference_to` + * then, `reference` since objectui#11070 round 4) is what makes + * `titleFormat` work, and copying `titleFormat` here would reach nothing. ⛔ NOT copied. ⭐ The grid has since retired it too * (objectui#6874), on exactly this reading. * * ⛔ Do not "restore parity" by widening this to the grid's seven. A member @@ -182,7 +188,7 @@ const DATE_PATTERN_FIELD_TYPES = new Set(['date', 'datetime']); * picker keys. The boundary is pinned in * `__tests__/lookupRelationalMeta-6694.test.tsx`. */ -const CELL_RELATIONAL_META_KEYS = ['reference_to', 'reference', 'display_field', 'displayField'] as const; +const CELL_RELATIONAL_META_KEYS = ['reference', 'display_field', 'displayField'] as const; /** * Copy {@link CELL_RELATIONAL_META_KEYS} off a schema field def, with @@ -244,9 +250,10 @@ function pickCellRelationalMeta(def: any): Partial { * def directly — the spelling `LookupCellRenderer` and `computeLookupExpand` * actually use. ⛔ Do not resurrect `referenceTo`. * - * ⭐ That reader ARRIVED (objectui#6694): `reference_to` / `reference` / - * `display_field` below, copied from the SCHEMA field def by - * {@link buildFieldMeta} and justified per key on `CELL_RELATIONAL_META_KEYS`. + * ⭐ That reader ARRIVED (objectui#6694): `reference` / `display_field` below + * (and `reference_to` beside them until objectui#11070 round 4 retired it), + * copied from the SCHEMA field def by {@link buildFieldMeta} and justified per + * key on `CELL_RELATIONAL_META_KEYS`. * They are the "future reader" both retirement notes predicted, in the spelling * they named, and they change neither verdict — the source is the schema field * def, never an authored column override, which is the exact distinction @@ -254,7 +261,10 @@ function pickCellRelationalMeta(def: any): Partial { * * ⚠️ Being `FieldMeta` members they GROW both derived bands in * `ObjectDataTable.tsx` — `EnrichedColumn`'s emit tombstones and - * `UnheldFieldMetaOverrideKey`'s read-side refusal. That is the intended + * `UnheldFieldMetaOverrideKey`'s read-side refusal. `reference_to` left the + * type in objectui#11070 round 4, so — by the rule at the top of this docblock + * — its refusal is re-stated by hand there + * (`ObjectDataTableRetiredReferenceToSnakeTombstone`). That is the intended * verdict rather than a side effect: an AUTHORED column may not source a * lookup's reference target (objectui#6597 measured no authoring story for * one), while the schema-derived write is reached by neither band. They landed @@ -268,9 +278,11 @@ export interface FieldMeta { options?: Array<{ value: any; label: string; color?: string }>; format?: string; currency?: string; - /** Lookup target object, snake_case — the spelling `LookupCellRenderer` reads first. */ - reference_to?: string; - /** Lookup target object, ObjectStack object-metadata spelling; the renderer's `||` fallback. */ + /** + * Lookup target object — the spelling `@objectstack/spec`'s `FieldSchema` + * declares and the only one `LookupCellRenderer` reads. Its snake_case twin + * `reference_to` was RETIRED from this type by objectui#11070 round 4. + */ reference?: string; /** Author-declared display field on the lookup — beats every resolver in the cell. */ display_field?: string; @@ -378,8 +390,9 @@ export function buildFieldMeta(params: BuildFieldMetaParams): FieldMeta { // ⛔ No `referenceTo` — RETIRED by objectui#6597 (enforce-or-remove, // withdraw). It resolved `overrides.referenceTo ?? meta?.referenceTo ?? // meta?.reference(.to) ?? meta?.target` on every call and reached no - // reader: `LookupCellRenderer` resolves its target from - // `reference_to` / `reference`, never this spelling. ⭐ That future reader + // reader: `LookupCellRenderer` resolved its target from + // `reference_to` / `reference` (`reference` alone since objectui#11070 + // round 4), never this spelling. ⭐ That future reader // ARRIVED in objectui#6694 — the spread below, in the schema field def's own // spelling, which is the one that retirement note pointed at. The // retirement stands: this is a SCHEMA-derived write with no `overrides.` diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 965964f1be..e37547242a 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -111,8 +111,8 @@ export const HeaderHighlight: React.FC = ({ // Shared with DetailSection (`enrichDetailField`) so the highlights // strip and the details body resolve an identical field shape — // including the relational keys a lookup picker needs (`multiple`, - // display/id fields, picker config), and the `reference` / - // `reference_to` spelling pair backend schemas use. + // display/id fields, picker config), and the `reference` target + // backend schemas use. const enrichedField = enrichDetailField( { name: field.name, label: field.label, type: resolvedType || 'text' }, objectDefField, diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index 0e5314f3af..861c422415 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -35,7 +35,7 @@ import { PermissionFacetLink } from './renderers/PermissionFacetLink'; import { TEXTUAL_REF_FALLBACK_TYPES } from './fieldEnrichment'; /** - * Field types that carry a `reference_to` for relational metadata but are NOT + * Field types that carry a `reference` for relational metadata but are NOT * edited via the lookup picker (they have their own dedicated inputs/renderers). * Used below so the inline-edit branch doesn't hijack them into a record picker. * @@ -189,7 +189,7 @@ export function extractLookupId(value: unknown): unknown { export interface InlineFieldInputProps { /** * Enriched field metadata — `type` plus any objectSchema enrichment (options, - * currency, precision, format, reference_to, widget…). The caller owns + * currency, precision, format, reference, widget…). The caller owns * enrichment so read-mode and edit-mode agree on the same resolved field * shape. Kept as a loose bag (matching the widgets' `field` props) since the * exact key set varies by field type. @@ -418,7 +418,7 @@ export const InlineFieldInput: React.FC = ({ editType === 'lookup' || editType === 'master_detail' || editType === 'tree' || - (!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(editType as string)); + (!!field.reference && !TEXTUAL_REF_FALLBACK_TYPES.has(editType as string)); if (isUserRef || isLookupRef) { const RefWidget = isUserRef ? UserField : LookupField; return ( diff --git a/packages/plugin-detail/src/RecordDetailPanel.tsx b/packages/plugin-detail/src/RecordDetailPanel.tsx index e995c25317..fe042089ba 100644 --- a/packages/plugin-detail/src/RecordDetailPanel.tsx +++ b/packages/plugin-detail/src/RecordDetailPanel.tsx @@ -171,8 +171,7 @@ export function buildRecordDetailFields( useGrouping: def.useGrouping, format: def.format, // Served schemas key the target as `reference` (ObjectStack - // convention, #2407); the panel can receive a raw schema from any - // DataSource, so both snake_case spellings are resolved here. + // convention, #2407), the only spelling read here. // // Two further arms stood here until objectui#6837 — `def.referenceTo` // and `def.target` — and they were NOT redundant-but-harmless: no @@ -190,19 +189,16 @@ export function buildRecordDetailFields( // // ⛔ Do not re-add a spelling arm here. A producer emitting a refused // spelling is fixed AT THE PRODUCER, or canonicalised once at the - // ingestion choke point (`normalizeSchemaReferenceKeys`, which stamps - // both snake_case keys from whichever spelling arrived) — never by a - // renderer-side alias. + // ingestion choke point (`normalizeSchemaReferenceKeys`, which folds a + // legacy spelling onto `reference`) — never by a renderer-side alias. // // objectui#6837 half 2 deleted the last read arm too: the RIGHT-hand - // side now reads `reference` alone, the only spelling the protocol - // declares. ⚠️ The LEFT-hand key is unchanged and must stay - // `reference_to` — it is the key this emit's TARGET contract declares - // (`DetailViewField` / `DetailViewFieldSchema` in `@object-ui/types`, - // which declares `reference_to` and never declares `reference`). - // Narrowing the read is protocol compliance; renaming the emitted key - // would be a separate view-contract change with its own weight. - reference_to: def.reference, + // side reads `reference` alone, the only spelling the protocol + // declares. objectui#11070 round 4 renamed the LEFT-hand key to match: + // this emit's TARGET contract (`DetailViewField` / + // `DetailViewFieldSchema` in `@object-ui/types`) declares `reference` + // now, and the widgets it feeds read nothing else. + reference: def.reference, reference_field: def.reference_field ?? def.referenceField, required: def.required, validation: def.validation, diff --git a/packages/plugin-detail/src/RecordMetaFooter.tsx b/packages/plugin-detail/src/RecordMetaFooter.tsx index f8caa165ad..f75256fbab 100644 --- a/packages/plugin-detail/src/RecordMetaFooter.tsx +++ b/packages/plugin-detail/src/RecordMetaFooter.tsx @@ -25,7 +25,7 @@ const AUDIT_FIELDS = AUDIT_FIELD_BY_ROLE; export interface RecordMetaFooterProps { /** The current record data; expected to contain audit fields when available. */ data: Record | null | undefined; - /** Resolved object schema (used to read reference_to for created_by/updated_by). */ + /** Resolved object schema (used to read `reference` for created_by/updated_by). */ objectSchema?: any; /** Object name for future i18n hooks (currently unused). */ objectName?: string; @@ -133,7 +133,7 @@ const UserRef: React.FC = ({ value, objectSchema, fieldName }) => const enrichedField: Record = { name: fieldName, type: fieldDef?.type || 'lookup', - reference_to: refTarget, + reference: refTarget, ...(fieldDef?.reference_field && { reference_field: fieldDef.reference_field }), }; const resolvedType = resolveCellRendererType(enrichedField as { type?: string }) || enrichedField.type; diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index f830068f17..2208ad0a91 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -1573,13 +1573,13 @@ export const RelatedList: React.FC = ({ // number cell (objectui#11026). ...(def.useGrouping !== undefined && { useGrouping: def.useGrouping }), ...(def.format && { format: def.format }), - // ⚠️ objectui#6837 half 2: the READ narrows to `reference` (the only + // objectui#6837 half 2 narrowed the READ to `reference` (the only // spelling the protocol declares — `FieldSchema` refuses `reference_to` - // by name). The EMITTED key is unchanged: it is what this emit's TARGET - // contract declares, and renaming it would be a separate change. - // Target contract here: `FieldMetadata` (`LookupFieldMetadata.reference_to` - // in `@object-ui/types`), handed straight to `CellRenderer` as `field`. - ...(def.reference && { reference_to: def.reference }), + // by name), and objectui#11070 round 4 moved the EMITTED key with it: + // the target contract here, `FieldMetadata` + // (`LookupFieldMetadata.reference` in `@object-ui/types`), handed + // straight to `CellRenderer` as `field`, declares `reference`. + ...(def.reference && { reference: def.reference }), ...(def.reference_field && { reference_field: def.reference_field }), }; return (value: any) => { diff --git a/packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsx index 2351022944..98fd615a50 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsx @@ -91,7 +91,7 @@ const objectSchema = { office_location: { type: 'location', label: 'Office Location' }, payload: { type: 'json', label: 'Payload' }, contract: { type: 'file', label: 'Contract' }, - owner_ref: { type: 'lookup', label: 'Owner', reference_to: 'account' }, + owner_ref: { type: 'lookup', label: 'Owner', reference: 'account' }, attachments: { type: 'file', label: 'Attachments', multiple: true }, line_items: { type: 'repeater', label: 'Line Items' }, logo: { type: 'image', label: 'Logo' }, diff --git a/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx index c73636a3bb..2b19c2a7e4 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx @@ -21,8 +21,8 @@ import type { DetailViewSection } from '@object-ui/types'; describe('DetailSection inline-edit reference fields', () => { const objectSchema = { fields: { - project: { type: 'master_detail', reference_to: 'projects' }, - factory: { type: 'lookup', reference_to: 'factories' }, + project: { type: 'master_detail', reference: 'projects' }, + factory: { type: 'lookup', reference: 'factories' }, title: { type: 'text' }, }, }; diff --git a/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx index cc20cfb4a2..d60c689b10 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx @@ -97,9 +97,9 @@ const OBJECT = 'opportunity'; const OBJECT_FIELDS: Record = { name: { type: 'text', label: 'Name' }, stage: { type: 'select', label: 'Stage' }, - account: { type: 'lookup', reference_to: 'accounts', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'accounts', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'departments', label: 'Dept' }, + account: { type: 'lookup', reference: 'accounts', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'accounts', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'departments', label: 'Dept' }, }; const RECORD = { id: 'o1', name: 'Big deal' }; diff --git a/packages/plugin-detail/src/__tests__/DetailView.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.test.tsx index de0c2384bb..348dc5e358 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.test.tsx @@ -635,8 +635,8 @@ describe('DetailView', () => { getObjectSchema: vi.fn().mockResolvedValue({ fields: { name: { type: 'text' }, - customer: { type: 'lookup', reference_to: 'contact' }, - account: { type: 'master_detail', reference_to: 'account' }, + customer: { type: 'lookup', reference: 'contact' }, + account: { type: 'master_detail', reference: 'account' }, }, }), findOne: vi.fn().mockResolvedValue({ name: 'Order 1', customer: { name: 'Alice' }, account: { name: 'Acme' } }), diff --git a/packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx index 5d01b8bdd0..0b06b6bf26 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx @@ -144,7 +144,7 @@ describe('RecordMetaFooter — audit fields default to a sys_user reference (#26 , diff --git a/packages/plugin-detail/src/__tests__/DetailView.userActionPredicates.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.userActionPredicates.test.tsx index 697abe5d63..d65d2c4425 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.userActionPredicates.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.userActionPredicates.test.tsx @@ -67,7 +67,7 @@ const INVOICE_USER_ACTIONS = { const OBJECT_FIELDS = { name: { type: 'text' }, status: { type: 'select' }, - owner: { type: 'lookup', reference_to: 'user' }, + owner: { type: 'lookup', reference: 'user' }, }; /** diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx index 4f618fc130..36518c376f 100644 --- a/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx @@ -22,6 +22,7 @@ vi.mock('@object-ui/fields', async (importOriginal) => { LookupField: ({ field }: any) => (
@@ -126,11 +127,12 @@ describe('HeaderHighlight — editable highlights (P2)', () => { * half). The `reference_to` case below therefore asserts a REFUSAL now, and is * bounded by the choke-point control beside it. * - * ⚠️ What did NOT change is the key the strip WRITES. `enrichDetailField` - * still stamps `reference_to` onto the enriched field, because that is the key - * `FieldMetadata` / `DetailViewField` — ObjectUI's OWN contracts — declare, and - * it is what `LookupField` reads. Both halves are asserted below: the read - * takes `reference`, the write emits `reference_to`. + * ⭐ objectui#11070 round 4 narrowed the key the strip WRITES as well. + * `enrichDetailField` stamps `reference` onto the enriched field — the key + * `FieldMetadata` / `DetailViewField`, ObjectUI's own contracts, declare since + * that round, and the only one `LookupField` reads. Both halves are asserted + * below: the read takes `reference`, the write emits `reference` and no + * `reference_to`. */ describe('HeaderHighlight — lookup highlight reference-key normalization', () => { const lookupFields = [{ name: 'account', label: 'Account' }] as any; @@ -162,13 +164,14 @@ describe('HeaderHighlight — lookup highlight reference-key normalization', () return screen.getByTestId('lookup-editor'); }; - it('passes the backend `reference` key to the LookupField as reference_to', () => { + it('passes the backend `reference` key to the LookupField as `reference`, and emits no `reference_to`', () => { const editor = renderStrip({ type: 'lookup', reference: 'showcase_account', reference_field: 'name', }); - expect(editor.getAttribute('data-reference-to')).toBe('showcase_account'); + expect(editor.getAttribute('data-reference')).toBe('showcase_account'); + expect(editor.getAttribute('data-reference-to')).toBe(''); expect(editor.getAttribute('data-reference-field')).toBe('name'); }); @@ -177,17 +180,18 @@ describe('HeaderHighlight — lookup highlight reference-key normalization', () // REFUSAL, and the test above is its non-vacuous control: a strip that had // stopped resolving anything would satisfy this one too. const editor = renderStrip({ type: 'lookup', reference_to: 'showcase_account' }); + expect(editor.getAttribute('data-reference')).toBe(''); expect(editor.getAttribute('data-reference-to')).toBe(''); }); it('a `reference_to`-only def that came through the ingestion choke point STILL resolves', () => { - // What bounds the break: `normalizeSchemaReferenceKeys` stamps `reference` - // from whichever spelling arrived, so any def that entered through + // What bounds the break: `normalizeSchemaReferenceKeys` folds a legacy + // spelling onto `reference`, so any def that entered through // `MetadataProvider` or `ObjectStackAdapter.getObjectSchema` is unaffected. // Only a def that bypassed that door reaches this reader raw. const def = { type: 'lookup', reference_to: 'showcase_account' }; normalizeSchemaReferenceKeys({ fields: { account: def } }); const editor = renderStrip(def); - expect(editor.getAttribute('data-reference-to')).toBe('showcase_account'); + expect(editor.getAttribute('data-reference')).toBe('showcase_account'); }); }); diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.autonumberRefFallback.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.autonumberRefFallback.test.tsx index 0977cdcf6f..0ee301c126 100644 --- a/packages/plugin-detail/src/__tests__/InlineFieldInput.autonumberRefFallback.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.autonumberRefFallback.test.tsx @@ -21,13 +21,13 @@ * What #4228 does NOT reach is this file's reference fallback: * * ``` - * const isLookupRef = … || (!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(editType)); + * const isLookupRef = … || (!!field.reference && !TEXTUAL_REF_FALLBACK_TYPES.has(editType)); * ``` * * That read is `InlineFieldInput`'s own, on EXPORTED public API, with no host * gate in front of it — a caller rendering the component directly (the SDUI * surfaces do) gets whatever the set says. So an `autonumber` carrying a - * `reference_to` — computed fields keep one for relational metadata, which is + * `reference` — computed fields keep one for relational metadata, which is * the entire reason this set exists — was hijacked into the RECORD PICKER, * offering the user a list of records to overwrite a machine-generated value * with. The `auto_number` spelling of the identical field was not. @@ -65,7 +65,7 @@ const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })), findOne: const renderField = (type: string, extra: Record = {}) => render( = {}) => ); describe('#4219 — the reference fallback carries both auto-number spellings', () => { - it('`autonumber` + `reference_to` renders the textual fallback, NOT the record picker', () => { + it('`autonumber` + `reference` renders the textual fallback, NOT the record picker', () => { renderField('autonumber'); // Before the fix this was the record picker: the set had no `autonumber`, - // so `!!reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has('autonumber')` was + // so `!!reference && !TEXTUAL_REF_FALLBACK_TYPES.has('autonumber')` was // true and `LookupField` took over the machine-generated identity. expect(recordPicker()).toBeNull(); expect(plainInput()).not.toBeNull(); expect(plainInput()).toHaveValue('INV-000317'); }); - it('`auto_number` + `reference_to` is unchanged (control — the spelling that already worked)', () => { + it('`auto_number` + `reference` is unchanged (control — the spelling that already worked)', () => { renderField('auto_number'); expect(recordPicker()).toBeNull(); expect(plainInput()).toHaveValue('INV-000317'); diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx index 042e573198..82afce3f37 100644 --- a/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx @@ -437,7 +437,7 @@ describe('controls — the routed families are untouched by the delegation', () it('`lookup` keeps the routed record picker', () => { const { container } = render( ({ data: [], total: 0 })) }} diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.test.tsx index 0abcd546fd..94e54aa527 100644 --- a/packages/plugin-detail/src/__tests__/InlineFieldInput.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.test.tsx @@ -39,7 +39,7 @@ describe('InlineFieldInput', () => { it('never leaks "[object Object]" for an $expand-ed reference value', () => { render( , @@ -85,7 +85,7 @@ describe('InlineFieldInput', () => { const find = vi.fn().mockResolvedValue({ data: [], total: 0 }); render( ({ /** Every probe is a `lookup`, so only the target SPELLING varies between them. */ const FIELD_DEFS: Record> = { // Live arm — the ONE spelling the protocol declares. objectui#6837 half 2 - // deleted the `reference_to` READ too; the drawer still EMITS `reference_to`, - // because that is the key its target contract (`DetailViewField`) declares. + // deleted the `reference_to` READ; objectui#11070 round 4 moved the EMIT to + // `reference`, the key its target contract (`DetailViewField`) now declares. spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' }, // Deleted arms — refused by name, zero producers in the cell. legacy_snake: { type: 'lookup', label: 'Legacy snake', reference_to: 'crm_account' }, @@ -173,9 +174,12 @@ function resolveFields(fields: Record) { return capturedFields.current; } -/** The `reference_to` the drawer resolved for one field name. */ +/** The `reference` the drawer resolved for one field name. */ function resolvedTarget(name: string, fields: Record = FIELD_DEFS) { - return resolveFields(fields).find((f: any) => f.name === name)?.reference_to; + const field = resolveFields(fields).find((f: any) => f.name === name); + // Nothing emits the retired spelling any more (objectui#11070 round 4). + expect(field?.reference_to).toBeUndefined(); + return field?.reference; } describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => { @@ -215,11 +219,11 @@ describe('RecordDetailDrawer resolves only contract-declared target spellings (o describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => { it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => { - // This is the mechanism, not a formality: the normalizer reads - // `reference_to ?? reference ?? referenceTo` and stamps both snake_case - // keys, so every def that entered through MetadataProvider or - // ObjectStackAdapter.getObjectSchema already carries `reference_to` by - // the time the drawer sees it. The deleted arm was dead weight for those. + // This is the mechanism, not a formality: the normalizer folds a + // legacy `reference_to` / `referenceTo` onto `reference`, so every def + // that entered through MetadataProvider or + // ObjectStackAdapter.getObjectSchema already carries `reference` by the + // time the drawer sees it. The deleted arm was dead weight for those. const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } }; normalizeSchemaReferenceKeys(schema); expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account'); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.addPickerFilter.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.addPickerFilter.test.tsx index e457545a8c..1df1647af7 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.addPickerFilter.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.addPickerFilter.test.tsx @@ -42,7 +42,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { const junctionSchema = { name: 'sys_user_position', fields: { - position: { type: 'lookup', label: 'Position', reference_to: 'sys_position' }, + position: { type: 'lookup', label: 'Position', reference: 'sys_position' }, }, }; diff --git a/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx index bb7d4b8183..a34b4f68ad 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx @@ -38,7 +38,7 @@ const junctionSchema = { permission_set_id: { type: 'lookup', label: 'Permission Set', - reference_to: 'sys_permission_set', + reference: 'sys_permission_set', }, }, }; diff --git a/packages/plugin-detail/src/__tests__/RelatedList.headerSort.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.headerSort.test.tsx index 2d600aeb13..c87c7a90bd 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.headerSort.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.headerSort.test.tsx @@ -74,8 +74,8 @@ const makeWindowedDS = (totalRecords = 12) => ({ name: 'contact', fields: { name: { type: 'text' }, - owner: { type: 'lookup', reference_to: 'user' }, - account: { type: 'lookup', reference_to: 'account' }, + owner: { type: 'lookup', reference: 'user' }, + account: { type: 'lookup', reference: 'account' }, }, })), }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx index 63d2405c73..833fcb0986 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx @@ -104,7 +104,7 @@ const objectSchema = { // DRIFT: present on the object, absent from the served projection. audited_at: { type: 'datetime', label: 'Audited At' }, // Relational carve-out — the projection answers `sortable: true` here. - owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, }, }; diff --git a/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx b/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx index f75b871c0c..b50ce8f705 100644 --- a/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx +++ b/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx @@ -113,8 +113,8 @@ const objectSchema = { name: 'deal', fields: { title: { type: 'text', label: 'Title' }, - account: { type: 'lookup', label: 'Account', reference_to: 'accounts' }, - parent_deal: { type: 'master_detail', label: 'Parent', reference_to: 'deals' }, + account: { type: 'lookup', label: 'Account', reference: 'accounts' }, + parent_deal: { type: 'master_detail', label: 'Parent', reference: 'deals' }, assignee: { type: 'user', label: 'Assignee' }, // No target key. It carried the retired snake_case one, which // `FieldSchema` refuses BY NAME — so the line asserted nothing, and diff --git a/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts b/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts index 9f0c695bf0..efb57c4555 100644 --- a/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts +++ b/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts @@ -27,8 +27,8 @@ describe('enrichDetailField', () => { expect(enriched).toMatchObject({ type: 'lookup', - // ObjectStack's `reference` spelling normalizes onto the canonical key. - reference_to: 'tags', + // `reference`, the one target spelling (objectui#11070 round 4). + reference: 'tags', multiple: true, displayField: 'name', idField: 'code', diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index c6655e8142..ca6b12c0f9 100644 --- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -109,7 +109,7 @@ function bucketsOf(type: string): string[] { /** * Metadata that makes each type's editor renderable in its CANONICAL shape. - * A picklist without options and a lookup without a `reference_to` are + * A picklist without options and a lookup without a `reference` are * degenerate configurations, not the type's normal form; both degenerate * picklist spellings are pinned by name in the sibling delegation suite. */ @@ -118,9 +118,9 @@ const FIELD_FIXTURE: Record> = { multiselect: { options: [{ label: 'A', value: 'a' }] }, radio: { options: [{ label: 'A', value: 'a' }] }, checkboxes: { options: [{ label: 'A', value: 'a' }] }, - lookup: { reference_to: 'crm_account' }, - master_detail: { reference_to: 'crm_account' }, - tree: { reference_to: 'crm_account' }, + lookup: { reference: 'crm_account' }, + master_detail: { reference: 'crm_account' }, + tree: { reference: 'crm_account' }, }; /** A value of the type's real SHAPE — the point is what the editor does with it. */ diff --git a/packages/plugin-detail/src/__tests__/referenceArms-6837.divergent.test.ts b/packages/plugin-detail/src/__tests__/referenceArms-6837.divergent.test.ts index e0a8dfd912..c08ff16c31 100644 --- a/packages/plugin-detail/src/__tests__/referenceArms-6837.divergent.test.ts +++ b/packages/plugin-detail/src/__tests__/referenceArms-6837.divergent.test.ts @@ -18,38 +18,42 @@ * refusals say an arm came back, these say which order it came back in. * ⛔ Neither set is the other's spare. * - * ## ⚠️ Both readers here are shape-2 sites: the READ narrowed, the WRITE did not + * ## Both readers here read AND write `reference` (objectui#11070 round 4) * * `enrichDetailField` and `deriveFieldGroupDetailSections` both take the target * off an OBJECT SCHEMA field def (the protocol, where `reference` is the only - * declared spelling) and stamp it onto a `DetailViewField`-shaped bag (ObjectUI's - * OWN contract, where `reference_to` is the declared spelling and `reference` is - * not declared at all). So the assertions below deliberately read - * `enriched.reference_to` while feeding `reference` — that is not a - * contradiction, it is the tier boundary, and pinning both halves in one - * expectation is what stops a future sweep from "tidying" the emitted key. + * declared spelling) and stamp it onto a `DetailViewField`-shaped bag. Until + * objectui#11070 round 4 that bag's contract declared `reference_to`, so these + * pins read `enriched.reference_to` while feeding `reference` — the READ had + * narrowed and the WRITE had not. Round 4 moved `DetailViewField` to + * `reference`, so the write narrowed too: the pins below read `reference` back, + * and each single-key control also asserts that no `reference_to` is emitted. */ import { describe, it, expect } from 'vitest'; import { enrichDetailField } from '../fieldEnrichment'; import { deriveFieldGroupDetailSections } from '../synth/buildDefaultPageSchema'; -describe('enrichDetailField consults `reference` and still emits `reference_to` (objectui#6837 half 2)', () => { +describe('enrichDetailField consults `reference` and emits `reference` (objectui#6837 half 2, objectui#11070 round 4)', () => { const enrich = (objectDefField: Record) => enrichDetailField({ name: 'account_id' }, { type: 'lookup', ...objectDefField }); it('a divergent def resolves through `reference` — restoring the legacy-first arm turns this red', () => { - expect(enrich({ reference: 'canonical_target', reference_to: 'legacy_target' }).reference_to).toBe( + expect(enrich({ reference: 'canonical_target', reference_to: 'legacy_target' }).reference).toBe( 'canonical_target', ); }); describe('single-key controls — without these, a helper that enriched nothing would pass the divergent case', () => { - it('`reference` alone is stamped onto the view field as `reference_to`', () => { - expect(enrich({ reference: 'canonical_target' }).reference_to).toBe('canonical_target'); + it('`reference` alone is stamped onto the view field as `reference`, and no `reference_to` is emitted', () => { + const enriched = enrich({ reference: 'canonical_target' }); + expect(enriched.reference).toBe('canonical_target'); + expect(enriched.reference_to).toBeUndefined(); }); it('`reference_to` alone is not read', () => { - expect(enrich({ reference_to: 'canonical_target' }).reference_to).toBeUndefined(); + const enriched = enrich({ reference_to: 'canonical_target' }); + expect(enriched.reference).toBeUndefined(); + expect(enriched.reference_to).toBeUndefined(); }); it('the field is still enriched either way, so the case above is not passing on an empty result', () => { @@ -58,7 +62,7 @@ describe('enrichDetailField consults `reference` and still emits `reference_to` }); }); -describe('deriveFieldGroupDetailSections consults `reference` and still emits `reference_to` (objectui#6837 half 2)', () => { +describe('deriveFieldGroupDetailSections consults `reference` and emits `reference` (objectui#6837 half 2, objectui#11070 round 4)', () => { const sectionField = (accountField: Record) => { // `deriveFieldGroupLayout` (the spec helper this adapter wraps) needs BOTH // halves of ADR-0085's grouping shape: declared groups, and per-field @@ -77,18 +81,22 @@ describe('deriveFieldGroupDetailSections consults `reference` and still emits `r }; it('a divergent def resolves through `reference` — restoring the legacy-first arm turns this red', () => { - expect(sectionField({ reference: 'canonical_target', reference_to: 'legacy_target' })?.reference_to).toBe( + expect(sectionField({ reference: 'canonical_target', reference_to: 'legacy_target' })?.reference).toBe( 'canonical_target', ); }); describe('single-key controls', () => { - it('`reference` alone is stamped onto the section field as `reference_to`', () => { - expect(sectionField({ reference: 'canonical_target' })?.reference_to).toBe('canonical_target'); + it('`reference` alone is stamped onto the section field as `reference`, and no `reference_to` is emitted', () => { + const field = sectionField({ reference: 'canonical_target' }); + expect(field?.reference).toBe('canonical_target'); + expect(field?.reference_to).toBeUndefined(); }); it('`reference_to` alone is not read', () => { - expect(sectionField({ reference_to: 'canonical_target' })?.reference_to).toBeUndefined(); + const field = sectionField({ reference_to: 'canonical_target' }); + expect(field?.reference).toBeUndefined(); + expect(field?.reference_to).toBeUndefined(); }); it('the section field is still derived either way, so the case above is not passing on a missing field', () => { diff --git a/packages/plugin-detail/src/fieldEnrichment.ts b/packages/plugin-detail/src/fieldEnrichment.ts index a02aa7042f..91f8ada6ea 100644 --- a/packages/plugin-detail/src/fieldEnrichment.ts +++ b/packages/plugin-detail/src/fieldEnrichment.ts @@ -10,7 +10,7 @@ import { isInlineExcludedFieldType, isMaskedFieldType } from '@object-ui/fields' /** * Field types the PLATFORM computes — the value is machine-owned and no user - * write is legitimate. They carry a `reference_to` for relational metadata but + * write is legitimate. They carry a `reference` for relational metadata but * have no editor of their own. * * One set, three readers, so they cannot drift: `InlineFieldInput` renders them @@ -29,8 +29,8 @@ import { isInlineExcludedFieldType, isMaskedFieldType } from '@object-ui/fields' * type: `plugin-form` lists both in each of its non-input sets, and this is the * shape that was missing here. The gap was not academic — the reader that has * no host gate in front of it is `InlineFieldInput`'s reference fallback - * (`!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`), on - * exported public API, so a spec-spelled auto-number carrying a `reference_to` + * (`!!field.reference && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`), on + * exported public API, so a spec-spelled auto-number carrying a `reference` * resolved into the RECORD PICKER — a list of records offered as replacements * for a machine-generated identity. The editability half of the same report is * held by the alias-aware shared exclusion since #4228; the two gates are a @@ -281,11 +281,11 @@ export function enrichDetailField( // legacy-only def is canonicalised ONCE at the ingestion choke point // (`normalizeSchemaReferenceKeys`, which warns in dev) — never here. // - // ⚠️ The READ narrows; the STAMPED key does not. `enriched` is a - // `DetailViewField`-shaped bag whose own contract declares `reference_to` - // and never declares `reference`, so the left-hand key below stays put. + // `enriched` is a `DetailViewField`-shaped bag, and that contract declares + // `reference` since objectui#11070 round 4, so the read and the stamped key + // are one spelling. A view field that already names its own target keeps it. const refTarget = objectDefField.reference; - if (refTarget && enriched.reference_to === undefined) enriched.reference_to = refTarget; + if (refTarget && enriched.reference === undefined) enriched.reference = refTarget; return enriched; } diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx index 6e5c4e02ac..b01154f2ad 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx @@ -118,7 +118,7 @@ const dealSchema = { label: 'Deal', titleFormat: '{account} - {deal_no}', fields: { - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, deal_no: { type: 'text', label: 'Deal No' }, amount: { type: 'number', label: 'Amount' }, }, diff --git a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index bf2c0b1215..9b48949212 100644 --- a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -681,12 +681,12 @@ export function deriveFieldGroupDetailSections( label: f.label || name, type: f.type || 'text', ...(f.options ? { options: f.options } : {}), - // ⚠️ objectui#6837 half 2: the READ narrows to `reference` (the only + // objectui#6837 half 2 narrowed the READ to `reference` (the only // spelling the protocol declares — `FieldSchema` refuses `reference_to` - // by name). The EMITTED key is unchanged: it is what this emit's TARGET - // contract declares, and renaming it would be a separate change. - // Target contract here: the DetailSection field bag (`DetailViewField`). - ...((f as any).reference ? { reference_to: (f as any).reference } : {}), + // by name), and objectui#11070 round 4 moved the EMITTED key with it: + // the target contract here, the DetailSection field bag + // (`DetailViewField`), declares `reference`. + ...((f as any).reference ? { reference: (f as any).reference } : {}), ...((f as any).reference_field ? { reference_field: (f as any).reference_field } : {}), ...((f as any).currency ? { currency: (f as any).currency } : {}), // Spec channel for per-field currency — renderers resolve diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 01a37de796..a704982fc4 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -227,7 +227,7 @@ only **required** one: | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries | | `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) | | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count | -| `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` | +| `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` | `FormField` also declares `[key: string]: any`, so an invented key type-checks here too. Two that a reader might expect, and that are **not** declared: diff --git a/packages/plugin-form/src/__tests__/parameterTypesNameable-7324.test.ts b/packages/plugin-form/src/__tests__/parameterTypesNameable-7324.test.ts index ba380b4b20..20fca0bd98 100644 --- a/packages/plugin-form/src/__tests__/parameterTypesNameable-7324.test.ts +++ b/packages/plugin-form/src/__tests__/parameterTypesNameable-7324.test.ts @@ -18,7 +18,7 @@ * - `deriveMasterDetail.ts` — `{ name?, fields?: Record }`, the * `childSchema` of five functions the entry exports; * - `schemaDefaults.ts` — `{ fields?: Record }`, the `objectSchema` of + * reference?} | undefined> }`, the `objectSchema` of * `omitServerResolvedDefaults`, which the entry also exports. * * Re-exporting either under the shared name would have put a name on the @@ -79,11 +79,15 @@ const PIN = { defaultsKeys: true satisfies Equals, /** The child one deliberately does not constrain a field value. */ childFieldValueIsAny: true satisfies IsAny, - /** The defaults one pins exactly the four members its rule reads. */ + /** + * The defaults one pins exactly the three members its rule reads. It pinned + * four until objectui#11070 round 4 retired `reference_to`: the rule reads + * the target as `reference` alone. + */ defaultsFieldValueIsPinned: false satisfies IsAny, defaultsFieldMembers: true satisfies Equals< keyof DefaultsFieldValue, - 'defaultValue' | 'type' | 'reference' | 'reference_to' + 'defaultValue' | 'type' | 'reference' >, } as const; @@ -110,7 +114,7 @@ const WRONG_ONE_EXPORTED_CONTROL = false satisfies Equals< const consumerHeldDefaultsSchema: FieldDefaultsSchemaLike = { fields: { created_at: { defaultValue: 'NOW()' }, - owner: { defaultValue: 'current_user', type: 'lookup', reference_to: 'sys_user' }, + owner: { defaultValue: 'current_user', type: 'lookup', reference: 'sys_user' }, stage: { defaultValue: 'draft' }, }, }; diff --git a/packages/plugin-form/src/currentUserDefault.test.tsx b/packages/plugin-form/src/currentUserDefault.test.tsx index 491689c40b..466517e73d 100644 --- a/packages/plugin-form/src/currentUserDefault.test.tsx +++ b/packages/plugin-form/src/currentUserDefault.test.tsx @@ -57,7 +57,7 @@ const OBJECT_SCHEMA = { title: { type: 'text', label: 'Title' }, applicant: { type: 'lookup', label: '申请人', reference: 'sys_user', defaultValue: 'current_user' }, // objectui-types spelling of the reference key — must be honoured too. - reviewer: { type: 'lookup', label: 'Reviewer', reference_to: 'sys_user', defaultValue: 'current_user' }, + reviewer: { type: 'lookup', label: 'Reviewer', reference: 'sys_user', defaultValue: 'current_user' }, // The dedicated user field type is the token's other legal home. owner_person: { type: 'user', label: 'Owner', defaultValue: 'current_user' }, // Token on an ILLEGAL type: the engine's validator refuses this authoring; @@ -99,7 +99,7 @@ describe('schemaDefaultValues — current_user resolution (#5683)', () => { it('seeds the acting user on the legal field shapes, and ONLY those', () => { const seeded = schemaDefaultValues(OBJECT_SCHEMA, { currentUserId: USER_ID }); expect(seeded.applicant).toBe(USER_ID); // lookup + reference - expect(seeded.reviewer).toBe(USER_ID); // lookup + reference_to + expect(seeded.reviewer).toBe(USER_ID); // lookup + reference expect(seeded.owner_person).toBe(USER_ID); // type: user expect('supplier' in seeded).toBe(false); // illegal type — validator territory expect('filed_at' in seeded).toBe(false); // NOW() stays server-owned diff --git a/packages/plugin-form/src/fieldSecurityPayload.test.tsx b/packages/plugin-form/src/fieldSecurityPayload.test.tsx index 8387faf896..13f001b8bd 100644 --- a/packages/plugin-form/src/fieldSecurityPayload.test.tsx +++ b/packages/plugin-form/src/fieldSecurityPayload.test.tsx @@ -79,7 +79,7 @@ afterEach(cleanup); const OBJECT_SCHEMA = { name: 'kpi_entry_line', fields: { - sheet: { type: 'master_detail', label: 'Sheet', reference_to: 'kpi_entry_sheet', readonly: true }, + sheet: { type: 'master_detail', label: 'Sheet', reference: 'kpi_entry_sheet', readonly: true }, indicator_name: { type: 'text', label: 'Indicator' }, target_value: { type: 'number', label: 'Target' }, weight: { type: 'number', label: 'Weight' }, diff --git a/packages/plugin-form/src/schemaDefaults.ts b/packages/plugin-form/src/schemaDefaults.ts index 99ac7e85c1..f502840f7e 100644 --- a/packages/plugin-form/src/schemaDefaults.ts +++ b/packages/plugin-form/src/schemaDefaults.ts @@ -103,15 +103,15 @@ export { isRuntimeDefault }; * the stricter of the pair (its field values are pinned, not `any`), so a * value legal here is legal there but not the reverse. * - * NOT `@object-ui/types`' `ObjectSchemaMetadata`: that type requires `name`, - * requires a `type` on every field, and has no `reference_to` member at all — - * and `isCurrentUserSeedField` below honours BOTH `reference` (the ObjectStack - * spelling) and `reference_to` (the objectui-types one) on purpose. + * NOT `@object-ui/types`' `ObjectSchemaMetadata`: that type requires `name` + * and requires a `type` on every field. `isCurrentUserSeedField` below reads + * the target as `reference`, the one spelling the spec declares and ObjectUI + * reads (objectui#11070 round 4). */ export interface FieldDefaultsSchemaLike { fields?: Record< string, - { defaultValue?: unknown; type?: unknown; reference?: unknown; reference_to?: unknown } | undefined + { defaultValue?: unknown; type?: unknown; reference?: unknown } | undefined >; } @@ -255,16 +255,16 @@ export function schemaDefaultValues( * The type gate mirrors the spec's own authoring rule (`field.zod` objectstack-ai/objectstack#7127: * `current_user` is legal "on `user` or `lookup` with `reference: 'sys_user'` * only"), so a token that somehow reached an illegal field type is left alone - * here exactly as the engine's validator would refuse it. `reference` is the - * ObjectStack schema spelling and `reference_to` the objectui-types one; both - * are honoured, same as `LookupField`'s own reader. + * here exactly as the engine's validator would refuse it. The target is read + * as `reference` alone — the spelling the spec's own rule names, and the only + * one `LookupField` reads (objectui#11070 round 4). */ function isCurrentUserSeedField( - f: { defaultValue?: unknown; type?: unknown; reference?: unknown; reference_to?: unknown } | undefined, + f: { defaultValue?: unknown; type?: unknown; reference?: unknown } | undefined, ): boolean { if (!f || !isCurrentUserDefaultToken(f.defaultValue)) return false; if (f.type === 'user') return true; - return f.type === 'lookup' && (f.reference === 'sys_user' || f.reference_to === 'sys_user'); + return f.type === 'lookup' && f.reference === 'sys_user'; } /** diff --git a/packages/plugin-form/src/sectionFields.spec-parity.test.ts b/packages/plugin-form/src/sectionFields.spec-parity.test.ts index e56a1abe52..8128e34eef 100644 --- a/packages/plugin-form/src/sectionFields.spec-parity.test.ts +++ b/packages/plugin-form/src/sectionFields.spec-parity.test.ts @@ -86,7 +86,8 @@ const TABLE: Record) => any) => void> reference: (f) => { const out = f({ reference: 'accounts' }); expect(out.reference).toBe('accounts'); - expect(out.reference_to).toBe('accounts'); // both spellings stamped (#2407) + // One spelling, no second stamped (objectui#11070 round 4). + expect(out.reference_to).toBeUndefined(); }, maxLength: (f) => expect(f({ maxLength: 10 }).maxLength).toBe(10), minLength: (f) => expect(f({ minLength: 2 }).minLength).toBe(2), diff --git a/packages/plugin-form/src/sectionFields.test.ts b/packages/plugin-form/src/sectionFields.test.ts index 520a736634..2e08439f14 100644 --- a/packages/plugin-form/src/sectionFields.test.ts +++ b/packages/plugin-form/src/sectionFields.test.ts @@ -49,17 +49,17 @@ describe('normalizeSectionField', () => { expect((f as any).disabled).toBe(true); }); - it('applies a spec reference override written under either key (`reference` or `reference_to`)', () => { - // Spec canon is `reference_to` (views.zod.ts) but `reference` (ObjectStack - // convention) is accepted too; both keys are stamped so any dual-key - // downstream reader sees the override (#2407 / PR #2587). - const specCanon = normalizeSectionField({ field: 'name', reference_to: 'accounts' }, ctx) as any; - expect(specCanon.reference).toBe('accounts'); - expect(specCanon.reference_to).toBe('accounts'); - - const stackConvention = normalizeSectionField({ field: 'name', reference: 'contacts' }, ctx) as any; - expect(stackConvention.reference).toBe('contacts'); - expect(stackConvention.reference_to).toBe('contacts'); + it('applies a spec reference override written as `reference`, and reads no `reference_to` (objectui#11070 round 4)', () => { + // Spec canon is `reference` — the spec's form-field schema declares it and + // refuses `reference_to` by name — and it is the only spelling the lookup + // and user widgets read, so it is the only one read and written here. + const specCanon = normalizeSectionField({ field: 'name', reference: 'contacts' }, ctx) as any; + expect(specCanon.reference).toBe('contacts'); + expect(specCanon.reference_to).toBeUndefined(); + + const retired = normalizeSectionField({ field: 'name', reference_to: 'accounts' }, ctx) as any; + expect(retired.reference).toBeUndefined(); + expect(retired.reference_to).toBeUndefined(); }); it('builds from the object schema for a string shorthand', () => { diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 9d36702ef2..e2c3459d42 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -394,13 +394,11 @@ function resolveSectionEntry( // draws it. const rawType = fd.type ?? (member ? undefined : ctx.objectSchema?.fields?.[fieldName]?.type); if (rawType != null) base.type = mapFieldTypeToFormType(rawType, { multiple: base.multiple }); - // Spec canon for the lookup target is `reference_to` (views.zod.ts); accept - // both spellings and stamp both keys so dual-key readers see the override. - const refOverride = fd.reference ?? fd.reference_to; - if (refOverride != null) { - base.reference = refOverride; - base.reference_to = refOverride; - } + // Spec canon for the lookup target is `reference` — `@objectstack/spec`'s + // form-field schema declares it and refuses `reference_to` by name — and it + // is the only spelling the lookup / user widgets read (objectui#11070 + // round 4), so it is the only one read and written here. + if (fd.reference != null) base.reference = fd.reference; if (fd.maxLength != null) base.maxLength = fd.maxLength; if (fd.minLength != null) base.minLength = fd.minLength; if (fd.min != null) base.min = fd.min; diff --git a/packages/plugin-form/src/systemManagedPayload.test.tsx b/packages/plugin-form/src/systemManagedPayload.test.tsx index 998a1e65bf..c70390b4ca 100644 --- a/packages/plugin-form/src/systemManagedPayload.test.tsx +++ b/packages/plugin-form/src/systemManagedPayload.test.tsx @@ -56,7 +56,7 @@ registerAllFields(); const CHILD_SCHEMA = { name: 'kpi_entry_line', fields: { - sheet: { type: 'master_detail', label: 'Sheet', reference_to: 'kpi_entry_sheet' }, + sheet: { type: 'master_detail', label: 'Sheet', reference: 'kpi_entry_sheet' }, plan_indicator: { type: 'lookup', label: 'Indicator' }, actual_value: { type: 'number', label: 'Actual' }, remark: { type: 'textarea', label: 'Remark' }, diff --git a/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx b/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx index 9c251f3435..cca0071e44 100644 --- a/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx @@ -177,7 +177,7 @@ describe('objectui#7333 — an EMPTY host array does not switch off the object-s fields: { id: { name: 'id', type: 'text' }, subject: { name: 'subject', type: 'text' }, - owner: { name: 'owner', type: 'lookup', reference_to: 'user' }, + owner: { name: 'owner', type: 'lookup', reference: 'user' }, visible_from: { name: 'visible_from', type: 'date' }, due_date: { name: 'due_date', type: 'date' }, }, diff --git a/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx index 3b147548c6..09a95f1a3c 100644 --- a/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx @@ -96,9 +96,9 @@ const TASK_FIELDS: Record = { name: { type: 'text' }, start: { type: 'date' }, end: { type: 'date' }, - project: { type: 'lookup', reference_to: 'projects' }, - secret_project: { type: 'lookup', reference_to: 'projects' }, - owner_dept: { type: 'master_detail', reference_to: 'departments' }, + project: { type: 'lookup', reference: 'projects' }, + secret_project: { type: 'lookup', reference: 'projects' }, + owner_dept: { type: 'master_detail', reference: 'departments' }, }; const GANTT_SCHEMA = { diff --git a/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx b/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx index c30c226508..8137db4a11 100644 --- a/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx @@ -86,7 +86,7 @@ const OBJECT_DEF = { fields: { id: { name: 'id', type: 'text' }, subject: { name: 'subject', type: 'text' }, - owner: { name: 'owner', type: 'lookup', reference_to: 'user' }, + owner: { name: 'owner', type: 'lookup', reference: 'user' }, visible_from: { name: 'visible_from', type: 'date' }, due_date: { name: 'due_date', type: 'date' }, }, diff --git a/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx b/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx index 37e2cc7f61..7b52c1ce7b 100644 --- a/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx @@ -327,7 +327,7 @@ describe('ObjectGantt quick filters — schema-driven options', () => { }); it('a `reference_to`-only def that came through the ingestion choke point STILL resolves', async () => { - // What bounds the break: `normalizeSchemaReferenceKeys` stamps `reference` + // What bounds the break: `normalizeSchemaReferenceKeys` folds onto `reference` // from whichever spelling arrived, so every def that entered through // `MetadataProvider` or `ObjectStackAdapter.getObjectSchema` is unaffected. // Only a def that bypassed that door reaches this reader raw. diff --git a/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx b/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx index 2535a1022e..ba964c44a7 100644 --- a/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx @@ -97,10 +97,10 @@ * * ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused * spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion - * choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case - * keys from whichever spelling arrived. Never a renderer-side alias: that is - * how ~20 per-consumer dual-key fallbacks got written under a normalizer whose - * own docstring says it exists "so per-consumer dual-key fallbacks can't drift". + * choke point — `normalizeSchemaReferenceKeys`, which folds a legacy spelling + * onto `reference`. Never a renderer-side alias: that is how ~20 per-consumer + * dual-key fallbacks got written under a normalizer whose own docstring said + * it existed "so per-consumer dual-key fallbacks can't drift". * * ⭐ THAT OPEN SCOPE IS NOW CLOSED FOR THIS READER — objectui#6837 half 2. * Maintainer ruling 2026-08-31 (第 6 场总监席决裁批 #14), 原文照录: @@ -111,17 +111,16 @@ * so this reader keeps ONE arm, `reference`, and the `reference_to` case below * moved from the live group to the refusal group. * - * ⛔ The tier-boundary caveat this paragraph used to carry is NOT retracted, it - * is SCOPED: it was always about ObjectUI's OWN contracts, and those keep - * `reference_to` as their canonical key. `LookupFieldMetadata`, - * `DetailViewFieldSchema` and the `FieldMetadata` bag declare `reference_to` - * and never declare `reference`, so the three widget-seam readers that read - * THAT bag (`fields/src/index.tsx#LookupCellRenderer`, - * `widgets/LookupField.tsx`, `widgets/UserField.tsx`) were deliberately NOT - * narrowed by half 2 — narrowing them would break their in-repo producers and - * turn `plugin-grid`'s `relationalMetaCopySet.derivation.test.ts` red, since - * that gate re-derives its read set from exactly those three sources and - * records `reference_to` there with verdict `adapter-stamped`. + * ⭐ The tier-boundary caveat this paragraph used to carry is now CLOSED too + * (objectui#11070 round 4, the seat's answer A under the same ruling). It was + * about ObjectUI's OWN contracts — `LookupFieldMetadata`, + * `DetailViewFieldSchema` and the `FieldMetadata` bag — which declared + * `reference_to`, so the three widget-seam readers of that bag + * (`fields/src/index.tsx#LookupCellRenderer`, `widgets/LookupField.tsx`, + * `widgets/UserField.tsx`) were not narrowed by half 2. Round 4 moved those + * contracts, their in-repo producers and those readers to `reference` in one + * step, and `plugin-grid`'s `relationalMetaCopySet.derivation.test.ts` gate + * dropped its `reference_to` row with them. * * ## 5. Ablation direction, predicted before running * @@ -295,10 +294,10 @@ describe('ObjectGantt resolves only contract-declared target spellings (objectui describe('the ingestion choke point is what makes the deletion lossless', () => { it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => { - // The mechanism, not a formality: the normalizer reads - // `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case - // keys, so every def that entered through `MetadataProvider` or - // `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by + // The mechanism, not a formality: the normalizer folds a legacy + // `reference_to` / `referenceTo` onto `reference`, so every def that + // entered through `MetadataProvider` or + // `ObjectStackAdapter.getObjectSchema` already carries `reference` by // the time this component sees it. The deleted arm was dead weight there. // // ⚠️ And this is exactly why the pin above still matters: the door is diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index d8faf2f027..cc2c2121b2 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -1670,7 +1670,7 @@ export const ObjectGantt: React.FC = ({ const quickFilterDefs = ganttConfig?.quickFilters; // Lookup/master_detail dimensions pull their full option domain from the - // referenced object (reference_to) via the data source — so the dropdown + // referenced object (reference) via the data source — so the dropdown // shows every possible value, not only those present in the loaded rows. const [lookupOptions, setLookupOptions] = useState>({}); useEffect(() => { diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.headerMenuSort-10727.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.headerMenuSort-10727.test.tsx index 1ec42239f8..f32d6598af 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.headerMenuSort-10727.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.headerMenuSort-10727.test.tsx @@ -87,7 +87,7 @@ function makeDataSource() { name: { type: 'text' }, amount: { type: 'currency' }, expected_revenue: { type: 'formula', expression: 'amount * probability / 100' }, - owner: { type: 'lookup', reference_to: 'user' }, + owner: { type: 'lookup', reference: 'user' }, }, }), } as any; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.linkCellAnchor.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.linkCellAnchor.test.tsx index 5e6c724aca..77d7fac6be 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.linkCellAnchor.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.linkCellAnchor.test.tsx @@ -251,7 +251,7 @@ describe('ObjectGrid link column — must-not-change (#4490)', () => { const { container } = renderGrid({ columns: [ { field: 'name', label: 'Name', link: true }, - { field: 'account_id', label: 'Account', type: 'lookup', reference_to: 'crm_account' }, + { field: 'account_id', label: 'Account', type: 'lookup', reference: 'crm_account' }, ], }); await firstLinkCell(); diff --git a/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx b/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx index 5c5ad029bb..fdc53f5bbb 100644 --- a/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx +++ b/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx @@ -324,7 +324,7 @@ describe('objectui#8755 leg A — a `dependsOn` lookup BULK param gates on an EM // reading of the spread, not of an adapter that copies everything. expect(field.help).toBeUndefined(); expect(field.type).toBe('lookup'); - expect(field.reference_to).toBe('contacts'); + expect(field.reference).toBe('contacts'); }); }); diff --git a/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts b/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts index 9724f632d7..8817683678 100644 --- a/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts +++ b/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts @@ -52,7 +52,7 @@ describe('bulkParamToField', () => { label: 'Queue', type: 'lookup', required: true, - reference_to: 'queues', + reference: 'queues', displayField: 'title', multiple: false, }); @@ -64,7 +64,7 @@ describe('bulkParamToField', () => { false, ); expect(field.type).toBe('user'); - expect(field.reference_to).toBe('sys_user'); + expect(field.reference).toBe('sys_user'); }); it('degrades a targetless lookup to a text input with a dev warning', () => { @@ -148,12 +148,12 @@ describe('the data-source rule is core\'s object, not a copy (objectui#4815)', ( // only the one under test would leave the others forked. `isLookupishParam` // (label prefetch + option source in BulkActionDialog), `fieldNeedsDataSource` // (the `dataSource` prop the dialog threads into the widget) and - // `bulkParamToField`'s `reference_to` / `displayField` branch must each + // `bulkParamToField`'s `reference` / `displayField` branch must each // reach core. const consumers: [string, string, () => unknown][] = [ ['isLookupishParam', 'user', () => isLookupishParam({ name: 'u', type: 'user' })], ['fieldNeedsDataSource', 'user', () => fieldNeedsDataSource({ type: 'user' })], - // The `reference_to` / `displayField` branch, reached with the widget key + // The `reference` / `displayField` branch, reached with the widget key // this param resolves to. ['bulkParamToField', 'lookup', () => bulkParamToField({ name: 'q', type: 'lookup', object: 'queues' }, false)], ]; diff --git a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx index 9e631131a6..76beee179f 100644 --- a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx +++ b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx @@ -123,12 +123,12 @@ const OPPORTUNITY_SCHEMA = { }, amount: { type: 'currency', label: 'Amount', currency: 'USD' }, close_date: { type: 'date', label: 'Close Date' }, - owner_id: { type: 'lookup', label: 'Owner', reference_to: 'user', system: true }, + owner_id: { type: 'lookup', label: 'Owner', reference: 'user', system: true }, id: { type: 'text', label: 'Id', hidden: true, system: true, readonly: true }, created_at: { type: 'datetime', label: 'Created At', system: true, readonly: true }, - created_by: { type: 'lookup', label: 'Created By', reference_to: 'user', system: true, readonly: true }, + created_by: { type: 'lookup', label: 'Created By', reference: 'user', system: true, readonly: true }, updated_at: { type: 'datetime', label: 'Updated At', system: true, readonly: true }, - updated_by: { type: 'lookup', label: 'Updated By', reference_to: 'user', system: true, readonly: true }, + updated_by: { type: 'lookup', label: 'Updated By', reference: 'user', system: true, readonly: true }, }, }; diff --git a/packages/plugin-grid/src/__tests__/inlineEditExitMode.test.tsx b/packages/plugin-grid/src/__tests__/inlineEditExitMode.test.tsx index 0c1bdf9737..af6711e17e 100644 --- a/packages/plugin-grid/src/__tests__/inlineEditExitMode.test.tsx +++ b/packages/plugin-grid/src/__tests__/inlineEditExitMode.test.tsx @@ -77,7 +77,7 @@ function makeDataSource() { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, qty: { type: 'number', label: 'Qty' }, - manager: { type: 'lookup', label: 'Manager', reference_to: 'users' }, + manager: { type: 'lookup', label: 'Manager', reference: 'users' }, }, }; }, diff --git a/packages/plugin-grid/src/__tests__/inlineEditLookupRepro.test.tsx b/packages/plugin-grid/src/__tests__/inlineEditLookupRepro.test.tsx index e2bd2570b1..5e95888f56 100644 --- a/packages/plugin-grid/src/__tests__/inlineEditLookupRepro.test.tsx +++ b/packages/plugin-grid/src/__tests__/inlineEditLookupRepro.test.tsx @@ -69,7 +69,7 @@ function makeDataSource() { fields: { id: { type: 'text' }, name: { type: 'text' }, - manager: { type: 'lookup', label: '管理责任人', reference_to: 'users' }, + manager: { type: 'lookup', label: '管理责任人', reference: 'users' }, }, }; }, diff --git a/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx b/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx index f0e6b33239..b0801983a2 100644 --- a/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx +++ b/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx @@ -46,8 +46,9 @@ * * Nothing renames them on the way in either: `ObjectStackAdapter.getObjectSchema` * (`@object-ui/data-objectstack`) is the choke point every schema read goes - * through, and its only key rewrite is `normalizeSchemaReferenceKeys` — the - * `reference` ⇄ `reference_to` pair, nothing else. `applyFieldWidgetOverrides` + * through, and its only key rewrite is `normalizeSchemaReferenceKeys` — a + * legacy `reference_to` folded onto `reference`, nothing else (it stamped the + * pair both ways until objectui#11070 round 4). `applyFieldWidgetOverrides` * adds `widget` and touches no other key. * * ⇒ On a live path the ONLY display-field spelling that can reach this grid is diff --git a/packages/plugin-grid/src/__tests__/mobileCardLookupDisplay.test.tsx b/packages/plugin-grid/src/__tests__/mobileCardLookupDisplay.test.tsx index 56016e9fab..21102d03d1 100644 --- a/packages/plugin-grid/src/__tests__/mobileCardLookupDisplay.test.tsx +++ b/packages/plugin-grid/src/__tests__/mobileCardLookupDisplay.test.tsx @@ -44,7 +44,7 @@ function makeDataSource() { name, fields: { id: { type: 'text' }, - owner: { type: 'lookup', label: 'Owner', reference_to: 'users' }, + owner: { type: 'lookup', label: 'Owner', reference: 'users' }, account_name: { type: 'text', label: 'Account Name' }, }, }), diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx index df7e2575d3..4766d0500f 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx @@ -53,8 +53,11 @@ const OBJECT = 'os_6711_report'; const MANAGER_DEF = { type: 'lookup', label: 'Manager', - reference_to: 'users', + // The target the grid copies — the one spelling read since objectui#11070 + // round 4 — beside the snake twin that round RETIRED, kept on the fixture so + // its absence from the copied meta is a reading, not an omission. reference: 'users', + reference_to: 'MUST_NOT_BE_COPIED', // The spec spelling — the only display pointer read since objectui#7155. displayField: 'name', // ⭐ The snake_case dialect objectui#7155 RETIRED. Kept on the fixture on @@ -76,6 +79,10 @@ const MANAGER_DEF = { /** * The keys that survive every retirement so far — the control. * + * ⭐ objectui#11070 round 4 shrank it again, from three to two: the choke + * point stopped stamping `reference_to` and no consumer reads it, so the target + * is copied as `reference` alone. + * * ⭐ objectui#7155 shrank this from six to three. It converged the lookup * dialect on the spec's camelCase, so `display_field` / `id_field` / * `description_field` / `lookup_filters` are no longer copied — `displayField` @@ -86,7 +93,7 @@ const MANAGER_DEF = { * retirement removed exactly its key" from "the copy stopped working". */ const SURVIVING_KEYS = [ - 'reference_to', 'reference', 'displayField', + 'reference', 'displayField', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -184,7 +191,8 @@ describe('objectui#6711 — ObjectGrid no longer copies `reference_to_field` ont for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } - expect(meta.reference_to).toBe('users'); + expect(meta.reference).toBe('users'); + expect(meta).not.toHaveProperty('reference_to'); expect(meta.displayField).toBe('name'); // objectui#7166 retired `lookupFilters` from the copy set — its only // reader is an editor widget, which `renderCellEditor` feeds from the diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx index fd7cc689d7..a3bfef5ef2 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx @@ -10,7 +10,8 @@ * `containers.tsx`, `DetailView.tsx`, `ObjectKanban.tsx`, `ObjectCalendar.tsx`, * `useRecordSearch.ts`), and the one that this grid's own inline picker uses is * `refObjectSchema?.titleFormat` in `LookupField` — the REFERENCED object's - * schema, fetched by `getSchema(referenceTo)`. So `reference_to` is what makes + * schema, fetched by `getSchema(referenceTo)`. So the target key (`reference_to` + * then, `reference` since objectui#11070 round 4) is what makes * `titleFormat` work on this path, and the copy reached nothing. * * Removing it therefore changes no rendering at all: every other test in this @@ -65,8 +66,11 @@ const OBJECT = 'os_6874_report'; const MANAGER_DEF = { type: 'lookup', label: 'Manager', - reference_to: 'users', + // The target the grid copies — the one spelling read since objectui#11070 + // round 4 — beside the snake twin that round RETIRED, kept on the fixture so + // its absence from the copied meta is a reading, not an omission. reference: 'users', + reference_to: 'MUST_NOT_BE_COPIED', // The spec spelling — the only display pointer read since objectui#7155. displayField: 'name', // ⭐ The snake_case dialect objectui#7155 RETIRED. Kept on the fixture on @@ -87,6 +91,10 @@ const MANAGER_DEF = { /** * The keys that survive every retirement so far — the control. * + * ⭐ objectui#11070 round 4 shrank it again, from three to two: the choke + * point stopped stamping `reference_to` and no consumer reads it, so the target + * is copied as `reference` alone. + * * ⭐ objectui#7155 shrank this from six to three. It converged the lookup * dialect on the spec's camelCase, so `display_field` / `id_field` / * `description_field` / `lookup_filters` are no longer copied — `displayField` @@ -97,7 +105,7 @@ const MANAGER_DEF = { * retirement removed exactly its key" from "the copy stopped working". */ const SURVIVING_KEYS = [ - 'reference_to', 'reference', 'displayField', + 'reference', 'displayField', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -195,7 +203,8 @@ describe('objectui#6874 — ObjectGrid no longer copies `titleFormat` onto field for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } - expect(meta.reference_to).toBe('users'); + expect(meta.reference).toBe('users'); + expect(meta).not.toHaveProperty('reference_to'); expect(meta.displayField).toBe('name'); // objectui#7166 retired `lookupFilters` from the copy set — its only // reader is an editor widget, which `renderCellEditor` feeds from the diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index 396250411e..e7ed1a89e2 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -47,7 +47,7 @@ * and the copy set is derived from `CONSUMERS_FED_THIS_BAG` alone. The three * retired keys can no longer be re-added under ANY verdict: * - * - as `spec` / `adapter-stamped` — the cell does not read them, so the + * - as `spec` — the cell does not read them, so the * derived copy set does not contain them and the copy-set assertion is red; * - under the `copiedWithoutCellReader` exit — objectui#7155 retired the * `legacy-alias` class that exit existed for, and the gate now asserts NO @@ -234,7 +234,8 @@ function assertExtractorFoundKnownChains(x: Extraction): void { // the surviving dialect cannot detect an extractor that stopped seeing the // other one. expect(x.cell).toContain('displayField'); - expect(x.cell).toContain('reference_to'); + // objectui#11070 round 4: the cell reads the target as `reference` alone. + expect(x.cell).toContain('reference'); expect(x['lookup-editor']).toContain('lookup_columns'); expect(x['lookup-editor']).toContain('lookupColumns'); // objectui#6153: this one is read through the ANNOTATED ALIAS `cascadeMeta` @@ -407,7 +408,7 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest // independent routes, and a hand-edited `readers` cannot carry both. const expected = Object.entries(RELATIONAL_META_READ_SET) .filter(([key, e]) => - (e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'widget-contract') + (e.verdict === 'spec' || e.verdict === 'widget-contract') && (cellRead.has(key) || e.copiedWithoutCellReader !== undefined)) .map(([k]) => k); expect([...RELATIONAL_META_KEYS].sort()).toEqual(expected.sort()); diff --git a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx index 131294b122..48d8f7962a 100644 --- a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx @@ -59,7 +59,7 @@ function makeDataSource() { id: { type: 'text' }, name: { type: 'text' }, status: { type: 'text' }, - owner: { type: 'lookup', reference_to: 'user' }, + owner: { type: 'lookup', reference: 'user' }, }, }), } as any; diff --git a/packages/plugin-grid/src/components/bulkParamToField.ts b/packages/plugin-grid/src/components/bulkParamToField.ts index 5f6c43d6ca..cfab4e4665 100644 --- a/packages/plugin-grid/src/components/bulkParamToField.ts +++ b/packages/plugin-grid/src/components/bulkParamToField.ts @@ -54,7 +54,7 @@ const USER_WIDGET_TYPES = new Set(['user']); /** * Whether the widget rendered for this key has to QUERY records to do its job — * so it must be handed the grid's `DataSource`, and its field shape needs the - * `reference_to` / `displayField` the picker queries with. + * `reference` / `displayField` the picker queries with. * * The reference-bearing half is NOT restated here: it is `EXPANDABLE_FIELD_TYPES` * from `@object-ui/core`, the one relational-field family the `$expand` builder @@ -177,7 +177,9 @@ export function bulkParamToField( }; if (widgetNeedsDataSource(type)) { - field.reference_to = object; + // `reference` is the only target spelling the picker widgets read + // (objectui#11070 round 4). + field.reference = object; if (typeof labelField === 'string') field.displayField = labelField; } diff --git a/packages/plugin-grid/src/relationalMetaKeys.ts b/packages/plugin-grid/src/relationalMetaKeys.ts index 54c791123c..b3a4e0d238 100644 --- a/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/packages/plugin-grid/src/relationalMetaKeys.ts @@ -79,7 +79,9 @@ * receivers rather than as a count: `packages/fields/src/index.tsx`, the file * that holds EVERY `CellRenderer`, contains zero occurrences of any of the * three, against a control of 22 occurrences of the `display_field` / - * `displayField` / `reference_to` spellings the cell does read. + * `displayField` / `reference_to` spellings the cell read then (since + * objectui#7155 and objectui#11070 round 4 it reads `displayField` and + * `reference` only). * * ⇒ The retirement is behaviour-preserving, and that is the half worth pinning: * all three still take effect in the inline picker with them OFF this table, @@ -142,12 +144,13 @@ * * Nothing manufactures one on the way in, either. `getObjectSchema` in * `@object-ui/data-objectstack` is the choke point every schema read passes - * through, and its only key rewrites are `normalizeSchemaReferenceKeys` (the - * `reference` ⇄ `reference_to` pair) and `applyFieldWidgetOverrides` (`widget`). - * Whatever the server serves for every other property arrives verbatim. + * through, and its only key rewrites are `normalizeSchemaReferenceKeys` (a + * legacy `reference_to` / `referenceTo` folded onto `reference`) and + * `applyFieldWidgetOverrides` (`widget`). Whatever the server serves for every + * other property arrives verbatim. * - * ⇒ Copying a key that is neither spec-declared nor adapter-stamped writes a - * member from the def on every column build that no producer can ever fill. + * ⇒ Copying a key that is not spec-declared writes a member from the def on + * every column build that no producer can ever fill. * That is precisely what objectui#6711 (`reference_to_field`), objectui#6625 * (`decimals`) and objectui#6597 (`referenceTo`) retired, and what * objectui#6531 removed from `getRecordDisplayName` on the same reasoning. So @@ -199,8 +202,9 @@ * `refObjectSchema?.titleFormat` in `LookupField.tsx` — the REFERENCED object's * schema, fetched by `getSchema(referenceTo)`, and the one that matters here; * `param.titleFormat` in `app-shell/utils/paramToField.ts`, off a resolved - * `ActionParamDef`. ⇒ copying `reference_to` is what makes `titleFormat` work - * on this path; copying `titleFormat` onto the meta reached nothing. + * `ActionParamDef`. ⇒ copying the target (`reference_to` then, `reference` + * since objectui#11070 round 4) is what makes `titleFormat` work on this path; + * copying `titleFormat` onto the meta reached nothing. * `plugin-dashboard/src/recordFields.tsx` recorded the same measurement first. * * Both absences stay pinned behaviourally at all three of `generateColumns`'s @@ -229,8 +233,10 @@ export type RelationalMetaVerdict = /** Spec-declared on `FieldSchema`. The spelling a live `getObjectSchema` serves. */ | 'spec' - /** Not spec-declared, but stamped onto every def by the adapter's choke point. */ - | 'adapter-stamped' + // ⭐ `adapter-stamped` — "not spec-declared, but stamped onto every def by + // the adapter's choke point" — left this union with its one member, + // `reference_to` (objectui#11070 round 4): the choke point no longer stamps + // it and no consumer reads it, so the verdict names nothing. /** * Declared on `@object-ui/types`' WIDGET metadata (`LookupFieldMetadata` / * `UserFieldMetadata`) and emitted by in-repo producers, with no twin on @@ -288,7 +294,6 @@ const LOOKUP_EDITOR_ONLY: readonly RelationalMetaConsumer[] = Object.freeze(['lo */ const PRODUCER_LICENSED_VERDICTS: ReadonlySet = new Set([ 'spec', - 'adapter-stamped', 'widget-contract', ]); @@ -323,8 +328,11 @@ export interface RelationalMetaEntry { */ export const RELATIONAL_META_READ_SET: Readonly> = { // ── The relational target ──────────────────────────────────────────────── - reference: { verdict: 'spec', readers: ALL_THREE, note: "FieldSchema.reference — the served spelling for a lookup's target object." }, - reference_to: { verdict: 'adapter-stamped', readers: ALL_THREE, note: 'normalizeSchemaReferenceKeys stamps it from `reference` at the getObjectSchema choke point.' }, + reference: { verdict: 'spec', readers: ALL_THREE, note: "FieldSchema.reference — the served spelling for a lookup's target object, and since objectui#11070 round 4 the only one any consumer reads." }, + // ⭐ objectui#11070 round 4 removed the `reference_to` row. It is not a + // verdict change: the key left this table because the three consumers + // stopped reading it and the choke point stopped stamping it, and a row here + // states that a swept consumer reads the key. reference_field: { verdict: 'no-producer', readers: ALL_THREE, note: 'Third leg of the display-field chain. Not on FieldSchema; zero occurrences in the producer repo (control: `displayField`, 68 files). objectui#6875.' }, // ── The display value ─────────────────────────────────────────────────── diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.expandFls-7429.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.expandFls-7429.test.tsx index 8a079227e1..6d8e5f4bdd 100644 --- a/packages/plugin-kanban/src/__tests__/ObjectKanban.expandFls-7429.test.tsx +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.expandFls-7429.test.tsx @@ -106,9 +106,9 @@ const OBJECT = 'deal'; const DEAL_FIELDS: Record = { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const ROW = { id: 'd1', name: 'Q3 renewal', status: 'open' }; diff --git a/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx b/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx index c6c02656ef..465f2e668d 100644 --- a/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx +++ b/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx @@ -76,7 +76,7 @@ const DEAL_SCHEMA = { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' }, // The only expandable member, so `$expand` has one predictable entry. - owner: { type: 'lookup', reference_to: 'user', label: 'Owner' }, + owner: { type: 'lookup', reference: 'user', label: 'Owner' }, }, }; diff --git a/packages/plugin-kanban/src/__tests__/resolveDisplay.opaqueId-6063.test.tsx b/packages/plugin-kanban/src/__tests__/resolveDisplay.opaqueId-6063.test.tsx index 8ecd4affdb..737be26e69 100644 --- a/packages/plugin-kanban/src/__tests__/resolveDisplay.opaqueId-6063.test.tsx +++ b/packages/plugin-kanban/src/__tests__/resolveDisplay.opaqueId-6063.test.tsx @@ -149,7 +149,7 @@ describe('kanban card descriptions suppress id-shaped values by VALUE, not by de // finding. Kept so the removal cannot silently take this case with it. const container = await renderBoard(OPAQUE, { ...TEXT_SCHEMA, - company: { type: 'lookup', reference_to: 'account' }, + company: { type: 'lookup', reference: 'account' }, }); expect(container.textContent).not.toContain(OPAQUE); }); diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index 342f123a93..b6e74f43a1 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -384,7 +384,8 @@ export const ObjectGallery: React.FC = (props) => { // legacy-only def is canonicalised ONCE at the ingestion choke point // (`normalizeSchemaReferenceKeys`, which warns in dev) — never here. const refTarget = (def as any).reference; - if (refTarget) enriched.reference_to = refTarget; + // The cell reads `reference` alone (objectui#11070 round 4). + if (refTarget) enriched.reference = refTarget; if ((def as any).reference_field) enriched.reference_field = (def as any).reference_field; } // Route the field label through the i18n dictionary so the auto- diff --git a/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx b/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx index 7df54f8198..3caab910bc 100644 --- a/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx @@ -95,7 +95,7 @@ const objectDef = { notes: { name: 'notes', type: 'textarea', label: 'Notes' }, secret_geo: { name: 'secret_geo', type: 'text', label: 'Home location' }, secret_lat: { name: 'secret_lat', type: 'number', label: 'Home latitude' }, - account: { name: 'account', type: 'lookup', reference_to: 'account', label: 'Account' }, + account: { name: 'account', type: 'lookup', reference: 'account', label: 'Account' }, }, }; diff --git a/packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx b/packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx index a8cd16c8bd..ff03c00303 100644 --- a/packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx @@ -33,9 +33,9 @@ const objectDef = { fields: { name: { type: 'text', label: 'Name' }, amount: { type: 'number', label: 'Amount' }, - owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, - account: { type: 'master_detail', label: 'Account', reference_to: 'accounts' }, - assignee: { type: 'user', label: 'Assignee', reference_to: 'sys_user' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, + account: { type: 'master_detail', label: 'Account', reference: 'accounts' }, + assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' }, // No target key. It carried the retired snake_case spelling, which // `FieldSchema` refuses BY NAME, and the sort picker's object-def branch // reads only the declared `reference` (objectui#6837 half 2) — so the diff --git a/packages/plugin-list/src/__tests__/ListView.sortFieldFallback.test.tsx b/packages/plugin-list/src/__tests__/ListView.sortFieldFallback.test.tsx index f911d2bedd..3b544f85f8 100644 --- a/packages/plugin-list/src/__tests__/ListView.sortFieldFallback.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.sortFieldFallback.test.tsx @@ -42,7 +42,7 @@ const objectDef = { name: { type: 'text', label: 'Name' }, plan_start_date: { type: 'date', label: 'Plan Start Date' }, status: { type: 'select', label: 'Status' }, - owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, total_amount: { type: 'formula', label: 'Total Amount' }, }, }; diff --git a/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx index 4758de0d04..0b7556cd9f 100644 --- a/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx @@ -74,7 +74,7 @@ const objectDef = { // DRIFT: a formula the platform DOES order by. rolled_total: { type: 'formula', label: 'Rolled Total' }, // Relational carve-out — the projection answers `sortable: true` here. - owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, }, }; diff --git a/packages/plugin-map/src/ObjectMap.descriptionDisplay-10456.test.tsx b/packages/plugin-map/src/ObjectMap.descriptionDisplay-10456.test.tsx index 79d5a98e41..42e6de5924 100644 --- a/packages/plugin-map/src/ObjectMap.descriptionDisplay-10456.test.tsx +++ b/packages/plugin-map/src/ObjectMap.descriptionDisplay-10456.test.tsx @@ -137,7 +137,7 @@ describe('marker descriptions render as a display string (objectui#10456)', () = name: 'site', fields: { site_name: { type: 'text' }, - owner: { type: 'lookup', reference_to: 'account' }, + owner: { type: 'lookup', reference: 'account' }, latitude: { type: 'number' }, longitude: { type: 'number' }, }, diff --git a/packages/plugin-map/src/ObjectMap.expandFls-7429.test.tsx b/packages/plugin-map/src/ObjectMap.expandFls-7429.test.tsx index 303d6d097d..361e965e94 100644 --- a/packages/plugin-map/src/ObjectMap.expandFls-7429.test.tsx +++ b/packages/plugin-map/src/ObjectMap.expandFls-7429.test.tsx @@ -114,9 +114,9 @@ const STORE_FIELDS: Record = { name: { type: 'text', label: 'Name' }, latitude: { type: 'number', label: 'Lat' }, longitude: { type: 'number', label: 'Lng' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const ROW = { id: 's1', name: 'Harbour Depot', latitude: 47.6062, longitude: -122.3321 }; diff --git a/packages/plugin-timeline/src/ObjectTimeline.expandFls-7429.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.expandFls-7429.test.tsx index 94fb8c38ac..90fa390e3e 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.expandFls-7429.test.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.expandFls-7429.test.tsx @@ -111,9 +111,9 @@ const TASK_FIELDS: Record = { id: { type: 'text', label: 'Id' }, subject: { type: 'text', label: 'Subject' }, starts_at: { type: 'datetime', label: 'Start' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const ROW = { id: 't1', subject: 'Ship it', starts_at: '2026-01-01T09:00:00Z' }; diff --git a/packages/plugin-timeline/src/ObjectTimeline.fetchGate-7895.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.fetchGate-7895.test.tsx index e6a0c97eb3..b794f0340d 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.fetchGate-7895.test.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.fetchGate-7895.test.tsx @@ -82,7 +82,7 @@ const OBJECT_SCHEMA = { id: { name: 'id', type: 'text' }, subject: { name: 'subject', type: 'text' }, starts_at: { name: 'starts_at', type: 'datetime' }, - owner: { name: 'owner', type: 'lookup', reference_to: 'user' }, + owner: { name: 'owner', type: 'lookup', reference: 'user' }, }, }; diff --git a/packages/plugin-timeline/src/ObjectTimeline.lookupDisplay-10530.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.lookupDisplay-10530.test.tsx index a46e0f02d3..3088fc414e 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.lookupDisplay-10530.test.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.lookupDisplay-10530.test.tsx @@ -81,7 +81,7 @@ const OBJECT = 'task'; const FIELDS = { subject: { type: 'text' }, starts_at: { type: 'datetime' }, - account: { type: 'lookup', reference_to: 'account' }, + account: { type: 'lookup', reference: 'account' }, }; /** An expanded lookup, as a server `$expand` returns the related record. */ diff --git a/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx b/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx index 37f0355cf9..b53e629144 100644 --- a/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx @@ -104,10 +104,10 @@ * * ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused * spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion - * choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case - * keys from whichever spelling arrived. Never a renderer-side alias: that is - * how ~20 per-consumer dual-key fallbacks got written under a normalizer whose - * own docstring says it exists "so per-consumer dual-key fallbacks can't drift". + * choke point — `normalizeSchemaReferenceKeys`, which folds a legacy spelling + * onto `reference`. Never a renderer-side alias: that is how ~20 per-consumer + * dual-key fallbacks got written under a normalizer whose own docstring said + * it existed "so per-consumer dual-key fallbacks can't drift". * * ⭐ THAT OPEN SCOPE IS NOW CLOSED FOR THIS READER — objectui#6837 half 2. * Maintainer ruling 2026-08-31 (第 6 场总监席决裁批 #14), 原文照录: @@ -118,17 +118,16 @@ * so this reader keeps ONE arm, `reference`, and the `reference_to` case below * moved from the live group to the refusal group. * - * ⛔ The tier-boundary caveat this paragraph used to carry is NOT retracted, it - * is SCOPED: it was always about ObjectUI's OWN contracts, and those keep - * `reference_to` as their canonical key. `LookupFieldMetadata`, - * `DetailViewFieldSchema` and the `FieldMetadata` bag declare `reference_to` - * and never declare `reference`, so the three widget-seam readers that read - * THAT bag (`fields/src/index.tsx#LookupCellRenderer`, - * `widgets/LookupField.tsx`, `widgets/UserField.tsx`) were deliberately NOT - * narrowed by half 2 — narrowing them would break their in-repo producers and - * turn `plugin-grid`'s `relationalMetaCopySet.derivation.test.ts` red, since - * that gate re-derives its read set from exactly those three sources and - * records `reference_to` there with verdict `adapter-stamped`. + * ⭐ The tier-boundary caveat this paragraph used to carry is now CLOSED too + * (objectui#11070 round 4, the seat's answer A under the same ruling). It was + * about ObjectUI's OWN contracts — `LookupFieldMetadata`, + * `DetailViewFieldSchema` and the `FieldMetadata` bag — which declared + * `reference_to`, so the three widget-seam readers of that bag + * (`fields/src/index.tsx#LookupCellRenderer`, `widgets/LookupField.tsx`, + * `widgets/UserField.tsx`) were not narrowed by half 2. Round 4 moved those + * contracts, their in-repo producers and those readers to `reference` in one + * step, and `plugin-grid`'s `relationalMetaCopySet.derivation.test.ts` gate + * dropped its `reference_to` row with them. * * ## 5. Ablation direction, predicted before running * @@ -265,10 +264,10 @@ describe('ObjectTree detects a parent pointer only through contract-declared tar describe('the ingestion choke point is what makes the deletion lossless', () => { it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => { - // The mechanism, not a formality: the normalizer reads - // `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case - // keys, so every def that entered through `MetadataProvider` or - // `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by + // The mechanism, not a formality: the normalizer folds a legacy + // `reference_to` / `referenceTo` onto `reference`, so every def that + // entered through `MetadataProvider` or + // `ObjectStackAdapter.getObjectSchema` already carries `reference` by // the time this component sees it. The deleted arm was dead weight there. // // ⚠️ And this is exactly why the pin above still matters: the door is diff --git a/packages/plugin-view/src/__tests__/ObjectView.expandFls-7429.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.expandFls-7429.test.tsx index 9716e43063..c95cb81825 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.expandFls-7429.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.expandFls-7429.test.tsx @@ -126,9 +126,9 @@ const OBJECT = 'task'; */ const TASK_FIELDS: Record = { name: { type: 'text', label: 'Name' }, - account: { type: 'lookup', reference_to: 'account', label: 'Account' }, - secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, - owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'department', label: 'Dept' }, }; const TASK_SCHEMA = { name: OBJECT, label: 'Task', fields: TASK_FIELDS }; diff --git a/packages/plugin-view/src/__tests__/ObjectView.expandGate.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.expandGate.test.tsx index 01a7913e09..8176b3aaa6 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.expandGate.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.expandGate.test.tsx @@ -116,12 +116,12 @@ vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ * assertion is on `$expand`'s contents against the schema — a field added here * with an expandable type must show up in the query or the test fails. */ -const TASK_FIELDS: Record = { +const TASK_FIELDS: Record = { name: { type: 'text', label: 'Name' }, amount: { type: 'currency', label: 'Amount' }, due_date: { type: 'date', label: 'Due' }, owner: { type: 'user', label: 'Owner' }, - account: { type: 'lookup', label: 'Account', reference_to: 'account' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, // No target key. It carried the retired snake_case spelling, which // `FieldSchema` refuses BY NAME (measured: `unrecognized_keys` plus a // rename hint, where a nonsense key draws the same refusal without one), so @@ -130,7 +130,7 @@ const TASK_FIELDS: Record { - // Each is read by a widget and none is declared. Two are the snake_case - // second spelling of a spec key this card DID declare (`reference_to` / - // `reference`, `min_length` / `minLength`): the seat's answer on - // objectui#11070 keeps the legacy spelling refused, so an author writes the - // spec's. `reference_to` is still WRITTEN by in-repo producers that feed the - // lookup readers, so its reads stay until that is ruled. The grid field's + // Each is read by a widget and none is declared. `min_length` is the + // snake_case second spelling of a spec key this card DID declare + // (`minLength`): the seat's answer on objectui#11070 keeps the legacy + // spelling refused, so an author writes the spec's. The grid field's // `columns` has an element shape not yet decided. ⛔ Declaring one is a // contract ruling, not a fix to this list. const PENDING: ReadonlyArray]> = [ - ['reference_to', { type: 'lookup', reference_to: 'users' }], ['min_length', { type: 'password', min_length: 8 }], ['columns', { type: 'grid', columns: [{ name: 'qty', type: 'number' }] }], ]; @@ -191,7 +196,13 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay // spellings are read by nothing and retired at once — no alias, no dual // read. The strict face refuses each by name, as before; what changed is // that the spec spelling beside it is now declared (block 1). + // + // Round 4 (the seat's answer A to Q1, under the objectui#6837 ruling): the + // `lookup` and `user` widgets and both read cells read the spec's + // `reference` only, and no in-repo producer writes `reference_to` any more, + // so it joins this list rather than waiting on one. const RETIRED: ReadonlyArray]> = [ + ['reference_to', { type: 'lookup', reference_to: 'users' }], ['return_type', { type: 'formula', return_type: 'number' }], ['summary_type', { type: 'summary', summary_type: 'sum' }], ['summary_object', { type: 'summary', summary_object: 'orders' }], @@ -202,6 +213,12 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); }); + it('the authored detail-view field declares `reference`, and `reference_to` is not in its mirror (round 4)', () => { + const keys = Object.keys(DetailViewFieldSchema.shape); + expect(keys).toContain('reference'); + expect(keys).not.toContain('reference_to'); + }); + it('`object-chart.dataSource` is refused by name until it is declared (the react-page wrapper no longer puns the adapter into that key; the declaration and the objectui#10770 node pin move together)', () => { const doc = { type: 'object-chart', objectName: 'task', chartType: 'bar', dataSource: { object: 'task' } }; expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toEqual(['dataSource']); @@ -242,6 +259,19 @@ export type assertionRetiredMembersAreGone = [ Expect, never>>, Expect, never>>, ]; +/** + * Round 4: the relational target is the spec's `reference`, carried BY + * REFERENCE on both field metadata types, and `reference_to` is gone from + * them and from the authored detail-view field — one spelling, no second. + */ +export type assertionReferenceIsTheOnlyTargetSpelling = [ + Expect>, + Expect>, + Expect>, + Expect, never>>, + Expect, never>>, + Expect, never>>, +]; export type assertionShowSubmitIsBoolean = Expect>; /** * Each binding member IS the spec's `ElementDataSource` — an exact match, not a diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 54d6e8bd1a..d49f0eb492 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -730,7 +730,19 @@ export interface LookupFilterDef { */ export interface LookupFieldMetadata extends BaseFieldMetadata { type: 'lookup' | 'master_detail'; - reference_to?: string; + /** + * The object the picker queries and the read cell resolves a name from — + * `@objectstack/spec`'s `FieldSchema.reference`, typed BY REFERENCE so the + * two cannot drift (objectui#11070). `LookupField`, `UserField` and the + * lookup / user read cells read this spelling and no other. + * + * It replaces the retired snake_case `reference_to`, which the spec refuses + * by name ("did you mean `reference_to` → `reference`?"). A served def that + * still spells a legacy key is folded onto `reference` once, at the + * ingestion choke point (`normalizeSchemaReferenceKeys` in + * `@object-ui/core`); a def handed to a widget directly is not. + */ + reference?: SpecField['reference']; reference_field?: string; multiple?: boolean; searchable?: boolean; @@ -1063,7 +1075,12 @@ export interface RatingFieldMetadata extends BaseFieldMetadata { export interface MasterDetailFieldMetadata extends BaseFieldMetadata { type: 'master_detail'; - reference_to?: string; + /** + * The parent object — `@objectstack/spec`'s `FieldSchema.reference`, typed + * BY REFERENCE, the same member {@link LookupFieldMetadata} carries. It + * replaces the retired snake_case `reference_to` (objectui#11070). + */ + reference?: SpecField['reference']; } /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 614748481f..36b5d95d9a 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1861,7 +1861,7 @@ export interface FormField { * The resolved object-field metadata **object** (typically a * {@link FieldMetadata} / server-served field definition), stashed by the * object-bound form paths so widgets can read `precision`, `currency`, - * `reference_to`, `dependsOn`, … It feeds the field-widget `field` prop. + * `reference`, `dependsOn`, … It feeds the field-widget `field` prop. * * ⚠️ Same key, different layer: in the SPEC form-view vocabulary `field` is * a **string** (the referenced object-field name). That authored shape ends @@ -1932,18 +1932,19 @@ export interface FormField { // typed BY REFERENCE to that member, so the two cannot drift; `pattern` is // the one the spec does not declare. // - // ⛔ Not every key a widget reads off the carrier is declared here. Two are + // ⛔ Not every key a widget reads off the carrier is declared here. One is // still read in a snake_case spelling beside a spec key of the same - // meaning — `reference_to` (spec `reference`) and `min_length` (spec - // `minLength`) — and neither is declared: a second spelling is not added - // to this contract. The SPEC spelling is the one declared (`reference`, - // below; `minLength`, above), and `reference_to` / `min_length` stay - // refused by the strict face (the seat's answer on objectui#11070). The - // formula and summary widgets read ONLY the spec spellings, `returnType` - // and `summaryOperations` (below); their snake_case forms are retired and - // refused. The grid field's `columns` is read too, but its element shape - // is undecided: the declared `GridColumnDefinition` (`./field-types.ts`) is - // not the shape `GridField` reads. It remains open on objectui#11070. + // meaning — `min_length` (spec `minLength`) — and it is not declared: a + // second spelling is not added to this contract. The SPEC spelling is the + // one declared (`minLength`, above), and `min_length` stays refused by the + // strict face (the seat's answer on objectui#11070). The lookup and user + // widgets read ONLY the spec's `reference` (below), and the formula and + // summary widgets ONLY the spec's `returnType` and `summaryOperations` + // (below); their snake_case forms (`reference_to`, `return_type`, + // `summary_type`, …) are retired and refused. The grid field's `columns` + // is read too, but its element shape is undecided: the declared + // `GridColumnDefinition` (`./field-types.ts`) is not the shape `GridField` + // reads. It remains open on objectui#11070. /** * Hold several values instead of one. Read by the `file`, `image`, @@ -1971,12 +1972,11 @@ export interface FormField { dimensions?: SpecField['dimensions']; /** * Target object of a `lookup` / `user` field, as `@objectstack/spec`'s - * `FieldSchema` spells it. The `lookup` widget resolves its target as - * `reference_to || reference` and the `user` widget as - * `reference || reference_to` (falling back to `sys_user`), so this is the - * object the picker queries through the injected adapter. The legacy - * `reference_to` spelling is still read by both, and is ⛔ NOT declared: - * the strict face refuses it, so an author writes this spelling + * `FieldSchema` spells it. The `lookup` widget resolves its target from + * this key alone and the `user` widget likewise (falling back to + * `sys_user`), so this is the object the picker queries through the + * injected adapter. The retired `reference_to` spelling is read by neither + * and is ⛔ NOT declared: the strict face refuses it by name * (objectui#11070). */ reference?: SpecField['reference']; diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 26e966d012..1ddb9a0f8b 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -196,9 +196,11 @@ export interface DetailViewField { */ options?: SelectOptionMetadata[]; /** - * Referenced object name for lookup/master_detail fields + * Referenced object name for lookup/master_detail fields — the spelling + * `@objectstack/spec`'s `FieldSchema` declares. It replaces the retired + * snake_case `reference_to` (objectui#11070), which nothing reads. */ - reference_to?: string; + reference?: string; /** * Display field on the referenced object for lookup/master_detail fields */ diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 0ba388b0a4..8622dadfef 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -87,7 +87,7 @@ export const DetailViewFieldSchema = z.object({ */ options: z.array(stripImportedDefaults(SpecSelectOptionSchema)).optional() .describe('Options for select/lookup fields'), - reference_to: z.string().optional().describe('Referenced object name for lookup/master_detail fields'), + reference: z.string().optional().describe('Referenced object name for lookup/master_detail fields'), reference_field: z.string().optional().describe('Display field on the referenced object'), currency: z.string().optional().describe('Currency code for currency fields (e.g. USD, EUR)'), dueLike: z.boolean().optional().describe( From c684de44abecfc7b1e8c3e1e7f06a2d5e9999bee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:48:45 +0000 Subject: [PATCH 2/5] test,docs(changeset): the copy-set floors follow reference_to out; dated notes on the pending entries this round made false The three relational copy-set controls asserted more than two keys; with reference_to retired the set is reference and displayField, so each floor now also names reference. The console shared-widget pin feeds its lookup the spec spelling. One changeset declares the round (minor, break stated), and fifteen pending changesets get an appended dated note. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .changeset/10535-person-name-read-gate.md | 2 ++ .../11070-field-metadata-spec-spellings.md | 2 ++ .changeset/11070-reference-to-round4.md | 32 +++++++++++++++++++ .../6528-resolve-reference-to-census.md | 2 ++ .../6597-retire-fieldmeta-referenceto.md | 2 ++ .../6694-dashboard-lookup-reference-meta.md | 2 ++ .changeset/6711-retire-reference-to-field.md | 2 ++ .../6837-gantt-tree-referenceto-arms.md | 2 ++ .../6837-recorddrawer-invented-target-arms.md | 2 ++ .changeset/6837-reference-to-arm-deletion.md | 2 ++ .changeset/6874-retire-titleformat.md | 2 ++ .../6875-grid-relational-meta-derive.md | 2 ++ .../7155-converge-lookup-dialect-camelcase.md | 2 ++ .../7166-retire-inert-fieldmeta-copies.md | 2 ++ ...24-plugin-form-nameable-parameter-types.md | 2 ++ ...d-reference-rule-derives-from-core-5312.md | 2 ++ .../FormPage.sharedWidgets-10179.test.tsx | 2 +- .../src/currentUserDefault.test.tsx | 3 +- .../__tests__/lookupPickerKeys-7154.test.tsx | 8 +++-- .../relationalMetaCopySet-7166.test.tsx | 6 ++-- .../relationalMetaCopySet.derivation.test.ts | 6 ++-- 21 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 .changeset/11070-reference-to-round4.md diff --git a/.changeset/10535-person-name-read-gate.md b/.changeset/10535-person-name-read-gate.md index 5760a26351..1fe60ad8e8 100644 --- a/.changeset/10535-person-name-read-gate.md +++ b/.changeset/10535-person-name-read-gate.md @@ -27,3 +27,5 @@ are unchanged. This is defence in depth: ObjectStack's `FieldMasker` already removes the fields a user may not read from the rows it returns, and on those rows nothing changes. The change matters for a backend that does not. + +⚠️ **Dated note, 2026-09-30 — the cell reads the person's object from `reference` alone — objectui#11070.** "the object the field's `reference_to` (or `reference`) names" above held when this change landed. Later in this same release objectui#11070 (round 4) narrowed `UserCellRenderer` to `reference`, with `sys_user` when it names none. The read gate is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/11070-field-metadata-spec-spellings.md b/.changeset/11070-field-metadata-spec-spellings.md index f32f57eb85..ee0b67ca25 100644 --- a/.changeset/11070-field-metadata-spec-spellings.md +++ b/.changeset/11070-field-metadata-spec-spellings.md @@ -14,3 +14,5 @@ The formula and summary field widgets read `@objectstack/spec`'s own spellings, **Breaking, priced as minor under the fixed group's version policy.** A literal annotated as `FormulaFieldMetadata`, `SummaryFieldMetadata` or `PasswordFieldMetadata` that still writes a retired member no longer compiles (an excess-property error naming the key), and at runtime a formula or summary field that carries only `return_type` or `summary_type` renders its value unformatted. `@objectstack/spec`'s `FieldSchema` refuses every retired spelling by name, so no spec-compliant producer writes them. The fix is the spec spelling: `returnType`, and `summaryOperations: { object, field, function }` (with `filter` for the former `summary_filter`). **Not in this change.** `LookupFieldMetadata.reference_to` and the lookup readers' `reference_to` read stay: in-repo producers still write `reference_to` onto the field definitions those readers are handed, so retiring the spelling is a separate decision on objectui#11070. The strict face keeps refusing `return_type`, `summary_type`, `summary_object` and `summary_field` on a hand-authored form's field, as before. + +⚠️ **Dated note, 2026-09-30 — the `reference_to` decision was taken in this same release — objectui#11070.** "Not in this change" above is still true of this change. Round 4 of objectui#11070, later in this same release, retired `LookupFieldMetadata.reference_to` and the lookup readers' `reference_to` read, and moved the in-repo producers to `reference`. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/11070-reference-to-round4.md b/.changeset/11070-reference-to-round4.md new file mode 100644 index 0000000000..fe1b684d36 --- /dev/null +++ b/.changeset/11070-reference-to-round4.md @@ -0,0 +1,32 @@ +--- +'@object-ui/types': minor +'@object-ui/core': minor +'@object-ui/fields': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-detail': minor +'@object-ui/plugin-form': minor +'@object-ui/plugin-grid': minor +'@object-ui/plugin-list': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/data-objectstack': patch +--- + +`reference` is now the only spelling ObjectUI writes or reads for a relational field's target object (objectui#11070, round 4, under the objectui#6837 ruling: 「objectui不是前端的项目吗?后端的元数据只要对,前端按协议执行就行了呀」). + +- **Types.** `LookupFieldMetadata`, `MasterDetailFieldMetadata` and `DetailViewField` (with its zod mirror `DetailViewFieldSchema`) declare `reference` and no longer declare `reference_to`. On the two field metadata types the member is typed by reference to `@objectstack/spec`'s `FieldSchema.reference`. `@object-ui/plugin-form`'s `FieldDefaultsSchemaLike` drops its `reference_to` member the same way. +- **Readers.** `LookupField`, `UserField`, `LookupCellRenderer`, `UserCellRenderer`, the inline editor's reference fallback, the form's `current_user` seeding and the inline-subform parent lookup read `reference` alone. +- **Emitters.** Every in-repo producer that builds a field definition or a widget `field` prop writes `reference`: the action-param dialog (`paramToField`), the bulk-action dialog, the record detail page, drawer, footer, related list and synthesised page, the gallery card, the form's section-field override and the flow designer's reference picker. The grid's and the dashboard's relational copy sets carry `reference` and no longer copy `reference_to`. +- **Ingestion.** `normalizeFieldReferenceKeys` (behind `ObjectStackAdapter.getObjectSchema` and `MetadataProvider`) still folds a legacy `reference_to` / `referenceTo` onto `reference` when `reference` is absent, and still warns in dev. It no longer stamps `reference_to` onto every relational definition, and it still never drops or overwrites a key. + +`@objectstack/spec`'s `FieldSchema` refuses `reference_to` by name, and objectstack#13847 rewrites stored ones on the serve path and in `os migrate meta`, so a definition served by an ObjectStack backend is unaffected. + +## ⚠️ BREAKING for a host that hands `reference_to` to the widgets directly + +The type change is a compile error for TypeScript that writes `reference_to` on `LookupFieldMetadata`, `MasterDetailFieldMetadata` or `DetailViewField`: rename it to `reference`. + +At runtime the break reaches exactly one kind of host: one that serves object definitions spelling the target only as `reference_to` through a `DataSource` other than `ObjectStackAdapter`, or that passes such a definition straight into `LookupField`, `UserField` or a cell renderer. Those definitions never pass the ingestion fold. Measured with an object-bound lookup field on `ObjectForm` and a lookup column on `ObjectGrid`, both fed by a hand-written `DataSource` whose `getObjectSchema` returns `{ type: 'lookup', reference_to: 'account' }`: + +- before this change, opening the picker queried `account` and the cell resolved the record's name; +- after it, the picker has no object to query (no `find` call is made) and the cell shows the raw id beside the unresolved-reference marker. + +The same definition served through `ObjectStackAdapter.getObjectSchema` still works, before and after: the fold adds `reference`, the picker queries `account`, the cell resolves the name, and the dev warning names the field. **Fix:** spell the target `reference` in the definition your `DataSource` serves. diff --git a/.changeset/6528-resolve-reference-to-census.md b/.changeset/6528-resolve-reference-to-census.md index 37ea994a95..095265267b 100644 --- a/.changeset/6528-resolve-reference-to-census.md +++ b/.changeset/6528-resolve-reference-to-census.md @@ -35,3 +35,5 @@ already head of the old chain, so any document carrying both is unaffected. The string / array / `{ object }` carriers are untouched: the carrier is a separate axis from the spelling and narrowing it needs its own census. + +⚠️ **Dated note, 2026-09-30 — `reference_to` is no longer live on ObjectUI's own view/field schema — objectui#11070.** The table's `reference_to` row said the spelling was "live only on ObjectUI's own view/field schema". Later in this same release objectui#11070 (round 4) moved that schema (`DetailViewField` / `DetailViewFieldSchema`) and the field metadata types to `reference`. The narrowing this change made is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6597-retire-fieldmeta-referenceto.md b/.changeset/6597-retire-fieldmeta-referenceto.md index 9e044dd2d3..dbaa3d4b3e 100644 --- a/.changeset/6597-retire-fieldmeta-referenceto.md +++ b/.changeset/6597-retire-fieldmeta-referenceto.md @@ -59,3 +59,5 @@ than assumed: `FieldMeta`, `AuthoredColumnOverrides`, `EnrichedColumn` and barrel re-exports only the `ObjectDataTable` component, and the package's `exports` map publishes only `"."`. No downstream type moves; this is a package-internal contract change plus a README correction, not a removal from a published type surface. + +⚠️ **Dated note, 2026-09-30 — `reference_to` has since left the cell and the grid's copy — objectui#11070.** The measurement above read `LookupCellRenderer` resolving its target from `field.reference_to` / `field.reference`, and `applyRelationalMeta` copying `reference_to`. Later in this same release objectui#11070 (round 4) narrowed the cell to `reference` alone and dropped `reference_to` from both copy sets. The verdict on `referenceTo` is unchanged. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6694-dashboard-lookup-reference-meta.md b/.changeset/6694-dashboard-lookup-reference-meta.md index a746a1dbd9..b3e27f800f 100644 --- a/.changeset/6694-dashboard-lookup-reference-meta.md +++ b/.changeset/6694-dashboard-lookup-reference-meta.md @@ -55,3 +55,5 @@ Behaviour note for existing dashboards: a lookup cell whose referenced object de `nameField` other than `name` / `title` will now show that declared name instead of the heuristic's pick, and valued lookup cells become links wherever the host publishes `recordHref`. + +⚠️ **Dated note, 2026-09-30 — the cell's reads and the copy set have since moved — objectui#11070.** Later in this same release objectui#11070 (round 4) narrowed `LookupCellRenderer` to `reference` alone, so "reads exactly `reference_to`, `reference` and `display_field`" and `useRefObjectSchema(reference_to)` above no longer describe what ships: the cell reads `reference`, and `buildFieldMeta` no longer copies `reference_to` (its `FieldMeta` member is retired, and `ObjectDataTable` refuses it by a hand-written tombstone). `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6711-retire-reference-to-field.md b/.changeset/6711-retire-reference-to-field.md index 61af2cd9a6..83321fd150 100644 --- a/.changeset/6711-retire-reference-to-field.md +++ b/.changeset/6711-retire-reference-to-field.md @@ -38,3 +38,5 @@ correct, so the absence is pinned directly instead (`__tests__/relationalMetaCopySet-6711.test.tsx`): all three call sites, each with a presence assertion on the eight surviving keys as the control against a fixture that passes by never reaching the copy path. + +⚠️ **Dated note, 2026-09-30 — `reference_to` has since left `LookupCellRenderer` and the copy set — objectui#11070.** The control above names `reference_to` among the keys `LookupCellRenderer` read. Later in this same release objectui#11070 (round 4) narrowed the cell to `reference` and removed `reference_to` from `RELATIONAL_META_KEYS`; the retirement of `reference_to_field` is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6837-gantt-tree-referenceto-arms.md b/.changeset/6837-gantt-tree-referenceto-arms.md index ca7ce11a55..d25658a3f5 100644 --- a/.changeset/6837-gantt-tree-referenceto-arms.md +++ b/.changeset/6837-gantt-tree-referenceto-arms.md @@ -51,3 +51,5 @@ surviving spellings. Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and `ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a named refusal for the deleted key. + +⚠️ **Dated note, 2026-09-30 — the choke point folds, it no longer stamps both keys — objectui#11070.** "`normalizeSchemaReferenceKeys` ... stamps both snake_case keys" above held when this change landed. Later in this same release objectui#11070 (round 4) made that pass fold a legacy spelling onto `reference` without stamping `reference_to`; a def that entered through it still carries `reference`, the one arm both components keep. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6837-recorddrawer-invented-target-arms.md b/.changeset/6837-recorddrawer-invented-target-arms.md index cc27971316..9f1d101e9e 100644 --- a/.changeset/6837-recorddrawer-invented-target-arms.md +++ b/.changeset/6837-recorddrawer-invented-target-arms.md @@ -34,3 +34,5 @@ pass over the same cells. Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live arms green beside a named refusal per deleted key. + +⚠️ **Dated note, 2026-09-30 — the choke point folds, and the drawer emits `reference` — objectui#11070.** "`normalizeSchemaReferenceKeys` ... stamps both snake_case keys" above held when this change landed. Later in this same release objectui#11070 (round 4) made that pass fold a legacy spelling onto `reference` without stamping `reference_to`, and the drawer's field list now carries the target as `reference`. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6837-reference-to-arm-deletion.md b/.changeset/6837-reference-to-arm-deletion.md index 1037200bab..489ab8583f 100644 --- a/.changeset/6837-reference-to-arm-deletion.md +++ b/.changeset/6837-reference-to-arm-deletion.md @@ -119,3 +119,5 @@ narrowing them would break in-repo producers, and `plugin-grid`'s `relationalMetaCopySet.derivation.test.ts` re-derives its read set from exactly those three sources — where `reference_to` is recorded with verdict `adapter-stamped`. `DetailViewFieldSchema` is likewise untouched. + +⚠️ **Dated note, 2026-09-30 — the stamp, the three readers and the emitted key have since moved — objectui#11070.** Later in this same release objectui#11070 (round 4) retired `reference_to` inside ObjectUI as well. `normalizeFieldReferenceKeys` now FOLDS a legacy `reference_to` / `referenceTo` onto `reference` and no longer stamps `reference_to`, so "Stamping is deliberately unchanged" and "stamp both snake_case keys" above no longer describe what ships. `LookupCellRenderer`, `LookupField` and `UserField` read `reference` only; `LookupFieldMetadata`, `MasterDetailFieldMetadata` and `DetailViewField` (with `DetailViewFieldSchema`) declare `reference`; and the emitters listed under "What did NOT change" write `reference`. A hand-written schema served through a `DataSource` other than `ObjectStackAdapter` that spells only `reference_to` therefore also loses the lookup picker's target and the read cell's name. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6874-retire-titleformat.md b/.changeset/6874-retire-titleformat.md index 5fcb8f6093..238e684718 100644 --- a/.changeset/6874-retire-titleformat.md +++ b/.changeset/6874-retire-titleformat.md @@ -56,3 +56,5 @@ removal is correct, so the absence is pinned directly instead (`__tests__/relationalMetaCopySet-6874.test.tsx`): all three call sites, each with a presence assertion on the seven surviving keys as the control against a fixture that passes by never reaching the copy path. + +⚠️ **Dated note, 2026-09-30 — the copied target key is now `reference` — objectui#11070.** "Copying `reference_to` is what makes `titleFormat` work" above held when this change landed. Later in this same release objectui#11070 (round 4) removed `reference_to` from `RELATIONAL_META_KEYS`; the target reaches the cell as `reference`, which is what the referenced schema is fetched by now. The `titleFormat` retirement is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/6875-grid-relational-meta-derive.md b/.changeset/6875-grid-relational-meta-derive.md index cf8a567aaf..c099ab042a 100644 --- a/.changeset/6875-grid-relational-meta-derive.md +++ b/.changeset/6875-grid-relational-meta-derive.md @@ -31,3 +31,5 @@ got a grid cell showing the referenced record's generic `.name` instead. in prose. - `plugin-dashboard`'s `CELL_RELATIONAL_META_KEYS` had the same omission in the same fallback chain and gains `displayField` too. + +⚠️ **Dated note, 2026-09-30 — the choke point's one reference rewrite is now one-way — objectui#11070.** "rewrites only the `reference` ⇄ `reference_to` pair" above held when this change landed. Later in this same release objectui#11070 (round 4) made that rewrite a fold onto `reference` that never stamps `reference_to`. Nothing else is rewritten on the way in, so the reasoning above is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/7155-converge-lookup-dialect-camelcase.md b/.changeset/7155-converge-lookup-dialect-camelcase.md index da9add6337..e58d94a8a5 100644 --- a/.changeset/7155-converge-lookup-dialect-camelcase.md +++ b/.changeset/7155-converge-lookup-dialect-camelcase.md @@ -51,3 +51,5 @@ inline-edit enrichment allow-list in `@object-ui/plugin-detail`. `plugin-grid`'s `relationalMetaKeys.ts` drops the four `legacy-alias` verdicts and retires that verdict class; its gate is restated to assert the class no longer exists rather than passing vacuously. + +⚠️ **Dated note, 2026-09-30 — `reference_to` did not stay — objectui#11070.** "`reference_to` in particular **stays**" above held when this change landed. Later in this same release objectui#11070 (round 4) retired it: the ingestion pass no longer stamps it, the widgets and read cells read `reference` only, and `LookupFieldMetadata` declares `reference`. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/7166-retire-inert-fieldmeta-copies.md b/.changeset/7166-retire-inert-fieldmeta-copies.md index 123e69ed72..13af8122f4 100644 --- a/.changeset/7166-retire-inert-fieldmeta-copies.md +++ b/.changeset/7166-retire-inert-fieldmeta-copies.md @@ -58,3 +58,5 @@ a consumer READS a key; it does not establish that a given BAG is how the consum spec-declared against the installed `FieldSchema`, and the sibling pins (`relationalMetaCopySet-6711` / `-6874`) turn the retired `lookupFilters` into a live negative assertion on a fixture that still declares it. + +⚠️ **Dated note, 2026-09-30 — the lookup and user cells read `reference` alone — objectui#11070.** Later in this same release objectui#11070 (round 4) retired `reference_to` from `LookupCellRenderer` and `UserCellRenderer`, so the sets of keys those cells read, as listed above, no longer include it. The retirement of the three keys this change removed is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/7324-plugin-form-nameable-parameter-types.md b/.changeset/7324-plugin-form-nameable-parameter-types.md index fccfe94b6b..20d0ff84f5 100644 --- a/.changeset/7324-plugin-form-nameable-parameter-types.md +++ b/.changeset/7324-plugin-form-nameable-parameter-types.md @@ -42,3 +42,5 @@ requires a `type` on every field, and has no `reference_to` member — while `isCurrentUserSeedField` honours both `reference` and `reference_to` on purpose. Adopting it would have narrowed what these functions accept and dropped one of the two honoured spellings, not widened anything. + +⚠️ **Dated note, 2026-09-30 — `FieldDefaultsSchemaLike` pins three members now — objectui#11070.** "the four field members its rule reads (`defaultValue`, `type`, `reference`, `reference_to`)" and "honours both `reference` and `reference_to`" above held when this change landed. Later in this same release objectui#11070 (round 4) retired `reference_to` there: `isCurrentUserSeedField` reads the target as `reference` alone, and the published type no longer carries a `reference_to` member. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/paramtofield-reference-rule-derives-from-core-5312.md b/.changeset/paramtofield-reference-rule-derives-from-core-5312.md index a3aa770c31..887c7faa8f 100644 --- a/.changeset/paramtofield-reference-rule-derives-from-core-5312.md +++ b/.changeset/paramtofield-reference-rule-derives-from-core-5312.md @@ -31,3 +31,5 @@ so the two shared nothing and no gate could report a split; and the two member sets already differed, by `tree`. Lockstep now holds mechanically — the pin is on object identity (a spy on core's `has`), so a member-identical private copy fails where a value check would pass. + +⚠️ **Dated note, 2026-09-30 — the target key `paramToField` hands the widget is now `reference` — objectui#11070.** Later in this same release objectui#11070 (round 4) moved that key from `reference_to` to `reference`, the only spelling `LookupField` and `UserField` read. The rule this change converged is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. diff --git a/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx b/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx index db75007d21..93a9378a94 100644 --- a/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx +++ b/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx @@ -59,7 +59,7 @@ const OBJECT_SCHEMA = { { value: 'gift', label: 'Gift' }, ], }, - account: { type: 'lookup', label: 'Account', reference_to: 'showcase_account' }, + account: { type: 'lookup', label: 'Account', reference: 'showcase_account' }, agree: { type: 'boolean', label: 'Agree', required: true }, priority: { type: 'radio', diff --git a/packages/plugin-form/src/currentUserDefault.test.tsx b/packages/plugin-form/src/currentUserDefault.test.tsx index 466517e73d..80a9fb7f75 100644 --- a/packages/plugin-form/src/currentUserDefault.test.tsx +++ b/packages/plugin-form/src/currentUserDefault.test.tsx @@ -56,7 +56,8 @@ const OBJECT_SCHEMA = { fields: { title: { type: 'text', label: 'Title' }, applicant: { type: 'lookup', label: '申请人', reference: 'sys_user', defaultValue: 'current_user' }, - // objectui-types spelling of the reference key — must be honoured too. + // A second sys_user lookup. It spelled the target `reference_to` until + // objectui#11070 round 4 retired that spelling; `reference` is the only one read. reviewer: { type: 'lookup', label: 'Reviewer', reference: 'sys_user', defaultValue: 'current_user' }, // The dedicated user field type is the token's other legal home. owner_person: { type: 'user', label: 'Owner', defaultValue: 'current_user' }, diff --git a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx index 8d926f2531..816ac1a1b5 100644 --- a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx +++ b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx @@ -29,7 +29,7 @@ * consumer is `` — the READ-ONLY cell * (`ObjectGrid.tsx`, all three column-building paths). For a relational * column that resolves to `LookupCellRenderer`, which reads exactly - * `reference_to`, `reference`, `display_field`, `displayField` and + * `reference`, `displayField` and * `reference_field` — none of the four. * - The inline EDITOR is a different seam: `renderCellEditor` looks the field * up in the object schema itself and spreads the WHOLE def into the widget @@ -192,8 +192,10 @@ describe('objectui#7154 — the four picker keys reach the grid’s inline picke it('none of the four is on the relational copy set (the premise this file re-measures)', () => { // Control: the copy set is populated and holds the key objectui#6875 added, // so "does not contain" below is a reading and not an empty list. - // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); + // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect, + // and objectui#11070 round 4 to 2 by retiring `reference_to`. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(1); + expect(RELATIONAL_META_KEYS).toContain('reference'); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of ['multiple', 'allowCreate', 'lookupPageSize', 'dependsOn']) { expect(RELATIONAL_META_KEYS).not.toContain(key); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx index 7798aca60a..edc9c4ea12 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx @@ -160,9 +160,11 @@ describe('objectui#7166 — the three retired keys were never delivered by this // Control: the copy set is populated and still holds the key objectui#6875 // genuinely delivered, so "does not contain" below is a reading. // ⚠️ objectui#7155 shrank the copy set from 7 to 3 (the snake_case dialect - // was retired), so this floor moved with it. It is still a floor, not a + // was retired), and objectui#11070 round 4 to 2 (`reference_to` was + // retired), so this floor moved with each. It is still a floor, not a // formality: at 0 every `not.toContain` below would pass vacuously. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(1); + expect(RELATIONAL_META_KEYS).toContain('reference'); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of ['descriptionField', 'lookupColumns', 'lookupFilters']) { expect(RELATIONAL_META_KEYS).not.toContain(key); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index e7ed1a89e2..f57ca1711d 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -483,8 +483,10 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest it('⛔ objectui#7166 — the three retired keys stay OUT of the copy set, and objectui#7187 makes that DERIVED', () => { const retired = ['descriptionField', 'lookupColumns', 'lookupFilters']; // Control: the copy set is populated, so "not contained" is a reading. - // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); + // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect, + // and objectui#11070 round 4 to 2 by retiring `reference_to`. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(1); + expect(RELATIONAL_META_KEYS).toContain('reference'); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of retired) { expect( From fa2ed9c9c524750b2e4588ce728405c33c4a7711 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:49:45 +0000 Subject: [PATCH 3/5] docs(plugin-grid): the relational-metadata comment names reference and displayField PR #11243 has landed, so the three column-building comments in ObjectGrid are in scope; they named reference_to and display_field, neither of which the copy set carries any more. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 8cc013f0a1..a57d8746df 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3468,7 +3468,7 @@ export const ObjectGrid: React.FC = ({ if (objectDefField.format) fieldMeta.format = objectDefField.format; if (objectDefField.options) fieldMeta.options = translateOptions(schema.objectName, col.field, objectDefField.options); } - // Preserve relational metadata (reference_to, display_field, …) so + // Preserve relational metadata (reference, displayField, …) so // lookup CELLS resolve ids to names. ⛔ Not the inline picker — that // reads the schema def directly, see `renderCellEditor` (objectui#7154). applyRelationalMeta(fieldMeta, objectDefField as any); @@ -3697,7 +3697,7 @@ export const ObjectGrid: React.FC = ({ if (fieldDef.format) fieldMeta.format = fieldDef.format; if (fieldDef.options) fieldMeta.options = translateOptions(schema.objectName, fieldName, fieldDef.options); } - // Preserve relational metadata (reference_to, display_field, …) so + // Preserve relational metadata (reference, displayField, …) so // lookup CELLS resolve ids to names. ⛔ Not the inline picker — that // reads the schema def directly, see `renderCellEditor` (objectui#7154). applyRelationalMeta(fieldMeta, fieldDef as any); @@ -3872,7 +3872,7 @@ export const ObjectGrid: React.FC = ({ if (fieldDef.format) fieldMeta.format = fieldDef.format; if (fieldDef.options) fieldMeta.options = translateOptions(schema.objectName, fieldName, fieldDef.options); } - // Preserve relational metadata (reference_to, display_field, …) so + // Preserve relational metadata (reference, displayField, …) so // lookup CELLS resolve ids to names. ⛔ Not the inline picker — that // reads the schema def directly, see `renderCellEditor` (objectui#7154). applyRelationalMeta(fieldMeta, fieldDef as any); From 338b19df77a94d5e3754f7b6296f150e3a0a5129 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:57:39 +0000 Subject: [PATCH 4/5] docs: the form-field mirror, the console form page, the plugin-form page and two console pins name reference PR #11233 has landed, so the remaining tail is in scope: the form-field zod comment, FormPage's metadata comment, plugin-form.mdx's field-slot row and the shared-field-resolver pin now spell the target reference. deriveRelatedLists' docblock drops the reference_to it has not read since objectui#6837 half 2. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .../components/FormPage.sharedFieldResolver-10179.test.tsx | 2 +- apps/console/src/components/FormPage.tsx | 2 +- content/docs/plugins/plugin-form.mdx | 2 +- packages/app-shell/src/utils/deriveRelatedLists.ts | 2 +- packages/types/src/zod/form.zod.ts | 5 +++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/console/src/components/FormPage.sharedFieldResolver-10179.test.tsx b/apps/console/src/components/FormPage.sharedFieldResolver-10179.test.tsx index 1d80d83652..7916488d93 100644 --- a/apps/console/src/components/FormPage.sharedFieldResolver-10179.test.tsx +++ b/apps/console/src/components/FormPage.sharedFieldResolver-10179.test.tsx @@ -209,7 +209,7 @@ describe('ARITY axis — `select` + `multiple: true` is the multiselect widget', }); it('CONTROL — a multi-value lookup keeps the lookup widget, which handles both arities itself', async () => { - renderForm({ refs: { type: 'lookup', label: 'Refs', multiple: true, reference_to: 'x' } }, ['refs']); + renderForm({ refs: { type: 'lookup', label: 'Refs', multiple: true, reference: 'x' } }, ['refs']); expect(await widgetOf('refs')).toBe('lookup'); }); }); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index efd897f790..13d0256960 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -540,7 +540,7 @@ interface RenderableField { /** * The object field exactly as the server served it — every key, not only the * ones {@link ObjectFieldDef} names — so the widget handed this row finds - * what it reads off its metadata (a lookup's `reference_to`, a currency's + * what it reads off its metadata (a lookup's `reference`, a currency's * `currency`, a code editor's `language`). See {@link widgetFieldOf}. * Absent on a row built without an object field behind it. Typed as the * slice this file names; at runtime it is the whole served object. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index ddbc336fb1..98f4697dfe 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -98,7 +98,7 @@ has **23 declared keys**, and `name` is the only **required** one: `type` and | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries | | `span` | `'auto' \| 'full'` | relative field width, independent of the column count (preferred) | | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count | -| `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, … | +| `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference`, … | `FormField` also declares `[key: string]: any`, so a misspelled or invented key is **not** a compile error — it is simply outside the contract. Two that a diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts index 73e6bdccf8..c656b91db0 100644 --- a/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -7,7 +7,7 @@ * parent's record DETAIL page. Both intents live on the relationship in the * data model — not in a hand-authored page. * - * This helper scans every object for fields whose `reference`/`reference_to` + * This helper scans every object for fields whose `reference` * points back at the parent object and produces one related-list descriptor per * eligible FK. The detail page (`RecordDetailView`) feeds these into the * `record:related_list` renderers. (It also fed `DetailView.related`, which is diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index e28251297f..1301034f97 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1017,8 +1017,9 @@ export const FormFieldSchema = z.object({ rows: stripImportedDefaults(SpecFieldSchema).shape.rows, accept: stripImportedDefaults(SpecFieldSchema).shape.accept, dimensions: stripImportedDefaults(SpecFieldSchema).shape.dimensions, - // The spec spelling of a lookup / user field's target object; the legacy - // `reference_to` is read too and deliberately stays undeclared (objectui#11070). + // The spec spelling of a lookup / user field's target object, and the only + // one the widgets read; the retired `reference_to` is read by nothing and + // stays undeclared (objectui#11070). reference: stripImportedDefaults(SpecFieldSchema).shape.reference, min: stripImportedDefaults(SpecFieldSchema).shape.min, max: stripImportedDefaults(SpecFieldSchema).shape.max, From 0f66c31e51b55f20c2bd32fe6a81b3f0dfea7e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:25:37 +0000 Subject: [PATCH 5/5] test: the new reference_to refusal pins cast without any Keeps each touched test file at its base count of no-explicit-any warnings. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- packages/core/src/utils/__tests__/reference-keys.test.ts | 2 +- packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx | 2 +- packages/fields/src/complex-widgets.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/__tests__/reference-keys.test.ts b/packages/core/src/utils/__tests__/reference-keys.test.ts index 305e050eeb..950be4c1a2 100644 --- a/packages/core/src/utils/__tests__/reference-keys.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.test.ts @@ -70,7 +70,7 @@ describe('normalizeFieldReferenceKeys', () => { normalizeFieldReferenceKeys(normalizeFieldReferenceKeys(field)); expect(field).toEqual({ type: 'user', reference: 'sys_user' }); - const legacy = { type: 'lookup', reference_to: 'accounts' } as any; + const legacy: Record = { type: 'lookup', reference_to: 'accounts' }; normalizeFieldReferenceKeys(normalizeFieldReferenceKeys(legacy)); expect(legacy).toEqual({ type: 'lookup', reference_to: 'accounts', reference: 'accounts' }); }); diff --git a/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx b/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx index a540fbdc9a..c2e31c0836 100644 --- a/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx +++ b/packages/fields/src/__tests__/lookupCellReferenceKey.test.tsx @@ -64,7 +64,7 @@ describe('LookupCellRenderer — reference key resolution', () => { , ); diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index 91f553ca70..8d304f8096 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -566,7 +566,7 @@ describe('Complex & Relationship Widgets', () => { );