Тема
Фаза 0 — Модель данных ядра и жизненный цикл контента
Статус: спека фазы 0, фундаментальная. Предшествует всем контрактам (блока, модуля, темы): они стоят на этой модели. Отвечает на вопрос, которого не было в брейншторме, но который пронизывает всю бизнес-модель: как контент сайта существует и переносится отдельно от кода. Опирается на проверенного донора
universalи явно фиксирует, где v2 от него отходит.🔄 Обогащение (15.07.2026): богатство типов контента (наборы свойств, типизированные связи 1:N/N:N с inverse, шаблоны list/detail, no-code-создание типа в админке, API типа) вынесено в отдельный спек — Движок типов контента (iblock). Раздел «Типы контента» ниже — базовая модель, спек её развивает до паритета с Битрикс-iblock.
Зачем это фундамент
Вся ценность CMS в деньгах держится на трёх операциях с контентом, а не с кодом:
- «развернуть сайт за день» — установка = код (composer) + контент (откуда?);
- «клиент забирает сайт при расторжении» (Q10) — забирает контент (в каком виде, с какими версиями блоков?);
- «universal мигрирует на ядро» (фаза 4) — переезжает контент со старой схемой.
Ни центр обновлений, ни контракты не описывают, что такое контент как переносимый артефакт. Update-center двигает код (composer.lock) и схему (миграции); но контент — данные клиента — живёт своей жизнью, и его жизненный цикл нигде не зафиксирован. Это и есть упущенный фундамент.
Граница: код / схема / контент
Три разные вещи с разными механизмами переноса. Их смешение — источник самых дорогих ошибок.
| Слой | Что это | Кто владеет | Механизм переноса | В git? |
|---|---|---|---|---|
| Код | ядро, модули, тема (PHP/Blade/JS) | студия | composer + Satis | да (skeleton) |
| Схема БД | структура таблиц | код (миграции) | php artisan migrate | да (в пакетах) |
| Контент | страницы, блоки, настройки, медиа, лиды | клиент | дамп + манифест версий (эта спека) | нет |
Инвариант: код и схему двигает composer/migrate, контент — отдельный конвейер.composer update физически не трогает контент; перенос сайта не тащит vendor/.
Ключевое решение: как хранится блок (расхождение с донором)
Донор universal хранит блок реляционно: строка в page_blocks с фиксированными колонками (heading, subheading, body, cta_text, cta_route, media_id) плюс дочерние таблицы под повторяющиеся части (block_faq_items, block_features, block_steps, block_gallery_images, block_table_rows, block_showcase_products).
v2 сознательно отходит от этого к JSONB-props (контракт блока). Причина — это условие открытого BlockRegistry и версионирования страниц:
| Аспект | Донор universal (реляционно) | v2 (JSONB props) |
|---|---|---|
| Новый тип блока | новая таблица + миграция в ядре | регистрация в BlockRegistry, 0 миграций |
| Сторонний модуль добавляет блок | невозможно без форка ядра | штатно (открытый реестр) |
| Версия схемы блока | схема БД (жёсткая) | _v в записи + data-миграция props |
| Снимок страницы (ревизия Q7) | нужно копировать N таблиц | один JSONB-снимок дерева блоков |
Фиксированные колонки типа heading | у каждого блока свои | нет — props свободной формы под схемой блока |
Цена расхождения: теряем табличную типизацию колонок блока (её заменяет Filament-схема + контрактный тест) и SQL-JOIN по полям блока (его заменяет GIN по JSONB). Это осознанный размен: блок — контент страницы, по нему не фильтруют списками; фильтруемое (каталог, вакансии) — это типы контента, у них модель другая. Порт из universal — не копирование таблиц, а перекладка в JSONB (фаза 1), с data-миграцией старого контента (фаза 4).
Таблицы ядра (эталонная модель)
Минимальный набор, который ядро создаёт своими миграциями. Префикс cms_ отделяет ядро от таблиц модулей и клиентского app/.
Страницы и блоки
cms_pages
id, slug (unique), title, status ∈ {draft, published},
template (string, default 'default' — имя раскладки из PageTemplateRegistry;
вьюха layouts/<template>.blade.php каскадом темы, fallback на дефолт —
волна A корп-MVP),
published_revision_id → cms_page_revisions.id (nullable),
is_system (bool), sort_order, locale, city_id (nullable — заготовка Q6),
seo (jsonb: title, description, keywords, og_image (id медиа), noindex),
lock_version, # optimistic lock (волна C, 409 в редакторе)
created_at, updated_at
# НЕТ колонок блоков: блоки живут в ревизии
cms_page_revisions
id, page_id → cms_pages.id (cascade),
blocks (jsonb) # снимок всего дерева блоков (см. контракт блока)
editor_id → users.id, note, created_at
# published-версия = pages.published_revision_id; черновик = последняя ревизияИнвариант хранения: страница не хранит блоки напрямую — только указатель на опубликованную ревизию. Черновик = ревизия новее опубликованной. Откат = смена published_revision_id на старую ревизию (дёшево, без потери данных). Page-cache отдаёт только published-ревизию; предпросмотр черновика — по токену в обход кеша.
Ретеншн ревизий — с первого дня, не «потом» (подтверждённая боль Directus: 504-е на ~1M строк журнала, ретеншн добавили только в 11.3 — разбор §16): конфиг cms.revisions.keep + джоба очистки в планировщике + индексы под неё (page_id, created_at) — входят в реализацию таблицы, а не остаются строкой спеки. Опубликованная и предыдущая published-ревизии не удаляются никогда.
Типы контента (custom types)
Отдельная от блоков модель (Q5): это сущности со списком и детальной, а не части страницы.
cms_content_types
id, key (unique), name, schema (jsonb) # определение полей типа; key = публичный slug типа
route_pattern, has_detail (bool), seo_enabled (bool)
cms_content_entries
id, content_type_id → cms_content_types.id,
slug, status, data (jsonb), # значения полей — GIN-индекс
locale (nullable), city_id (nullable), site_id (nullable), # измерения по стандарту §4
lock_version, # optimistic lock (решено 15.07.2026, engine §16)
published_at, created_at, updated_at
# functional partial-индексы поверх data — для filterable/sortable свойств (engine §12)
unique (content_type_id, slug)
cms_content_relations # N:N связи типов (engine §5.2, волна C)
id, from_entry_id → cms_content_entries (cascade),
to_entry_id → cms_content_entries (cascade),
relation_code, sort_order, created_at
unique (from_entry_id, to_entry_id, relation_code)
index (to_entry_id, relation_code) # обратная выборка без full scanТерминология (решение волны C, 15.07.2026): спека движка называет идентификатор типа slug — физическая колонка key этой таблицы; в коде канонический алиас-атрибут ContentType::$slug. Полное переименование колонки отложено до ревизии пресетов (волна G): затрагивает 20+ файлов и пересекается с Field->key. Колонки city_id/site_id/lock_version заведены миграцией волны C (требование standard §4 об измерениях и optimistic-lock).
Граница с блоком зафиксирована: блок → часть страницы (в ревизии), тип контента → самостоятельная запись (своя таблица, свой роут). Один движок полей (Q8) обслуживает и схему блока, и схему типа контента, и конструктор форм — не три реализации. Полная спека подсистемы — движок «Типы контента».
Виджеты, меню, настройки, SEO, медиа, лиды
cms_widgets id, type, area ∈ {sidebar,footer,header,…}, props (jsonb),
sort_order, conditions (jsonb), # где показывать (Q4: своя сущность)
is_active, _v, locale/city_id (nullable), created_at, updated_at
# полная схема — в WidgetRegistry-спеке (единственный источник)
cms_menus / cms_menu_items дерево пунктов (parent_id), привязка к page/url/entry
cms_settings group, key, value, type, locale/city_id (nullable) # ← донор universal 1:1
cms_seo_meta (entity_type, entity_id, city_id/locale nullable), title, description,
h1, keywords, canonical, og_image, robots, lock_version # волна C
cms_seo_templates page_type, city_id/locale (nullable), title/description/h1_template,
is_active, lock_version # плейсхолдеры {city_gen}/{company_name}/…
cms_seo_blocks page_type, position ∈ {above_content, below_content}, format ∈ {text,html},
content, city_id/entity (nullable), is_active, sort, lock_version
# вывод — шов FilterBus `seo.content` (волна C)
media spatie/medialibrary (не cms_-префикс — внешний пакет)
cms_leads заявки: type, status, payload (jsonb), consent_at, sourcecms_settings и SEO-таблицы переносятся из universal как есть (проверены продом, модель key-value с group+locale/city_id — рабочая). Расхождение только по блокам.
Служебные (владение и версии)
cms_module_versions текущая версия каждого модуля на сайте # ← update-center
cms_module_history журнал применённых скриптов # ← update-center
cms_block_usage (view/materialized) какой блок _v на каких страницах — для оценки data-миграцииВладение данными между слоями
Правило, которого не было: кто чем владеет и как читает чужое.
- Ядро владеет
cms_*. Модуль не пишет в таблицы ядра напрямую и не читает их схему — только через контракт (cms/core-contracts:PageRepository,SettingStore,ContentRepository), либо через события/фильтры. Смена внутренней схемы ядра, не меняющая контракт, не ломает модули. - Модуль владеет своими таблицами (префикс модуля,
down()обязателен). Ядро о них не знает; связь только через полиморфные точки (cms_seo_meta,cms_leads.payload, события). - Проект (клиент) владеет
app/Local/и своим контентом в БД. Обновление ядра/модуля физически не может тронуть ниapp/, ни данные (только additive-схему).
Это и есть физический смысл «expand-contract» из центра обновлений: модуль читает данные ядра через контракт, поэтому ядро может расширить таблицу (expand), все читатели продолжают работать, и лишь потом, отдельным релизом, сжать (contract).
Жизненный цикл контента
1. Установка нового сайта
composer create-project cms/skeleton даёт код. Дальше — конвейер контента:
php artisan cms:install
→ migrate (схема ядра)
→ cms:seed --profile=corporate # стартовый контент из ПРОФИЛЯ, не хардкод
→ активация темы (её демо-контент, см. ниже)
→ создание админа, базовые настройкиСтартовый контент — профиль установки (composer-пакет cms/profile-* или папка в skeleton): набор страниц, меню, настроек, демо-блоков в переносимом формате (см. ниже), а не PHP-сидер с хардкодом (нарушало бы anti-hardcode). Профиль — это «стартовый контент под тип сайта»: corporate, catalog, landing.
2. Сидинг темой и модулем
Тема/модуль привозят свой демо-контент, чтобы клиент не начинал с белого листа:
- тема кладёт в свой пакет
content/набор демо-страниц/виджетов в переносимом формате; - при активации темы —
cms:content:importиз еёcontent/(идемпотентно,updateOrCreateпо стабильному ключу, не по autoincrement id); - модуль так же может привезти демо-записи своего типа контента.
Правило: демо-контент импортируется по стабильным ключам (slug/key), не по id — id у клиента будут другими. Это то же правило, что в сидерах: связи по slug/name, не по id.
3. Экспорт / перенос сайта (Q10)
При расторжении клиент забирает самодостаточный сайт. Что именно передаётся:
php artisan cms:export --out=site-bundle.zip
bundle:
├── database.sql # дамп PostgreSQL (весь контент клиента)
├── media/ # файлы медиатеки
├── manifest.json # версии ядра/модулей/блоков на момент экспорта ← критично
└── skeleton/ # код сайта (тема + app/Local), composer-вендоренныйmanifest.json — ключ переносимости: без него дамп с JSONB-блоками версии _v=3 непонятен новому разработчику. Манифест фиксирует, какая версия схемы каждого блока/типа действовала, чтобы принимающая сторона могла либо поставить те же версии пакетов, либо прогнать cms:blocks:migrate до своих. Доступ к Satis при этом отключается — но сайт самодостаточен и работает (вендоренные пакеты в skeleton/).
Hash-проверка при переносе схемы типов (оптимистическая блокировка, модель Directus schema diff/apply — разбор §11): cms:export кладёт в manifest.json hash схем cms_content_types; cms:import сверяет hash целевой среды и отклоняет apply, если прод-схему изменили между снятием диффа и применением (обход — явный --force). Закрывает гонку «пока переносили тип с dev, клиент поменял его на проде».
4. Миграция universal на ядро (фаза 4)
Частный, самый тяжёлый случай импорта: реляционные блоки донора → JSONB v2.
php artisan cms:import:universal
→ читает page_blocks + block_* таблицы universal
→ маппит каждый тип в JSONB-props соответствующего BlockType v2 (со стартовым _v)
→ переносит settings/seo как есть (модель совпадает)
→ создаёт ревизии страниц (снимок JSONB), проставляет published_revision_idЭто разовый ETL, а не штатная процедура — но он проверяет модель на реальных данных и потому проектируется в фазе 0 как контрольный пример: если модель данных не позволяет чисто принять universal, значит модель неверна.
Переносимый формат контента
Между «дампом SQL» (машинный, для полного переноса) и «сидером» (код) нужен третий, человекочитаемый формат для демо-контента тем/модулей и профилей — чтобы он лежал в git пакета, ревьюился и не зависел от id.
- формат: YAML/JSON-файлы на сущность (страница = файл с блоками; настройки = файл group);
- ключи связей — стабильные (slug, setting-key), не autoincrement;
- несёт
_vблоков — импорт применяет data-миграцию до актуальной версии; - симметричные команды
cms:content:export/cms:content:import(диапазон/фильтр).
Референс — flat-file модель Statamic (контент отдельно от кода) и seed-подход Laravel, но адаптированные: боевой контент клиента — в БД (PostgreSQL, а не flat-file); flat-file — только для переносимого демо/стартового контента пакетов.
Открытые детали для фазы 0
- гранулярность ревизии: снимок всей страницы (просто, дороже по месту) vs дифф блоков (сложнее) — по умолчанию снимок, как проще и надёжнее для отката;
ретеншн ревизий— ✅ решено (2026-07-14):cms.revisions.keep+ джоба очистки + индексы обязательны в реализации таблицы (см. «Страницы и блоки» выше);- медиа в переносимом формате: ссылка на файл в
media/bundle vs встраивание; - локаль/город как измерение хранения — заготовка полей
locale/city_idесть в модели, но активирует их модуль мультиязычности (Q6); - нужен ли профиль установки отдельным пакетом или частью skeleton (склонение — пакет, чтобы обновлялся независимо).
Связи
- Родительские решения: Q10 (лицензия/перенос), Q7 (ревизии), Q5 (типы контента), Q1 (первый потребитель).
- Стоит под контрактом блока — тот детализирует форму одного блока, эта спека — где блоки живут и как переносятся.
- Стыки: центр обновлений (код+схема двигает он, контент — здесь), путь C → богатое ядро из пакетов, дорожная карта фаза 1/4.