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:
@@ -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) ===
|
||||
|
||||
Reference in New Issue
Block a user