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

@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-ответы.

SSoT — Zod, AsyncAPI — производное

Источник истины — именно 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 уже на сервере

Поле 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-coresubscribeToSse), и каждое сообщение — это конверт события. Контракты дают форму 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;
}
}

Ключевые экспорты

ЭкспортТипНазначение
eventEnvelopeSchemaZod-схемаСхема конверта всех событий (валидация на сервере)
EventEnvelopeтипz.infer конверта — форма любого события
makeEnvelope(input)функцияСборка конверта (продюсер-сторона; поддерживает детерминированный id через deterministicId → UUIDv5)
eventCatalogas 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-документа
uuidSchemaZod-схемаОбщий UUID (с .meta) — переиспользуется в payload-схемах
isoDateSchemaZod-схемаISO 8601 дата-время
moneyAmountSchemaZod-схема{ amount, currency } (минимальные единицы)
paginationSchema / PaginationZod-схема / тип{ limit, cursor }
dateRangeSchema / DateRangeZod-схема / тип{ from, to } с from <= to
*Schema / типы payloadZod-схемы / типыPer-event схемы и их z.infer-типы (ShiftReportApproved, AssetTransferred, …)
Для фронта релевантна верхушка

makeEnvelope, migrateEventEnvelope, defineEvent, buildAsyncApiDocument — продюсер/инфра/build-инструменты backend. Фронту обычно нужны только EventEnvelope, EventName, EventDataByName и при необходимости per-event типы. Полный список — в API-референсе.

Подводные камни и важные правила

warning
data в конверте — unknown

EventEnvelope.data намеренно unknown. Без сужения по evt.type TypeScript не знает форму — всегда дискриминируй по type и приводи через EventDataByName<'...'>. Не доверяй форме «на глаз».

warning
Версионирование схем (version)

У каждого события есть version (major.minor). Конверты живут в outbox/DLQ долго и могут прийти со старой версией. На backend migrateEventEnvelope поднимает data до текущей схемы; если миграции нет — летит EventVersionMismatchError (→ DLQ). На фронте просто учитывай, что breaking-change схемы — это bump version, а не «тихая» правка поля.

Конвенция имён событий неизменна

Имя <context>.<entity>.<verb-past> — часть контракта. Подписки/маршрутизация завязаны на строку имени; глагол всегда в прошедшем времени (created, transferred, approved).

Anti-drift

Контракт событий не копируется во фронт руками — он импортируется из @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.

Ссылки