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/6527-retired-field-key-tombstone-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/types': minor
'@object-ui/app-shell': patch
'@object-ui/plugin-designer': patch
---

One tombstone registry for the designer seam's retired field keys
(objectui#6527). Three independently maintained `RETIRED_FIELD_KEYS` literals
— the metadata-admin read door (`object-fields-io.ts`), `MetadataService`'s
carry-over and `MetadataFieldsPage`'s carry-over — become derivations from a
single registry in `@object-ui/types` (`RETIRED_FIELD_KEY_TOMBSTONES` +
`retiredFieldKeysFor(site)`), naming each retired key, the card that retired
it, and its PER-SITE applicability.

Per-site behaviour is unchanged — this is a consolidation, and each site's
effective strip set is pinned equal to its pre-consolidation literal. The two
deliberate asymmetries a naive union would have destroyed are now recorded as
data and pinned:

- `formula` stays stripped by the two write-side carry-overs and is NOT
stripped by the read door — ruled on objectui#6526 (option B): the
`ObjectFieldInspector` migration path (objectui#6043) stands, and the
registry test makes that ruling mechanical.
- `sortOrder` stays a single-site strip at `MetadataService`'s carry-over,
now explicitly recorded as the registry's one DEFENSIVE entry (objectui#6045
measured that no shipped writer ever populated a field-level one).
60 changes: 31 additions & 29 deletions packages/app-shell/src/services/MetadataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
import { stripReadDecorations } from '@objectstack/spec/kernel';
import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack';
import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types';
// The retired-field-key tombstone registry lives at a dedicated internal
// subpath, not the main barrel — objectui#6527 option B (maintainer ruling,
// 2026-08-28): a barrel import eagerly evaluates every other barrel member,
// which widened an unrelated consumer's module graph under the prior shape.
import { retiredFieldKeysFor } from '@object-ui/types/internal/retired-field-keys';

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -256,38 +261,35 @@ function toFieldsMap(fields: FieldMetadataPayload[]): Record<string, FieldMetada
* Field keys a designer once WROTE that `FieldSchema` refuses BY NAME, dropped
* out of {@link carryOver} (objectui#6488).
*
* Measured against the installed `@objectstack/spec` 17.2.0, each one on an
* otherwise-green field:
* Derived from the tombstone registry (`RETIRED_FIELD_KEY_TOMBSTONES` in
* `@object-ui/types`, objectui#6527) — this carry-over is the registry's
* `metadataServiceCarryOver` site. The registry names each retired key, the
* card that retired it, and which sites strip it; the per-key evidence lives
* there. This site's list is the widest of the three because it is the one
* with a recorded DEFENSIVE entry: no shipped writer ever populated a
* field-level `sortOrder` (objectui#6045 — "the key never reached the wire"),
* and it rides here as insurance against a document some OTHER client stored
* while an older server accepted the key — see its tombstone for the recorded
* verdict. Every other entry is a key a shipped build emitted before its card
* retired it, so a document stored back then can still carry it inside a
* field.
*
* indexed => unrecognized_keys — "never a FieldSchema key" (objectui#4644)
* referenceTo => unrecognized_keys — "Did you mean `referenceTo` -> `reference`?" (objectui#6041)
* formula => unrecognized_keys — "Did you mean `formula` -> `expression`?" (objectui#6043)
* isSystem => unrecognized_keys — "Did you mean `isSystem` -> `system`?" (objectui#6044)
* sortOrder => unrecognized_keys (objectui#6045)
* Carrying such a key out again would be a hard `422 INVALID_METADATA` that
* blocks EVERY later save of that object — and with the controls gone, an
* author has no way to clear it from the UI. Stripping is what makes an
* edit-and-save round-trip of such an object come out parseable; nothing that
* the server would store is lost, because these are exactly the values it
* refuses.
*
* Every one of them is a key SOME designer build emitted before its card
* retired it, so a document stored back then can still carry it inside a field.
* Carrying it out again would be a hard `422 INVALID_METADATA` that blocks
* EVERY later save of that object — and with the controls gone, an author has
* no way to clear it from the UI. Stripping is what makes an edit-and-save
* round-trip of such an object come out parseable; nothing that the server
* would store is lost, because these are exactly the values it refuses.
*
* The list is keyed to those tombstones and is NOT a blanket unknown-key purge:
* every other key the server sent still rides through, which is the whole point
* of the carry-over. Deliberately not derived from `FieldSchema`'s accept set
* either — measured on 17.2.0, a plugin-registered key (`x_plugin_thing`) is
* `unrecognized_keys` to the INSTALLED spec while the SERVER that sent it
* accepts it, so filtering by the client's schema would drop precisely the keys
* this card exists to preserve.
*
* ⚠ This is the repo's THIRD copy of a retired-field-key list, each scoped to
* one writer's own history (`plugin-designer`'s `MetadataFieldsPage` carries
* four, `app-shell`'s `previews/object-fields-io` carries `['indexed']`).
* Unifying them spans `MetadataFieldsPage.tsx`, which objectui#6489 owns on
* this same seam, so it is filed rather than folded in here.
* The strip is keyed to those tombstones and is NOT a blanket unknown-key
* purge: every other key the server sent still rides through, which is the
* whole point of the carry-over. Deliberately not derived from `FieldSchema`'s
* accept set either — measured on 17.2.0, a plugin-registered key
* (`x_plugin_thing`) is `unrecognized_keys` to the INSTALLED spec while the
* SERVER that sent it accepts it, so filtering by the client's schema would
* drop precisely the keys this card exists to preserve.
*/
const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'formula', 'isSystem', 'sortOrder'] as const;
const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataServiceCarryOver');

/**
* The previous SERVER entry for one field, minus {@link RETIRED_FIELD_KEYS} —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,20 @@ const SAMPLE: Record<(typeof RETIRED_FIELD_KEYS)[number], unknown> = {

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:
// The list is derived from the tombstone registry (objectui#6527:
// `RETIRED_FIELD_KEY_TOMBSTONES` in `@object-ui/types`, site
// `metadataAdminFieldsReadDoor`), 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.
// Both have a case of their own below, and both are also pinned at the
// registry itself (`retired-field-key-tombstones.test.ts` — `formula`'s
// absence is the objectui#6526 option B ruling made mechanical). See the
// registry before adding a fourth entry.
expect([...RETIRED_FIELD_KEYS]).toEqual(['indexed', 'referenceTo', 'isSystem']);
});

Expand Down Expand Up @@ -137,8 +141,9 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#
// 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.
// fails. RULED, objectui#6526 option B (2026-08-27): the migration path
// stands and the key is NOT stripped here; the 422 diagnostic points the
// author at the formula editor instead (PR #6624).
const out = roundTrip({
total: { type: 'formula', formula: 'price * quantity' },
}) as Record<string, Record<string, unknown>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,105 +17,75 @@
* {@link RETIRED_FIELD_KEYS} — see the note on that constant.
*/

// The retired-field-key tombstone registry lives at a dedicated internal
// subpath, not the main barrel — objectui#6527 option B (maintainer ruling,
// 2026-08-28): a barrel import eagerly evaluates every other barrel member
// (including `spec-report.ts`'s read of `@objectstack/spec/ui`), which is
// what widened an unrelated test's partial mock into a suite failure under
// the prior shape. A subpath import never pulls the barrel in.
import { retiredFieldKeysFor } from '@object-ui/types/internal/retired-field-keys';

import type { FieldTypeId } from './field-types.js';

export type Shape = 'array' | 'record';

/**
* Field keys the ObjectStack spec REJECTS by name, stripped on read.
*
* Measured against the installed `@objectstack/spec` 17.2.0, each one on an
* otherwise-green field (`{ type: 'text', label: 'L' }`), and through the whole
* object document `PUT /api/v1/meta/object/:name` validates:
*
* indexed => unrecognized_keys "never a FieldSchema key; a field-level
* index flag built no index (#2377). Declare the index in the
* object's `indexes[]`." (objectui#4644)
* referenceTo => unrecognized_keys "Did you mean `referenceTo` ->
* `reference`?" (objectui#6041)
* isSystem => unrecognized_keys "Did you mean `isSystem` -> `system`?"
* (objectui#6044)
*
* `ObjectSchema.safeParse` reports them at `["fields", <name>]`, which is the
* hard 422 (`INVALID_METADATA`) that blocks EVERY subsequent save of the object
* until the author finds and clears the key — and the controls that wrote them
* are retired, so there is no UI path left to clear it.
*
* Every key here is one a SHIPPED build wrote, so a stored object can still
* carry it inside a field. That premise was verified per key rather than
* assumed (objectui#6519):
* Derived from the tombstone registry (`RETIRED_FIELD_KEY_TOMBSTONES` in
* `@object-ui/types`, objectui#6527) — this door is the registry's
* `metadataAdminFieldsReadDoor` site. The registry names each retired key, the
* card that retired it, and which sites strip it; the per-key evidence lives
* there. What stays HERE is this door's own contract:
*
* - `indexed` — the field inspector's `Indexed` checkbox wrote it until
* objectui#4644, measured blocking saves on console 17.0.0 GA.
* - `referenceTo` — emitted by BOTH designer writers until objectui#6041:
* `MetadataService.toFieldPayload` (`referenceTo: field.referenceTo`) and
* `MetadataFieldsPage.fromDesignerField` (`referenceTo: designed.referenceTo`).
* - `isSystem` — declared on `MetadataFieldsPage`'s `ServerFieldSchema` and
* read back (`isSystem: raw.isSystem`) until objectui#6044, i.e. served
* field entries were expected to carry it; it left again through a verbatim
* carry-over spread rather than any named emit site.
* Every key this door strips is one a SHIPPED build wrote (verified per key,
* objectui#6519), so a stored draft can still carry it inside a field —
* `ObjectSchema.safeParse` reports it at `["fields", <name>]`, the hard 422
* (`INVALID_METADATA`) that blocks EVERY subsequent save of the object, with
* the writing controls retired and no UI path left to clear the key. Stripping
* on load — not a data migration — is what makes an edit-and-save round-trip
* of such a draft come out parseable. `readFields` 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, so one strip here covers every writer.
* 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.
*
* Neither key loses anything on the way out: where the spec has a spelling for
* the concept it is a SEPARATE key that is NOT stripped — `reference` and
* `system` are real `FieldSchema` keys and ride through untouched, which is
* what lets the designer read them back.
* ── The two registry keys this door deliberately does NOT strip ──
*
* ── Two keys the spec also refuses and this door deliberately does NOT strip ──
* Read both before adding a fourth entry; each was measured, and they fail the
* strip for DIFFERENT reasons.
*
* `formula` (objectui#6043) — the premise holds and the strip still does not.
* The Field Designer's textarea wrote it, so stored objects carry it; but
* `ObjectFieldInspector` is this platform's sanctioned migration surface for
* exactly that key. The legacy value seeds its CEL editor
* (`readPredicate(def.expression ?? def.formula)`) and the first edit commits
* `formula` — RULED, objectui#6526 option B (2026-08-27): this door reads
* drafts a live editor also MIGRATES, which neither write-side carry-over
* does. `ObjectFieldInspector` seeds its linting CEL editor from
* `readPredicate(def.expression ?? def.formula)` and the first edit commits
* the spec key and clears the alias (`patchDef({ expression: …, formula:
* undefined })`). Stripping here empties that editor: measured on
* objectui#6519, adding `formula` to this list turns
* `ObjectFieldInspector.test.tsx`'s pin *commits edits to `expression` and
* migrates the legacy `formula` key* RED (`Tests 1 failed | 42 passed`) with the
* editor rendering `""` instead of the authored source — after which the next
* save drops the text for good. objectui#6043 refused the blind rename in
* `plugin-designer` PRECISELY because this linting editor exists to migrate the
* value properly; a strip here would discard what that ruling preserved. So a
* `formula` draft stays blocked at the server until the author edits the
* formula, which is a worse-than-nothing trade only a maintainer should make —
* it is raised on objectui#6519, not taken here.
*
* `sortOrder` (objectui#6045) — the premise itself fails. `FieldSchema` refuses
* it by name too, and `MetadataService`'s `carryOver` does list it, but no
* writer on this tree ever populated a FIELD-level `sortOrder`: it was declared
* on the UI model and on the wire shape and left undefined, so `toFieldPayload`
* emitted `sortOrder: undefined` and `JSON.stringify` dropped it. objectui#6045
* removed it as objectui#4687's shape (a declaration with zero readers and zero
* writers), not objectui#6041's rename. No shipped build stored one, so no
* draft this door reads can carry one, and stripping it would be dead code that
* reads like a measurement. (The object-level `sortOrder` on `ObjectDefinition`
* and the saved-view `sortOrder` in `ObjectView` are different concepts living
* outside `fields` entirely, so neither passes through this door.) Evidence of a
* stored field-level `sortOrder` would change this — add it then, not defensively.
* undefined })`) — the migration objectui#6043 preserved when it refused the
* blind rename. Stripping here empties that editor and the authored source is
* gone on the next save (measured on objectui#6519: with `formula` in this
* list, the inspector's pin *commits edits to `expression` and migrates the
* legacy `formula` key* goes RED with the editor rendering `""`). A `formula`
* draft instead stays blocked at the server until the author makes that one
* migrating edit, and the client-side 422 diagnostic names the field and
* points at the Formula (CEL) editor (PR #6624).
*
* Stripping on load — not a data migration — is what makes an edit-and-save
* round-trip of such a draft come out parseable. `readFields` 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, so one strip here covers every
* writer.
* `sortOrder` — the premise fails: no writer on this tree ever populated a
* field-level one (objectui#6045 — objectui#4687's zero-readers/zero-writers
* shape, not a rename), so no draft this door reads can carry it, and a strip
* would be dead code that reads like a measurement. This door's contract is
* "add it on evidence, not defensively"; `MetadataService`'s carry-over keeps
* it as that site's one recorded-DEFENSIVE entry instead. (The object-level
* `sortOrder` on `ObjectDefinition` and the saved-view `sortOrder` in
* `ObjectView` are different concepts living outside `fields` entirely, so
* neither passes through this door.)
*
* Same shape as `PermissionAdvancedFacets`' `RETIRED_RLS_KEYS`
* (objectstack#7130). Keyed to the tombstones, never a blanket unknown-key
* purge: every other key the designer does not render still survives.
*
* ⚠ This is one of THREE retired-key lists on this seam, each scoped to its own
* writer's history (`MetadataFieldsPage`'s `carryOver` carries four,
* `MetadataService`'s carries five). They are NOT nested, and the two paragraphs
* above are why: this door reads drafts a live editor also migrates, which the
* two write-side lists do not. Unifying them spans
* `plugin-designer/src/MetadataFieldsPage.tsx`, which objectui#6489 owns in
* flight, so objectui#6519 scoped itself to this file and left unification to a
* follow-up rather than collide on that file.
* `object-fields-io.retiredKeys.test.ts` pins the derived list and both
* absences from this door's side; the registry's own test pins the same facts
* at the source.
*/
export const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'isSystem'] as const;
export const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataAdminFieldsReadDoor');

/** Drop {@link RETIRED_FIELD_KEYS} from one field definition. */
function stripRetiredFieldKeys(def: Record<string, unknown>): Record<string, unknown> {
Expand Down
Loading
Loading