diff --git a/.changeset/6519-retired-field-key-strip.md b/.changeset/6519-retired-field-key-strip.md new file mode 100644 index 0000000000..b4c449fd0a --- /dev/null +++ b/.changeset/6519-retired-field-key-strip.md @@ -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. 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 0d4d876be2..08167dd871 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 @@ -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. @@ -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>; + 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>; - 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>; + 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>; - 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 + >; + + 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>; + 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>; + + 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>; + 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 >; - 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', () => { @@ -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>; - 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', () => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts index c6aa0f78a1..36482f50a3 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts @@ -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'; /** @@ -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", ]`, 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): 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>).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 + >).map(({ name, ...def }) => [name as string, def]), + ), + }; + expect(ObjectSchema.safeParse(body).success).toBe(true); + }); +}); 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 0eefb0e350..d37902d392 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 @@ -24,37 +24,98 @@ export type Shape = 'array' | 'record'; /** * Field keys the ObjectStack spec REJECTS by name, stripped on read. * - * `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: + * 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: * - * 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 => 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) * - * The real surface is object-level `indexes: [{ name, fields, unique }]`, - * materialised by `SqlDriver.syncDeclaredIndexes` — nothing in this repo - * reads a field-level `indexed`, so there is no behaviour to preserve. + * `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. * - * Until objectui#4644 the field inspector offered an `Indexed` checkbox that - * wrote the key, so drafts authored in Studio can still carry it — and every - * write path here spreads the def it read (`{ ...def, ...patch }`), which - * would carry the key straight back out to - * `PUT /api/v1/meta/object/:name`, where it is a hard 422 - * (`INVALID_METADATA`) that blocks EVERY subsequent save of the object until - * the author finds and clears 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): * - * 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), so one strip here covers every writer. + * - `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. + * + * 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. + * + * ── 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 + * 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. + * + * 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. * * Same shape as `PermissionAdvancedFacets`' `RETIRED_RLS_KEYS` - * (objectstack#7130). Keyed to the tombstone, never a blanket unknown-key + * (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. */ -export const RETIRED_FIELD_KEYS = ['indexed'] as const; +export const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'isSystem'] as const; /** Drop {@link RETIRED_FIELD_KEYS} from one field definition. */ function stripRetiredFieldKeys(def: Record): Record {