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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/referenceto-carryover-8896.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@object-ui/plugin-designer": patch
"@object-ui/app-shell": patch
"@object-ui/types": patch
---

Keep a relationship target stored only as the retired `referenceTo` spelling (objectui#8896)

Two field-IO sites deleted the retired `referenceTo` key without keeping its
value, so an object whose lookup or master-detail target survived only under the
pre-objectui#6041 spelling lost the target on the way through:

- The Field Designer's carried-through half (fields whose stored type the
designer cannot author, objectui#8060) re-emits the stored document verbatim
with no read door in front of it. The strip took the target and the relationship
guard then refused the whole object's save — including a save the author
triggered by editing an entirely different field, on a page that renders the
offending field read-only, so its "Pick the target object" advice named a
control that does not exist there.
- The object designer's single read door for `draft.fields` deleted the target on
load, leaving the target editor empty and committing the loss on the next save.

Both sites now lift the value onto the spec spelling `reference` before dropping
the retired key. The retired key still never reaches the wire, a live `reference`
is never overwritten by a stale legacy value, and a field with no usable target
under either spelling is still refused.
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#8896 — the metadata-admin read door loses a relationship target
* stored only under the retired `referenceTo` spelling.
*
* ## The claim this file falsifies, and where it is written
*
* `RETIRED_FIELD_KEYS`' own docblock in `object-fields-io.ts`:
*
* > Nothing is lost on the way out: where the spec has a spelling for the
* > concept it is a SEPARATE key (`reference`, `system`) that is NOT stripped
* > and rides through untouched, which is what lets the designer read it back.
*
* and the tombstone registry's entry for the key
* (`types/src/internal/retired-field-keys.ts`):
*
* > The strip loses nothing — every write path re-emits the designer's target
* > under `reference`, and the read door's writers never emit the retired
* > spelling.
*
* Both hold only when the draft ALSO carries the spec spelling. A draft holding
* the target ONLY as `referenceTo` has it deleted on load with nothing left
* behind: `readFields` is the single read door for `draft.fields` across the
* whole object designer, and `writeFields` writes each def back verbatim, so
* the loss is committed on the next save. `ObjectFieldInspector`'s target
* editor reads `def.reference` and renders EMPTY, and every reader downstream
* of this door reads the canonical key alone — `resolveReferenceTo` was
* deliberately reduced to `def.reference` by objectui#6837's ruling that
* protocol normalization belongs on the server and the readers just execute
* the protocol.
*
* ## What the repair is, and the two things it is NOT
*
* The retired KEY is still stripped — it must be: `FieldSchema` refuses
* `referenceTo` by name, so carrying it out is the hard 422 the strip exists
* to prevent. What changes is that the VALUE is recovered into the spec
* spelling first, which is the same "recover at the door, read canonically
* everywhere else" shape objectui#6837 settled on.
*
* ⛔ NOT a per-reader fallback arm. objectui#6837 deleted those on a maintainer
* ruling and this file does not bring one back — the pins below assert on what
* comes OUT of the door, and `resolveReferenceTo` keeps reading `reference`
* alone.
*
* ⛔ NOT a generic `specEquivalent` migration driven off the registry. The
* registry says so itself ("Documentation for the reader, NEVER an instruction
* to migrate a value mechanically"), because objectui#6043 refused exactly that
* for `formula`, whose value is a LANGUAGE and not a name.
*/

import { describe, it, expect } from 'vitest';
import { FieldSchema } from '@objectstack/spec/data';
import { readFields, writeFields, RETIRED_FIELD_KEYS } from './object-fields-io';
import { resolveReferenceTo } from '../inspectors/useDatasetFields';

/** Round-trip a `draft.fields` value the way every designer write path does. */
function roundTrip(fields: unknown) {
return writeFields(readFields(fields));
}

const unrecognizedKeys = (result: ReturnType<typeof FieldSchema.safeParse>): string[] =>
result.success
? []
: result.error.issues
.filter((i) => i.code === 'unrecognized_keys')
.flatMap((i) => (i as unknown as { keys: string[] }).keys);

describe('the instrument', () => {
it('the installed `FieldSchema` refuses `referenceTo` by name and accepts `reference`', () => {
// Without this the recovery below could be "fixed" by simply carrying the
// retired key through, which is the 422 this door exists to prevent.
expect(unrecognizedKeys(FieldSchema.safeParse({ type: 'lookup', label: 'L', referenceTo: 'account' })))
.toContain('referenceTo');
expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: 'account' }).success).toBe(true);
});

it('`referenceTo` is one of the keys this door strips', () => {
expect([...RETIRED_FIELD_KEYS]).toContain('referenceTo');
});
});

describe('objectui#8896 · a target stored only as `referenceTo` survives the read door', () => {
it('record-shaped draft: the value arrives under the spec spelling, the retired key does not', () => {
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', referenceTo: 'account' },
}) as Record<string, Record<string, unknown>>;

expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner', reference: 'account' });
expect('referenceTo' in out.owner_id).toBe(false);
});

it('array-shaped draft: the same, through the other branch of the door', () => {
const out = roundTrip([
{ name: 'owner_id', type: 'lookup', label: 'Owner', referenceTo: 'account' },
]) as Array<Record<string, unknown>>;

expect(out[0]).toEqual({ name: 'owner_id', type: 'lookup', label: 'Owner', reference: 'account' });
expect('referenceTo' in out[0]).toBe(false);
});

it('the def the designer reads carries the target, so the inspector renders it', () => {
// The consumer-side statement of the same fact: every reader downstream of
// this door reads `reference` alone, by objectui#6837's ruling.
const view = readFields({ owner_id: { type: 'master_detail', label: 'Parent', referenceTo: 'invoice' } });
expect(view.entries[0].def.reference).toBe('invoice');
expect(resolveReferenceTo(view.entries[0].def)).toBe('invoice');
});

it('what comes out of the door parses through the real FieldSchema', () => {
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', referenceTo: 'account' },
}) as Record<string, Record<string, unknown>>;

const result = FieldSchema.safeParse(out.owner_id);
expect(unrecognizedKeys(result)).toEqual([]);
expect(result.success).toBe(true);
});
});

/**
* ⭐ The firing controls. Each one fails on a build that "fixed" this by
* recovering unconditionally, and the first two also fail on a build that
* simply stopped stripping the key.
*/
describe('objectui#8896 · firing controls', () => {
it('the spec spelling WINS — a stale legacy value never overwrites a live target', () => {
// A pre-objectui#6041 designer wrote both keys; the canonical one is the
// one the author has been editing ever since.
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', reference: 'account', referenceTo: 'stale_legacy' },
}) as Record<string, Record<string, unknown>>;

expect(out.owner_id.reference).toBe('account');
expect('referenceTo' in out.owner_id).toBe(false);
});

it('a field with NO usable target gains no invented one', () => {
// `unrecognized_keys` fires on the key's PRESENCE, so an empty retired key
// is real and stored. Recovering it would smuggle a target that names no
// object past every gate downstream.
const out = roundTrip({
a: { type: 'lookup', label: 'A', referenceTo: '' },
b: { type: 'lookup', label: 'B', referenceTo: ' ' },
c: { type: 'text', label: 'C' },
}) as Record<string, Record<string, unknown>>;

expect('reference' in out.a).toBe(false);
expect('reference' in out.b).toBe(false);
expect('reference' in out.c).toBe(false);
expect(RETIRED_FIELD_KEYS.filter((k) => k in out.a)).toEqual([]);
expect(RETIRED_FIELD_KEYS.filter((k) => k in out.b)).toEqual([]);
});

it('the OTHER retired keys are still dropped with nothing left behind', () => {
// The recovery is keyed to ONE tombstone, not to `specEquivalent` in
// general: `isSystem` has a spec equivalent too and is deliberately not
// recovered — its strip IS the whole write half of objectui#6044.
const out = roundTrip({
code: { type: 'text', label: 'Code', indexed: true, isSystem: true },
}) as Record<string, Record<string, unknown>>;

expect(out.code).toEqual({ type: 'text', label: 'Code' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ const SAMPLE: Record<(typeof RETIRED_FIELD_KEYS)[number], unknown> = {
isSystem: true,
};

/**
* What is left on `{ type: 'lookup', label: 'Owner' }` after the door has seen
* that key — the falsification half of each case below, and the one place the
* three keys stop being interchangeable (objectui#8896).
*
* `indexed` and `isSystem` leave NOTHING behind, for two different recorded
* reasons: the field-level index flag built no index at all (objectui#4644 —
* the concept moved to the object's `indexes[]`, so there is no field-level
* value to keep), and the system flag's spec spelling `system` is a separate
* key the draft either carries or does not, so the strip IS the whole write
* half of objectui#6044 and re-stamping it would invent a flag the author
* never set.
*
* `referenceTo` is the one whose VALUE the draft may hold nowhere else. It is
* the same CONCEPT as `reference` and the same KIND of value — a bare object
* name — so deleting it destroyed the only copy of a relationship target and
* left the designer's target editor empty. The door keeps that value under the
* spec spelling now; the retired key is dropped exactly as the other two are,
* which is what every `key in out` assertion below is stating.
*/
const RESIDUE: Record<(typeof RETIRED_FIELD_KEYS)[number], Record<string, unknown>> = {
indexed: {},
referenceTo: { reference: 'account' },
isSystem: {},
};

describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#6519)', () => {
it('names exactly the three keys this door strips', () => {
// The list is derived from the tombstone registry (objectui#6527:
Expand All @@ -77,8 +103,10 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#
}) as Record<string, Record<string, unknown>>;

expect(key in out.owner_id).toBe(false);
// Falsification: keyed to the tombstone, not a blanket unknown-key purge.
expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner' });
// Falsification: keyed to the tombstone, not a blanket unknown-key purge
// — and, for the one key whose value has nowhere else to live, the value
// survives under the spec spelling (see {@link RESIDUE}).
expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner', ...RESIDUE[key] });
});

it(`drops \`${key}\` from an array-shaped draft on round-trip`, () => {
Expand All @@ -87,7 +115,7 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#
]) as Array<Record<string, unknown>>;

expect(key in out[0]).toBe(false);
expect(out[0]).toEqual({ name: 'owner_id', type: 'lookup', label: 'Owner' });
expect(out[0]).toEqual({ name: 'owner_id', type: 'lookup', label: 'Owner', ...RESIDUE[key] });
});
}

Expand All @@ -102,7 +130,9 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#
>;

expect(RETIRED_FIELD_KEYS.filter((k) => k in out.owner_id)).toEqual([]);
expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner' });
// Every retired key is gone; the lookup's target is not, because it is a
// VALUE and not a key — the asymmetry {@link RESIDUE} records.
expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner', reference: 'account' });
});

it('drops falsy values too — the key itself is what the parse rejects', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export type Shape = 'array' | 'record';
* concept it is a SEPARATE key (`reference`, `system`) that is NOT stripped
* and rides through untouched, which is what lets the designer read it back.
*
* ⚠️ That sentence was UNCONDITIONAL and, for one key, measurably false
* (objectui#8896): it holds only where the draft ALSO carries the spec
* spelling. A draft holding a relationship target ONLY as `referenceTo` had it
* deleted here with nothing left behind. {@link stripRetiredFieldKeys} now
* keeps that one value under `reference` before dropping the key, so the
* sentence is true as written — see there for why the repair is keyed to this
* key alone and is NOT a `specEquivalent` migration.
*
* ── The two registry keys this door deliberately does NOT strip ──
*
* `formula` — RULED, objectui#6526 option B (2026-08-27): this door reads
Expand Down Expand Up @@ -87,12 +95,75 @@ export type Shape = 'array' | 'record';
*/
export const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataAdminFieldsReadDoor');

/** Drop {@link RETIRED_FIELD_KEYS} from one field definition. */
/**
* The pre-objectui#6041 spelling of a relationship target, and the spec key it
* was renamed to.
*
* Named as a PAIR here rather than derived from the tombstone's
* `specEquivalent`: the registry is explicit that `specEquivalent` is
* "Documentation for the reader, NEVER an instruction to migrate a value
* mechanically", because objectui#6043 refused exactly that for `formula` —
* whose value is a LANGUAGE, so a blind rename launders non-CEL text into a
* formula that parses green and evaluates to null. A relationship target is a
* bare object NAME under both spellings, which is what makes reading it under
* either one a read rather than a migration, and that fact is specific to this
* one key.
*/
const RETIRED_REFERENCE_KEY = 'referenceTo';
const SPEC_REFERENCE_KEY = 'reference';

/** Does this value NAME a target object? Blank and non-string name none. */
function isUsableTarget(value: unknown): value is string {
return typeof value === 'string' && value.trim() !== '';
}

/**
* Drop {@link RETIRED_FIELD_KEYS} from one field definition, keeping a
* relationship target the draft holds ONLY under the retired spelling
* (objectui#8896).
*
* ## The claim this repairs — it is this module's own, one paragraph up
*
* {@link RETIRED_FIELD_KEYS}' note said "Nothing is lost on the way out: where
* the spec has a spelling for the concept it is a SEPARATE key (`reference`,
* `system`) that is NOT stripped and rides through untouched". That holds only
* when the draft ALSO carries the spec spelling. A draft holding the target
* only as `referenceTo` had it deleted here with nothing left behind — and
* this is the SINGLE read door for `draft.fields` across the whole object
* designer, with `writeFields` writing each def back verbatim, so the loss
* committed on the next save. `ObjectFieldInspector`'s target editor reads
* `def.reference` and rendered empty; so did every other reader, because
* objectui#6837's ruling ("protocol normalization belongs on the server, the
* front end just executes the protocol") deleted the per-reader legacy arms.
*
* 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.
*
* ## Three things this does not do
*
* ⛔ The retired key still never survives — `FieldSchema` refuses it by name,
* which is the whole reason this door strips.
* ⛔ Never overwrites a live `reference`: the spec spelling is what the author
* has been editing, and a stale legacy value beside it is the older truth.
* ⛔ Never invents a target. `unrecognized_keys` fires on the key's PRESENCE,
* so `referenceTo: ''` is a real stored state; recovering it would hand every
* gate downstream a target that names no object.
*/
function stripRetiredFieldKeys(def: Record<string, unknown>): Record<string, unknown> {
const present = RETIRED_FIELD_KEYS.filter((k) => k in def);
if (present.length === 0) return def;
const next = { ...def };
for (const k of present) delete next[k];
if (
(present as readonly string[]).includes(RETIRED_REFERENCE_KEY)
&& !isUsableTarget(next[SPEC_REFERENCE_KEY])
&& isUsableTarget(def[RETIRED_REFERENCE_KEY])
) {
next[SPEC_REFERENCE_KEY] = def[RETIRED_REFERENCE_KEY];
}
return next;
}

Expand Down
Loading
Loading