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
@@ -10,6 +10,7 @@ export type PropertyKind =
| 'date'
| 'datetime'
| 'array_string'
| 'opaque' // Сложный тип (nested object, array of objects), редактируется только через JSON-таб
export type PropertyDef = {
id: string
@@ -25,6 +26,9 @@ export type PropertyDef = {
minimum?: number
maximum?: number
uniqueItems?: boolean
/** Для kind: 'opaque' — оригинальный JSON Schema fragment, передаётся
* без изменений на save. Защита от lossy round-trip при nested objects. */
rawSchema?: JsonSchema
}
export const PROPERTY_KINDS: PropertyKind[] = [
@@ -128,6 +132,14 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
...(p.uniqueItems ? { uniqueItems: true } : {}),
...uniqueExt,
} as JsonSchema
case 'opaque':
// Round-trip raw schema без изменений. Description можно overwrite
// если admin отредактировал в metadata-табе, остальное as-is.
return p.rawSchema
? (desc !== undefined && desc !== p.rawSchema.description
? { ...p.rawSchema, description: desc }
: p.rawSchema)
: { type: 'string', description: desc }
}
}
@@ -193,7 +205,10 @@ const inferKind = (
uniqueItems: Boolean((raw as { uniqueItems?: boolean }).uniqueItems),
}
}
return { ...base, kind: 'string' }
// Fallback: nested objects, complex arrays, unknown types — preserve raw
// schema полностью. Lossy round-trip (object → string fallback) ломал
// breaking-change detector + терял nested properties (см. spacecraft.orbit).
return { ...base, kind: 'opaque', rawSchema: raw }
}
// === schema diff (breaking detection) ===