Entity / Aggregate: SelectionSnapshot
Дом полей иммутабельной копии подборки для поста news (инвариант INV-SEL-001).
Блок «подборка» поста и его документация ссылаются сюда, не переописывая поля.
Назначение
Подборка (Selection) — живой объект: владелец меняет состав и название. Пост news с блоком-подборкой
после публикации должен оставаться неизменным: для поста делается своя копия подборки, чтобы после
публикации поста создатель подборки не мог её изменить, но при необходимости мог заменить один блок
подборки на другой. SelectionSnapshot — эта копия: полный слепок состава подборки на
момент создания, замороженный typed-jsonb-документом. Автор поста меняет содержимое только заменой
блока целиком — создаётся новый snapshot, старый переводится в Detached (заменён), сам snapshot
никогда не редактируется.
Создание — синхронный internal-вызов из news (m2m) при добавлении/замене блока-подборки в посте
(поток «Публикация поста | news ← selections (копия)» — service-boundaries.md).
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст selections, таблица selections.selection_snapshots |
| Assumptions | контракт блока поста (news хранит SnapshotId в typed-jsonb блоке) — будущий док docs/06-services/news/domain/post.md |
Классификация
| Поле | Значение |
|---|---|
| Контекст | selections |
| Kind | Snapshot (append-only копия; AggregateRoot со статусом привязки, содержимое иммутабельно) |
| Source of truth | selections (этот контекст); для PostId source of truth — news |
| Таблица | selections.selection_snapshots |
| Tenant isolation | не tenanted (ADR-0052 п.3: опубликованный контент — платформенные данные); record-level мутаций — через m2m news |
Snapshot имеет статус привязки Active/Detached; замороженный контент версионируется полем
ContractVersion jsonb-модели SelectionSnapshotContent. Snapshot не удаляется (история постов
news хранит полные snapshot-ы) и контент не версионируется правками — вместо правки создаётся новый
snapshot. Гонка одновременного detach (замена блока + удаление поста) защищена оптимистической
блокировкой.
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор snapshot-а; news хранит его в блоке поста. |
SourceSelectionId | Guid | Да | FK → selections.selections(id) restrict; index ix_selection_snapshots_selection_id | app | Подборка-источник. Restrict + soft delete подборки гарантируют разрешимость навсегда. |
OwnerUserId | Guid | Да | index ix_selection_snapshots_owner; логическая ссылка identity.users(id) без FK | копия Selection.OwnerUserId на момент снятия | Владелец исходной подборки (для отображения «подборка от…» решает news; здесь — аудит). |
PostId | Guid | Да | index ix_selection_snapshots_post_id; логическая ссылка на пост news — без FK (чужой контекст) | параметр internal-вызова news | Пост, в блок которого встроен snapshot. |
Content | SelectionSnapshotContent (jsonb) | Да | jsonb, typed-модель ниже; иммутабельно (INV-SEL-001) | вычисление из Selection + items на момент снятия | Замороженный слепок подборки. |
Status | SnapshotStatus | Да | enum, хранится как text, varchar(16), index ix_selection_snapshots_status | app | Active — встроен в блок поста; Detached — заменён другим snapshot-ом или пост удалён. |
DetachedAt | DateTimeOffset? | Нет | заполняется вместе со Status = Detached | app | Момент отвязки. |
DetachReason | SnapshotDetachReason? | Нет | enum, хранится как text, varchar(16); обязателен при Detached | app (параметр detach-вызова news) | BlockReplaced / PostDeleted. |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | app | Штампы. |
Enum SnapshotStatus
| Значение | Описание |
|---|---|
Active | Snapshot встроен в актуальную версию блока поста; ровно один Active на блок (со стороны news). |
Detached | Отвязан: блок заменён новым snapshot-ом либо пост удалён. Хранится вечно (история постов news ссылается на старые snapshot-ы). |
Enum SnapshotDetachReason
| Значение | Описание |
|---|---|
BlockReplaced | Автор поста заменил блок-подборку целиком (создан новый snapshot). |
PostDeleted | Пост удалён в news. |
Value objects
Value objects не используются (контент — typed jsonb ниже).
Модели JSONB
SelectionSnapshotContent (jsonb, ContractVersion = 1)
Полный слепок подборки на момент снятия; после записи не изменяется никогда (INV-SEL-001).
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
Title | string | Да | 1..256 — копия Selection.Title | v1 |
Description | string? | Нет | ≤ 2000 — копия Selection.Description | v1 |
SourceSelectionPublicCode | string | Да | 12 base62 — копия Selection.PublicCode; позволяет посту дать ссылку «открыть живую подборку» | v1 |
Items | IReadOnlyList<SelectionSnapshotItem> | Да | 1..200 элементов (INV-SNP-004), порядок = порядок в подборке | v1 |
TakenAt | DateTimeOffset | Да | момент снятия слепка | v1 |
Вложенная модель SelectionSnapshotItem
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
PropertyObjectId | Guid | Да | копия SelectionItem.PropertyObjectId; display-данные объекта недвижимости добирает рендер поста батчем в catalog (цены в слепок сознательно не замораживаются — пост показывает актуальные) | v1 |
SortOrder | int | Да | >= 0, копия нормализованного SelectionItem.SortOrder | v1 |
Comment | string? | Нет | ≤ 4000, копия SelectionItem.Comment | v1 |
Совместимость: изменения модели — additive-only в рамках ContractVersion = 1 (новые optional-поля);
breaking — новый ContractVersion, читатель обязан поддерживать все версии, встречающиеся в БД
(snapshot-ы не мигрируются — они иммутабельны); unknown-поля при чтении игнорируются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
SelectionSnapshot → Selection | reference (внутри контекста) | SourceSelectionId | грузится при создании; далее не загружается (контент самодостаточен) | restrict (подборка soft-deleted — snapshot живёт) |
SelectionSnapshot → Post (news) | логическая внешняя ссылка | PostId | не загружается; news обращается сюда по SnapshotId | — (удаление поста → detach-вызов, не удаление) |
SelectionSnapshotItem.PropertyObjectId → PropertyObject | логическая ссылка | PropertyObjectId | display-данные — батч рендера в catalog | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-SEL-001 SnapshotImmutable | Content после создания не изменяется никем и никогда (включая владельца подборки и автора поста); единственная «правка» — замена блока целиком: новый snapshot + отвязка (BlockReplaced) старого | операции обновления контента не существует; свойство неизменяемо после создания; проверяется в ревью/тестах | — (операции обновления контента не существует) |
| INV-SNP-002 SourceActive | Snapshot создаётся только из существующей, не удалённой подборки (DeletedAt == null) | при снятии snapshot — подборка-источник существует и не удалена (INV-SEL-009) | SELECTIONS_SOURCE_DELETED |
| INV-SNP-003 CallerIsNews | Создание/detach — только m2m-актор news (пользовательского API нет); авторство поста и право владельца подборки вставлять её в пост проверяет news до вызова | политика авторизации internal-области | 403 |
| INV-SNP-004 NonEmptyContent | В слепке 1..200 элементов (пустую подборку в пост вставлять нельзя) | при снятии snapshot | SELECTIONS_SNAPSHOT_EMPTY |
| INV-SNP-005 DetachOnce | Переход Active → Detached однократен; повторный detach идемпотентен (успех без изменений) | при отвязке (переход однократен, оптимистическая блокировка) | — |
| INV-SNP-006 NeverDeleted | Snapshot не удаляется (история постов news хранит ссылки на прежние snapshot-ы) | операции удаления нет; retention не применяется | — |
Переходы состояний (полный перечень)
Инициатор всех переходов — доверенный m2m-сервис news (INV-SNP-003); пользовательского пути нет.
Содержимое (Content) иммутабельно в любом статусе (INV-SEL-001) — статус отражает только привязку
к блоку поста.
| From | To | Условие / триггер | Инициатор | DetachReason | Факты / события |
|---|---|---|---|---|---|
[*] | Active | Снятие копии из существующей, не удалённой (INV-SNP-002), непустой (1..200, INV-SNP-004) подборки | m2m news (автор поста) | — | SelectionSnapshotCreatedEvent |
Active | Detached | Автор поста заменил блок-подборку целиком (создан новый snapshot) | m2m news | BlockReplaced | SelectionSnapshotDetachedEvent |
Active | Detached | Пост удалён в news (saga удаления поста) | m2m news | PostDeleted | SelectionSnapshotDetachedEvent |
Detached | Detached | Повторная отвязка — идемпотентный no-op без изменений и без события (INV-SNP-005) | m2m news | (не перезаписывается) | — |
Перехода в удаление нет: snapshot append-only и хранится вечно (INV-SNP-006). Снятие snapshot-а не
меняет подборку-источник; последующий soft delete источника snapshot не трогает (FK restrict), а
guard INV-SNP-002 при новом снятии из уже удалённой подборки даёт SELECTIONS_SOURCE_DELETED.
Алгоритм снятия snapshot (internal m2m из news)
- Контекст и доступ. Операцию выполняет доверенный m2m-сервис
news(client-credentials); пользовательского пути нет (INV-SNP-003). Вход:SourceSelectionId,PostId,RequestedByUserId(автор поста — прокидываетnews). Идемпотентность: если на ту же пару (PostId,SourceSelectionId) уже есть snapshot в статусеActive, он возвращается как успех — повторный вызов при retrynewsне создаёт дубля. - Проверка входа и предусловий. Вызыватель — m2m
news(INV-SNP-003), иначе отказ403. Guid-поля непустые. Подборка-источник должна существовать (иначеSELECTIONS_NOT_FOUND) и быть не удалённой —DeletedAt == null(INV-SNP-002), иначеSELECTIONS_SOURCE_DELETED. В подборке ≥ 1 элемента (INV-SNP-004), иначеSELECTIONS_SNAPSHOT_EMPTY. - Что читаем. Подборка-источник целиком (с элементами — граница агрегата, ≤ 200). Объекты недвижимости не проверяются (INV-SEL-008).
- Бизнес-правила и маппинг. Собрать замороженный контент
SelectionSnapshotContent(ContractVersion = 1):Title,Description,SourceSelectionPublicCode— из подборки;Items— её элементы в порядкеSortOrder(PropertyObjectId,SortOrder,Comment);TakenAt— момент снятия. Создать snapshot в статусеActive,OwnerUserId— копия владельца подборки. Фиксируется доменный фактSelectionSnapshotCreatedEvent. - Что меняется. Появляется запись snapshot-а. Исходная подборка не изменяется.
- События. Интеграционные не публикуются: вызов синхронный,
newsполучаетSnapshotIdв ответе и сам фиксирует блок поста (события поста — зонаnews). Доменный фактSelectionSnapshotCreatedEvent— для внутренних подписчиков (метрики). - Согласованность. Snapshot создаётся в одном согласованном изменении.
- Результат.
SnapshotId,SourceSelectionId,PostId, число элементов слепка, момент снятия.
Отвязка snapshot (та же internal-область, m2m news): найти snapshot по id (иначе
SELECTIONS_SNAPSHOT_NOT_FOUND); при статусе Active — перевести в Detached (DetachedAt = now,
DetachReason), зафиксировать факт SelectionSnapshotDetachedEvent; при статусе Detached —
идемпотентный no-op без изменений (INV-SNP-005). Интеграционных событий нет.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
SelectionSnapshotCreatedEvent | доменное | Создание ([*] → Active) | SnapshotId, SourceSelectionId, PostId, ItemsCount |
SelectionSnapshotDetachedEvent | доменное | Active → Detached | SnapshotId, DetachReason |
Интеграционные Kafka-события не публикуются: единственный потребитель (news) взаимодействует
синхронно и владеет своей стороной связи (PostId ↔ SnapshotId). Появление второго потребителя —
повод добавить pin.selections.snapshot-created.v1 (additive).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree(id); ix_selection_snapshots_selection_id btree(source_selection_id); ix_selection_snapshots_post_id btree(post_id); ix_selection_snapshots_owner btree(owner_user_id); ix_selection_snapshots_status btree(status). GIN по content не нужен — jsonb читается только целиком по PK |
| Партиционирование / retention | Нет; snapshot-ы храним вечно (INV-SNP-006), объём мал (посты с блоком-подборкой — редкие, ≤ 200 items × ~120 байт) |
| Concurrency | Оптимистическая блокировка — гонка двойного detach (правка не должна затирать чужую) |
| Permissions / PII | Создание/detach — только m2m news (INV-SNP-003); чтение GET /internal/snapshots/{id} — m2m news (рендер поста) и публичная выдача контента через news (пост сам публичный); OwnerUserId наружу не отдаётся (отображение автора — зона news); PII в контенте нет (Comment пишет владелец для публичной подборки) |
| Audit | Append-only + Status/DetachedAt/DetachReason; связка «какая версия поста показывала какой snapshot» — snapshot-история постов в news |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: Post, Entity / Aggregate: PostVersion, Entity / Aggregate: Selection (+ child SelectionItem), JSONB document: PostContentDocument (typed-блоки поста)
- API (13): Admin: модерация постов и комментариев (m2m), API контекста selections — индекс, DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots), DELETE /api/v1/selections/{id} — Удалить подборку (soft delete), GET /api/v1/internal/snapshots/{id} — Контент snapshot-а для рендера поста (m2m news), GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста, GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста, POST /api/v1/internal/selections/{id}/snapshots — Снять snapshot подборки для поста (m2m news), POST /api/v1/internal/snapshots/{id}/detach — Отвязать snapshot (замена блока целиком, m2m news), POST /api/v1/posts/{id}/publish — публикация поста (snapshot + событие), POST /api/v1/posts/{id}/unpublish — снять пост с публикации, PUT /api/v1/posts/{id}/blocks — Редактирование блоков поста (embed через selections) (+1)
- Use Cases (5): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot), UC-NWS-006. История версий поста (полный snapshot), UC-SEL-004. Автор поста вставляет подборку в пост (иммутабельный snapshot блока), UC-SEL-005. Агент отправляет подборку клиенту в лиде/сделке
- События (5): События контекста selections: карта топиков и отклонённые события, События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.selections.selection-created (SelectionCreatedKafkaEvent), Event Contract: pin.selections.selection-deleted (SelectionDeletedKafkaEvent), Event Contract: pin.selections.selection-updated (SelectionUpdatedKafkaEvent)
- Фоновые задачи (1): Background Job: SnapshotDetachRetryJob
- index.md (2): News Service (Pin.News), Selections Service (Pin.Selections)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- selection.md — источник (
Selection/SelectionItem), INV-SEL-002..010. - ../README.md — internal-область API, зависимость
news. - service-boundaries.md (поток «Публикация поста»), ADR-0052, ADR-0056.
- Будущие пути:
docs/06-services/news/domain/post.md(блок typed jsonb соSnapshotId),docs/06-services/news/README.md.