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 |
| Kind | Snapshot (append-only полный слепок; содержимое иммутабельно, статуса нет) |
| Source of truth | news (этот контекст) |
| Таблица | news.post_versions |
| Tenant isolation | не tenanted-строго — наследует контекст поста; опубликованный контент платформенный (ADR-0052 п.3) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK pk_post_versions | app | Идентификатор версии. |
PostId | Guid | Да | FK → news.posts(id) restrict; index ix_post_versions_post | app | Пост, чью публикацию фиксирует слепок. Restrict — версии живут, пока жив пост. |
VersionNumber | int | Да | >= 1; unique ux_post_versions_number (post_id, version_number) | app | Монотонный номер в пределах поста: Post.CurrentVersionNumber + 1 (INV-PV-002). |
Snapshot | PostSnapshotDocument (jsonb) | Да | typed jsonb (версионируется, ContractVersion); иммутабельно (INV-PV-001) | вычисление из Post на момент публикации | Полный слепок всех полей поста (см. JSONB). |
Reason | PostVersionReason | Да | enum (text), varchar(16) | app | Причина захвата: Published / Republished. |
CreatedByUserId | Guid | Да | логическая ссылка identity.users(id) без FK (чужой контекст) | app | Кто опубликовал/переопубликовал (актор перехода). |
CreatedAt | DateTimeOffset | Да | 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 |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
Name | string | Да | копия Post.Name | v1 |
Suffix | string | Да | копия Post.Suffix | v1 |
Title | string | Да | копия Post.Title | v1 |
Subtitle | string? | Нет | копия Post.Subtitle | v1 |
CategoryId | Guid | Да | копия Post.CategoryId | v1 |
CategorySlug | string | Да | человекочитаемый slug на момент публикации (категория могла быть переименована позже — заморожен для исторической ссылки) | v1 |
AuthorUserId | Guid | Да | копия Post.AuthorUserId | v1 |
AuthorType | PostAuthorType | Да | enum строкой; дом — post.md | v1 |
AuthorCompanyId | Guid? | Нет | заполнен для авторства от компании | v1 |
ProjectRefs | IReadOnlyList<Guid> | Да | связанные проекты (может быть пустым) | v1 |
CoverFileId | Guid? | Нет | обложка (storage/CDN, без FK) | v1 |
CommentsEnabled | bool | Да | копия флага комментариев | v1 |
Content | PostContentDocument | Да | полная копия блоков контента на момент публикации (включая SelectionEmbed.SnapshotId); дом модели — post-block.md | v1 |
CapturedAt | DateTimeOffset | Да | момент снятия слепка | v1 |
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
PostVersion → Post | reference (внутри контекста, обратная от Post) | PostId | restrict; delete поста → версии остаются до retention |
Snapshot.Content → SelectionSnapshot | логическая ссылка (цепочка иммутабельности) | SelectionEmbed.SnapshotId внутри jsonb | — (snapshot не удаляется, INV-SNP-006) |
Snapshot.Content → PropertyObject (display) | логическая ссылка | PropertyObjectId в блоках | — (display-данные добираются актуальные батчем в catalog) |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-PV-001 AppendOnly | Snapshot (и вся запись) после вставки не изменяется никем и никогда | операции обновления не существует | — |
| INV-PV-002 MonotonicVersion | VersionNumber = 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+ uniqueux_post_versions_number(post_id,version_number) пропускают ровно один; проигравший получаетNEWS_VERSION_CONFLICT, перечитывает актуальныеStatus/CurrentVersionNumberи (если правки ещё актуальны) повторяет публикацию с номером+1. Дыр и дублей в нумерации не возникает. - Свежесть исторического рендера. Версия замораживает состав/порядок/тексты поста и
SnapshotIdembed-блоков (иммутабельная цепочка, 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-сущность не нужна |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: Post, JSONB document: PostContentDocument (typed-блоки поста)
- API (9): Admin: модерация постов и комментариев (m2m), DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots), GET /api/v1/authoring/posts — Мои посты и черновики, GET /api/v1/posts/{id}/versions — История версий поста (snapshot), 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} — обновить метаданные поста
- Use Cases (3): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot), UC-NWS-006. История версий поста (полный snapshot)
- События (1): Event Contract: pin.news.post-published (PostPublishedKafkaEvent)
- registries (1): Реестр сущностей PIN
Связанные документы
- 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).