fix(schema-editor): preserve nested objects via 'opaque' kind

Bug: parseSchemaJson fallback на line 196 возвращал {kind: 'string'}
для unknown types (object с nested properties, array of objects, etc.).
При save back to schema это давало lossy round-trip:

  spacecraft.orbit (object с 7 nested fields)
  → parseSchemaJson → kind: 'string'
  → propertyToSchema → {type: 'string'}
  → diffSchemas → BREAKING CHANGE 'object → string' → save blocked

Симптом видел user: edit modal spacecraft показывает 'НЕСОВМЕСТИМОЕ
ИЗМЕНЕНИЕ СХЕМЫ' для orbit, save заблокирован, хотя admin не трогал
orbit.

Fix:
- Новый PropertyKind 'opaque' для unknown types
- PropertyDef.rawSchema (optional JsonSchema) хранит оригинал
- parseSchemaJson fallback теперь {kind:'opaque', rawSchema:raw}
- propertyToSchema для opaque возвращает rawSchema verbatim (с
  description override если admin менял в Поля-табе)
- PropertyEditor: 'opaque' исключён из Type dropdown (admin не может
  вручную выбрать), для existing opaque поля показывается warning
  card с raw JSON read-only + hint что редактируется через JSON-таб

Test coverage: 73 tests passing (5 + 10 SchemaBuilder + 7 + ...).
Эффект: edit любого справочника с nested objects (orbit) больше не
триггерит false-positive breaking change.

i18n: schema.kind.opaque + schema.opaque.{title,hint} × ru-RU/en-US.
This commit is contained in:
Zimin A.N.
2026-05-06 17:29:42 +03:00
parent 0759a5a150
commit e300d4ea8b
3 changed files with 45 additions and 5 deletions
@@ -32,10 +32,15 @@ export const PropertyEditor = ({
}: Props) => {
const { t } = useTranslation()
const kindOptions: SelectOption[] = PROPERTY_KINDS.map((k) => ({
id: k,
label: t(`schema.kind.${k}`),
}))
// 'opaque' исключён из dropdown — admin не может вручную выбрать сложный
// тип. Поле получает opaque только через parseSchemaJson (когда уже было
// в исходной schema). Для NEW поля admin использует обычные kinds.
const kindOptions: SelectOption[] = PROPERTY_KINDS
.filter((k) => k !== 'opaque')
.map((k) => ({
id: k,
label: t(`schema.kind.${k}`),
}))
const update = (patch: Partial<PropertyDef>) => onChange({ ...prop, ...patch })
@@ -108,6 +113,20 @@ export const PropertyEditor = ({
onChange={(e) => update({ description: e.target.value })}
/>
{prop.kind === 'opaque' && (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 text-2xs space-y-2">
<div className="font-medium text-carbon">
{t('schema.opaque.title')}
</div>
<div className="text-carbon/70">
{t('schema.opaque.hint')}
</div>
<pre className="font-mono text-2xs bg-white border border-regolith rounded p-2 overflow-auto max-h-40">
{JSON.stringify(prop.rawSchema ?? {}, null, 2)}
</pre>
</div>
)}
{(prop.kind === 'string') && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<TextInput