Skip to content

Commit ef1563e

Browse files
committed
Add field-reference-to-alias conversion: stored reference_to serves as reference
Protocol-18 ADR-0087 D2 entry canonicalizing the legacy objectql field-key dialect reference_to -> reference on object and object-extension fields, retiredFromLoadPath from day one (FieldSchema keeps its named rejection). Registered in the step-18 migration chain so os migrate meta (and --stored) rewrites old sources; serve-face end-to-end pins in metadata-protocol prove a stored row seeded around the Zod gate is served with only the canonical spelling across getMetaItems / getMetaItem / loadMetaFromDb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2
1 parent 6b285ec commit ef1563e

5 files changed

Lines changed: 310 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Add the ADR-0087 D2 conversion `field-reference-to-alias` (#13700, ui#6837 half 1):
6+
the legacy objectql field-key dialect `reference_to` canonicalizes to `reference` on
7+
object and object-extension fields. The entry is `retiredFromLoadPath` from day one —
8+
`FieldSchema` has always refused the key by name, and that authoring-surface rejection
9+
is unchanged — so the widened accept surface is exactly the two paths that meet data
10+
written around the Zod gate: stored `sys_metadata` rehydration (every serve seam now
11+
emits only the canonical spelling, the guarantee objectui needs before deleting its
12+
`reference ?? reference_to` fallback arms) and `os migrate meta` (including
13+
`--stored`), which now rewrites the key mechanically. House #4923 precedence: an
14+
already-canonical `reference` wins — a redundant twin is dropped, a disagreeing pair
15+
is kept for the author to reconcile.

‎packages/metadata-protocol/src/protocol.stored-conversions.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,60 @@ describe('getMetaItems — stored rows are served canonical (#3903)', () => {
147147
});
148148
});
149149

150+
// [#13700 — ui#6837 half 1] A stored object row carrying the legacy objectql
151+
// `reference_to` dialect. `FieldSchema` has always REFUSED the key (so this
152+
// row could only have been written by a seam that bypasses the parse — which
153+
// is exactly what the stub seeding reproduces), and objectui's
154+
// `reference ?? reference_to` fallback arms are being deleted on the strength
155+
// of this suite: the serve face must only ever emit `reference`.
156+
const legacyReferenceToRow = {
157+
type: 'object',
158+
name: 'crm_contact',
159+
metadata: {
160+
name: 'crm_contact',
161+
label: 'Contact',
162+
fields: {
163+
company_id: { type: 'lookup', label: 'Company', reference_to: 'crm_company' },
164+
title: { type: 'text', label: 'Title' },
165+
},
166+
},
167+
};
168+
169+
describe('getMetaItems — stored reference_to serves as reference (#13700, ui#6837 half 1)', () => {
170+
it('serves the lookup target under the canonical key ONLY', async () => {
171+
const { engine } = makeStubEngine([legacyReferenceToRow]);
172+
const protocol = new ObjectStackProtocolImplementation(engine);
173+
const res = await protocol.getMetaItems({ type: 'object' });
174+
const obj = (res.items as any[]).find((i) => i.name === 'crm_contact');
175+
expect(obj.fields.company_id.reference).toBe('crm_company');
176+
expect('reference_to' in obj.fields.company_id).toBe(false);
177+
// The non-relationship sibling rides through untouched.
178+
expect(obj.fields.title).toEqual({ type: 'text', label: 'Title' });
179+
});
180+
181+
it('single read is canonical too, with clean _diagnostics (chain-owned history is not "invalid")', async () => {
182+
const { engine } = makeStubEngine([legacyReferenceToRow]);
183+
const protocol = new ObjectStackProtocolImplementation(engine);
184+
const res: any = await protocol.getMetaItem({ type: 'object', name: 'crm_contact' });
185+
expect(res.item.fields.company_id.reference).toBe('crm_company');
186+
expect('reference_to' in res.item.fields.company_id).toBe(false);
187+
// Unconverted, this row reads as invalid metadata (the dialect key is
188+
// an `unrecognized_keys` rejection); converted first, it is valid —
189+
// the serve face OWNS this history rather than reporting it broken.
190+
expect(res.item._diagnostics?.valid).toBe(true);
191+
});
192+
193+
it('boot hydration registers the CONVERTED body', async () => {
194+
const { engine, registered } = makeStubEngine([legacyReferenceToRow]);
195+
const protocol = new ObjectStackProtocolImplementation(engine);
196+
const res = await protocol.loadMetaFromDb();
197+
expect(res).toEqual({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false });
198+
const obj = registered.find((r) => r.kind === 'object')!;
199+
expect(obj.body.fields.company_id.reference).toBe('crm_company');
200+
expect('reference_to' in obj.body.fields.company_id).toBe(false);
201+
});
202+
});
203+
150204
describe('getMetaItem — single stored read is canonical (#3903)', () => {
151205
it('returns the converted body with clean _diagnostics (chain-owned history is not "invalid")', async () => {
152206
const { engine } = makeStubEngine([legacyObjectRow]);

‎packages/spec/src/conversions/conversions.test.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,4 +1379,120 @@ describe('conversion layer (ADR-0087 D2)', () => {
13791379
expect(entry!.toMajor).toBe(17);
13801380
});
13811381
});
1382+
1383+
/**
1384+
* `field-reference-to-alias` (#13700 — ui#6837 half 1).
1385+
*
1386+
* The fixture pair pins before → after; what needs its own cover is the
1387+
* SPLIT this entry lives on: the authoring surface keeps REJECTING
1388+
* `reference_to` by name (the schema pin below), while the stored/migrate
1389+
* paths — the only paths that meet data written around the Zod gate —
1390+
* canonicalize it. Every conversion case here therefore runs
1391+
* `includeRetired` (the stored posture); the one case without it pins that
1392+
* the load seam does NOT quietly accept the dialect.
1393+
*/
1394+
describe('field-reference-to-alias (#13700 — the stored `reference_to` dialect canonicalizes)', () => {
1395+
const stackWith = (fields: Record<string, unknown>) => ({
1396+
objects: [{ name: 'crm_contact', label: 'Contact', fields }],
1397+
});
1398+
const fieldsOf = (stack: Record<string, unknown>) =>
1399+
(stack.objects as Array<{ fields: Record<string, Record<string, unknown>> }>)[0]!.fields;
1400+
const convert = (stack: Record<string, unknown>, notices?: ConversionNotice[]) =>
1401+
applyConversions(stack, { includeRetired: true, onNotice: notices ? (n) => notices.push(n) : undefined });
1402+
1403+
it('renames the dialect key on a lookup field (stored posture)', () => {
1404+
const notices: ConversionNotice[] = [];
1405+
const out = convert(
1406+
stackWith({ company_id: { type: 'lookup', label: 'Company', reference_to: 'crm_company' } }),
1407+
notices,
1408+
);
1409+
expect(fieldsOf(out).company_id).toEqual({ type: 'lookup', label: 'Company', reference: 'crm_company' });
1410+
expect(fieldsOf(out).company_id).not.toHaveProperty('reference_to');
1411+
expect(notices.filter((n) => n.conversionId === 'field-reference-to-alias')).toHaveLength(1);
1412+
});
1413+
1414+
it('emits a notice that names the surface, the site and the graduation', () => {
1415+
const notices: ConversionNotice[] = [];
1416+
convert(stackWith({ company_id: { type: 'lookup', reference_to: 'crm_company' } }), notices);
1417+
expect(notices).toHaveLength(1);
1418+
expect(notices[0]).toMatchObject({
1419+
conversionId: 'field-reference-to-alias',
1420+
surface: 'field.reference_to',
1421+
from: 'reference_to',
1422+
to: 'reference',
1423+
path: 'objects[0].fields.company_id.reference',
1424+
toMajor: 18,
1425+
retiresIn: 19,
1426+
});
1427+
});
1428+
1429+
it('keeps a `reference_to` that DISAGREES with an existing `reference` (both survive)', () => {
1430+
// The house precedence `renameKey` encodes since #4923: two different
1431+
// targets are the author's to reconcile — no rewrite, no notice.
1432+
const before = stackWith({
1433+
parent_id: { type: 'lookup', reference: 'crm_account', reference_to: 'crm_branch' },
1434+
});
1435+
const notices: ConversionNotice[] = [];
1436+
const out = convert(structuredClone(before), notices);
1437+
expect(out).toEqual(before);
1438+
expect(notices).toHaveLength(0);
1439+
});
1440+
1441+
it('is idempotent — the canonical shape is not a match', () => {
1442+
const before = stackWith({ company_id: { type: 'lookup', reference: 'crm_company' } });
1443+
const out = applyConversions(before, { includeRetired: true });
1444+
expect(out).toBe(before);
1445+
});
1446+
1447+
it('does NOT run on the plain load posture — the authoring surface keeps its rejection', () => {
1448+
// `retiredFromLoadPath`: without `includeRetired` (the
1449+
// `normalizeStackInput` posture) the dialect is untouched here, so the
1450+
// schema's named rejection stays the ONLY authoring-surface answer —
1451+
// accepting it quietly at load would widen the authoring surface, which
1452+
// is exactly what #11567/#13222 spent their refusal doors closing.
1453+
const before = stackWith({ company_id: { type: 'lookup', reference_to: 'crm_company' } });
1454+
const notices: ConversionNotice[] = [];
1455+
const out = applyConversions(structuredClone(before), { onNotice: (n) => notices.push(n) });
1456+
expect(out).toEqual(before);
1457+
expect(notices).toHaveLength(0);
1458+
});
1459+
1460+
it('reaches a STORED object row, so data at rest canonicalizes on rehydration', () => {
1461+
// `applyConversionsToStoredItem` wraps the row as `{ objects: [row] }`
1462+
// (#3903) and pins `includeRetired` — the serve-face guarantee ui#6837
1463+
// half 2 waits on lives on this seam.
1464+
const notices: ConversionNotice[] = [];
1465+
const row = {
1466+
name: 'crm_contact',
1467+
label: 'Contact',
1468+
fields: { company_id: { type: 'lookup', label: 'Company', reference_to: 'crm_company' } },
1469+
};
1470+
const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) });
1471+
expect(out.fields.company_id).toEqual({ type: 'lookup', label: 'Company', reference: 'crm_company' });
1472+
expect(notices.map((n) => n.conversionId)).toEqual(['field-reference-to-alias']);
1473+
});
1474+
1475+
/**
1476+
* The premise pin: this conversion is only correct while `FieldSchema`
1477+
* declares exactly one relationship spelling and refuses the dialect BY
1478+
* NAME. If the alias ever becomes a declared key, the entry above turns
1479+
* from history-replay into a silent overwrite of live data — this is the
1480+
* test that fails first.
1481+
*/
1482+
it('the canonical field schema declares `reference` and rejects `reference_to` with the rename', async () => {
1483+
const { FieldSchema } = await import('../data/field.zod.js');
1484+
expect(
1485+
FieldSchema.safeParse({ name: 'company_id', type: 'lookup', reference: 'crm_company' }).success,
1486+
).toBe(true);
1487+
1488+
const rejected = FieldSchema.safeParse({ name: 'company_id', type: 'lookup', reference_to: 'crm_company' });
1489+
expect(rejected.success).toBe(false);
1490+
const issue = rejected.error!.issues.find((i) => i.code === 'unrecognized_keys');
1491+
expect(issue).toBeDefined();
1492+
expect(issue!.message).toContain('`reference_to`');
1493+
// The rename the conversion performs, said out loud to the author who is
1494+
// typing the key right now.
1495+
expect(issue!.message).toContain('`reference`');
1496+
});
1497+
});
13821498
});

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8320,6 +8320,118 @@ const formViewOptionDefaultRemoved: MetadataConversion = {
83208320
},
83218321
};
83228322

8323+
/**
8324+
* Field `reference_to` → `reference` (protocol 18, #13700 — ui#6837 half 1).
8325+
*
8326+
* One concept — the target object of a `lookup`/`master_detail` field — with
8327+
* two spellings, one per layer. `FieldSchema` declares `reference` and has
8328+
* always REFUSED `reference_to` by name (`unrecognized_keys`, carrying the
8329+
* rename: "Did you mean `reference_to` → `reference`?"); `reference_to` is the
8330+
* legacy objectql runtime dialect, which is exactly the spelling a row written
8331+
* by a seam that goes around Zod carries (`registerObject` deliberately skips
8332+
* the parse, #3896). The consumer-side reads of the dialect have been retired
8333+
* one by one — the SQL DDL door (#11567), the Mongo schema-sync door (#13222),
8334+
* the verify deriver (#13250) — each replaced by a loud refusal. What none of
8335+
* those doors reach is metadata AT REST.
8336+
*
8337+
* **Why a conversion when the schema already rejects** — the load-bearing
8338+
* half, and the reason this card blocks ui#6837. A stored `sys_metadata` row
8339+
* is runtime JSON: it is served without ever meeting the Zod gate, and the
8340+
* rejection surface does not reach back in time to rows already written. Today
8341+
* objectui still carries `reference ?? reference_to` fallback arms, so such a
8342+
* row still renders; ui#6837 half 2 deletes those arms ("the backend's
8343+
* metadata must be right; the frontend executes the protocol" — maintainer
8344+
* ruling on that card). Without this entry the deletion would silently degrade
8345+
* every at-rest `reference_to` lookup to a targetless picker — a raw-UUID
8346+
* text box, the #3405 failure shape, with no diagnostic anywhere. This entry
8347+
* makes the serve face own that history: `applyConversionsToStoredItem`
8348+
* replays the FULL chain (retired entries included) on every stored-row
8349+
* rehydration, so the wire only ever carries `reference`.
8350+
*
8351+
* **`retiredFromLoadPath` from day one** — the pre-launch one-step-rename
8352+
* shape ({@link MetadataConversion.retiredFromLoadPath}): the key never had a
8353+
* live authoring window to expire, because the schema never accepted it. The
8354+
* authoring surface keeps teaching with its named rejection; this entry covers
8355+
* the two paths that serve or rewrite EXISTING data — stored rehydration and
8356+
* `os migrate meta`. The DDL doors above keep guarding the third path
8357+
* (metadata handed straight to a driver, around both the gate and the stored
8358+
* pass); they are downstream of this entry, not replaced by it.
8359+
*
8360+
* Deliberately NOT converted here: `referenceTo` (camelCase). That spelling is
8361+
* objectui's *resolved action-param* dialect — a different surface with its
8362+
* own alias table (`ACTION_PARAM_KEY_ALIASES`, `ui/action.zod.ts`) — and it is
8363+
* not the spelling the objectql runtime wrote into stored object rows. Widening
8364+
* this entry to a spelling with no measured at-rest population would be scope
8365+
* invented at conversion time; the schema's rejection covers it either way.
8366+
*
8367+
* Precedence is the house rule {@link renameKey} encodes (#4923) and nothing
8368+
* new: an already-canonical `reference` WINS — a redundant twin is dropped, a
8369+
* DISAGREEING pair is left for the author to reconcile rather than the loader
8370+
* picking a target. Covers object fields and object-extension fields: the same
8371+
* `FieldSchema`, so the same dialect ({@link fieldConditionalRequiredToRequiredWhen}
8372+
* is the precedent, one protocol back).
8373+
*/
8374+
const fieldReferenceToAlias: MetadataConversion = {
8375+
id: 'field-reference-to-alias',
8376+
toMajor: 18,
8377+
retiredFromLoadPath: true,
8378+
surface: 'field.reference_to',
8379+
summary:
8380+
"field key 'reference_to' → 'reference' (the legacy objectql runtime dialect for a "
8381+
+ "lookup/master_detail target; stored rows must serve the canonical spelling before "
8382+
+ "objectui deletes its `reference ?? reference_to` fallback arms — ui#6837 half 1)",
8383+
apply(stack, emit) {
8384+
const withObjects = mapObjectFieldsKey(stack, 'objects', 'reference_to', 'reference', emit);
8385+
return mapObjectFieldsKey(withObjects, 'objectExtensions', 'reference_to', 'reference', emit);
8386+
},
8387+
fixture: {
8388+
before: {
8389+
objects: [{
8390+
name: 'crm_contact',
8391+
label: 'Contact',
8392+
fields: {
8393+
// The dialect spelling — the shape a legacy stored row carries.
8394+
company_id: { type: 'lookup', label: 'Company', reference_to: 'crm_company' },
8395+
// Canonical only: untouched.
8396+
owner_id: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
8397+
// Both spellings, SAME target: the redundant twin goes (#4923).
8398+
account_id: { type: 'lookup', label: 'Account', reference: 'crm_account', reference_to: 'crm_account' },
8399+
// Both spellings, DIFFERENT targets: kept, so the author reconciles
8400+
// the two rather than the loader picking where the field points.
8401+
parent_id: { type: 'lookup', label: 'Parent', reference: 'crm_account', reference_to: 'crm_branch' },
8402+
},
8403+
}],
8404+
objectExtensions: [{
8405+
extend: 'crm_task',
8406+
fields: {
8407+
project_id: { type: 'master_detail', label: 'Project', reference_to: 'crm_project' },
8408+
},
8409+
}],
8410+
},
8411+
after: {
8412+
objects: [{
8413+
name: 'crm_contact',
8414+
label: 'Contact',
8415+
fields: {
8416+
company_id: { type: 'lookup', label: 'Company', reference: 'crm_company' },
8417+
owner_id: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
8418+
account_id: { type: 'lookup', label: 'Account', reference: 'crm_account' },
8419+
parent_id: { type: 'lookup', label: 'Parent', reference: 'crm_account', reference_to: 'crm_branch' },
8420+
},
8421+
}],
8422+
objectExtensions: [{
8423+
extend: 'crm_task',
8424+
fields: {
8425+
project_id: { type: 'master_detail', label: 'Project', reference: 'crm_project' },
8426+
},
8427+
}],
8428+
},
8429+
// company_id (rename), account_id (redundant twin dropped), project_id
8430+
// (extension rename). owner_id and the disagreeing parent_id emit nothing.
8431+
expectedNotices: 3,
8432+
},
8433+
};
8434+
83238435
export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
83248436
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
83258437
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
@@ -8407,6 +8519,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
84078519
objectGridDefaultSortRemoved,
84088520
permissionAllowRestorePurgeRemoved,
84098521
formViewOptionDefaultRemoved,
8522+
fieldReferenceToAlias,
84108523
],
84118524
};
84128525

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5304,7 +5304,17 @@ const step18: MigrationStep = {
53045304
'overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager ' +
53055305
'configs are not stack collection members, the additionalTypes reasoning). The ' +
53065306
'customization that actually ships: ADR-0005\'s org overlay and ADR-0126\'s ' +
5307-
'packaged-metadata model.',
5307+
'packaged-metadata model. ' +
5308+
'Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → ' +
5309+
'`reference` on lookup/master_detail fields (#13700, ui#6837 half 1). `FieldSchema` ' +
5310+
'has always refused `reference_to` by name, but stored `sys_metadata` rows written by ' +
5311+
'seams that bypass the parse still carry it, held up today only by objectui\'s ' +
5312+
'`reference ?? reference_to` fallback arms — which ui#6837 half 2 deletes. The ' +
5313+
'mechanical conversion renames the key (house #4923 precedence: a canonical ' +
5314+
'`reference` wins, a disagreeing pair is kept for the author), replays on every ' +
5315+
'stored-row rehydration so the serve face only ever emits the canonical spelling, ' +
5316+
'and `os migrate meta` rewrites old sources; the authoring-surface rejection with ' +
5317+
'its rename prescription is unchanged.',
53085318
conversionIds: [
53095319
'field-malformed-scale-precision-removed',
53105320
'record-chatter-position-vocabulary',
@@ -5320,6 +5330,7 @@ const step18: MigrationStep = {
53205330
'object-grid-default-sort-removed',
53215331
'permission-allow-restore-purge-removed',
53225332
'form-view-option-default-removed',
5333+
'field-reference-to-alias',
53235334
],
53245335
semantic: [
53255336
// One file per entry under `entries/semantic/`, concatenated here sorted by

0 commit comments

Comments
 (0)