JSONB document: PostContentDocument (typed-блоки поста)
Дом typed-jsonb модели содержимого поста news: содержимое в виде блоков — коллекция jsonb (блоки
текстовые, галерея изображений, подборки объектов). Документ не произвольный jsonb — строго
типизированная полиморфная модель. Проекции (модели ответа, рендер, snapshot версии поста) ссылаются
сюда и перечисляют только дельту.
Назначение
Содержимое поста — упорядоченная коллекция типизированных блоков. Каждый блок — sealed-подтип базового
PostBlock с дискриминатором Kind; сериализация полиморфна по Kind. Модель —
единственный дом контракта содержимого: колонка content агрегата Post (owner — post.md) и
переиспользуется внутри PostSnapshotDocument при снятии версии поста (post-version.md),
где становится иммутабельной.
Storage
| Поле | Значение |
|---|
| Service | news |
| Entity | Post (owner колонки) |
| Column | content (jsonb) |
| Document version field | ContractVersion (int) |
| Owner | news |
| Переиспользование | внутри PostSnapshotDocument → news.post_versions.snapshot (post-version.md) |
Классификация
| Поле | Значение |
|---|
| Контекст | news |
| Kind | typed JSONB document — полиморфная модель содержимого (не сущность, не отдельная таблица); версионируется полем ContractVersion (= 1); полиморфизм по дискриминатору Kind |
| Source of truth | news (Post.content); блок-подборка ссылается на source of truth selections через SnapshotId |
| Колонка / хранение | news.posts.content (jsonb); та же модель — в news.post_versions.snapshot внутри PostSnapshotDocument (там иммутабельна) |
| Tenant isolation | tenant несёт владелец-сущность Post (post.md); сам документ tenant-agnostic (опубликованный контент — платформенные данные, ADR-0052 п.3) |
Поля документа
Корень PostContentDocument (jsonb, ContractVersion = 1)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
ContractVersion | int | Да | Нет | = 1 (IDocument) | v1 |
Blocks | IReadOnlyList<PostBlock> | Да | Нет | 0..200; порядок рендера = SortOrder; пустой у черновика, >= 1 требуется для publish (INV-NWS-004) | v1 |
База PostBlock (полиморфная, discriminator Kind)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
BlockId | Guid | Да | Нет | уникален в пределах документа; стабилен между версиями поста (для diff/переиспользования) | v1 |
Kind | PostBlockKind (enum-string) | Да | Нет | Text/ImageGallery/SelectionEmbed — JSON-дискриминатор подтипа | v1 |
SortOrder | int | Да | Нет | >= 0; задаёт порядок блоков | v1 |
Enum PostBlockKind
| Значение | Подтип |
|---|
Text | TextBlock |
ImageGallery | ImageGalleryBlock |
SelectionEmbed | SelectionEmbedBlock |
Nested-модели (typed, sealed-подтипы PostBlock)
TextBlock (Kind = Text)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
Format | TextFormat (enum-string) | Да | Нет | Markdown/PlainText, default Markdown | v1 |
Text | string | Да | Нет | 1..20000; sanitized при рендере (не при хранении) | v1 |
PlainText | string | Да | Нет | <= 20000; производный plaintext — вклад в Post.SearchText → Elasticsearch-проекция поиска (post.md, ../search/posts.md) | v1 |
ImageGalleryBlock (Kind = ImageGallery)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
Layout | GalleryLayout (enum-string) | Да | Нет | Single/Grid/Carousel, default Grid | v1 |
Images | IReadOnlyList<GalleryImage> | Да | Нет | 1..50 | v1 |
GalleryImage (owned nested)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
FileId | Guid | Да | Нет | ссылка на storage/CDN, без FK (чужой контекст) | v1 |
SortOrder | int | Да | Нет | >= 0 | v1 |
Caption | string? | Нет | Да | <= 512 | v1 |
Alt | string? | Нет | Да | <= 256 | v1 |
Width | int? | Нет | Да | > 0, px | v1 |
Height | int? | Нет | Да | > 0, px | v1 |
SelectionEmbedBlock (Kind = SelectionEmbed)
| Поле | Тип | Обязательное | Nullable | Ограничения | Since |
|---|
SnapshotId | Guid | Да | Нет | ссылка на selections.SelectionSnapshot (source of truth — selections); иммутабельная копия состава (INV-SEL-001) | v1 |
SourceSelectionId | Guid | Да | Нет | echo — «открыть живую подборку» | v1 |
SourceSelectionPublicCode | string | Да | Нет | 12 base62 — ссылка на актуальную подборку (UC-SEL-003) | v1 |
Title | string | Да | Нет | <= 256; кэш заголовка snapshot-а (скелет/подпись до подгрузки) | v1 |
ItemsCount | int | Да | Нет | 1..200; кэш количества | v1 |
Display-данные объектов недвижимости не хранятся — рендер добирает актуальные из catalog по
PropertyObjectId (через snapshot); заморожены только состав/порядок/комментарии подборки (в snapshot-е), цены — нет.
Value objects
Отдельных value objects нет: документ хранится целиком как jsonb в колонке content; nested-модели
(GalleryImage, sealed-подтипы PostBlock) — часть typed-графа документа, а не колонки/таблицы.
Модель JSONB (полиморфизм)
Поля подтипов — в таблицах выше; диаграмма показывает полиморфизм и композицию.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
Post.content | owned (jsonb-колонка) | — | читается целиком по PK поста | — (часть агрегата Post) |
PostSnapshotDocument → PostContentDocument | переиспользование (embedded) | — | часть snapshot-а версии (post-version.md) | иммутабельно, не мигрируется |
SelectionEmbedBlock.SnapshotId → SelectionSnapshot | логическая ссылка (чужой контекст) | SnapshotId | internal GET selections при set-blocks/publish; рендер добирает контент | restrict со стороны selections (snapshot вечен, INV-SNP-006) |
SelectionEmbedBlock → PropertyObject (catalog) | логическая, через snapshot | PropertyObjectId (в snapshot-е) | display-данные — батч рендера в catalog | — |
GalleryImage.FileId → storage/CDN | логическая, без FK | FileId | URL строит рендер | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-NWS-004 PublishNonEmpty | Publish требует Blocks.Count >= 1 (черновик может быть пустым) | агрегат Post при publish | NEWS_POST_EMPTY_CONTENT |
| INV-NWS-B01 MaxBlocks | Blocks.Count <= 200 | PostContentValidator | NEWS_POST_CONTENT_TOO_MANY_BLOCKS |
| INV-NWS-B02 UniqueBlockId | BlockId уникален в документе | PostContentValidator | NEWS_POST_BLOCK_ID_DUPLICATE |
| INV-NWS-B03 TextBounds | TextBlock.Text 1..20000, PlainText <= 20000 | PostContentValidator | NEWS_TEXT_BLOCK_INVALID |
| INV-NWS-B04 GallerySize | 1..50 изображений; каждый FileId непуст | PostContentValidator | NEWS_GALLERY_BLOCK_INVALID |
| INV-NWS-B05 EmbedSnapshotActive | SelectionEmbedBlock.SnapshotId непуст и (при set-blocks/publish) ссылается на существующий Active-snapshot (internal GET selections) | PostContentValidator + internal-вызов | NEWS_EMBED_SNAPSHOT_INVALID |
| INV-NWS-B06 KnownKind | Неизвестный Kind при чтении → fail (строго типизировано) | polymorphic reader | NEWS_POST_CONTENT_UNKNOWN_KIND |
Собственной статусной машины у документа нет — жизненный цикл (Draft → Published → …) несёт владелец-
сущность Post (post.md), stateDiagram-v2 не применяется; внутри PostSnapshotDocument
документ иммутабелен (правится только пересозданием версии).
Доменные и интеграционные события
Документ сам событий не порождает — он часть агрегата Post. Мутации содержимого (SetBlocks,
Publish), их доменные/интеграционные события (PostPublishedEvent, pin.news.*) — в post.md.
Встраивание блока-подборки инициирует создание SelectionSnapshot синхронным m2m-вызовом news → selections
(selection-snapshot.md), без Kafka.
Встраивание подборки (интеграция news → selections)
Интеграция для SelectionEmbedBlock — синхронная, m2m, без Kafka (дом решения —
events README, «Отклонённые события»). Последовательность и исходы при set-blocks/
publish:
- Автор добавляет/меняет
SelectionEmbedBlock, указывая живую подборку. news синхронно вызывает
internal API selections, чтобы получить/подтвердить SnapshotId (иммутабельная копия состава,
INV-SEL-001) и закэшировать Title/ItemsCount для скелета.
- Успех:
SnapshotId фиксируется в typed-jsonb блоке в транзакции news (связь PostId ↔ SnapshotId
держит news); блок валиден (INV-NWS-B05).
- Snapshot существует, но не
Active (отвязан/удалён на стороне selections): блок невалиден →
NEWS_EMBED_SNAPSHOT_INVALID; при publish — отказ (INV-NWS-004 требует все embed на Active-snapshot).
selections недоступен/таймаут: set-blocks/publish не завершается успехом (embed нельзя подтвердить) —
деградация с ошибкой уровня операции, автор повторяет; частичная запись поста без подтверждённого
SnapshotId не сохраняется. Метрика/алерт selections 5xx — на борде news-overview.
- Рендер поста (и исторической версии) добирает актуальные display-данные объектов недвижимости из
catalog по PropertyObjectId (через snapshot) батчем; замораживается только состав/порядок/подписи
подборки, цены — нет (Запреты).
Acceptance Criteria
- Пустой черновик валиден. Дано
Blocks = []; когда сохраняется черновик; тогда документ валиден;
при попытке publish — NEWS_POST_EMPTY_CONTENT (INV-NWS-004).
- Лимит блоков. Дано
> 200 блоков; когда валидация содержимого; тогда
NEWS_POST_CONTENT_TOO_MANY_BLOCKS (INV-NWS-B01).
- Уникальность BlockId. Дано два блока с одинаковым
BlockId; когда валидация; тогда
NEWS_POST_BLOCK_ID_DUPLICATE (INV-NWS-B02).
- Границы текста. Дано
TextBlock.Text пустой или > 20000; когда валидация; тогда
NEWS_TEXT_BLOCK_INVALID (INV-NWS-B03); PlainText уходит вкладом в Post.SearchText.
- Размер галереи. Дано галерея с
0 или > 50 изображений (или пустой FileId); когда валидация;
тогда NEWS_GALLERY_BLOCK_INVALID (INV-NWS-B04).
- Embed на активный snapshot. Дано
SelectionEmbedBlock.SnapshotId ссылается на Active-snapshot;
когда set-blocks/publish; тогда блок валиден; если snapshot не Active/пуст —
NEWS_EMBED_SNAPSHOT_INVALID (INV-NWS-B05).
- Неизвестный Kind. Дано документ с неизвестным
Kind; когда чтение; тогда
NEWS_POST_CONTENT_UNKNOWN_KIND (INV-NWS-B06) — строгая типизация, additive-поля при этом игнорируются.
Validation
- Проверки содержимого (INV-NWS-B01..B06 выше):
<= 200 блоков; уникальность BlockId; границы
TextBlock; галерея 1..50; для каждого SelectionEmbedBlock — SnapshotId непуст и при
set-blocks/publish ссылается на Active-snapshot (проверка через internal GET selections).
- Обратно совместимое чтение: unknown-поля при чтении игнорируются, unknown-
Kind → fail
(строгая типизация, INV-NWS-B06).
Versioning
| Version | Change | Reader | Writer |
|---|
1 | Initial: Text/ImageGallery/SelectionEmbed | Read v1 | Write v1 |
Additive-only в рамках ContractVersion = 1: новые optional-поля и новые Kind — additive с safe-default
reader (старый reader пропускает неизвестные additive-поля, но не неизвестный Kind — новый Kind
вводится только когда все читатели обновлены). Breaking — новый ContractVersion; читатель обязан
поддерживать все версии из БД. Snapshot-ы версий поста иммутабельны и не мигрируются.
Запреты
- Не хранить secrets, raw tokens, provider credentials.
- Не хранить связи-вместо-таблиц: комментарии к посту — таблица; версии поста — таблица
(
post_versions), не массив внутри документа.
- Не хранить unbounded external payload: display-данные объектов недвижимости в блоке не кэшируются —
только
PropertyObjectId (через snapshot); цены не замораживаются в блоке.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | GIN по content не создаётся — документ читается целиком по PK поста; полнотекстовый поиск — Elasticsearch-проекция PostSearchDocument, питаемая из Post.SearchText/TextBlock.PlainText, не по jsonb (post.md, ../search/posts.md) |
| Партиционирование / retention | нет; версии-snapshot-ы иммутабельны и не мигрируются |
| Concurrency | на уровне владельца Post (оптимистическая блокировка) — документ отдельной блокировки не имеет |
| Permissions / PII | публичный контент (пост публикуется); PII в блоках нет; Text sanitized при рендере (XSS) |
| Audit | версия содержимого — через PostVersion/PostSnapshotDocument (post-version.md) |
Tests
- проверки содержимого отклоняют
> 200 блоков, дубли BlockId, Text вне границ, галерею вне 1..50,
пустой/невалидный embed (несуществующий/не-Active snapshot);
- старая версия читается корректно; новый writer не ломает старого reader-а при rollout (additive-поля игнорируются);
- embed-блок хранит только
SnapshotId (не замороженные цены объектов недвижимости); документ без secrets/tokens.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: Post, Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design), Entity / Aggregate: PostVersion
- API (8): DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots), GET /api/v1/posts — Глобальная лента + поиск, GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста, GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста, POST /api/v1/posts/{id}/publish — публикация поста (snapshot + событие), POST /api/v1/posts/{id}/unpublish — снять пост с публикации, PUT /api/v1/posts/{id} — обновить метаданные поста, PUT /api/v1/posts/{id}/blocks — Редактирование блоков поста (embed через selections)
- Use Cases (4): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot), UC-NWS-005. Поиск постов (Elasticsearch), UC-NWS-006. История версий поста (полный snapshot)
- Фоновые задачи (1): Background Job: SnapshotDetachRetryJob
- Поиск (1): PostSearchDocument
- registries (1): Реестр сущностей PIN
Связанные документы
- post.md — владелец колонки
content, статусная машина и события поста, SearchText/search-проекция.
- post-version.md — переиспользование модели внутри
PostSnapshotDocument (иммутабельно).
- selection-snapshot.md —
SnapshotId, иммутабельность (INV-SEL-001).
catalog — display объектов недвижимости по PropertyObjectId (батч рендера); UC-NWS-002 (создание поста), UC-SEL-003 (живая подборка).