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

218 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Realtime messages и subscriptions
> Пояснение Domain API поверх WebSocket, SSE, GraphQL subscriptions и provider SDK.
## Связанные правила
- [`SLM-L2-API-R006`](../../rules/level-2.md#slm-l2-api-r006)
- [`SLM-L2-ERROR-R009`](../../rules/level-2.md#slm-l2-error-r009)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
- [`SLM-L2-REALTIME-R029`](../../rules/level-2.md#slm-l2-realtime-r029)
## Граница транспорта
Realtime transport находится внутри adapter:
```text
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 независимо от транспорта:
```ts
export type ChatApi = {
sendMessage: (
command: SendMessageCommand,
) => Promise<ChatMessage>
}
```
Port возвращает типизированный technical outcome:
```ts
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:
```text
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 не выполнена:
```text
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:
```ts
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:
```ts
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:
```text
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:
```text
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.