Skip to content

Commit 89b0ee6

Browse files
os-zhuangclaude
andauthored
fix(core+data-objectstack+app-shell): canonicalize reference/reference_to at the schema chokepoints (#2407) (#2598)
Backend object schemas key a relational field's target as `reference` (ObjectStack convention: showcase_project.account → { type: 'lookup', reference: 'showcase_account' }), while ObjectUI types and most consumers read `reference_to`. PR #2587 fixed HeaderHighlight case-by-case; this closes the class of bug at the chokepoints instead. - @object-ui/core: new normalizeFieldReferenceKeys / normalizeSchemaReferenceKeys — stamp BOTH snake_case keys whenever either (or legacy camelCase referenceTo) is present; in-place, idempotent, never overwrites existing keys. - ObjectStackAdapter.getObjectSchema: run the pass on the cached schema (same seam as applyFieldWidgetOverrides) — covers every dataSource.getObjectSchema consumer. - app-shell MetadataProvider: run the pass on `object` items at ingestion — covers every useMetadata().objects consumer. Remaining single-key readers confirmed against the live showcase schema and fixed with the dual-key fallback (they can receive schemas that bypass the chokepoints — mock/api data sources, spec-authored configs): - ObjectGantt quick-filters: lookup/master_detail option domain never loaded for reference-keyed schemas (refObject was undefined). - RecordDetailDrawer: lookup cells lost reference_to, so id→name never resolved in the quick-look drawer. - ReportView column hydration: read referenceTo/reference?.to/target — none of which exist on store object defs; lookup report columns never got their target. - attachInlineSubforms (mirror direction): parent resolved only from `reference`, missing reference_to-keyed defs. - sectionFields spec override (mirror direction): author-supplied reference_to on a form field spec was silently dropped. - metadataConverters.toFieldDefinition: referenceTo now falls back to the served `reference` key. Unit tests cover the utility, the adapter pass, gantt quick-filter options, inline subforms, and the section-field override. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2ded18c commit 89b0ee6

14 files changed

Lines changed: 286 additions & 7 deletions

File tree

‎packages/app-shell/src/providers/MetadataProvider.merge.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,24 @@ describe('attachInlineSubforms — relationship-level inlineEdit', () => {
144144
const plain = [{ name: 'a', fields: { x: { type: 'text' } } }];
145145
expect(attachInlineSubforms(plain)).toBe(plain);
146146
});
147+
148+
it('resolves the parent from a reference_to-keyed field (ObjectUI convention)', () => {
149+
// Served schemas use `reference`; ObjectUI-authored / normalized defs use
150+
// `reference_to` — the parent resolution must accept either key.
151+
const out = attachInlineSubforms([
152+
{ name: 'order', fields: { number: { type: 'text' } } },
153+
{
154+
name: 'order_line',
155+
fields: {
156+
qty: { type: 'number' },
157+
order: { type: 'master_detail', reference_to: 'order', inlineEdit: true },
158+
},
159+
},
160+
]);
161+
const order = out.find((o) => o.name === 'order')!;
162+
expect(order.form?.subforms?.[0]).toMatchObject({
163+
childObject: 'order_line',
164+
relationshipField: 'order',
165+
});
166+
});
147167
});

‎packages/app-shell/src/providers/MetadataProvider.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type ReactNode,
88
} from 'react';
99
import { type ObjectStackAdapter } from '@object-ui/data-objectstack';
10+
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
1011
import { resolveInlineMode } from '@object-ui/plugin-form';
1112
import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataState } from '@object-ui/react';
1213
import { usePreviewDrafts } from '../preview/PreviewModeContext';
@@ -257,7 +258,8 @@ export function attachInlineSubforms(objects: any[]): any[] {
257258
const d: any = fdef;
258259
if (!fname || !d?.inlineEdit) continue;
259260
if (d.type !== 'master_detail' && d.type !== 'lookup') continue;
260-
const parent = d.reference;
261+
// Served schemas use `reference`; ObjectUI-authored defs use `reference_to`.
262+
const parent = d.reference ?? d.reference_to;
261263
if (!parent) continue;
262264
(inlineByParent[parent] ||= []).push({
263265
childObject: child.name,
@@ -400,6 +402,13 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
400402
const promise = fetchItems
401403
.then((res: unknown) => {
402404
const items = extractItems(res);
405+
// Canonicalize `reference` ↔ `reference_to` on object field defs at
406+
// ingestion (the store-side choke point, mirroring the adapter's
407+
// getObjectSchema pass) so `useMetadata().objects` consumers can
408+
// read either key (#2407 / PR #2587). Idempotent, in place.
409+
if (type === 'object') {
410+
for (const it of items) normalizeSchemaReferenceKeys(it);
411+
}
403412
entry.items = items;
404413
entry.status = 'ready';
405414
entry.error = null;

‎packages/app-shell/src/utils/metadataConverters.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export interface MetadataField {
5858
track_history?: boolean;
5959
indexed?: boolean;
6060
reference_to?: string;
61+
/** ObjectStack-convention key for the relational target (what the server serves). */
62+
reference?: string;
6163
referenceTo?: string;
6264
formula?: string;
6365
}
@@ -125,7 +127,7 @@ export function toFieldDefinition(field: MetadataField, index: number): Designer
125127
externalId: field.externalId || false,
126128
trackHistory: field.trackHistory || field.track_history || false,
127129
indexed: field.indexed || false,
128-
referenceTo: field.reference_to || field.referenceTo || undefined,
130+
referenceTo: field.reference_to || field.reference || field.referenceTo || undefined,
129131
formula: field.formula || undefined,
130132
};
131133
}

‎packages/app-shell/src/views/ReportView.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,14 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {
405405
if (out.type === undefined && meta.type !== undefined) out.type = meta.type;
406406
if (out.options === undefined && Array.isArray(meta.options)) out.options = meta.options;
407407
if (out.referenceTo === undefined) {
408-
const ref = meta.referenceTo || meta.reference?.to || meta.target;
408+
// Metadata-store object defs key the lookup target as `reference`
409+
// (string, ObjectStack convention); `reference_to` covers normalized /
410+
// ObjectUI-authored defs (#2407 / PR #2587).
411+
const ref =
412+
meta.reference_to ||
413+
meta.referenceTo ||
414+
(typeof meta.reference === 'string' ? meta.reference : meta.reference?.to) ||
415+
meta.target;
409416
if (ref) out.referenceTo = ref;
410417
}
411418
if (out.label === undefined && meta.label) out.label = meta.label;

‎packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ export * from './utils/chart-series.js';
4848
export * from './utils/dataset-format.js';
4949
export * from './utils/record-title.js';
5050
export * from './utils/export-filename.js';
51+
export * from './utils/reference-keys.js';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
normalizeFieldReferenceKeys,
12+
normalizeSchemaReferenceKeys,
13+
} from '../reference-keys';
14+
15+
describe('normalizeFieldReferenceKeys', () => {
16+
it('stamps reference_to from the ObjectStack-convention `reference` key', () => {
17+
// Exactly what the showcase backend serves for showcase_project.account.
18+
const field = { type: 'lookup', reference: 'showcase_account' } as any;
19+
normalizeFieldReferenceKeys(field);
20+
expect(field.reference_to).toBe('showcase_account');
21+
expect(field.reference).toBe('showcase_account');
22+
});
23+
24+
it('stamps `reference` from a reference_to-keyed (ObjectUI-authored) field', () => {
25+
const field = { type: 'master_detail', reference_to: 'showcase_project' } as any;
26+
normalizeFieldReferenceKeys(field);
27+
expect(field.reference).toBe('showcase_project');
28+
});
29+
30+
it('accepts legacy camelCase referenceTo as a source', () => {
31+
const field = { type: 'lookup', referenceTo: 'accounts' } as any;
32+
normalizeFieldReferenceKeys(field);
33+
expect(field.reference_to).toBe('accounts');
34+
expect(field.reference).toBe('accounts');
35+
});
36+
37+
it('never overwrites keys that are already set', () => {
38+
// Divergent keys are broken metadata; first-wins (reference_to) but the
39+
// existing `reference` value must not be clobbered.
40+
const field = { reference_to: 'a', reference: 'b' } as any;
41+
normalizeFieldReferenceKeys(field);
42+
expect(field.reference_to).toBe('a');
43+
expect(field.reference).toBe('b');
44+
});
45+
46+
it('is a no-op for non-relational fields, empty targets, and non-objects', () => {
47+
const plain = { type: 'text' } as any;
48+
normalizeFieldReferenceKeys(plain);
49+
expect('reference_to' in plain).toBe(false);
50+
expect('reference' in plain).toBe(false);
51+
52+
const empty = { type: 'lookup', reference: '' } as any;
53+
normalizeFieldReferenceKeys(empty);
54+
expect('reference_to' in empty).toBe(false);
55+
56+
expect(normalizeFieldReferenceKeys(null)).toBeNull();
57+
expect(normalizeFieldReferenceKeys('lookup' as any)).toBe('lookup');
58+
});
59+
60+
it('is idempotent', () => {
61+
const field = { type: 'user', reference: 'sys_user' } as any;
62+
normalizeFieldReferenceKeys(normalizeFieldReferenceKeys(field));
63+
expect(field).toEqual({ type: 'user', reference: 'sys_user', reference_to: 'sys_user' });
64+
});
65+
});
66+
67+
describe('normalizeSchemaReferenceKeys', () => {
68+
it('normalizes every field of a map-shaped schema in place', () => {
69+
const schema = {
70+
name: 'showcase_project',
71+
fields: {
72+
name: { type: 'text' },
73+
account: { type: 'lookup', reference: 'showcase_account' },
74+
team_members: { type: 'user', reference: 'sys_user', multiple: true },
75+
},
76+
} as any;
77+
const out = normalizeSchemaReferenceKeys(schema);
78+
expect(out).toBe(schema); // mutates the cached object, not a copy
79+
expect(schema.fields.account.reference_to).toBe('showcase_account');
80+
expect(schema.fields.team_members.reference_to).toBe('sys_user');
81+
expect('reference_to' in schema.fields.name).toBe(false);
82+
});
83+
84+
it('normalizes array-shaped field containers', () => {
85+
const schema = {
86+
fields: [
87+
{ name: 'project', type: 'master_detail', reference: 'showcase_project' },
88+
{ name: 'title', type: 'text' },
89+
],
90+
} as any;
91+
normalizeSchemaReferenceKeys(schema);
92+
expect(schema.fields[0].reference_to).toBe('showcase_project');
93+
});
94+
95+
it('tolerates schemas without fields and non-object input', () => {
96+
expect(normalizeSchemaReferenceKeys(null)).toBeNull();
97+
expect(normalizeSchemaReferenceKeys({ name: 'x' } as any)).toEqual({ name: 'x' });
98+
expect(normalizeSchemaReferenceKeys({ fields: 'nope' } as any)).toEqual({ fields: 'nope' });
99+
});
100+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* ObjectUI — reference-key canonicalization
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Backend object schemas follow the ObjectStack convention and name a
11+
* relational field's target object `reference`
12+
* (e.g. `{ type: 'lookup', reference: 'showcase_account' }`), while ObjectUI's
13+
* types — and most in-repo consumers — historically read `reference_to`
14+
* (some legacy configs also carry camelCase `referenceTo`). A consumer that
15+
* reads only one key silently loses the relation under the other convention:
16+
* the exact bug HeaderHighlight had (#2407 / PR #2587), where a served
17+
* `reference`-keyed lookup rendered a raw id.
18+
*
19+
* `normalizeFieldReferenceKeys` stamps BOTH snake_case keys onto the field
20+
* definition whenever any of the three spellings is present, so downstream
21+
* reads work regardless of which single key they check. It mutates the field
22+
* in place — the ObjectStack adapter caches the schema object and re-serves
23+
* it, so the one pass must stick — and is idempotent. Keys that are already
24+
* set are never overwritten.
25+
*/
26+
export function normalizeFieldReferenceKeys<T>(fieldDef: T): T {
27+
if (!fieldDef || typeof fieldDef !== 'object') return fieldDef;
28+
const f = fieldDef as Record<string, unknown>;
29+
const target = f.reference_to ?? f.reference ?? f.referenceTo;
30+
if (target == null || target === '') return fieldDef;
31+
if (f.reference_to === undefined) f.reference_to = target;
32+
if (f.reference === undefined) f.reference = target;
33+
return fieldDef;
34+
}
35+
36+
/**
37+
* Apply {@link normalizeFieldReferenceKeys} to every field of an object
38+
* schema. Accepts both field-container shapes the metadata API serves —
39+
* a `name → def` map or an array of defs — and tolerates anything else by
40+
* returning the input untouched. Mutates in place; idempotent.
41+
*
42+
* This is meant to run at the choke point where object schemas enter the
43+
* client (`ObjectStackAdapter.getObjectSchema`, the app-shell metadata
44+
* provider) so per-consumer dual-key fallbacks can't drift.
45+
*/
46+
export function normalizeSchemaReferenceKeys<T>(schema: T): T {
47+
const fields =
48+
schema && typeof schema === 'object' ? (schema as { fields?: unknown }).fields : null;
49+
if (!fields || typeof fields !== 'object') return schema;
50+
const defs = Array.isArray(fields) ? fields : Object.values(fields);
51+
for (const def of defs) normalizeFieldReferenceKeys(def);
52+
return schema;
53+
}

‎packages/data-objectstack/src/getObjectSchema.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,38 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
7575
expect(Object.keys(schema.fields)).toEqual(['x']);
7676
});
7777

78+
it('canonicalizes the ObjectStack-convention `reference` key onto `reference_to` (and back)', async () => {
79+
// The server names a relational field's target `reference`
80+
// (showcase_project.account → { type: 'lookup', reference: 'showcase_account' }),
81+
// while most consumers read `reference_to` (#2407 / PR #2587). getObjectSchema
82+
// is the choke point every schema read goes through, so both keys must come
83+
// back stamped — regardless of which convention the served schema used.
84+
const { fetchImpl } = makeFetch({
85+
name: 'showcase_project',
86+
fields: {
87+
name: { type: 'text' },
88+
account: { type: 'lookup', reference: 'showcase_account' },
89+
team_members: { type: 'user', reference: 'sys_user', multiple: true },
90+
legacy: { type: 'master_detail', reference_to: 'showcase_order' },
91+
},
92+
});
93+
const adapter = new ObjectStackAdapter({
94+
baseUrl: 'http://localhost:3000',
95+
autoReconnect: false,
96+
fetch: fetchImpl as any,
97+
});
98+
99+
const schema: any = await adapter.getObjectSchema('showcase_project');
100+
101+
expect(schema.fields.account.reference_to).toBe('showcase_account');
102+
expect(schema.fields.team_members.reference_to).toBe('sys_user');
103+
// Mirror direction: `.reference`-only readers (e.g. attachInlineSubforms)
104+
// must also see the target on a reference_to-authored schema.
105+
expect(schema.fields.legacy.reference).toBe('showcase_order');
106+
// Non-relational fields stay untouched.
107+
expect('reference_to' in schema.fields.name).toBe(false);
108+
});
109+
78110
it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
79111
// Inert-metadata regression guard. The inline select editor filters its
80112
// options by the object's `state_machine` validation (objectui#2110). That

‎packages/data-objectstack/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type {
2323
ImportJobUndoResult,
2424
ListImportJobsOptions,
2525
} from '@object-ui/types';
26-
import { convertFiltersToAST } from '@object-ui/core';
26+
import { convertFiltersToAST, normalizeSchemaReferenceKeys } from '@object-ui/core';
2727
import { MetadataCache } from './cache/MetadataCache';
2828
import {
2929
ObjectStackError,
@@ -1642,6 +1642,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
16421642
this.fetchObjectSchemaFresh(objectName),
16431643
);
16441644

1645+
// Canonicalize the relational-target key: the server names it
1646+
// `reference` (ObjectStack convention) while most consumers read
1647+
// `reference_to` (#2407 / PR #2587). Stamping both here — the choke
1648+
// point every schema read goes through — means no per-consumer
1649+
// dual-key fallback can drift. Idempotent on the cached object.
1650+
normalizeSchemaReferenceKeys(schema);
1651+
16451652
// ADR-0056 P2 (epic #2398): stamp structured-widget hints onto specific
16461653
// platform fields. This is the single choke point both the record form
16471654
// (ObjectForm) and the detail view (DetailView/DetailSection) read the

‎packages/plugin-detail/src/RecordDetailDrawer.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ export function RecordDetailDrawer({
245245
precision: def.precision,
246246
scale: (def as any).scale,
247247
format: def.format,
248-
reference_to: def.reference_to ?? def.referenceTo ?? def.target,
248+
// Served schemas key the target as `reference` (ObjectStack
249+
// convention, #2407); the drawer can receive a raw schema from any
250+
// DataSource, so resolve every spelling.
251+
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
249252
reference_field: def.reference_field ?? def.referenceField,
250253
required: def.required,
251254
validation: def.validation,

0 commit comments

Comments
 (0)