From ec93ccfae7b31caa3e591470967cd018e719311e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:24:00 +0000 Subject: [PATCH 1/3] refactor(types,app-shell,plugin-designer): one tombstone registry for the designer seam's retired field keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independently maintained RETIRED_FIELD_KEYS literals on the designer seam (the metadata-admin read door in object-fields-io.ts, MetadataService's carryOver, MetadataFieldsPage's carryOver) become derivations from a single tombstone registry in @object-ui/types: RETIRED_FIELD_KEY_TOMBSTONES names each retired key, the card that retired it, and its PER-SITE applicability, and retiredFieldKeysFor(site) is the only supported way for a site to obtain its strip list. Deliberately NOT a union of the three lists — per-site behaviour is unchanged and pinned per site: - `formula` stays stripped by the two write-side carry-overs and is NOT applicable at the read door, per the objectui#6526 option B ruling (ObjectFieldInspector's linting CEL editor migrates the legacy key; stripping on read destroys authored expression text). The registry test makes that ruling mechanical. - `sortOrder` stays a single-site strip at MetadataService's carry-over and is now recorded as the registry's one DEFENSIVE entry, with the objectui#6045 measurement (no shipped writer ever populated a field-level one) cited on the tombstone instead of the entry reading like a measurement. The per-key evidence that used to live in three file-local comment blocks moves onto the tombstones; each site keeps only its own mechanics (read-door strip-on-load contract, carry-over bounds, this-writer history). Not touched here: scripts/check-designer-field-key-parity.mjs still reads the declared payload shapes, not this registry; pinning the registry as that gate's single source is a cross-lane follow-up (gate-class scripts/ is domain:devx), named in the PR body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- ...27-retired-field-key-tombstone-registry.md | 26 ++ .../app-shell/src/services/MetadataService.ts | 56 ++-- .../object-fields-io.retiredKeys.test.ts | 15 +- .../previews/object-fields-io.ts | 128 +++----- .../src/MetadataFieldsPage.tsx | 88 +++--- .../retired-field-key-tombstones.test.ts | 237 +++++++++++++++ packages/types/src/index.ts | 15 + packages/types/src/retired-field-keys.ts | 284 ++++++++++++++++++ 8 files changed, 681 insertions(+), 168 deletions(-) create mode 100644 .changeset/6527-retired-field-key-tombstone-registry.md create mode 100644 packages/types/src/__tests__/retired-field-key-tombstones.test.ts create mode 100644 packages/types/src/retired-field-keys.ts diff --git a/.changeset/6527-retired-field-key-tombstone-registry.md b/.changeset/6527-retired-field-key-tombstone-registry.md new file mode 100644 index 0000000000..c0dd51cb63 --- /dev/null +++ b/.changeset/6527-retired-field-key-tombstone-registry.md @@ -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). diff --git a/packages/app-shell/src/services/MetadataService.ts b/packages/app-shell/src/services/MetadataService.ts index 958eda1174..5c50aa36c6 100644 --- a/packages/app-shell/src/services/MetadataService.ts +++ b/packages/app-shell/src/services/MetadataService.ts @@ -18,6 +18,7 @@ import { stripReadDecorations } from '@objectstack/spec/kernel'; import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { retiredFieldKeysFor } from '@object-ui/types'; import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types'; // --------------------------------------------------------------------------- @@ -249,38 +250,35 @@ function toFieldsMap(fields: FieldMetadataPayload[]): Record 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} — diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts index 08167dd871..a685914511 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts @@ -53,7 +53,9 @@ 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 @@ -61,8 +63,10 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui# // `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']); }); @@ -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>; diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index d37902d392..e616d8620f 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -17,6 +17,8 @@ * {@link RETIRED_FIELD_KEYS} — see the note on that constant. */ +import { retiredFieldKeysFor } from '@object-ui/types'; + import type { FieldTypeId } from './field-types.js'; export type Shape = 'array' | 'record'; @@ -24,98 +26,60 @@ 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", ]`, 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", ]`, 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): Record { diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 37b909af12..62335efeea 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -31,7 +31,7 @@ */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { DESIGNER_FIELD_TYPES } from '@object-ui/types'; +import { DESIGNER_FIELD_TYPES, retiredFieldKeysFor } from '@object-ui/types'; import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack'; import { FieldDesigner } from './FieldDesigner'; @@ -125,62 +125,46 @@ function toDesignerField(name: string, raw: ServerFieldSchema): DesignerFieldDef } /** - * Field keys the ObjectStack spec REJECTS by name (objectui#4644). + * Field keys the ObjectStack spec REJECTS by name, dropped out of + * {@link carryOver}. * - * `indexed` was never a `FieldSchema` key — the field-level flag built no - * index (objectstack#2377 removed it) and, since objectstack#4001 closed the - * silent-drop shape, `FieldSchema.safeParse` refuses it outright. Object-level - * `indexes[]` is the real surface. + * Derived from the tombstone registry (`RETIRED_FIELD_KEY_TOMBSTONES` in + * `@object-ui/types`, objectui#6527) — this carry-over is the registry's + * `metadataFieldsPageCarryOver` 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 what is specific to THIS writer's history: * - * The Advanced section of {@link FieldDesigner} used to offer it, so objects - * saved from this page can still carry the key — and `fromDesignerField` - * spreads `prev` verbatim to preserve unknown keys, which would carry it back - * out to `PUT /api/v1/meta/object/:name` as a hard 422 (`INVALID_METADATA`) - * that blocks every later save. Stripping it out of the carried-over keys is - * what makes an edit-and-save round-trip of such an object come out - * parseable; it is keyed to the tombstone, so every other unknown key the - * designer does not render still survives. - */ -/* - * objectui#6041 adds `referenceTo`. Renaming the emit site alone does not - * unblock an object a previous designer build already saved: that stored - * payload still carries `referenceTo`, `carryOver` spreads `prev` verbatim, - * and the key would round-trip straight back out to the same 422. Stripping it - * on the way out is what makes an edit-and-save of an ALREADY-BLOCKED object - * come out parseable. The target itself is not lost — `fromDesignerField` - * re-emits it under the spec spelling `reference` on the very next line. - * - * objectui#6044 adds `isSystem` for the same reason and with one difference - * worth stating: `fromDesignerField` never NAMES it, so the only way out is the - * verbatim `carryOver` spread — which makes this line, not any emit site, the - * whole write half of that card. The spec spelling `system` is not stripped: it - * is a real `FieldSchema` key, so a server-injected flag rides through - * untouched, which is exactly what lets `toDesignerField` read it back. - * - * objectui#6043 adds `formula`, and it is the one entry here that is NOT half - * of a rename — the difference matters, because it is the only reason this - * strip loses anything: + * Each key is one this page's own era wrote (`indexed` via the Advanced + * section of {@link FieldDesigner}, objectui#4644; `referenceTo` via + * `fromDesignerField`'s old emit line, objectui#6041; `isSystem` as a declared + * `ServerFieldSchema` member served back to us, objectui#6044; `formula` via + * the retired formula textarea, objectui#6043), and `fromDesignerField` + * spreads `prev` verbatim to preserve unknown keys — so a stored object from + * that era would carry the key straight back out to + * `PUT /api/v1/meta/object/:name` as a hard 422 (`INVALID_METADATA`) that + * blocks every later save, with no control left on screen to clear it. + * Stripping the carried-over keys is what makes an edit-and-save round-trip of + * such an object come out parseable; it is keyed to the tombstones, so every + * other unknown key the designer does not render still survives. * - * - For `referenceTo` and `isSystem`, `fromDesignerField` re-emits the value - * under the spec spelling on a later line, so stripping costs nothing. - * - For `formula` there is no re-emit, because the card REFUSED the rename. - * `FieldSchema` does not parse CEL at the key level (17.2.0 accepts - * `expression: '!!!not cel at all!!!'`), so migrating a stored `formula` - * into `expression` would launder a non-CEL string — typically the - * `price * quantity` the retired control's own placeholder taught — into a - * valid key name, where it parses green and then evaluates to null at - * runtime. That is the silent failure the card exists to avoid, so the key - * is dropped rather than renamed. + * Two of the four cost nothing: `fromDesignerField` re-emits the lookup target + * under the spec spelling `reference` on the very next line, and the system + * flag is read back from the spec spelling `system` (never re-emitted — the + * strip IS the whole write half of objectui#6044). `formula` is the one entry + * whose strip DROPS a value, and that is objectui#6043's deliberate trade: the + * server refuses to store it, a blind rename to `expression` would launder + * non-CEL text into a formula that parses green and evaluates to null, and + * with the textarea gone stripping is the only way out of the 422. The + * migration surface for the VALUE is metadata-admin's `ObjectFieldInspector` + * (ruled again on objectui#6526, option B), which is why the READ door's list + * does not include `formula` while this write door's does. `expression` itself + * is a real `FieldSchema` key and rides through `carryOver` untouched. * - * Dropping it is what makes an already-blocked object saveable again, and there - * is no gentler option: with the control gone, an author has no other way to - * clear the key, so leaving it would keep the object 422-blocked forever. The - * value being dropped is one the server already refuses to store, so nothing - * that ever persisted is lost. `expression` is NOT stripped — it is a real - * `FieldSchema` key, so a formula authored in metadata-admin rides through - * `carryOver` untouched. + * `sortOrder` is absent here on the same unmeasured premise it always had: no + * shipped writer on this tree ever populated a field-level one (objectui#6045) + * — see its tombstone for the one site that keeps a recorded-defensive strip. */ -const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'isSystem', 'formula'] as const; +const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataFieldsPageCarryOver'); /** Carry over `prev`'s unknown keys, minus {@link RETIRED_FIELD_KEYS}. */ function carryOver(prev?: ServerFieldSchema): ServerFieldSchema { diff --git a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts new file mode 100644 index 0000000000..0a24a71667 --- /dev/null +++ b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts @@ -0,0 +1,237 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6527 — the retired-field-key tombstone registry's own pins. + * + * The registry replaced three independently maintained per-site literals + * (`object-fields-io.ts`, `MetadataService.ts`, `MetadataFieldsPage.tsx`) that + * had drifted. The sites derive their strip lists from it via + * `retiredFieldKeysFor`, so what needs pinning HERE is the registry's own + * contract — and above all the two decisions a naive union of the old lists + * would have silently made in the wrong direction: + * + * 1. `formula` must NOT be stripped at the read door — RULED, objectui#6526 + * option B (director seat, 2026-08-27): "keep the adjudicated migration + * path (objectui#6043) — do NOT strip `formula` at the read door." + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula` and the first edit migrates the value; + * stripping on read empties that editor and destroys authored source. + * 2. `sortOrder` has a recorded verdict, not a unioned entry: no shipped + * writer on this tree ever populated a field-level one (objectui#6045 — + * "the key never reached the wire"), so it stays a single-site DEFENSIVE + * strip and the registry says so in data. + * + * The per-site round-trip behaviour stays pinned where it always was, from the + * consuming side: `object-fields-io.retiredKeys.test.ts` (the read door, + * including `formula` and `sortOrder` carried through), + * `MetadataService.fieldKeyCarryOver.test.ts` and the + * `MetadataFieldsPage.retired*.test.tsx` / `specKey*.test.tsx` files (the + * write doors, on captured PUT bytes). Those suites passing unchanged against + * the derived lists is the consolidation's parity measurement. + */ + +import { describe, expect, it } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; + +import { + RETIRED_FIELD_KEY_SITES, + RETIRED_FIELD_KEY_TOMBSTONES, + retiredFieldKeysFor, + type RetiredFieldKey, +} from '../retired-field-keys.js'; +import { + RETIRED_FIELD_KEY_TOMBSTONES as fromBarrel, + retiredFieldKeysFor as retiredFieldKeysForFromBarrel, +} from '../index.js'; + +/** An otherwise-green field — the positive control every probe rides on. */ +const BASE_FIELD = { name: 'amount', type: 'number', label: 'Amount' } as const; + +/** + * A representative value per registry key, as the retiring card measured the + * key on the wire. `Record` makes adding a tombstone + * without a probe value a compile error. + */ +const SAMPLE: Record = { + indexed: true, + referenceTo: 'account', + formula: 'price * quantity', + isSystem: true, + sortOrder: 3, +}; + +/** + * A representative value per recorded `specEquivalent`, so the "the concept + * lives HERE today" claim is probed against the installed schema rather than + * trusted. + */ +const SPEC_EQUIVALENT_SAMPLE: Record = { + reference: 'account', + system: true, + expression: 'price * quantity', +}; + +describe('retired-field-key registry · membership criterion (the instrument)', () => { + it('accepts the base field — the control that makes the refusals a result', () => { + expect(FieldSchema.safeParse(BASE_FIELD).success).toBe(true); + }); + + it('every registry key is refused BY NAME by the installed FieldSchema', () => { + // The membership criterion: stripping is safe exactly because the server + // refuses to store these values. A key the schema ACCEPTS in this loop is + // a registry entry that would make the strips delete authored metadata. + for (const { key } of RETIRED_FIELD_KEY_TOMBSTONES) { + const r = FieldSchema.safeParse({ ...BASE_FIELD, [key]: SAMPLE[key] }); + expect({ key, success: r.success }).toEqual({ key, success: false }); + expect(r.success ? [] : r.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + }); + + it('every recorded specEquivalent is a key the installed FieldSchema ACCEPTS', () => { + // `specEquivalent` documents where the CONCEPT lives today. Recording a + // spelling the schema refuses would send the next resolver to a dead key. + for (const { key, specEquivalent } of RETIRED_FIELD_KEY_TOMBSTONES) { + if (specEquivalent === null) continue; + expect(specEquivalent in SPEC_EQUIVALENT_SAMPLE).toBe(true); + const r = FieldSchema.safeParse({ + ...BASE_FIELD, + [specEquivalent]: SPEC_EQUIVALENT_SAMPLE[specEquivalent], + }); + expect({ key, specEquivalent, success: r.success }).toEqual({ key, specEquivalent, success: true }); + } + }); +}); + +describe('retired-field-key registry · the ruled asymmetry (objectui#6526, option B)', () => { + it('⭐ `formula` is NOT applicable at the read door — the ruling made mechanical', () => { + // Ruled 2026-08-27 on objectui#6526 (option B): the read door reads drafts + // `ObjectFieldInspector`'s linting CEL editor also MIGRATES — it seeds + // from `def.expression ?? def.formula`, the first edit commits + // `expression` and clears the alias (objectui#6043), and the client-side + // 422 diagnostic points the author at that editor (PR #6624). Flipping + // this flag strips the draft before the editor sees it: the editor seeds + // `""` and the authored expression text is destroyed on the next save. + // This pin going red means someone is overturning a maintainer ruling — + // that needs a new ruling, not a "consistency" edit. + const formula = RETIRED_FIELD_KEY_TOMBSTONES.find((t) => t.key === 'formula'); + expect(formula?.sites).toEqual({ + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }); + expect(retiredFieldKeysFor('metadataAdminFieldsReadDoor')).not.toContain('formula'); + }); + + it('`sortOrder` carries its recorded verdict: defensive, and at ONE site only', () => { + // objectui#6045 measured that no shipped writer ever populated a + // field-level `sortOrder`, so the entry is insurance, not a measured fix — + // kept at `MetadataService`'s carry-over with the measurement recorded + // (objectui#6527) rather than silently unioned into the other sites. + // Red here means either the entry spread without evidence, or the verdict + // was erased without recording a new one. + const sortOrder = RETIRED_FIELD_KEY_TOMBSTONES.find((t) => t.key === 'sortOrder'); + expect(sortOrder?.defensive).toBe(true); + expect(sortOrder?.sites).toEqual({ + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: false, + }); + }); + + it('`sortOrder` is the ONLY defensive entry — every other strip is measured', () => { + // Adding a defensive entry is a recorded decision, not a default. If this + // list grows, the new entry's tombstone must carry its own verdict the way + // `sortOrder`'s does. + expect(RETIRED_FIELD_KEY_TOMBSTONES.filter((t) => t.defensive).map((t) => t.key)).toEqual([ + 'sortOrder', + ]); + }); +}); + +describe('retired-field-key registry · per-site parity with the pre-consolidation literals', () => { + // objectui#6527 is a consolidation, so behaviour parity per site is the + // claim. These are the three literals the sites carried before deriving from + // the registry (re-taken on the merged ref at dispatch time). The read door + // is also pinned from the consuming side in + // `object-fields-io.retiredKeys.test.ts`; the write doors are pinned on + // captured PUT bytes in their own suites. + + it('read door — exactly the objectui#6519 list, in the same order', () => { + expect([...retiredFieldKeysFor('metadataAdminFieldsReadDoor')]).toEqual([ + 'indexed', + 'referenceTo', + 'isSystem', + ]); + }); + + it("MetadataService carry-over — exactly objectui#6488's five, in the same order", () => { + expect([...retiredFieldKeysFor('metadataServiceCarryOver')]).toEqual([ + 'indexed', + 'referenceTo', + 'formula', + 'isSystem', + 'sortOrder', + ]); + }); + + it("MetadataFieldsPage carry-over — exactly its four; registry (retirement) order", () => { + // The pre-consolidation literal read ['indexed', 'referenceTo', + // 'isSystem', 'formula']; the derived list is the same SET in registry + // order (formula's card objectui#6043 predates isSystem's objectui#6044). + // The site's strip is a delete loop, so the set is the behaviour. + expect([...retiredFieldKeysFor('metadataFieldsPageCarryOver')]).toEqual([ + 'indexed', + 'referenceTo', + 'formula', + 'isSystem', + ]); + }); +}); + +describe('retired-field-key registry · hygiene', () => { + it('keys are unique', () => { + const keys = RETIRED_FIELD_KEY_TOMBSTONES.map((t) => t.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('every tombstone names its retiring card as `objectui#`', () => { + // The template-literal type enforces this at compile time for TS readers; + // this runtime pin holds the same line for JS readers (the parity gate is + // an .mjs script and the follow-up pins it on this registry). + for (const { key, retiredBy } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, ok: /^objectui#\d+$/.test(retiredBy) }).toEqual({ key, ok: true }); + } + }); + + it('every tombstone answers every site — no column is left implicit', () => { + for (const { key, sites } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, sites: Object.keys(sites).sort() }).toEqual({ + key, + sites: [...RETIRED_FIELD_KEY_SITES].sort(), + }); + } + }); + + it('every tombstone applies at at least one site — no dead entries', () => { + for (const { key, sites } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, applied: Object.values(sites).some(Boolean) }).toEqual({ + key, + applied: true, + }); + } + }); + + it('the registry and its derivation are exported from the package barrel', () => { + // The three strip sites import from `@object-ui/types`; a registry that + // fell out of the barrel would break them at build time, but this pin + // makes the wiring a stated fact rather than an accident. + expect(fromBarrel).toBe(RETIRED_FIELD_KEY_TOMBSTONES); + expect(retiredFieldKeysForFromBarrel).toBe(retiredFieldKeysFor); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f4086c39c2..90530cb533 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -685,6 +685,21 @@ export { DESIGNER_FIELD_TYPES, } from './designer.js'; +// Retired-field-key tombstone registry — the single source for the designer +// seam's per-site strip lists (objectui#6527). +export type { + RetiredFieldKeySite, + RetiredFieldKeyTombstone, + RetiredFieldKey, + RetiredFieldKeysAt, +} from './retired-field-keys.js'; + +export { + RETIRED_FIELD_KEY_SITES, + RETIRED_FIELD_KEY_TOMBSTONES, + retiredFieldKeysFor, +} from './retired-field-keys.js'; + // ============================================================================ // API and Events - API Integration and Event Handling // ============================================================================ diff --git a/packages/types/src/retired-field-keys.ts b/packages/types/src/retired-field-keys.ts new file mode 100644 index 0000000000..f9951abc39 --- /dev/null +++ b/packages/types/src/retired-field-keys.ts @@ -0,0 +1,284 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - Retired-field-key tombstone registry (objectui#6527) + * + * The single place naming each FIELD key a designer once wrote that the + * installed `FieldSchema` refuses BY NAME, the card that retired it, and its + * PER-SITE applicability on the designer seam. The three strip sites derive + * their lists from this registry via {@link retiredFieldKeysFor}; before + * objectui#6527 each site kept its own literal and the three had drifted. + * + * ## Why this is a registry and NOT one shared array + * + * The three strip sets are DELIBERATELY not nested, and unioning them would + * silently decide two adjudicated questions in the wrong direction: + * + * - `formula` is stripped by the two WRITE doors and must NOT be stripped by + * the READ door — ruled on objectui#6526 (option B, 2026-08-27): + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula` and the author's first edit commits + * `expression` and clears the alias, the migration objectui#6043 preserved. + * Stripping at the read door destroys that authored source text. + * - `sortOrder` applies at ONE site only and is the registry's one + * `defensive` entry — see its tombstone. + * + * So applicability is per site, recorded here as data, and the per-site pins in + * `packages/types/src/__tests__/retired-field-key-tombstones.test.ts` are what + * keep both of those decisions mechanical rather than conventional. + * + * ## Membership criterion + * + * Every key in this registry is refused by the installed `FieldSchema` with + * `unrecognized_keys` — measured, and pinned by the registry's own test. That + * is what makes stripping safe: the server refuses to store these values, so a + * strip can never lose anything that persisted. A key the schema ACCEPTS must + * never be added here; stripping an accepted key deletes authored metadata. + * + * The strips these tombstones feed are keyed to the registry and are NOT a + * blanket unknown-key purge (AGENTS.md #0.1): a plugin-registered key the + * INSTALLED spec does not know is `unrecognized_keys` to the client while the + * SERVER that sent it accepts it, so filtering by the schema's accept set would + * drop precisely the keys the carry-overs exist to preserve. + * + * `scripts/check-designer-field-key-parity.mjs` (the declared-shape parity + * gate) does not read this registry yet — pinning it as that gate's single + * source is the cross-lane follow-up named on objectui#6527. + */ + +/** + * The strip sites on the designer seam, in the order their columns read in the + * tombstones below. + * + * - `metadataAdminFieldsReadDoor` — READ: + * `app-shell/src/views/metadata-admin/previews/object-fields-io.ts` + * (`readFields`, the single read door for `draft.fields` across the whole + * object designer). Strips on load, so an edit-and-save round-trip of a + * poisoned draft comes out parseable. This door reads drafts a live editor + * also MIGRATES, which neither write door does — that is why its column is + * not the union of the other two. + * - `metadataServiceCarryOver` — WRITE: + * `app-shell/src/services/MetadataService.ts` (`carryOver`, objectui#6488). + * Bounds the per-field carry-over of `saveFields` / `saveObject` so a stored + * document's retired keys do not ride back out to + * `PUT /api/v1/meta/object/:name` as a hard 422. + * - `metadataFieldsPageCarryOver` — WRITE: + * `plugin-designer/src/MetadataFieldsPage.tsx` (`carryOver`). Same bound for + * the standalone Field Designer's own save path. + */ +export const RETIRED_FIELD_KEY_SITES = [ + 'metadataAdminFieldsReadDoor', + 'metadataServiceCarryOver', + 'metadataFieldsPageCarryOver', +] as const; + +/** One strip site on the designer seam. See {@link RETIRED_FIELD_KEY_SITES}. */ +export type RetiredFieldKeySite = (typeof RETIRED_FIELD_KEY_SITES)[number]; + +/** One retired field key's tombstone. See {@link RETIRED_FIELD_KEY_TOMBSTONES}. */ +export interface RetiredFieldKeyTombstone { + /** The refused spelling, exactly as `FieldSchema` names it in `unrecognized_keys`. */ + readonly key: string; + /** The card that retired the key — the home of the full evidence. */ + readonly retiredBy: `objectui#${number}`; + /** + * The accepted `FieldSchema` key that carries this CONCEPT today, or `null` + * when the spec has no field-level equivalent. Documentation for the reader, + * NEVER an instruction to migrate a value mechanically: objectui#6043 refused + * exactly that rename for `formula`, because `FieldSchema` validates the key + * and not the language, so a blind rename launders non-CEL text into a + * formula that parses green and evaluates to null. + */ + readonly specEquivalent: string | null; + /** + * `true` when NO measurement shows any stored document can carry the key — + * the strip is insurance rather than a measured fix, and the entry says so + * instead of reading like a measurement. Kept-with-a-note is a recorded + * verdict (objectui#6527); a defensive entry must never spread to more sites + * without evidence. + */ + readonly defensive: boolean; + /** Which sites strip this key. Every `false` is deliberate and documented. */ + readonly sites: Readonly>; +} + +/** + * The tombstones, in the order the cards retired the keys. + * + * Adding an entry: the key must be measured `unrecognized_keys` against the + * installed `FieldSchema` (the registry test enforces this), and each site + * column is its own decision — a site that cannot see the key (read door: + * "no draft can carry it"; a carry-over: "this writer never emitted it") gets + * `false` and a reason here, not a defensive `true`. + */ +export const RETIRED_FIELD_KEY_TOMBSTONES = [ + { + /* + * Never a FieldSchema key at all: the field-level flag built no index + * (objectstack#2377 removed it) and the object's `indexes[]` is the real + * surface — hence no `specEquivalent`, the concept moved levels rather + * than spellings. Shipped in console 17.0.0 GA as the field inspector's + * `Indexed` checkbox, so stored objects from that era carry it. + */ + key: 'indexed', + retiredBy: 'objectui#4644', + specEquivalent: null, + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * A rename: the spec spells the lookup target `reference` + * ("Did you mean `referenceTo` -> `reference`?"). 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. + */ + key: 'referenceTo', + retiredBy: 'objectui#6041', + specEquivalent: 'reference', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * The one entry whose strip DROPS a value, and the one whose read-door + * column is a ruling rather than a symmetry: + * + * - WRITE doors strip it (`true` twice): the server refuses to store the + * value, and with the authoring control retired an author has no other + * way to clear it, so leaving it would keep the object 422-blocked + * forever. objectui#6043 REFUSED the rename to `expression` — see + * `specEquivalent` on the interface for why a blind rename is worse + * than the drop. + * - The READ door must NOT strip it (`false`) — RULED, objectui#6526 + * option B (2026-08-27, inherited by objectui#6527): + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula`; the first edit commits `expression` + * and clears the alias (objectui#6043's migration), and the client-side + * 422 diagnostic names the field and points at that editor (PR #6624). + * Stripping here empties the editor and destroys the authored + * expression text — measured: the inspector's migration pin renders + * `""` and fails. Flipping this `false` is overturning a maintainer + * ruling, not tidying an inconsistency. + */ + key: 'formula', + retiredBy: 'objectui#6043', + specEquivalent: 'expression', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * A rename with an asymmetric write half: the spec spells it `system`, and + * `fromDesignerField` / `toFieldPayload` never NAME `isSystem`, so the + * carry-over strip is the entire write half of the retirement (the spec + * spelling rides through untouched, which is what lets the designers read + * the flag back). + */ + key: 'isSystem', + retiredBy: 'objectui#6044', + specEquivalent: 'system', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * The registry's one DEFENSIVE entry, and its one single-site column — + * both facts are pinned, and both were decided on the record rather than + * unioned in (objectui#6527, re-measured 2026-08-27 on this tree): + * + * objectui#6045 retired the key as objectui#4687's zero-readers / + * zero-writers shape, NOT as a rename: `DesignerFieldDefinition` declared + * it, `toFieldPayload` copied it, nothing ever populated it, and + * `JSON.stringify` dropped the `undefined` — "the key never reached the + * wire". No shipped writer on this tree ever emitted a field-level + * `sortOrder`, so no stored document written by these designers can carry + * one. The spec has no field-level ordering key at all: field order is + * declaration order in the object's `fields` record, and the near-spelling + * `sortable` is a boolean ("whether field is sortable in list views") — a + * different concept, not an equivalent. + * + * - `metadataServiceCarryOver: true` — objectui#6488 added the strip + * when it built the carry-over, as insurance against a document some + * OTHER client stored while an older server accepted the key. Kept, + * with the measurement recorded here instead of silently: dropping it + * would turn such a document (if one exists — unmeasured either way) + * into a 422 that blocks every save with no UI way out, for the price + * of one honest entry. + * - The other two sites never had it and gain nothing: the read door + * strips only what a draft can measurably carry (its own contract: + * "add it then, not defensively"), and `MetadataFieldsPage`'s + * carry-over has the same unmeasured premise it always had. Widening + * either would be the naive union this registry exists to prevent. + * + * Evidence of a stored field-level `sortOrder` flips this entry to a + * measured three-site strip; evidence that none can exist retires it + * entirely. Either way, update the tombstone and its pins together. + * + * Distinct keys, same spelling: the object-level `sortOrder` retired by + * objectui#6223 (an `ObjectSchema` matter, still declared on + * `ObjectDefinition` as the Object Manager's display order) and the + * saved-view `sortOrder` in `app-shell`'s `ObjectView` (per-view display + * order). Neither is this field-level key. + */ + key: 'sortOrder', + retiredBy: 'objectui#6045', + specEquivalent: null, + defensive: true, + sites: { + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: false, + }, + }, +] as const satisfies readonly RetiredFieldKeyTombstone[]; + +/** The union of every retired field key named in the registry. */ +export type RetiredFieldKey = (typeof RETIRED_FIELD_KEY_TOMBSTONES)[number]['key']; + +/** + * The retired field keys that apply at `S` — the literal union, derived from + * the tombstone data so a site's list and its type cannot drift apart. + */ +export type RetiredFieldKeysAt = Extract< + (typeof RETIRED_FIELD_KEY_TOMBSTONES)[number], + { sites: { [K in S]: true } } +>['key']; + +/** + * The strip list for one site, in registry (retirement) order. + * + * This is the ONLY supported way for a strip site to obtain its list — a site + * that inlines a literal is a fourth copy, which is exactly the drift + * objectui#6527 closed. The cast is sound because the return type is derived + * from the same tombstone data the filter reads. + */ +export function retiredFieldKeysFor( + site: S, +): readonly RetiredFieldKeysAt[] { + return RETIRED_FIELD_KEY_TOMBSTONES.filter((t) => t.sites[site]).map( + (t) => t.key, + ) as RetiredFieldKeysAt[]; +} From 5a1f6067337c964acfc58bdf6abf7f318f8a5b7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:52:02 +0000 Subject: [PATCH 2/3] refactor(types): move the retired-field-key tombstone registry off the main barrel (objectui#6527 option B) Maintainer ruling, 2026-08-28: the registry moves to a dedicated internal subpath, `@object-ui/types/internal/retired-field-keys`, instead of the package's main barrel. Importing the barrel eagerly evaluates every other module it re-exports -- including `spec-report.ts`'s read of `@objectstack/spec/ui` -- which is what widened an unrelated test's partial spec mock into a failed suite (packages/app-shell/src/views/metadata-admin/clientValidation.skew.test.tsx) under the prior (option A) shape. A subpath import never pulls the barrel in, so that suite passes again with zero changes to the test or its mock. Four edits, exactly as ruled: - Remove the registry's re-exports from the `@object-ui/types` main barrel (`packages/types/src/index.ts`). - Add one dedicated internal subpath to the `exports` map (`./internal/retired-field-keys`), and move the registry's source under `packages/types/src/internal/` to match -- the package's existing `./zod` subpath is the precedent for a subpath's source living in a matching real subdirectory rather than a flat file behind a renamed export key. - Rewrite the 3 consuming imports (MetadataService.ts, MetadataFieldsPage.tsx, object-fields-io.ts) to the subpath. - Re-point the registry's own barrel-wiring pin at the subpath, and add a companion pin that the registry is NOT exported from the main barrel, so a regression back to option A turns a test red instead of drifting silently. Nothing else changes: the objectui#6526 formula read-door asymmetry stays encoded and pinned, the sortOrder defensive verdict stays recorded, and scripts/check-designer-field-key-parity.mjs is untouched (domain:devx lane). Changeset unchanged: '@object-ui/types': minor (already correct). --- .../app-shell/src/services/MetadataService.ts | 6 ++- .../previews/object-fields-io.ts | 8 +++- .../src/MetadataFieldsPage.tsx | 7 +++- packages/types/package.json | 4 ++ .../retired-field-key-tombstones.test.ts | 41 ++++++++++++++----- packages/types/src/index.ts | 23 ++++------- .../src/{ => internal}/retired-field-keys.ts | 0 7 files changed, 62 insertions(+), 27 deletions(-) rename packages/types/src/{ => internal}/retired-field-keys.ts (100%) diff --git a/packages/app-shell/src/services/MetadataService.ts b/packages/app-shell/src/services/MetadataService.ts index 5c50aa36c6..eec3f9b0d0 100644 --- a/packages/app-shell/src/services/MetadataService.ts +++ b/packages/app-shell/src/services/MetadataService.ts @@ -18,8 +18,12 @@ import { stripReadDecorations } from '@objectstack/spec/kernel'; import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { retiredFieldKeysFor } from '@object-ui/types'; 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 diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index e616d8620f..6fd3399e24 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -17,7 +17,13 @@ * {@link RETIRED_FIELD_KEYS} — see the note on that constant. */ -import { retiredFieldKeysFor } 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 +// (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'; diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 62335efeea..f6aac3b9a0 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -31,8 +31,13 @@ */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { DESIGNER_FIELD_TYPES, retiredFieldKeysFor } from '@object-ui/types'; +import { DESIGNER_FIELD_TYPES } from '@object-ui/types'; import type { DesignerFieldDefinition, DesignerFieldType } 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'; import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack'; import { FieldDesigner } from './FieldDesigner'; diff --git a/packages/types/package.json b/packages/types/package.json index e65f4efde7..b4d320b13c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -51,6 +51,10 @@ "./zod": { "types": "./dist/zod/index.zod.d.ts", "import": "./dist/zod/index.zod.js" + }, + "./internal/retired-field-keys": { + "types": "./dist/internal/retired-field-keys.d.ts", + "import": "./dist/internal/retired-field-keys.js" } }, "files": [ diff --git a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts index 0a24a71667..fe7500b489 100644 --- a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts +++ b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts @@ -44,11 +44,14 @@ import { RETIRED_FIELD_KEY_TOMBSTONES, retiredFieldKeysFor, type RetiredFieldKey, -} from '../retired-field-keys.js'; +} from '../internal/retired-field-keys.js'; +// The bare-specifier subpath, exercised the way the three real consuming +// sites reach the registry — objectui#6527 option B (maintainer ruling, +// 2026-08-28): the registry is DELIBERATELY off the package's main barrel. import { - RETIRED_FIELD_KEY_TOMBSTONES as fromBarrel, - retiredFieldKeysFor as retiredFieldKeysForFromBarrel, -} from '../index.js'; + RETIRED_FIELD_KEY_TOMBSTONES as fromSubpath, + retiredFieldKeysFor as retiredFieldKeysForFromSubpath, +} from '@object-ui/types/internal/retired-field-keys'; /** An otherwise-green field — the positive control every probe rides on. */ const BASE_FIELD = { name: 'amount', type: 'number', label: 'Amount' } as const; @@ -227,11 +230,29 @@ describe('retired-field-key registry · hygiene', () => { } }); - it('the registry and its derivation are exported from the package barrel', () => { - // The three strip sites import from `@object-ui/types`; a registry that - // fell out of the barrel would break them at build time, but this pin - // makes the wiring a stated fact rather than an accident. - expect(fromBarrel).toBe(RETIRED_FIELD_KEY_TOMBSTONES); - expect(retiredFieldKeysForFromBarrel).toBe(retiredFieldKeysFor); + it('the registry and its derivation are exported from the dedicated internal subpath', () => { + // The three strip sites import `@object-ui/types/internal/retired-field-keys` + // directly; a registry whose wiring fell off that subpath would break them + // at build time, but this pin makes the wiring a stated fact rather than + // an accident. + expect(fromSubpath).toBe(RETIRED_FIELD_KEY_TOMBSTONES); + expect(retiredFieldKeysForFromSubpath).toBe(retiredFieldKeysFor); + }); + + it('the registry is NOT exported from the main package barrel', async () => { + // objectui#6527 option B (maintainer ruling, 2026-08-28): the registry was + // deliberately un-exported from `@object-ui/types`'s main barrel. Importing + // that barrel eagerly evaluates every other module it re-exports — + // including `spec-report.ts`'s read of `@objectstack/spec/ui` — which is + // what widened an unrelated consumer's partial spec mock into a failed + // suite under the prior (option A) shape. This pin guards the regression: + // re-adding the re-export to `index.ts` turns it red. + // Type-only exports (`RetiredFieldKeySite` etc.) erase at runtime and + // cannot be probed this way; the compile-time half of this guard is the + // three consuming sites' own subpath imports failing to typecheck if the + // subpath's types ever moved, which is not this pin's job to duplicate. + const barrel: Record = await import('../index.js'); + expect('RETIRED_FIELD_KEY_TOMBSTONES' in barrel).toBe(false); + expect('retiredFieldKeysFor' in barrel).toBe(false); }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 90530cb533..3b7ecf5c4c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -685,20 +685,15 @@ export { DESIGNER_FIELD_TYPES, } from './designer.js'; -// Retired-field-key tombstone registry — the single source for the designer -// seam's per-site strip lists (objectui#6527). -export type { - RetiredFieldKeySite, - RetiredFieldKeyTombstone, - RetiredFieldKey, - RetiredFieldKeysAt, -} from './retired-field-keys.js'; - -export { - RETIRED_FIELD_KEY_SITES, - RETIRED_FIELD_KEY_TOMBSTONES, - retiredFieldKeysFor, -} from './retired-field-keys.js'; +// Retired-field-key tombstone registry (objectui#6527) is DELIBERATELY not +// re-exported from this main barrel — ruled objectui#6527 option B +// (maintainer, 2026-08-28). It lives at the dedicated internal subpath +// `@object-ui/types/internal/retired-field-keys` instead: importing this +// barrel eagerly evaluates every other module it re-exports (including +// `spec-report.ts`'s read of `@objectstack/spec/ui`), and a registry export +// added here widened that evaluation graph enough to break an unrelated +// consumer's partial spec mock. A subpath import never pulls this barrel in. +// See `packages/types/src/internal/retired-field-keys.ts` for the registry. // ============================================================================ // API and Events - API Integration and Event Handling diff --git a/packages/types/src/retired-field-keys.ts b/packages/types/src/internal/retired-field-keys.ts similarity index 100% rename from packages/types/src/retired-field-keys.ts rename to packages/types/src/internal/retired-field-keys.ts From 97c36c4e47ac41f7a7e049209ef3d14522fe140e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:33:51 +0000 Subject: [PATCH 3/3] test(types): pin the retired-key subpath by reading the exports map, not by self-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry's wiring pin imported the package's own bare subpath specifier `@object-ui/types/internal/retired-field-keys` from inside `packages/types/src/`, which `check:self-import` refuses (objectui#4801): that specifier resolves through the package's OWN `exports` map to `dist/`, and turbo gives `type-check`/`test` only `^build` — the DEPENDENCIES' builds, never this package's own — so on a cold CI cache the declarations have not been produced and the file fails TS2307. Green on every machine that has ever built, red in CI only. The runtime half of that pin was also tautological rather than a wiring test: the repo-root `vitest.config.mts` aliases `@object-ui/types` to `packages/types/src` by prefix (the `/zod` entry directly above it exists precisely because that matching is prefix-based), so both sides of the `toBe` resolved to the same source module. It compared a module to itself and could not fail. So the module half becomes a relative import — the same module on both surfaces that read it — and the wiring half moves to a manifest-level assertion in the style of this package's existing `package-exports-manifest.test.ts`: read `package.json` and pin the `./internal/retired-field-keys` entry's exact shape, then DERIVE the source path from the declared target and prove it exists. Deriving is what keeps it a wiring assertion; two independent literals that happen to agree would still pass with the entry re-pointed at nothing. The declared `dist/` target itself is deliberately not stat-ed — that would reintroduce the very build-order dependency this replaces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../retired-field-key-tombstones.test.ts | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts index fe7500b489..87f229c779 100644 --- a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts +++ b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts @@ -36,6 +36,10 @@ * the derived lists is the consolidation's parity measurement. */ +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { FieldSchema } from '@objectstack/spec/data'; @@ -45,13 +49,25 @@ import { retiredFieldKeysFor, type RetiredFieldKey, } from '../internal/retired-field-keys.js'; -// The bare-specifier subpath, exercised the way the three real consuming -// sites reach the registry — objectui#6527 option B (maintainer ruling, -// 2026-08-28): the registry is DELIBERATELY off the package's main barrel. -import { - RETIRED_FIELD_KEY_TOMBSTONES as fromSubpath, - retiredFieldKeysFor as retiredFieldKeysForFromSubpath, -} from '@object-ui/types/internal/retired-field-keys'; + +// `package.json` is read the same way `package-exports-manifest.test.ts` +// reads it (createRequire + readFileSync), NOT by importing the bare +// specifier `@object-ui/types/internal/retired-field-keys` from inside this +// package's own `src/` — objectui#4801 / scripts/check-package-self-import.mjs: +// that specifier resolves through the package's OWN `exports` map to `dist/`, +// and neither `type-check` nor `test` has a build-order dependency on this +// package's own build (turbo's `^build` reaches only DEPENDENCIES), so on a +// cold CI cache the self-import fails with TS2307 -- green only on a machine +// that happened to have built `dist/` already. It also would not have proven +// what it looked like it proved: this repo's vitest config aliases the bare +// package name straight to `src/` (vitest.config.mts), so even a passing +// self-import here would be resolving through that alias, never through the +// real `exports` map a genuine external consumer uses. +const require = createRequire(import.meta.url); +const packageJsonPath = require.resolve('../../package.json'); +const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + exports?: Record | string>; +}; /** An otherwise-green field — the positive control every probe rides on. */ const BASE_FIELD = { name: 'amount', type: 'number', label: 'Amount' } as const; @@ -230,13 +246,49 @@ describe('retired-field-key registry · hygiene', () => { } }); - it('the registry and its derivation are exported from the dedicated internal subpath', () => { + it('package.json declares the internal subpath, pointed at this module\'s own dist output', () => { // The three strip sites import `@object-ui/types/internal/retired-field-keys` // directly; a registry whose wiring fell off that subpath would break them // at build time, but this pin makes the wiring a stated fact rather than // an accident. - expect(fromSubpath).toBe(RETIRED_FIELD_KEY_TOMBSTONES); - expect(retiredFieldKeysForFromSubpath).toBe(retiredFieldKeysFor); + // + // This reads the DECLARATION rather than round-tripping an import of the + // bare specifier from inside the package's own `src/` (objectui#4801 / + // scripts/check-package-self-import.mjs — see the import block above for + // why that form is both a cold-CI-cache TS2307 hazard AND, separately, + // not actually a wiring test: vitest's own alias config resolves the bare + // package name straight to `src/`, so a self-import here would never + // exercise the real `exports` map a genuine external consumer resolves + // through). + const entry = pkg.exports?.['./internal/retired-field-keys']; + expect(entry).toEqual({ + types: './dist/internal/retired-field-keys.d.ts', + import: './dist/internal/retired-field-keys.js', + }); + + // The declared `dist/` target is NOT checked for existence: `test` has no + // build-order dependency on this package's OWN build (the same gap + // check-package-self-import.mjs polices for `type-check`), so a fresh CI + // cache has no `dist/` yet and asserting it existed would reintroduce the + // exact hazard this pin replaces — see `package-exports-manifest.test.ts`'s + // header for the same reasoning applied to the root export. + // + // What IS available unconditionally is the SOURCE the declared target is + // generated from — and it is DERIVED FROM THE DECLARATION rather than + // restated as a second constant. That derivation is what keeps this half a + // WIRING assertion: two independent literals that happen to agree would + // still pass if the `exports` entry were re-pointed at a module that does + // not exist. `tsc` mirrors the package's `src` tree to `dist` one-to-one + // (rootDir: ./src, outDir: ./dist — packages/types/tsconfig.json), so + // inverting that mapping over the declared target proves the subpath + // resolves to a real module, with no build required first. + const declaredImport = (entry as Record).import; + const srcRelative = declaredImport.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts'); + const srcPath = resolve(dirname(packageJsonPath), srcRelative); + expect({ srcRelative, exists: existsSync(srcPath) }).toEqual({ + srcRelative: 'src/internal/retired-field-keys.ts', + exists: true, + }); }); it('the registry is NOT exported from the main package barrel', async () => {