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

Entity / Aggregate: PostVersion

Дом полей append-only полного snapshot-а поста news. Модели ответов «история версий», payload доменного события и любые проекции ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

PostVersionиммутабельная запись истории публикаций поста: система хранит историю изменений полным snapshot-ом поста со всеми полями, а не diff. Каждая публикация (первичная или повторная) фиксирует полный слепок поста отдельной строкой; правки черновика (Draft) версий не создают — версионируются только переходы в Published (INV-NWS-005, дом post.md). Запись создаётся append-only агрегатом Post в одном согласованном изменении с переходом публикации и после вставки не изменяется никогда.

Ключевое свойство консистентности цепочки иммутабельности. Внутри Snapshot.Content embed-блоки подборок ссылаются на SnapshotId объектов SelectionSnapshot, которые тоже иммутабельны и не удаляются (selection-snapshot.md, INV-SNP-006). Поэтому историческая версия поста рендерит исторический состав подборки; при этом display-данные объектов недвижимости (цены/статусы) добираются рендером батчем из catalog — они актуальные, а не замороженные (в слепок сознательно не копируются).

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

ПолеЗначение
Контекстnews
KindSnapshot (append-only полный слепок; содержимое иммутабельно, статуса нет)
Source of truthnews (этот контекст)
Таблицаnews.post_versions
Tenant isolationне tenanted-строго — наследует контекст поста; опубликованный контент платформенный (ADR-0052 п.3)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK pk_post_versionsappИдентификатор версии.
PostIdGuidДаFK → news.posts(id) restrict; index ix_post_versions_postappПост, чью публикацию фиксирует слепок. Restrict — версии живут, пока жив пост.
VersionNumberintДа>= 1; unique ux_post_versions_number (post_id, version_number)appМонотонный номер в пределах поста: Post.CurrentVersionNumber + 1 (INV-PV-002).
SnapshotPostSnapshotDocument (jsonb)Даtyped jsonb (версионируется, ContractVersion); иммутабельно (INV-PV-001)вычисление из Post на момент публикацииПолный слепок всех полей поста (см. JSONB).
ReasonPostVersionReasonДаenum (text), varchar(16)appПричина захвата: Published / Republished.
CreatedByUserIdGuidДалогическая ссылка identity.users(id) без FK (чужой контекст)appКто опубликовал/переопубликовал (актор перехода).
CreatedAtDateTimeOffsetДаindex (композит ix_post_versions_post)appМомент захвата версии.

Enum PostVersionReason

ЗначениеОписание
PublishedПервичная публикация: переход Draft → Published.
RepublishedПовторная публикация после правок: переход Published/Unpublished → Published.

Хранится строкой; unknown-значения невозможны — enum пишется только доменом news (не из событий).

Value objects

VO не используются — весь слепок хранится typed-jsonb-моделью PostSnapshotDocument.

Модели JSONB (versioned)

PostSnapshotDocument (jsonb, колонка snapshot, ContractVersion = 1)

Полный слепок поста на момент публикации; после записи не изменяется никогда (INV-PV-001). Изменения модели — additive-only в рамках ContractVersion = 1 (новые optional-поля); breaking — новый ContractVersion, читатель обязан поддерживать все версии в БД (snapshot-ы не мигрируются — иммутабельны); unknown-поля при чтении игнорируются.

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
NamestringДакопия Post.Namev1
SuffixstringДакопия Post.Suffixv1
TitlestringДакопия Post.Titlev1
Subtitlestring?Неткопия Post.Subtitlev1
CategoryIdGuidДакопия Post.CategoryIdv1
CategorySlugstringДачеловекочитаемый slug на момент публикации (категория могла быть переименована позже — заморожен для исторической ссылки)v1
AuthorUserIdGuidДакопия Post.AuthorUserIdv1
AuthorTypePostAuthorTypeДаenum строкой; дом — post.mdv1
AuthorCompanyIdGuid?Нетзаполнен для авторства от компанииv1
ProjectRefsIReadOnlyList<Guid>Дасвязанные проекты (может быть пустым)v1
CoverFileIdGuid?Нетобложка (storage/CDN, без FK)v1
CommentsEnabledboolДакопия флага комментариевv1
ContentPostContentDocumentДаполная копия блоков контента на момент публикации (включая SelectionEmbed.SnapshotId); дом модели — post-block.mdv1
CapturedAtDateTimeOffsetДамомент снятия слепкаv1

Связи

СвязьТипКлючDelete behavior
PostVersion → Postreference (внутри контекста, обратная от Post)PostIdrestrict; delete поста → версии остаются до retention
Snapshot.Content → SelectionSnapshotлогическая ссылка (цепочка иммутабельности)SelectionEmbed.SnapshotId внутри jsonb— (snapshot не удаляется, INV-SNP-006)
Snapshot.Content → PropertyObject (display)логическая ссылкаPropertyObjectId в блоках— (display-данные добираются актуальные батчем в catalog)

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

ИнвариантУсловиеКогда проверяетсяОшибка
INV-PV-001 AppendOnlySnapshot (и вся запись) после вставки не изменяется никем и никогдаоперации обновления не существует
INV-PV-002 MonotonicVersionVersionNumber = Post.CurrentVersionNumber + 1; глобально unique(post_id, version_number)при захвате версии (публикации)NEWS_VERSION_CONFLICT (нарушение unique)
INV-PV-003 SameTxВерсия создаётся в одном согласованном изменении с переходом Post → Published (INV-NWS-005)при публикации— (атомарность)
INV-PV-004 NeverEditByDraftПравки Draft версию не пишут — захват только при переходе в Publishedпри публикации

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
PostVersionCapturedEventдоменноеЗахват версии (переход Post → Published)PostId, VersionNumber, Reason, CreatedByUserId, CapturedAt

Событие поднимает агрегат Post в момент захвата версии — PostVersion append-only child, собственных событий не поднимает. Назначение — внутренние подписчики (метрики/аудит). Интеграционные Kafka-события PostVersion не публикует: история постов — внутреннее состояние news; внешние подписчики читают событие публикации поста (дом post.md).

Конкурентность и согласованность

  • Захват атомарен с публикацией. Строка PostVersion создаётся в одном согласованном изменении с переходом Post → Published и с Post.CurrentVersionNumber += 1 (INV-PV-003, INV-NWS-005). Частичная запись невозможна: либо есть и версия, и обновлённый пост, и событие публикации, либо ничего.
  • Гонка двух публикаций. Два конкурирующих перехода → Published одного поста претендуют на один VersionNumber = CurrentVersionNumber + 1. Оптимистическая блокировка Post + unique ux_post_versions_number (post_id, version_number) пропускают ровно один; проигравший получает NEWS_VERSION_CONFLICT, перечитывает актуальные Status/CurrentVersionNumber и (если правки ещё актуальны) повторяет публикацию с номером +1. Дыр и дублей в нумерации не возникает.
  • Свежесть исторического рендера. Версия замораживает состав/порядок/тексты поста и SnapshotId embed-блоков (иммутабельная цепочка, INV-SNP-006), но display-данные объектов недвижимости (цены/статусы) добираются рендером батчем из catalog и потому актуальны, а не заморожены. Читатель исторической версии видит исторический состав подборки с сегодняшними ценами — это осознанное правило, не рассинхрон.
  • Ретеншн. Версии не удаляются вместе с постом (restrict) — только отдельным retention-процессом (отложен). Это гарантирует, что событие публикации всегда разрешимо в конкретный слепок.

Acceptance Criteria

  • Первая публикация → версия #1. Дано пост в Draft; когда автор публикует; тогда создаётся PostVersion с VersionNumber = 1, Reason = Published, CreatedByUserId = актор, полным Snapshot.
  • Правки черновика версий не пишут. Дано пост в Draft; когда автор многократно правит блоки/заголовки без публикации; тогда ни одной строки PostVersion не появляется (INV-PV-004).
  • Republish → следующий номер. Дано Published пост с CurrentVersionNumber = 2; когда автор публикует после правок; тогда создаётся PostVersion #3, Reason = Republished.
  • Иммутабельность. Дано существующая версия; когда пост далее правится и переопубликуется; тогда старая строка не изменяется — появляется новая (INV-PV-001).
  • Конфликт номера. Дано два одновременных перехода → Published; когда оба претендуют на один VersionNumber; тогда один фиксируется, второй — NEWS_VERSION_CONFLICT (INV-PV-002) и перечитывает состояние.
  • Исторический состав, актуальные цены. Дано версия с embed-подборкой, где цены объектов позже изменились в catalog; когда читается историческая версия; тогда состав подборки — на момент публикации, цены — текущие.
  • Доступ к истории. Дано читатель — не автор и не менеджер компании-автора без news.posts.manage; когда он запрашивает историю версий; тогда доступ закрыт (история не public).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK btree(id); unique ux_post_versions_number(post_id, version_number); ix_post_versions_post(post_id, created_at desc) — история версий поста. GIN по snapshot не нужен — jsonb читается только целиком по PK/PostId
Партиционирование / retentionНет; объём умеренный. Retention Archived-версий отложен (см. post.md)
ConcurrencyОптимистик-токен не нужен — append-only вставка; коллизию номера снимает ux_post_versions_number (INV-PV-002)
Permissions / PIIЧтение истории — автор поста / менеджер компании-автора / право news.posts.manage (не public). PII нет (публичный контент)
AuditСам snapshot и есть аудит: CreatedByUserId + CreatedAt + Reason + полный слепок отвечают «кто / когда / что опубликовал». Отдельная Audit-сущность не нужна

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

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

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

  • post.md — дом агрегата Post, INV-NWS-005, PostAuthorType, CurrentVersionNumber.
  • post-block.md — дом PostContentDocument (блоки контента, SelectionEmbed).
  • ../../selections/domain/selection-snapshot.md — цепочка иммутабельности (INV-SNP-006, SnapshotId).
  • Сценарий UC-NWS-006 (история версий поста).
  • ADR-0052 (платформенные данные не tenanted).