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

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 teamPIN Engagement
Статус документаdraft
Область действияконтекст selections, таблица selections.selection_snapshots
Assumptionsконтракт блока поста (news хранит SnapshotId в typed-jsonb блоке) — будущий док docs/06-services/news/domain/post.md

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

ПолеЗначение
Контекстselections
KindSnapshot (append-only копия; AggregateRoot со статусом привязки, содержимое иммутабельно)
Source of truthselections (этот контекст); для 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)ИсточникОписание
IdGuidДаPKappИдентификатор snapshot-а; news хранит его в блоке поста.
SourceSelectionIdGuidДаFK → selections.selections(id) restrict; index ix_selection_snapshots_selection_idappПодборка-источник. Restrict + soft delete подборки гарантируют разрешимость навсегда.
OwnerUserIdGuidДаindex ix_selection_snapshots_owner; логическая ссылка identity.users(id) без FKкопия Selection.OwnerUserId на момент снятияВладелец исходной подборки (для отображения «подборка от…» решает news; здесь — аудит).
PostIdGuidДаindex ix_selection_snapshots_post_id; логическая ссылка на пост news — без FK (чужой контекст)параметр internal-вызова newsПост, в блок которого встроен snapshot.
ContentSelectionSnapshotContent (jsonb)Даjsonb, typed-модель ниже; иммутабельно (INV-SEL-001)вычисление из Selection + items на момент снятияЗамороженный слепок подборки.
StatusSnapshotStatusДаenum, хранится как text, varchar(16), index ix_selection_snapshots_statusappActive — встроен в блок поста; Detached — заменён другим snapshot-ом или пост удалён.
DetachedAtDateTimeOffset?Нетзаполняется вместе со Status = DetachedappМомент отвязки.
DetachReasonSnapshotDetachReason?Нетenum, хранится как text, varchar(16); обязателен при Detachedapp (параметр detach-вызова news)BlockReplaced / PostDeleted.
CreatedAt / UpdatedAtDateTimeOffsetДаappШтампы.

Enum SnapshotStatus

ЗначениеОписание
ActiveSnapshot встроен в актуальную версию блока поста; ровно один 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
ContractVersionintДа= 1v1
TitlestringДа1..256 — копия Selection.Titlev1
Descriptionstring?Нет≤ 2000 — копия Selection.Descriptionv1
SourceSelectionPublicCodestringДа12 base62 — копия Selection.PublicCode; позволяет посту дать ссылку «открыть живую подборку»v1
ItemsIReadOnlyList<SelectionSnapshotItem>Да1..200 элементов (INV-SNP-004), порядок = порядок в подборкеv1
TakenAtDateTimeOffsetДамомент снятия слепкаv1

Вложенная модель SelectionSnapshotItem

ПолеТипОбязательностьОграниченияSince version
PropertyObjectIdGuidДакопия SelectionItem.PropertyObjectId; display-данные объекта недвижимости добирает рендер поста батчем в catalog (цены в слепок сознательно не замораживаются — пост показывает актуальные)v1
SortOrderintДа>= 0, копия нормализованного SelectionItem.SortOrderv1
Commentstring?Нет≤ 4000, копия SelectionItem.Commentv1

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

Связи

СвязьТипКлючПравило загрузкиDelete behavior
SelectionSnapshot → Selectionreference (внутри контекста)SourceSelectionIdгрузится при создании; далее не загружается (контент самодостаточен)restrict (подборка soft-deleted — snapshot живёт)
SelectionSnapshot → Post (news)логическая внешняя ссылкаPostIdне загружается; news обращается сюда по SnapshotId— (удаление поста → detach-вызов, не удаление)
SelectionSnapshotItem.PropertyObjectId → PropertyObjectлогическая ссылкаPropertyObjectIddisplay-данные — батч рендера в catalog

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-SEL-001 SnapshotImmutableContent после создания не изменяется никем и никогда (включая владельца подборки и автора поста); единственная «правка» — замена блока целиком: новый snapshot + отвязка (BlockReplaced) старогооперации обновления контента не существует; свойство неизменяемо после создания; проверяется в ревью/тестах— (операции обновления контента не существует)
INV-SNP-002 SourceActiveSnapshot создаётся только из существующей, не удалённой подборки (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 элементов (пустую подборку в пост вставлять нельзя)при снятии snapshotSELECTIONS_SNAPSHOT_EMPTY
INV-SNP-005 DetachOnceПереход Active → Detached однократен; повторный detach идемпотентен (успех без изменений)при отвязке (переход однократен, оптимистическая блокировка)
INV-SNP-006 NeverDeletedSnapshot не удаляется (история постов news хранит ссылки на прежние snapshot-ы)операции удаления нет; retention не применяется

Переходы состояний (полный перечень)

Инициатор всех переходов — доверенный m2m-сервис news (INV-SNP-003); пользовательского пути нет. Содержимое (Content) иммутабельно в любом статусе (INV-SEL-001) — статус отражает только привязку к блоку поста.

FromToУсловие / триггерИнициаторDetachReasonФакты / события
[*]ActiveСнятие копии из существующей, не удалённой (INV-SNP-002), непустой (1..200, INV-SNP-004) подборкиm2m news (автор поста)SelectionSnapshotCreatedEvent
ActiveDetachedАвтор поста заменил блок-подборку целиком (создан новый snapshot)m2m newsBlockReplacedSelectionSnapshotDetachedEvent
ActiveDetachedПост удалён в news (saga удаления поста)m2m newsPostDeletedSelectionSnapshotDetachedEvent
DetachedDetachedПовторная отвязка — идемпотентный 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)

  1. Контекст и доступ. Операцию выполняет доверенный m2m-сервис news (client-credentials); пользовательского пути нет (INV-SNP-003). Вход: SourceSelectionId, PostId, RequestedByUserId (автор поста — прокидывает news). Идемпотентность: если на ту же пару (PostId, SourceSelectionId) уже есть snapshot в статусе Active, он возвращается как успех — повторный вызов при retry news не создаёт дубля.
  2. Проверка входа и предусловий. Вызыватель — 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.
  3. Что читаем. Подборка-источник целиком (с элементами — граница агрегата, ≤ 200). Объекты недвижимости не проверяются (INV-SEL-008).
  4. Бизнес-правила и маппинг. Собрать замороженный контент SelectionSnapshotContent (ContractVersion = 1): Title, Description, SourceSelectionPublicCode — из подборки; Items — её элементы в порядке SortOrder (PropertyObjectId, SortOrder, Comment); TakenAt — момент снятия. Создать snapshot в статусе Active, OwnerUserId — копия владельца подборки. Фиксируется доменный факт SelectionSnapshotCreatedEvent.
  5. Что меняется. Появляется запись snapshot-а. Исходная подборка не изменяется.
  6. События. Интеграционные не публикуются: вызов синхронный, news получает SnapshotId в ответе и сам фиксирует блок поста (события поста — зона news). Доменный факт SelectionSnapshotCreatedEvent — для внутренних подписчиков (метрики).
  7. Согласованность. Snapshot создаётся в одном согласованном изменении.
  8. Результат. 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 → DetachedSnapshotId, DetachReason

Интеграционные Kafka-события не публикуются: единственный потребитель (news) взаимодействует синхронно и владеет своей стороной связи (PostIdSnapshotId). Появление второго потребителя — повод добавить 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 пишет владелец для публичной подборки)
AuditAppend-only + Status/DetachedAt/DetachReason; связка «какая версия поста показывала какой snapshot» — snapshot-история постов в news

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

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

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

  • 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.