Appearance
JSONB shapes — kontrakty pól
HARD-06 (audit 2026-05-12) — formalna dokumentacja shape-ów JSONB w PIM. Backend wcześniej jedyne dawał implicit kontrakt przez
*Validatori writer-y; frontend musiał reverse-engineerować shape z gotowych payloadów. Każdy bug typu "atrybuty się nie zapisują" (PR #511) był w istocie nieporozumieniem co do envelope shape-u. Ten dokument jest authoritative source-em.
Schemas używają JSON Schema 2020-12. Plik docelowo zostanie zlinkowany z CLAUDE.md jako wymagana lektura przy kontaktach z attributes_indexed / validation_rules / completeness.
1. attributes_indexed — denormalizowany cache atrybutów
Tabela: objects.attributes_indexed JSONB DEFAULT '{}'Index: objects_attributes_indexed_gin USING GIN (attributes_indexed)Writer: AttributesIndexedRebuilder::rebuild()Readers: cały admin (przez unwrapAttributesIndexed helper) + Meilisearch indexer.
Shape
Mapa attribute.code → wartość. Każda wartość to envelope (nie raw value). Envelope może mieć dodatkowe meta pola pod overlay locale/channel w przyszłości.
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "pim:jsonb:attributes_indexed",
"type": "object",
"additionalProperties": {
"type": "object",
"description": "Envelope wartości. Nośnik wartości zależy od typu atrybutu — patrz `oneOf` niżej (#2738: poprzednia wersja wymagała `value` dla WSZYSTKICH typów, co odrzucało poprawne koperty select/multiselect/price produkowane przez writer).",
"oneOf": [
{
"title": "scalar (text, wysiwyg, number, boolean, date, metric…)",
"required": ["value"]
},
{
"title": "select",
"required": ["option_code"]
},
{
"title": "multiselect",
"required": ["option_codes"]
},
{
"title": "price",
"required": ["amount", "currency"]
}
],
"properties": {
"value": {
"description": "Wartość skalarna atrybutu w typie zgodnym z Attribute.type: dla date string ISO YYYY-MM-DD; dla boolean true/false; dla text/wysiwyg string; dla number liczba. Typy select / multiselect / price NIE używają tego pola — mają własne nośniki niżej."
},
"option_code": {
"type": "string",
"description": "Kod wybranej opcji dla atrybutu typu select (np. \"red\")."
},
"option_codes": {
"type": "array",
"items": { "type": "string" },
"description": "Kody wybranych opcji dla atrybutu typu multiselect (np. [\"new\",\"sale\"])."
},
"amount": {
"type": ["number", "string"],
"description": "Kwota dla atrybutu typu price (para z `currency`)."
},
"currency": {
"type": "string",
"description": "Kod waluty ISO 4217 dla atrybutu typu price (np. \"PLN\")."
},
"locale": {
"type": ["string", "null"],
"description": "Locale code (pl/en/de/cs) jeśli wartość jest locale-scoped. Brak = global (wszystkie locale)."
},
"channel": {
"type": ["string", "null"],
"description": "Channel code jeśli wartość jest channel-scoped. Brak = global."
},
"provenance": {
"enum": ["manual", "import", "agent", "integration", null],
"description": "Skąd przyszła wartość. Reserved `agent` na Fazę 2."
}
},
"additionalProperties": true
}
}Przykłady
json
{
"name": { "value": "Buty sportowe Air Max 90" },
"color": { "option_code": "red" },
"tags": { "option_codes": ["new", "sale"] },
"price": { "amount": 299.00, "currency": "PLN" },
"release_date":{ "value": "2027-03-15" },
"in_stock": { "value": true }
}Reguły dla readerów
- Zawsze czytaj przez
unwrapAttributesIndexed(raw)(admin) — helper passthroughuje wpisy bez envelope dla bezpieczeństwa migracji. - NIE rób
typeof attrs.name === 'string'—attrs.nameto envelope, nie string. - Po unwrapowaniu wartość ma typ zgodny z
Attribute.type. Per-type rendering wAttrRow.
Reguły dla writerów
- NIGDY nie pisz
attributes_indexedręcznie. Single source of truth: zapisuj wObjectValueprzezObjectAttributesUpserter→AttributesIndexedRebuilderautomatycznie odbuduje cache. - Jeśli musisz pisać bezpośrednio (np.
DemoCatalogSeeder), zachowaj envelope:[$code => ['value' => $rawValue]].
2. validation_rules — per-Attribute walidacja
Tabela: attributes.validation_rules JSONB DEFAULT '{}'Reader: TypeValidator (per-type implementacje: TextValidator, NumberValidator, SelectValidator, …).
Shape (per-type)
Schema unionowa — pole validation_rules jest interpretowane przez validator zgodnie z Attribute.type. Klucze nieużywane przez dany type są ignorowane (graceful degradation przy migracji).
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "pim:jsonb:validation_rules",
"type": "object",
"properties": {
"max_length": { "type": "integer", "minimum": 1, "description": "text, wysiwyg" },
"min_length": { "type": "integer", "minimum": 0, "description": "text, wysiwyg" },
"pattern": { "type": "string", "description": "text, email — regex JS-compatible (email: extra domain allow-list on top of RFC 5322 check)" },
"color_format": { "enum": ["hex", "rgb"], "description": "color (#1177) — `hex` (#RRGGBB, default) or `rgb` (rgb(r, g, b))" },
"format": { "enum": ["ean13", "gtin14", "isbn13", "isbn10", "url", "iso_country"], "description": "identifier (#1179) — digit count + check digit (GTIN mod-10 / ISBN-10 mod-11); text (DP-06 #2036) — `url` (valid absolute URL) or `iso_country` (ISO 3166-1 alpha-2, case-insensitive)" },
"require_https": { "type": "boolean", "description": "text (DP-06 #2036) — z `format: url` wymusza schemat https" },
"min": { "type": ["number", "string"], "description": "number, metric, price (number); date, datetime (ISO 8601 string — floor)" },
"max": { "type": ["number", "string"], "description": "number, metric, price (number); date, datetime (ISO 8601 string — ceil)" },
"min_amount": { "type": "number", "description": "price — wymóg na amount" },
"max_amount": { "type": "number", "description": "price" },
"currencies": { "type": "array", "items": { "type": "string", "minLength": 3, "maxLength": 3 }, "description": "price — allowed ISO 4217 codes" },
"max_count": { "type": "integer", "minimum": 1, "description": "multiselect, tags — max liczba wybranych opcji" },
"min_count": { "type": "integer", "minimum": 0, "description": "multiselect, tags" },
"allowed_kinds":{ "type": "array", "items": { "type": "string" }, "description": "asset, relation — allowed ObjectType.kind" },
"min_date": { "type": "string", "format": "date", "description": "date" },
"max_date": { "type": "string", "format": "date", "description": "date" }
},
"additionalProperties": false
}Przykłady
json
{ "max_length": 255 } // name (text)
{ "min": 0, "currencies": ["PLN", "EUR", "USD"] } // price
{ "max_count": 5 } // tags (multiselect)
{ "min_date": "2020-01-01" } // release_date (date)
{ "format": "url", "require_https": true } // datasheet_url (text)
{ "format": "iso_country" } // country_of_origin (text)Notki
- Pusta mapa
{}= brak walidacji poza built-inAttribute::typeconstraints. - Klucze nieznane danego typu są ignorowane przez validator (nie błąd). To pozwala dorzucić nowy klucz dla nowego typu bez breaking migration.
identifier(#1179) — unikalność per ObjectType na poziomie DB. Wartość trzymana wobject_values.value->>'value'jest mirrorowana przez triggerobject_values_sync_identifier_trgdo kolumnidentifier_value+identifier_object_type_id(NULL dla nie-identifier rows). PartialUNIQUE INDEX object_values_identifier_uniq (tenant_id, identifier_object_type_id, attribute_id, identifier_value) WHERE identifier_value IS NOT NULLegzekwuje unikalność; app-level pre-check (IdentifierUniquenessValidator) daje czytelne 409 przed constraintem. Identifier attrs są wymuszane jako non-localizable + non-scopable (jedna wartość per obiekt). Kolumny są zarządzane wyłącznie przez trigger — Doctrine ich nie mapuje ani nie zapisuje.
2a. object_types.validation_rules — reguły cross-field (DP-07 #2037, ADR-0025)
Tabela: object_types.validation_rules JSONB DEFAULT '[]' NOT NULLWriter: ObjectTypeService::update() (strict parse przez CrossFieldRules — JSONB nigdy nie niesie śmieci) Reader: CrossFieldRulesValidator — egzekwowanie w OBU ścieżkach zapisu wartości (ObjectAttributesUpserter → 422 przed zapisem; BatchValueWriter → issue kind: 'cross_field').
Shape
Lista reguł dwóch rodzajów:
json
[
{ "type": "compare", "left": "weight_net", "op": "lte", "right": "weight_gross" },
{ "type": "require_when",
"if": { "field": "expandable_storage", "operator": "equals", "value": true },
"then": { "required": "max_sd_card_gb" } }
]compare.op∈lt | lte | gt | gte | eq | neq;left/rightto kody atrybutów numerycznych (number/metric/price) tego samego typu (guard przy PATCH; bez konwersji jednostek/walut).require_when.if= kształtVisibleWhenRule(ten sam coattribute_group_attributes.visible_when).- Ewaluacja wyłącznie na global scope (locale=null, channel=null); brakująca/pusta strona
compare→ SKIP;require_whenstrzela gdy warunek prawdziwy i target pusty. Pełna tabela semantyki: ADR-0025.
3. completeness — denormalizowana kompletność
Tabela: objects.completeness JSONB DEFAULT '{}' + redundant objects.completeness_pct SMALLINT DEFAULT 0Writer: AttributesIndexedRebuilder::rebuild() (ten sam co attributes_indexed).
Shape
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "pim:jsonb:completeness",
"type": "object",
"required": ["global"],
"properties": {
"global": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Procent uzupełnionych pól z `ObjectType.completeness_rules.required` (0-100, integer)."
},
"per_channel": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": 0, "maximum": 100 },
"description": "Per-channel pct gdy ObjectType ma channel-scoped attributes (Faza 1 / channel publication)."
},
"per_locale": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": 0, "maximum": 100 },
"description": "Per-locale pct gdy attributes są localizable. Klucz = locale code (pl/en/de/cs)."
}
},
"additionalProperties": false
}Przykład
json
{
"global": 75,
"per_channel": { "shopify": 80, "baselinker": 60 },
"per_locale": { "pl": 100, "en": 50 }
}Reguły
globalzawsze obecne — fallback100gdycompleteness_rules.requiredjest pusta.per_channel/per_localeopcjonalne — frontend powinien czytać przezcompleteness?.per_channel?.[channel].completeness_pct(SMALLINT) jest mirroremglobaldla szybkiego sortu/index. Nigdy nie dezsynchronizować — pisać atomicznie razem z JSONB.
4. variant_axes — definicja osi wariantów
Tabela: objects.variant_axes JSONB NULLABLEWriter: GenerateVariantsHandler (po wygenerowaniu wariantów dla mastera). Reader: VariantsTab w admin (apps/admin/src/components/catalog/variants-tab.tsx).
Shape
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "pim:jsonb:variant_axes",
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "type": "string" },
"description": "Lista option codes z Attribute.options (dla select/multiselect typu)."
},
"description": "Klucz = Attribute.code (musi być typu select/multiselect z predefined options). Wartość = lista wybranych opcji do wygenerowania kombinacji."
}Przykład
json
{
"color": ["red", "blue", "black"],
"size": ["S", "M", "L", "XL"]
}12 wariantów (3 × 4) zostanie wygenerowanych przy GenerateVariantsHandler.
Reguły
- Klucz
nullna master = brak osi → operator decyduje per ticket variants tab. - Tylko select/multiselect attributes mogą być axes (osie potrzebują predefined values do iteracji).
- Po wygenerowaniu wariantów wartość się nie aktualizuje —
variant_axesopisuje DEFINICJĘ osi, nie istniejące warianty (te są wobjects.parent_idchain).
5. provenance_meta — meta wartości po stronie ObjectValue
Tabela: object_values.provenance_meta JSONB DEFAULT '{}'Writer: ObjectAttributesUpserter przy save.
Shape
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "pim:jsonb:provenance_meta",
"type": "object",
"properties": {
"source": { "type": "string", "description": "Identyfikator źródła (import session uuid, integration name, agent run id)" },
"imported_at": { "type": "string", "format": "date-time" },
"user_id": { "type": "string", "format": "uuid" },
"agent_run_id": { "type": "string", "format": "uuid", "description": "provenance=agent — id AgentRun (epik 0.7, tabela agent_runs)" },
"model": { "type": "string", "description": "provenance=agent — id modelu Anthropic użytego w runie (np. claude-sonnet-*)" },
"intent": { "type": "string", "description": "provenance=agent — intencja usera, z której powstała zmiana (skrót)" },
"channel": { "type": "string", "description": "Integration channel id jeśli provenance=integration" },
"source_attributes": { "type": "array", "items": { "type": "string" }, "description": "provenance=agent, opcjonalne (AICG) — kody atrybutów-źródeł użytych jako fakty przy generowaniu treści (audyt „skąd ten fakt")" },
"recipe_id": { "type": ["string", "null"], "format": "uuid", "description": "provenance=agent, opcjonalne (AICG) — id ContentRecipe, z którego wygenerowano wartość" }
},
"additionalProperties": true
}Shape per provenance=agent (AGENT-P0-04 #1947, ADR-0024; rozszerzenie AICG-P0-02 #2326, ADR-0030)
Wartości zapisane przez agenta (po akcepcie batcha pending_changes) niosą:
json
{ "agent_run_id": "<uuid AgentRun>", "model": "claude-sonnet-…", "intent": "ustaw cenę 100 wszystkim bez ceny" }agent_run_id linkuje wartość do runu (audyt + tooltip badge'a „agent" w UI, P6-05).
Wartości wygenerowane przez toole treści (epik AICG, ADR-0030) niosą dodatkowo dwa opcjonalne pola:
json
{
"agent_run_id": "<uuid AgentRun>",
"model": "claude-sonnet-…",
"intent": "generate_product_description",
"source_attributes": ["material", "color", "brand"],
"recipe_id": "<uuid ContentRecipe|null>"
}source_attributes— dokładnie te kody atrybutów, których wartości weszły do promptu jako fakty (kontrakt anty-halucynacyjny: audyt „skąd ten fakt"). Brak pola = zapis sprzed AICG lub nie-treściowy; readery traktują jak[].recipe_id—ContentRecipe, według którego pisano. Brak pola /null= generacja bez przepisu; readery traktują jaknull.- Backward compatibility: oba pola są czysto addytywne (reguła cross-cutting #2) — zapisy
provenance_metasprzed rozszerzenia parsują bez zmian, a projekcja doattributes_indexed(AttributesIndexedRebuilder::globalSlot) przepuszcza je tylko, gdy są obecne i poprawnie typowane (malformed → dropped, nigdy nie propagowane).
Reguły
additionalProperties: true— dodatkowe pola wolno dorzucać per provenance type (forward-compat).- Wartość zawsze powiązana z
ObjectValue.provenanceenum (manual/import/agent/integration). - Frontend wykorzystuje przez
<ProvenanceBadge>— pokazuje tylkoprovenance+ tooltip zsource.
Reguły ogólne (cross-cutting)
- Defensive read po stronie frontendu: każdy reader JSONB MUSI mieć fallback na missing key + invalid type. Wzór:
unwrapAttributesIndexed. Nigdyattrs.name.valuebez null-checka —attrs.namemoże nie istnieć. - Backward compatible writers: dodanie nowego klucza do envelope = additive (nie wymusza migracji frontendu). Usunięcie/rename = breaking (wymaga koordynacji + migration ticket).
- Indeksy GIN są na całym dokumencie JSONB, nie per-key. Zapytania
WHERE attributes_indexed @> '{"color":{"value":"red"}}'będą szybkie. Per-key index (functional index) dorzucamy jeśli konkretne zapytanie wymaga (np. fast price lookup → osobny migration ticket). - Validacja shape: dziś jest implicit (validators po stronie writera). Future (po Fazie 1): JSON Schema validation w Symfony Validator constraints + automated test który asercjuje że produkcyjne payloady matchują schema-y z tego pliku.
Powiązane
- ADR-009 (
Project Plan/01-architektura-pim.md§13) — generalizacjaObjectTypejako first-class. agent/lessons.md"Patterns to Follow" → "attributes_indexedma envelope{value: ...}" (PR #511).apps/admin/src/lib/attributes-indexed.ts— shared reader helper.apps/api/src/Catalog/Application/AttributesIndexedRebuilder.php— single writer dla cache.
6. object_values.value — kanon per AttributeType (ADR-0019)
Cache attributes_indexed kopiuje envelope verbatim z ObjectValue.value — poniższe shape'y obowiązują w obu miejscach. Authoritative: ADR-0019 (docs/adr/0019-import-v2-engine-contracts.md), egzekwowane przez wspólny rdzeń walidacji (IMP2-1.4 / #1466).
| Typy | Shape |
|---|---|
| text, textarea, wysiwyg, number, date, datetime, boolean, color, email, identifier | {"value": <scalar>} |
| select | {"option_code": "<code>"} |
| multiselect | {"option_codes": ["<code>", …]} |
| price | {"amount": <number>, "currency": "<ISO>"} |
| metric | {"value": <number>, "unit": "<code>"} |
| asset | {"asset_id": "<uuid>"} |
| relation, reference | {"object_id": "<uuid>"} |
Legacy odstępstwa (select {"value": code} z admina, osie wariantów {"value": x} z GenerateVariants, price {"value": n} bez waluty) migruje jednorazowo #1464; do tego czasu readery mogą mieć tolerancyjny fallback, po migracji fallbacki znikają.
7. object_types.workflow_publish_gate — gate completeness na publikacji (WFL-P1-04, ADR-0029)
Tabela: object_types.workflow_publish_gate JSONB NULLABLE · NULL = gate wyłączony (default).
json
{
"$id": "pim:jsonb:workflow-publish-gate",
"type": ["object", "null"],
"required": ["enabled", "min_completeness_pct"],
"properties": {
"enabled": { "type": "boolean" },
"min_completeness_pct": { "type": "integer", "minimum": 0, "maximum": 100 },
"scope": { "enum": ["global", "per_channel"], "default": "global" },
"channels": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"description": "Wymagane (niepuste) gdy scope=per_channel — kody kanałów sprawdzane przeciw completeness.per_channel."
}
},
"additionalProperties": false
}Reguły
- Egzekwowany przez guard na przejściach
publishiapprovemaszynyobject_editorial(CompletenessGateGuard); odmowa =TransitionBlockercodecompleteness_gate+ lista brakujących wymaganych atrybutów (409 / discovery). scope=globalczyta mirrorobjects.completeness_pct;scope=per_channelczytacompleteness.per_channel[channel]z fallbackiem do global, gdy kanał nie ma policzonego pct (kontrakt §3: pole opcjonalne).- Shape walidowany na write-edge (
ObjectType::setWorkflowPublishGate()→ 422 przezPATCH /api/object_types/{id}).
