Граф нод и рёбер

Универсальная схема данных: всё в БД — это либо nodes (сущность любого типа), либо edges (типизированная связь). Объясняет реальные типы в проде, когда добавляется scoped-таблица, и тяжёлые случаи.

Ключевое архитектурное решение: новые типы сущностей появляются без миграций схемы. DM создаёт node_type с slug и label — и сразу может создавать ноды этого типа. Поля per-тип живут в JSONB-колонке fields, а scoped attrs-таблицы (item_attributes) добавляются только когда нужны типизированные индексы или CHECK-ограничения.


Схема nodes / edges (миграция 001)

nodes: id, campaign_id, type_id, title, fields jsonb, content text,
       search_vector tsvector, owner_user_id, created_at, updated_at

edges: id, campaign_id, source_id, target_id, type_id, label, meta jsonb,
       created_at

search_vector — GIN-индекс по тексту из title + content + всех текстовых значений fields. Поддерживается триггером, обновляется автоматически.

Node types в проде

Типы нод — per-campaign (каждая кампания может иметь свои). Базовые создаются при инициализации кампании через миграции:

node_type.slugРусское имяПоля в fields
characterПерсонаж (PC)статблок, уровни, класс, раса, и др.
npcНПСстатблок (миграции 013/018)
loopПетляnumber, status, length_days
sessionСессияsession_number, loop_number, day_from, day_to, recap, played_at
encounterЭнкаунтерmirror-нода; title синхронизируется с encounters (триггер 039)
stashОбщакпустые fields; сигнальная нода для бухгалтерии (миграция 035)
itemПредметhot-поля вынесены в item_attributes; cold: srd_slug, description
electiveФакультативkind, link, comment (миграция 029)
ПользовательскиелюбыеDM создаёт через UI

npc и character — типы, которые знают статблок. Все остальные типы — DM создаёт вручную (локации, фракции, квесты, лор). Система не ограничивает.

Edge types в проде

Базовые рёбра (is_base=true, без campaign_id):

edge_type.slugНаправлениеСмысл
containsloop → sessionсессия принадлежит петле
participated_insession → characterPC участвовал в сессии (миграция 032)
appeared_insession → npcNPC появился в сессии (миграция 114)

Кампанейские рёбра (is_base=false, с campaign_id):

edge_type.slugСмысл
has_electivePC взял факультатив (миграция 029)
ПользовательскиеDM создаёт любые

Когда добавляется scoped attrs-таблица

Правило: JSONB достаточно, если поле только читается и отображается. Отдельная таблица нужна, когда:

  1. Нужен типизированный индекс для фильтрации / сортировки. Пример: item_attributes.rarity, item_attributes.price_gp — в каталоге фильтрация по редкости и цене критична для performance.
  2. Нужен CHECK-констрейнтrarity IN ('common','uncommon',...) нельзя выразить на JSONB поле.
  3. Нужен FKitem_attributes.node_id references nodes(id) ON DELETE CASCADE гарантирует очистку.

Примеры в проде: item_attributes (миграция 043), encounter_loot_drafts (миграция 039).

Тяжёлые случаи

Mirror-нода энкаунтера

encounters — отдельная таблица (не nodes), унаследованная от раннего дизайна. Миграция 039 связала каждый энкаунтер с mirror-нодой типа encounter: триггер create_encounter_mirror_node создаёт ноду при вставке в encounters; sync_encounter_title_to_mirror синхронизирует title.

В каталоге/сайдбаре mirror-ноды фильтруются через node_types.slug != 'encounter' в SQL-запросах — они технические, не контентные.

Stash-нода

Один stash-узел на кампанию (title='Общак'). Не просматривается в обычном каталоге — это «виртуальный кошелёк» кампании. actor_pc_id = null в транзакциях означает «эта операция идёт в/из общака».

Elective — кампанейский тип

elective создан только для кампании mat-ucheniya (не глобальный базовый тип). Связь PC → elective — ребром has_elective.

Поиск по графу

search_vector на nodes — полнотекстовый поиск на русском (tsvector с конфигурацией russian). Индексируется: title + content + все строковые значения fields. Поиск работает в каталоге и сайдбаре.

Сайдбар (lib/sidebar-cache.ts) кэширует node_types + все ноды кампании на 60 секунд с тегом sidebar:<campaignId>. Мутация ноды → invalidateSidebar(campaignId).


features/catalog/README.md — как граф используется в каталоге. → engine-vs-content.md — почему D&D-поля в item_attributes должны уехать в content-pack.