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
46 changes: 46 additions & 0 deletions .changeset/6519-retired-field-key-strip.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/app-shell': patch
---

The object designer's field-IO read door now strips `referenceTo` and `isSystem` alongside
`indexed`, so a draft authored before those controls were retired can be edited and saved
again (objectui#6519).

`previews/object-fields-io.ts` is the single read door for `draft.fields` across the whole
object designer — inspector, form designer, design surface, settings / validations / API
panels — and `writeFields` writes each def back verbatim. Its strip set named one key while
`FieldSchema` refuses five by name, so a stored field carrying any of the others
round-tripped straight back out to `PUT /api/v1/meta/object/:name`. Measured on the
installed `@objectstack/spec` 17.2.0, through the whole document that endpoint validates:

```
ObjectSchema.safeParse({ name:'account', label:'Account',
fields: { amount: { type:'number', label:'A', referenceTo: 1 } } })
=> unrecognized_keys at ["fields","amount"]
```

which is the hard `422 INVALID_METADATA` that blocks EVERY later save of that object, with
the control that wrote the key retired and no UI path left to clear it. This is the shape
objectui#4644 closed in this same file for `indexed`, applied to the siblings that were
left open.

Both added keys were verified to be reachable rather than assumed: `referenceTo` was
emitted by both designer writers until objectui#6041 (`MetadataService.toFieldPayload` and
`MetadataFieldsPage.fromDesignerField`), and `isSystem` was a declared server-field key the
designer read back until objectui#6044. Neither loses anything — the spec spellings
`reference` and `system` are separate, accepted keys and ride through untouched.

Two keys `FieldSchema` also refuses are deliberately NOT stripped, each for its own
measured reason, and the tombstone on `RETIRED_FIELD_KEYS` carries both in full:

- `formula` (objectui#6043) — `ObjectFieldInspector` seeds its linting CEL editor from
`def.expression ?? def.formula` and the first edit commits `expression` and clears the
alias. Stripping at the read door empties that editor and the authored source is gone on
the next save; objectui#6043 refused a blind rename precisely because that migration
surface exists. Dropping the text anyway is a maintainer call, raised on objectui#6519.
- `sortOrder` (objectui#6045) — no writer on this tree ever populated a FIELD-level one, so
no draft this door reads can carry one; a strip would be dead code that reads like a
measurement.

Unifying the three retired-key lists on this seam is deliberately not part of this change:
it spans `plugin-designer/src/MetadataFieldsPage.tsx`, which objectui#6489 owns in flight.
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,36 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the object designer's field IO never carries `indexed` back out
* (objectui#4644).
* Pins that the object designer's field IO never carries a retired FieldSchema
* key back out (objectui#4644 for `indexed`, objectui#6519 for its siblings).
*
* `indexed` is not a `FieldSchema` key and never was — the field-level flag
* built no index (objectstack#2377 removed it), and since objectstack#4001
* closed the silent-drop shape `FieldSchema.safeParse` refuses it by name:
* Each key here is refused BY NAME by `FieldSchema` — measured on the installed
* `@objectstack/spec` 17.2.0:
*
* Unrecognized key(s) on this field: `indexed`.
* • never a FieldSchema key; a field-level index flag built no index
* (#2377). Declare the index in the object's `indexes[]`.
* indexed "never a FieldSchema key; a field-level index flag built no
* index (#2377). Declare the index in the object's `indexes[]`."
* referenceTo "Did you mean `referenceTo` -> `reference`?"
* isSystem "Did you mean `isSystem` -> `system`?"
*
* Measured on console 17.0.0 GA: the field inspector shipped an `Indexed`
* checkbox writing the key, and saving the draft came back
* Measured on console 17.0.0 GA for `indexed`: the field inspector shipped an
* `Indexed` checkbox writing the key, and saving the draft came back
* `HTTP 422 {"code":"INVALID_METADATA"}` — the toggle stayed ticked, so every
* later save of that object stayed blocked too.
* later save of that object stayed blocked too. The two siblings added by
* objectui#6519 are the same shape from the same era: each was written by a
* shipped designer build (see the per-key evidence on `RETIRED_FIELD_KEYS`), so
* a stored object can still carry it inside a field.
*
* The two keys the spec refuses that this door deliberately does NOT strip —
* `formula` and `sortOrder` — have a case each below, because the temptation to
* "finish the set" is exactly what those two paragraphs of the tombstone exist
* to stop.
*
* Retiring the control is only half of it. `readFields` deliberately preserves
* unknown keys and every write path spreads the def it read, so a draft
* authored before the retirement would carry the key straight back out to
* `PUT /api/v1/meta/object/:name` with no control left on screen to clear it.
* Stripping on load is what un-breaks those saves — which is why the pins
* below are round-trips (`readFields` `writeFields`), not just reads.
* below are round-trips (`readFields` -> `writeFields`), not just reads.
*
* Both draft shapes are covered because `readFields` normalises two of them
* (array and record) through separate branches.
Expand All@@ -36,39 +44,122 @@ function roundTrip(fields: unknown) {
return writeFields(readFields(fields));
}

describe('object-fields-io · retired FieldSchema keys (objectui#4644)', () => {
it('names `indexed` as retired', () => {
expect([...RETIRED_FIELD_KEYS]).toEqual(['indexed']);
/** A representative value per key, as the retired control actually wrote it. */
const SAMPLE: Record<(typeof RETIRED_FIELD_KEYS)[number], unknown> = {
indexed: true,
referenceTo: 'account',
isSystem: true,
};

describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#6519)', () => {
it('names exactly the three keys this door strips', () => {
// The list is the tombstone, and its two ABSENCES are deliberate:
// `formula` — premise holds, strip refused: ObjectFieldInspector
// migrates the legacy key through its linting CEL editor,
// and stripping empties that editor (measured: the pin
// `commits edits to \`expression\` …` goes red).
// `sortOrder` — premise fails: no writer on this tree ever populated a
// field-level one, so no draft can carry it and a strip
// would be dead code that reads like a measurement.
// Both have a case of their own below. See the tombstone before adding a
// fourth entry.
expect([...RETIRED_FIELD_KEYS]).toEqual(['indexed', 'referenceTo', 'isSystem']);
});

it('drops `indexed` from a record-shaped draft on round-trip', () => {
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', indexed: true },
}) as Record<string, Record<string, unknown>>;
for (const key of RETIRED_FIELD_KEYS) {
it(`drops \`${key}\` from a record-shaped draft on round-trip`, () => {
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', [key]: SAMPLE[key] },
}) as Record<string, Record<string, unknown>>;

expect('indexed' 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' });
});
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' });
});

it('drops `indexed` from an array-shaped draft on round-trip', () => {
const out = roundTrip([
{ name: 'owner_id', type: 'lookup', label: 'Owner', indexed: true },
]) as Array<Record<string, unknown>>;
it(`drops \`${key}\` from an array-shaped draft on round-trip`, () => {
const out = roundTrip([
{ name: 'owner_id', type: 'lookup', label: 'Owner', [key]: SAMPLE[key] },
]) as Array<Record<string, unknown>>;

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

it('drops every retired key at once — one poisoned field can carry several', () => {
// A draft from the era when all four controls shipped carries all four, and
// `unrecognized_keys` reports them together: clearing three of four leaves
// the object exactly as blocked as before.
const poisoned = Object.fromEntries(RETIRED_FIELD_KEYS.map((k) => [k, SAMPLE[k]]));
const out = roundTrip({ owner_id: { type: 'lookup', label: 'Owner', ...poisoned } }) as Record<
string,
Record<string, unknown>
>;

expect(RETIRED_FIELD_KEYS.filter((k) => k in out.owner_id)).toEqual([]);
expect(out.owner_id).toEqual({ type: 'lookup', label: 'Owner' });
});

it('drops `indexed: false` too — the key itself is what the parse rejects', () => {
it('drops falsy values too — the key itself is what the parse rejects', () => {
// The GA rejection is `unrecognized_keys`: it fires on the key's presence,
// so an un-ticked-but-persisted `false` blocks the save exactly as a
// `true` does. Leaving falsy values behind would fix only half the drafts.
const out = roundTrip({ code: { type: 'text', indexed: false } }) as Record<
const out = roundTrip({
code: { type: 'text', indexed: false, isSystem: false, referenceTo: '', formula: '' },
}) as Record<string, Record<string, unknown>>;
expect(RETIRED_FIELD_KEYS.filter((k) => k in out.code)).toEqual([]);
});

it('leaves the spec spelling of each renamed concept untouched', () => {
// `reference` and `system` are real `FieldSchema` keys — the strip is keyed
// to the REFUSED spelling only. Dropping the accepted one instead would
// silently delete a lookup's target and a field's system flag on every read.
const out = roundTrip({
owner_id: { type: 'lookup', label: 'Owner', reference: 'account', system: true, expression: 'a + b' },
}) as Record<string, Record<string, unknown>>;

expect(out.owner_id).toEqual({
type: 'lookup',
label: 'Owner',
reference: 'account',
system: true,
expression: 'a + b',
});
});

it('carries a legacy `formula` through — not stripped, on purpose', () => {
// `FieldSchema` refuses `formula` by name as well, and a shipped control
// wrote it, so unlike `sortOrder` the premise holds here. The strip is
// refused for a different reason: `ObjectFieldInspector` seeds its CEL
// editor from `def.expression ?? def.formula` and the first edit commits
// `expression` and clears the alias, which is the migration objectui#6043
// preserved when it refused to rename the key blindly. Stripping at this
// door empties that editor and the authored source is gone on the next
// save — measured: with `formula` in the list, that pin renders `""` and
// fails. Until a maintainer rules that the text may be dropped
// (objectui#6519), it rides through.
const out = roundTrip({
total: { type: 'formula', formula: 'price * quantity' },
}) as Record<string, Record<string, unknown>>;
expect(out.total).toEqual({ type: 'formula', formula: 'price * quantity' });
});

it('carries a field-level `sortOrder` through — not stripped, on purpose', () => {
// The deliberate asymmetry with `MetadataService`'s five-key `carryOver`.
// `FieldSchema` refuses `sortOrder` by name as well, but no writer on this
// tree ever populated a field-level one (objectui#6045 removed it as
// objectui#4687's zero-readers/zero-writers shape, not objectui#6041's
// rename), so no draft this door reads can carry it. This case is the
// module's own contract stated on the key that most tempts a defensive
// addition: strip the tombstoned keys, carry everything else. Evidence of a
// stored field-level `sortOrder` would flip it — update the tombstone and
// this pin together.
const out = roundTrip({ code: { type: 'text', sortOrder: 3 } }) as Record<
string,
Record<string, unknown>
>;
expect('indexed' in out.code).toBe(false);
expect(out.code).toEqual({ type: 'text', sortOrder: 3 });
});

it('leaves every other unknown key on the field untouched', () => {
Expand DownExpand Up@@ -102,11 +193,12 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644)', () => {
const out = roundTrip({
a: { type: 'text', indexed: true },
b: { type: 'text' },
c: { type: 'number', indexed: true },
c: { type: 'lookup', referenceTo: 'account' },
d: { type: 'text', isSystem: true },
}) as Record<string, Record<string, unknown>>;

expect(Object.values(out).some((d) => 'indexed' in d)).toBe(false);
expect(Object.keys(out)).toEqual(['a', 'b', 'c']);
expect(Object.values(out).some((d) => RETIRED_FIELD_KEYS.some((k) => k in d))).toBe(false);
expect(Object.keys(out)).toEqual(['a', 'b', 'c', 'd']);
});

it('returns the original def object when there is nothing to strip', () => {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { FieldSchema } from '@objectstack/spec/data';
import { FieldSchema, ObjectSchema } from '@objectstack/spec/data';
import { RETIRED_FIELD_KEYS, newField, readFields, writeFields } from './object-fields-io.js';

/**
Expand DownExpand Up@@ -155,3 +155,88 @@ describe('strip-on-load is what makes a pre-#4644 draft saveable again', () => {
expect(out.nickname.zzzDefinitelyNotAKey).toBe('kept');
});
});
/**
* The 422 shape, asserted on the BODY the designer PUTs rather than on a
* helper's return value (objectui#6519).
*
* `writeFields` returns the `fields` map; what fails in production is
* `PUT /api/v1/meta/object/:name` validating the WHOLE object document. Parsing
* only the field def would leave the path unmeasured, and the path is the part
* that says a save of THIS object is blocked: `ObjectSchema` reports the refusal
* at `["fields", <name>]`, which is what comes back as
* `422 {"code":"INVALID_METADATA"}` and stays broken for every later save until
* the key is cleared — with the retired controls gone, from no UI at all.
*/
const emittedBody = (fields: unknown) => ({
name: 'account',
label: 'Account',
fields: roundTrip(fields),
});

/** `[path, keys]` for every `unrecognized_keys` issue, path included. */
const refusedAt = (result: ReturnType<typeof ObjectSchema.safeParse>): Array<[string, string[]]> =>
result.success
? []
: result.error.issues
.filter((i) => i.code === 'unrecognized_keys')
.map((i) => [i.path.join('.'), (i as unknown as { keys: string[] }).keys] as [string, string[]]);

describe('the emitted PUT body — the document that actually 422s', () => {
it('control: the whole document is otherwise accepted', () => {
// Without this, every refusal below is compatible with a schema that
// refuses every document, and the paths would prove nothing.
expect(ObjectSchema.safeParse(emittedBody({ amount: { type: 'number', label: 'Amount' } })).success).toBe(
true,
);
});

for (const key of RETIRED_FIELD_KEYS) {
it(`control: an UN-STRIPPED \`${key}\` is refused at ["fields","amount"]`, () => {
// Deliberately NOT routed through `readFields` — this is the raw draft a
// pre-retirement build stored, and the assertion is the 422's own shape:
// the refusal is reported on the FIELD ENTRY, not the object.
const raw = { name: 'account', label: 'Account', fields: { amount: { type: 'number', label: 'A', [key]: 1 } } };
const result = ObjectSchema.safeParse(raw);
expect(result.success).toBe(false);
expect(refusedAt(result)).toEqual([['fields.amount', [key]]]);
});

it(`a draft carrying \`${key}\` comes out of the round-trip as a parseable body`, () => {
// The two halves together are the real assertion: the control above
// proves the key is genuinely fatal, this proves the read door removes it
// before the body is built. Either alone is compatible with a broken strip.
const body = emittedBody({ amount: { type: 'number', label: 'A', [key]: 1 } });
expect(ObjectSchema.safeParse(body).success).toBe(true);
expect((body.fields as Record<string, Record<string, unknown>>).amount).not.toHaveProperty(key);
});
}

it('a draft carrying ALL of them at once comes out parseable', () => {
// How a draft from the era actually looks: the controls shipped together,
// and `unrecognized_keys` reports them together, so clearing a subset
// leaves the object exactly as blocked.
const poisoned = Object.fromEntries(RETIRED_FIELD_KEYS.map((k) => [k, 1]));
const raw = {
name: 'account',
label: 'Account',
fields: { amount: { type: 'number', label: 'A', ...poisoned } },
};
expect(refusedAt(ObjectSchema.safeParse(raw))).toEqual([['fields.amount', [...RETIRED_FIELD_KEYS]]]);
expect(ObjectSchema.safeParse(emittedBody(raw.fields)).success).toBe(true);
});

it('the array draft shape reaches the same parseable body', () => {
// `readFields` normalises the two draft shapes through separate branches,
// and only the record shape is what `ObjectSchema` accepts on the wire.
const body = {
name: 'account',
label: 'Account',
fields: Object.fromEntries(
(roundTrip([{ name: 'amount', type: 'number', label: 'A', referenceTo: 'account', indexed: true }]) as Array<
Record<string, unknown>
>).map(({ name, ...def }) => [name as string, def]),
),
};
expect(ObjectSchema.safeParse(body).success).toBe(true);
});
});
Loading
Loading