@mineflow/contracts
@mineflow/contracts — единый источник правды (SSoT) для доменных событий MineFlow: Zod-схемы payload каждого события, реестр всех событий (eventCatalog) и схема общего конверт а (eventEnvelopeSchema). Тот же пакет, из которого backend публикует события в outbox/Redis Streams, фронтенд использует для типизации realtime-нагрузок (SSE) и интеграций.
В слоистой архитектуре SDK (ADR-0042) это shared-пакет уровня контракта: он не делает запросов и не рендерит UI — он описывает форму данных, которые ходят по событийной шине. Для REST-формы данных есть отдельный слой L0 (@mineflow/api-client / @mineflow/api-zod); contracts отвечает строго за события, не за HTTP-ответы.
Источник истины — именно Zod-схемы в этом пакете. Документ AsyncAPI 3.1.0 (docs/asyncapi.json) генерируется из eventCatalog + eventEnvelopeSchema через z.toJSONSchema() — это машинно-читаемая проекция контракта, а не параллельный источник. См. функцию buildAsyncApiDocument и ADR-0013.
Установка
pnpm add @mineflow/contracts
# peer-зависимость (используется как обычный import { z } из схем):
pnpm add zod
В монорепо MineFlow пакет резолвится как workspace:*. Рантайм-зависимость у пакета одна — zod.
Схемы (eventCatalog, *Schema) — это runtime-значения Zod. Если фронту нужна только типизация SSE-нагрузки, импортируй type-символы (EventName, EventDataByName, ShiftReportApproved, …) — это zero-runtime и ничего не тянет в бандл. Полноценные схемы держи на серверной стороне (валидация при publish/consume).
Конверт события (envelope)
Все события MineFlow едут в одном конверте — eventEnvelopeSchema (ADR-0013). Полезная нагрузка лежит в поле data, метаданные — снаружи:
import type { EventEnvelope } from '@mineflow/contracts';
// EventEnvelope:
// {
// id: string; // UUID события
// type: string; // "<context>.<entity>.<verb-past>", напр. "eam.asset.transferred"
// version: string; // "major.minor" схемы data, напр. "1.0"
// occurredAt: string; // ISO 8601
// producedBy: string; // имя продюсера
// correlationId?: string; // UUID, опционально
// causationId?: string; // UUID, опционально
// organizationId: string; // владелец события (ADR-0020 multi-tenancy)
// data: unknown; // payload — типизируется по type через eventCatalog
// }
data в самом конверте типизирован как unknown намеренно: конкретная форма зависит от type. Сузить её до конкретного события помогает eventCatalog (см. ниже).
Поле organizationId есть в каждом событии (ADR-0020), но фронт его не задаёт и не фильтрует руками — multi-tenancy изоляцию делает backend по JWT. На клиенте конверт приходит уже отскоупленным под организацию пользователя.
Каталог событий
eventCatalog — это as const-реестр всех известных событий: ключ — имя события (<context>.<entity>.<verb-past>), значение — { version, schema }. Из него выводятся два полезных типа:
EventName— объединение всех имён событий ('eam.asset.transferred' | 'prd.shift-report.approved' | …);EventDataByName<N>— типdataдля конкретного имени (черезz.inferего схемы).
import type { EventName, EventDataByName } from '@mineflow/contracts';
type AssetTransferred = EventDataByName<'eam.asset.transferred'>;
// { assetId: string; fromObjectId: string; toObjectId: string; performedBy: string; reason?: string }
type ApprovedData = EventDataByName<'prd.shift-report.approved'>;
// { reportId; organizationId; productionObjectId; shiftDate; shiftType; approvedBy;
// approvedAt; approveSagaId; actorId; summary: { drilledMeters; blastedBlocks; … } }
Конвенция имён жёсткая: <context>.<entity>.<verb-past> — eam.asset.created, prd.shift-report.rejected-after-approve, scm.fuel.* и т.д. Полный перечень имён — в API-референсе (тип EventName) либо прямо в eventCatalog.
Использование на фронтенде: типизация SSE-нагрузок
Главный сценарий для фронта — типобезопасный realtime. SSE-фид notifications/событий приходит через аутентифицированный fetch-стрим (@mineflow/client-core → subscribeToSse), и каждое сообщение — это конверт со бытия. Контракты дают форму data без догадок:
import { subscribeToSse } from '@mineflow/client-core';
import type { EventEnvelope, EventName, EventDataByName } from '@mineflow/contracts';
// Узкоспециализированный конверт под конкретное имя события
type TypedEnvelope<N extends EventName> = Omit<EventEnvelope, 'type' | 'data'> & {
type: N;
data: EventDataByName<N>;
};
const sub = subscribeToSse<EventEnvelope>(
'/api/v1/events/stream',
{
onMessage: (evt) => {
// сузить по type и получить точный тип data
if (evt.type === 'prd.shift-report.approved') {
const data = evt.data as EventDataByName<'prd.shift-report.approved'>;
console.log('утверждён рапорт', data.reportId, data.summary.drilledMeters);
}
},
},
{ fetchImpl: authedFetch }, // Bearer-fetch из п ровайдера; reconnect с backoff внутри
);
// позже
sub.close();
Для пользовательских уве домлений не нужно подписываться на сырой поток вручную — в @mineflow/client-react есть useNotificationFeed, который держит подписку и кладёт новые элементы в кэш. Сырой subscribeToSse + типы из contracts нужны, когда строишь свой живой фид по доменным событиям (дашборд, аудит-лента). Подробный сетап потока — рецепт Realtime / SSE.
Использование: типизация интеграций
Если фронт (или BFF/edge-функция) принимает события из внешнего интеграционного канала, contracts даёт ровно тот же контракт, что и у backend-консьюмеров — без дублирования форм руками. Сузив EventEnvelope по type, можно строить дискриминируемые обработчики:
import type { EventEnvelope, EventDataByName } from '@mineflow/contracts';
function handle(evt: EventEnvelope): void {
switch (evt.type) {
case 'eam.asset.created':
onAssetCreated(evt.data as EventDataByName<'eam.asset.created'>);
break;
case 'prd.shift-report.rejected':
onReportRejected(evt.data as EventDataByName<'prd.shift-report.rejected'>);
break;
default:
// неизвестное/неинтересное событие — игнор
break;
}
}
Ключевые экспорты
| Экспорт | Тип | Назначение |
|---|---|---|
eventEnvelopeSchema | Zod-схема | Схема конверта всех событий (валидация на сервере) |
EventEnvelope | тип | z.infer конверта — форма любого события |
makeEnvelope(input) | функция | Сборка конверта (продюсер-сторона; поддерживает детерминированный id через deterministicId → UUIDv5) |
eventCatalog | as const объект | Реестр всех событий: { [name]: { version, schema } } |
EventName | тип | Объединение всех имён событий |
EventDataByName<N> | тип | Тип data для конкретного имени события |
defineEvent(entry) | функция | Хелпер-объявление записи каталога с проверкой типов миграций |
EventCatalogEntry<T> | тип | Форма одной записи каталога (version / schema / migrations) |
migrateEventEnvelope(envelope) | функция | Up-миграция data старого конверта к текущей версии схемы |
EventVersionMismatchError | класс | Бросается, когда для старой version нет зарегистрированной миграции (→ DLQ) |
buildAsyncApiDocument(options) | функция | Генерация AsyncAPI 3.1.0 из каталога (build-tooling, не для фронта) |
AsyncApiDocument | тип | Форма генерируемого AsyncAPI-документа |
uuidSchema | Zod-схема | Общий UUID (с .meta) — переиспользуется в payload-схемах |
isoDateSchema | Zod-схема | ISO 8601 дата-время |
moneyAmountSchema | Zod-схема | { amount, currency } (минимальные единицы) |
paginationSchema / Pagination | Zod-схема / тип | { limit, cursor } |
dateRangeSchema / DateRange | Zod-схема / тип | { from, to } с from <= to |
*Schema / типы payload | Zod-схемы / типы | Per-event схемы и их z.infer-типы (ShiftReportApproved, AssetTransferred, …) |
makeEnvelope, migrateEventEnvelope, defineEvent, buildAsyncApiDocument — продюсер/инфра/build-инструменты backend. Фронту обычно нужны только EventEnvelope, EventName, EventDataByName и при необходимости per-event типы. Полный список — в API-референсе.
Подводные камни и важные правила
data в конверте — unknownEventEnvelope.data намеренно unknown. Без сужения по evt.type TypeScript не знает форму — всегда дискриминируй по type и приводи через EventDataByName<'...'>. Не доверяй форме «на глаз».
version)У каждого события есть version (major.minor). Конверты живут в outbox/DLQ долго и могут прийти со старой версией. На backend migrateEventEnvelope поднимает data до текущей схемы; если миграции нет — летит EventVersionMismatchError (→ DLQ). На фронте просто учитывай, что breaking-change схемы — это bump version, а не «тихая» правка поля.
Имя <context>.<entity>.<verb-past> — часть контракта. Подписки/маршрутизация завязаны на строку имени; глагол всегда в прошедшем времени (created, transferred, approved).
Контракт событий не копируется во фронт руками — он импортируется из @mineflow/contracts. Тот же пакет публикует backend, тот же документ AsyncAPI проверяется breaking-diff'ом в CI. Поэтому форма SSE-нагрузки на клиенте гарантированно совпадает с тем, что реально шлёт сервер.
React Native
Пакет платформо-агностичен — это чистые типы/схемы Zod без платформенных API, работает в RN без оговорок. Платформенная специфика только в транспорте SSE: дефолтный fetch в RN не отдаёт ReadableStream body, поэтому в subscribeToSse нужно передать потоковый fetchImpl (react-native-fetch-api). Сами типы из contracts при этом те же. Детали транспорта — рецепт React Native и Realtime / SSE.
Ссылки
- Полный API-референс из кода → /api/contracts/
- REST-референс (OpenAPI / Redoc) → /rest/
- Связанные гайды:
@mineflow/client-core(SSE-транспорт),@mineflow/client-react(useNotificationFeed),@mineflow/api-zod(Zod-схемы REST-форм) - Рецепты: Realtime / SSE, React Native