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

10 KiB
Raw Blame History

Assemblies и production-граф

Пояснение обязательной штатной сборки, дополнительных контекстов, environment compatibility и lifecycle.

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

Назначение

Assembly является SLM-модулем Group assemblies. Она выбирает production adapters своего домена, вызывает фабрики api и возвращает готовый именованный граф Domain API для одного объявленного production-контекста.

api/factory + adapters + cross-domain APIs
  → assembly
  → named Domain API graph

Assembly не добавляет предметные методы, модели, transitions или ошибки. Она также не владеет framework state: готовый API передаётся framework binding или composition.

Импорт assembly не запускает side effects. Граф появляется только после вызова builder.

Обязательная default assembly

Каждый пакет содержит модуль assemblies/default:

auth/assemblies/
├── default/
│   └── index.ts
└── administration/
    └── index.ts

default является штатной production-сборкой домена для одного baseline capability set, объявленного проектом. Она может быть browser-only, server-only, worker-compatible или действительно isomorphic. Имя не сообщает environment compatibility.

Пример metadata:

assemblies:
  default:
    capabilities: [fetch, web-crypto]
    conditions: [browser, import]
  administration:
    capabilities: [node, server-secrets]
    conditions: [node, import]

Формат metadata не нормирован, но checker должен получать capability set и resolver conditions из явного project mapping, а не угадывать их по имени default.

Дополнительные assemblies

Дополнительная assembly появляется, когда отличается реальная production-граница:

  • набор Domain API;
  • dependencies или providers;
  • trust boundary;
  • environment capabilities;
  • scope или lifecycle;
  • способ аутентификации;
  • realtime guarantees.

Хорошие имена описывают контекст: administration, realtime-session, worker, rsc. Имя rsc оправдано только при отличающемся RSC wiring; само наличие Server Component не требует отдельной assembly.

Не создаётся assembly-заглушка с методами, бросающими NOT_SUPPORTED. Контекст возвращает только реально доступные API.

Штатный граф

import type {
  AuthSessionApi,
} from '@/domains/auth/api'

import {
  createAuthSessionApi,
} from '@/domains/auth/api/factory'

import {
  createAuthRestAdapter,
} from '@/domains/auth/adapters/identity-rest'

export type AuthGraph = Readonly<{
  session: AuthSessionApi
}>

export const createAuth = (): AuthGraph => {
  const session = createAuthSessionApi({
    identity: createAuthRestAdapter(),
  })

  return { session }
}

Обычный graph owner импортирует только production builder:

import {
  createAuth,
} from '@/domains/auth/assemblies/default'

const auth = createAuth()

Factory и concrete adapter остаются construction details assembly. Тесты API и adapters импортируют соответствующие границы напрямую.

React + Vite и Next.js

В React + Vite default часто использует browser adapters:

assemblies/default
  → browser REST adapter
  → browser WebSocket adapter

В Next.js та же default может считаться isomorphic только при совместимом executable graph под всеми заявленными conditions. Runtime branch не делает импорт безопасным:

// Недостаточное доказательство изоморфности.
if (typeof window === 'undefined') {
  return createServerAdapter()
}

return createBrowserAdapter()

Если server и client требуют разных concrete dependencies, используются разные assemblies или framework-specific resolver entries, проверяемые отдельно.

RSC boundary

RSC не переносит API instance с сервера в браузер:

Server Component
  → request-scoped server assembly
  → server Domain API instance
  → public serializable value
  → Client Component boundary
  → separate client assembly
  → separate client Domain API instance

Server Component исполняется в server scope. Его импорт Client Component является framework reference, а не обычным executable edge RSC graph. При включённом SSR или prerender сам Client Component дополнительно исполняется в отдельном server render graph, а затем в browser hydration graph; обе фазы проверяются, а browser-only effects объявляются как framework-deferred edges. Server Action создаёт и очищает собственный request graph на каждый вызов.

Через boundary не передаются functions, API objects, ports, adapters, mutable cache clients или request secrets.

Cross-domain input

Assembly зависимого домена принимает готовый API аргументом:

import type {
  AuthSessionApi,
} from '@/domains/auth/api'

export type CreateUserInput = Readonly<{
  auth: Pick<AuthSessionApi, 'getSession'>
}>

export const createUser = ({
  auth,
}: CreateUserInput): UserGraph => {
  const profile = createUserProfileApi({
    auth,
    profile: createUserProfileRestAdapter(),
  })

  return { profile }
}

Graph owner выполняет runtime-связь:

const auth = createAuth()
const user = createUser({
  auth: auth.session,
})

User assembly делает только type-only импорт Auth API. Она не импортирует Auth factory, adapter или assembly. Общий runtime dependency graph остаётся ацикличным.

Dependency-connected graph

Наличие assemblies/default у каждого Level 2 package не требует eager-сборки всех доменов:

route A
  → auth/default
  → user/default

route B
  → catalog/default

Graph owner вызывает только builders, необходимые текущему scope. Module-level вызов createAuth() и global registry готовых APIs нарушают явное владение scope.

Lifecycle

Factory не запускает запрос, socket, subscription или timer во время создания API. Явная операция, которая позже запускает ресурс, возвращает cleanup:

const subscription = await chat.subscribe(observer)

try {
  await runScope()
} finally {
  await subscription.close()
}

Если assembly создаёт owned resource или получает lifecycle handle adapter-owned resource, результат предоставляет aggregate cleanup:

export type ChatAssembly = Readonly<{
  apis: ChatGraph
  dispose: () => Promise<void>
}>

Cleanup является идемпотентным. После завершившегося cleanup resource не вызывает callbacks.

У каждого resource ровно один owner. Adapter, который сам создаёт connection или source cache, остаётся владельцем и экспортирует lifecycle handle; assembly только включает этот handle в aggregate cleanup. Если connection создаёт assembly, adapter получает borrowed capability и не закрывает её самостоятельно.

Частичная ошибка сборки

Assembly регистрирует cleanup сразу после создания каждого owned resource и сразу после получения adapter lifecycle handle. Если следующий шаг завершается ошибкой, все зарегистрированные obligations выполняются до передачи ошибки caller-у:

export const createChat = async (): Promise<ChatAssembly> => {
  const cleanups: Array<() => Promise<void>> = []

  try {
    const connection = await createRealtimeConnection()
    cleanups.push(connection.close)

    const history = createHistoryAdapter(connection)
    const messages = createMessagesApi({ history })

    return {
      apis: { messages },
      dispose: createIdempotentReverseCleanup(cleanups),
    }
  } catch (error) {
    await runReverseCleanup(cleanups)
    throw error
  }
}

Реализация helper не нормирована. Нормативны достижимость cleanup на failure path, обратный dependency order и отсутствие callbacks после завершения disposal.

Assembly без cleanup obligations возвращает только API graph и не добавляет пустой dispose для симметрии. Наличие adapter-owned resource с переданным handle уже является cleanup obligation, даже если assembly не считается его владельцем.