Files
slm-design/DRAFT/level-2/domains/realtime.md
2026-08-02 22:53:05 +03:00

9.9 KiB
Raw Blame History

Realtime messages и subscriptions

Пояснение Domain API поверх WebSocket, SSE, GraphQL subscriptions и provider SDK.

Связанные правила

Граница транспорта

Realtime transport находится внутри adapter:

WebSocket / SSE / GraphQL / SDK
  → adapter
  → realtime port
  → Domain API
  → domain event/outcome/error
  → framework projection

Domain API не экспортирует WebSocket, MessageEvent, raw frames, SDK subscription, provider error или transport close code. Port также не должен быть generic socket API с send(frame) и onMessage(frame): он описывает capability, необходимую конкретному домену.

Realtime-команда

Публичная команда может выглядеть как обычный Promise независимо от транспорта:

export type ChatApi = {
  sendMessage: (
    command: SendMessageCommand,
  ) => Promise<ChatMessage>
}

Port возвращает типизированный technical outcome:

export type SendMessagePortFailure =
  | Readonly<{ type: 'FORBIDDEN' }>
  | Readonly<{ type: 'RATE_LIMITED' }>
  | Readonly<{ type: 'UNAVAILABLE' }>
  | Readonly<{ type: 'OUTCOME_UNKNOWN' }>

export type ChatRealtimePort = {
  sendMessage: (
    command: SendMessagePortCommand,
  ) => Promise<PortResult<ChatMessageRecord, SendMessagePortFailure>>
}

Domain API преобразует port result в ChatMessage или собственную ChatError. Для прикладного consumer transport остаётся незаметным.

Correlation

socket.send() подтверждает только локальную отправку frame. Чтобы завершить sendMessage() результатом server command, protocol должен сопоставить command и acknowledgement:

Domain API command
  → adapter assigns operationId
  → transport frame
  → server ACK or ERROR with operationId
  → adapter settles pending port operation
  → Domain API maps outcome

Adapter владеет protocol registry pending operations и не бросает error из async onmessage, который невозможно поймать вокруг исходного send. Он завершает соответствующую Promise или другой объявленный operation channel.

Correlation contract фиксирует:

  • источник и scope уникальности operation ID;
  • момент, когда команда считается принятой или выполненной;
  • поведение при duplicate и late acknowledgement;
  • timeout и cancellation;
  • очистку pending operation при disconnect;
  • связь command outcome с последующими domain events.

Если provider не возвращает correlation metadata, API не обещает индивидуальный результат. Такая операция является fire-and-forget, а поздний отказ публикуется отдельным domain event либо доступна только общая ошибка transport scope.

Outcome uncertainty и idempotency

Disconnect после отправки и до acknowledgement не доказывает, что command не выполнена:

frame sent
  → connection lost
  → server may have committed command
  → acknowledgement unknown

Port возвращает OUTCOME_UNKNOWN, если это различие нужно Domain API. Автоматический retry безопасен только при provider guarantee или idempotency key. Domain API не преобразует неопределённый outcome в ложное MESSAGE_NOT_SENT.

Subscription

Публичная subscription предоставляет проверенные events и явный cleanup:

export type ChatEvent =
  | Readonly<{
      type: 'MESSAGE_CREATED'
      message: ChatMessage
      revision: number
    }>
  | Readonly<{
      type: 'MESSAGE_REMOVED'
      messageId: string
      revision: number
    }>

export type ChatSubscription = Readonly<{
  close: () => Promise<void>
}>

export type ChatObserver = Readonly<{
  onEvent: (event: ChatEvent) => void
  onError: (error: ChatRealtimeError) => void
  onStatus: (status: ChatRealtimeStatus) => void
}>

export type ChatApi = {
  subscribe: (
    observer: ChatObserver,
  ) => Promise<ChatSubscription>
}

Callback, async iterable или другой project-wide channel допустимы. Обязательны типизированные domain events/errors, определённый lifecycle и cleanup.

Stable errors и statuses

Начальная ошибка подключения может завершить subscribe() domain error. Ошибка после успешного запуска приходит через stream channel.

Не каждый transport failure становится domain error. Adapter может восстановить соединение и опубликовать только устойчивый status:

export type ChatRealtimeStatus =
  | Readonly<{ type: 'CONNECTED' }>
  | Readonly<{ type: 'RECONNECTING' }>
  | Readonly<{ type: 'RESYNC_REQUIRED' }>
  | Readonly<{ type: 'CLOSED' }>

Публичные errors описывают реакции приложения, например CHAT_REALTIME_UNAVAILABLE, CHAT_FORBIDDEN или CHAT_SESSION_EXPIRED. Close codes, provider messages и SDK classes остаются внутри adapter.

Caller-initiated close не является domain error.

Ordering, duplicates и resync

Realtime port явно объявляет:

  • гарантируется ли порядок событий;
  • возможна ли at-least-once delivery;
  • кто устраняет duplicates;
  • содержит ли event revision или sequence;
  • как обнаруживается gap после reconnect;
  • откуда загружается authoritative snapshot.

Если adapter не может доказать непрерывность, Domain API публикует RESYNC_REQUIRED. Framework binding invalidates projection и получает snapshot через query Domain API.

Binding не применяет raw delta к публичной модели. Если безопасный merge содержит предметную семантику, его выполняет операция Domain API или pure-функция api/runtime.

Shared connection

Один adapter может multiplex несколько ports и subscriptions через физическое соединение. Connection имеет явные owner, scope, multiplicity и cleanup:

assembly-owned connection
  ├── chat messages port
  ├── presence port
  └── notification port

Cleanup отдельной subscription снимает её lease. Cleanup assembly закрывает shared connection после завершения всех принадлежащих графу operations. После awaited cleanup новые callbacks запрещены.

Если создание connection завершилось успешно, а следующий шаг assembly упал, connection закрывается на rollback path до возврата ошибки.

Framework materialization

Framework binding выбирает техническую реакцию на domain event:

MESSAGE_CREATED
  → update query cache verified full model

RESYNC_REQUIRED
  → invalidate query
  → fetch snapshot through Domain API

Zustand, QueryClient, Pinia или другой store не импортирует socket adapter и не интерпретирует protocol frame. Он хранит только public values, events, statuses и errors Domain API.

SSR, RSC и workers

Browser assembly может включать realtime adapter, а request/RSC assembly — только query API. Отсутствующий realtime API не заменяется throwing stub.

Server process или worker получает отдельную assembly и scope, если ему действительно нужна долгоживущая subscription. Server Component не открывает connection, которая переживает request, без отдельного owner вне request scope.

Тестовые границы

API-тест с fake realtime port проверяет mapping records, failures, stable errors и public events. Adapter contract test проверяет protocol frames, correlation, timeout, disconnect, duplicate acknowledgement, reconnect, resync и cleanup. Framework test проверяет materialization и invalidation. Assembly test проверяет shared connection, rollback и отсутствие callbacks после disposal.

Контрольные случаи:

  • acknowledgement приходит после timeout;
  • duplicate acknowledgement приходит после reconnect;
  • event приходит раньше command acknowledgement;
  • disconnect происходит после send и до ACK;
  • unsubscribe завершается во время pending callback;
  • adapter получает malformed payload;
  • следующий resource assembly падает после открытия connection.