Skip to content

Фаза 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, source

cms_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 (склонение — пакет, чтобы обновлялся независимо).

Связи

Внутренняя база знаний студии