Перейти к основному содержимому

JSONB document: PostContentDocument (typed-блоки поста)

Дом typed-jsonb модели содержимого поста news: содержимое в виде блоков — коллекция jsonb (блоки текстовые, галерея изображений, подборки объектов). Документ не произвольный jsonb — строго типизированная полиморфная модель. Проекции (модели ответа, рендер, snapshot версии поста) ссылаются сюда и перечисляют только дельту.

Назначение

Содержимое поста — упорядоченная коллекция типизированных блоков. Каждый блок — sealed-подтип базового PostBlock с дискриминатором Kind; сериализация полиморфна по Kind. Модель — единственный дом контракта содержимого: колонка content агрегата Post (owner — post.md) и переиспользуется внутри PostSnapshotDocument при снятии версии поста (post-version.md), где становится иммутабельной.

Storage

ПолеЗначение
Servicenews
EntityPost (owner колонки)
Columncontent (jsonb)
Document version fieldContractVersion (int)
Ownernews
Переиспользованиевнутри PostSnapshotDocumentnews.post_versions.snapshot (post-version.md)

Классификация

ПолеЗначение
Контекстnews
Kindtyped JSONB document — полиморфная модель содержимого (не сущность, не отдельная таблица); версионируется полем ContractVersion (= 1); полиморфизм по дискриминатору Kind
Source of truthnews (Post.content); блок-подборка ссылается на source of truth selections через SnapshotId
Колонка / хранениеnews.posts.content (jsonb); та же модель — в news.post_versions.snapshot внутри PostSnapshotDocument (там иммутабельна)
Tenant isolationtenant несёт владелец-сущность Post (post.md); сам документ tenant-agnostic (опубликованный контент — платформенные данные, ADR-0052 п.3)

Поля документа

Корень PostContentDocument (jsonb, ContractVersion = 1)

ПолеТипОбязательноеNullableОграниченияSince
ContractVersionintДаНет= 1 (IDocument)v1
BlocksIReadOnlyList<PostBlock>ДаНет0..200; порядок рендера = SortOrder; пустой у черновика, >= 1 требуется для publish (INV-NWS-004)v1

База PostBlock (полиморфная, discriminator Kind)

ПолеТипОбязательноеNullableОграниченияSince
BlockIdGuidДаНетуникален в пределах документа; стабилен между версиями поста (для diff/переиспользования)v1
KindPostBlockKind (enum-string)ДаНетText/ImageGallery/SelectionEmbed — JSON-дискриминатор подтипаv1
SortOrderintДаНет>= 0; задаёт порядок блоковv1

Enum PostBlockKind

ЗначениеПодтип
TextTextBlock
ImageGalleryImageGalleryBlock
SelectionEmbedSelectionEmbedBlock

Nested-модели (typed, sealed-подтипы PostBlock)

TextBlock (Kind = Text)

ПолеТипОбязательноеNullableОграниченияSince
FormatTextFormat (enum-string)ДаНетMarkdown/PlainText, default Markdownv1
TextstringДаНет1..20000; sanitized при рендере (не при хранении)v1
PlainTextstringДаНет<= 20000; производный plaintext — вклад в Post.SearchText → Elasticsearch-проекция поиска (post.md, ../search/posts.md)v1

ImageGalleryBlock (Kind = ImageGallery)

ПолеТипОбязательноеNullableОграниченияSince
LayoutGalleryLayout (enum-string)ДаНетSingle/Grid/Carousel, default Gridv1
ImagesIReadOnlyList<GalleryImage>ДаНет1..50v1

GalleryImage (owned nested)

ПолеТипОбязательноеNullableОграниченияSince
FileIdGuidДаНетссылка на storage/CDN, без FK (чужой контекст)v1
SortOrderintДаНет>= 0v1
Captionstring?НетДа<= 512v1
Altstring?НетДа<= 256v1
Widthint?НетДа> 0, pxv1
Heightint?НетДа> 0, pxv1

SelectionEmbedBlock (Kind = SelectionEmbed)

ПолеТипОбязательноеNullableОграниченияSince
SnapshotIdGuidДаНетссылка на selections.SelectionSnapshot (source of truth — selections); иммутабельная копия состава (INV-SEL-001)v1
SourceSelectionIdGuidДаНетecho — «открыть живую подборку»v1
SourceSelectionPublicCodestringДаНет12 base62 — ссылка на актуальную подборку (UC-SEL-003)v1
TitlestringДаНет<= 256; кэш заголовка snapshot-а (скелет/подпись до подгрузки)v1
ItemsCountintДаНет1..200; кэш количестваv1

Display-данные объектов недвижимости не хранятся — рендер добирает актуальные из catalog по PropertyObjectId (через snapshot); заморожены только состав/порядок/комментарии подборки (в snapshot-е), цены — нет.

Value objects

Отдельных value objects нет: документ хранится целиком как jsonb в колонке content; nested-модели (GalleryImage, sealed-подтипы PostBlock) — часть typed-графа документа, а не колонки/таблицы.

Модель JSONB (полиморфизм)

Поля подтипов — в таблицах выше; диаграмма показывает полиморфизм и композицию.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Post.contentowned (jsonb-колонка)читается целиком по PK поста— (часть агрегата Post)
PostSnapshotDocumentPostContentDocumentпереиспользование (embedded)часть snapshot-а версии (post-version.md)иммутабельно, не мигрируется
SelectionEmbedBlock.SnapshotIdSelectionSnapshotлогическая ссылка (чужой контекст)SnapshotIdinternal GET selections при set-blocks/publish; рендер добирает контентrestrict со стороны selections (snapshot вечен, INV-SNP-006)
SelectionEmbedBlockPropertyObject (catalog)логическая, через snapshotPropertyObjectId (в snapshot-е)display-данные — батч рендера в catalog
GalleryImage.FileId → storage/CDNлогическая, без FKFileIdURL строит рендер

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-NWS-004 PublishNonEmptyPublish требует Blocks.Count >= 1 (черновик может быть пустым)агрегат Post при publishNEWS_POST_EMPTY_CONTENT
INV-NWS-B01 MaxBlocksBlocks.Count <= 200PostContentValidatorNEWS_POST_CONTENT_TOO_MANY_BLOCKS
INV-NWS-B02 UniqueBlockIdBlockId уникален в документеPostContentValidatorNEWS_POST_BLOCK_ID_DUPLICATE
INV-NWS-B03 TextBoundsTextBlock.Text 1..20000, PlainText <= 20000PostContentValidatorNEWS_TEXT_BLOCK_INVALID
INV-NWS-B04 GallerySize1..50 изображений; каждый FileId непустPostContentValidatorNEWS_GALLERY_BLOCK_INVALID
INV-NWS-B05 EmbedSnapshotActiveSelectionEmbedBlock.SnapshotId непуст и (при set-blocks/publish) ссылается на существующий Active-snapshot (internal GET selections)PostContentValidator + internal-вызовNEWS_EMBED_SNAPSHOT_INVALID
INV-NWS-B06 KnownKindНеизвестный Kind при чтении → fail (строго типизировано)polymorphic readerNEWS_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:

  1. Автор добавляет/меняет SelectionEmbedBlock, указывая живую подборку. news синхронно вызывает internal API selections, чтобы получить/подтвердить SnapshotId (иммутабельная копия состава, INV-SEL-001) и закэшировать Title/ItemsCount для скелета.
  2. Успех: SnapshotId фиксируется в typed-jsonb блоке в транзакции news (связь PostId ↔ SnapshotId держит news); блок валиден (INV-NWS-B05).
  3. Snapshot существует, но не Active (отвязан/удалён на стороне selections): блок невалиден → NEWS_EMBED_SNAPSHOT_INVALID; при publish — отказ (INV-NWS-004 требует все embed на Active-snapshot).
  4. selections недоступен/таймаут: set-blocks/publish не завершается успехом (embed нельзя подтвердить) — деградация с ошибкой уровня операции, автор повторяет; частичная запись поста без подтверждённого SnapshotId не сохраняется. Метрика/алерт selections 5xx — на борде news-overview.
  5. Рендер поста (и исторической версии) добирает актуальные 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; для каждого SelectionEmbedBlockSnapshotId непуст и при set-blocks/publish ссылается на Active-snapshot (проверка через internal GET selections).
  • Обратно совместимое чтение: unknown-поля при чтении игнорируются, unknown-Kindfail (строгая типизация, INV-NWS-B06).

Versioning

VersionChangeReaderWriter
1Initial: Text/ImageGallery/SelectionEmbedRead v1Write 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.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • post.md — владелец колонки content, статусная машина и события поста, SearchText/search-проекция.
  • post-version.md — переиспользование модели внутри PostSnapshotDocument (иммутабельно).
  • selection-snapshot.mdSnapshotId, иммутабельность (INV-SEL-001).
  • catalog — display объектов недвижимости по PropertyObjectId (батч рендера); UC-NWS-002 (создание поста), UC-SEL-003 (живая подборка).