@opetope/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +345 -0
  4. package/README.ru.md +344 -0
  5. package/dist/application-compiler-edges.d.ts +3 -0
  6. package/dist/application-compiler-edges.js +2 -0
  7. package/dist/application-compiler-edges.js.map +1 -0
  8. package/dist/application-compiler-graph.d.ts +8 -0
  9. package/dist/application-compiler-graph.js +2 -0
  10. package/dist/application-compiler-graph.js.map +1 -0
  11. package/dist/application-compiler.d.ts +116 -0
  12. package/dist/application-compiler.js +2 -0
  13. package/dist/application-compiler.js.map +1 -0
  14. package/dist/application-conditions.d.ts +18 -0
  15. package/dist/application-conditions.js +2 -0
  16. package/dist/application-conditions.js.map +1 -0
  17. package/dist/application-definition.d.ts +30 -0
  18. package/dist/application-definition.js +2 -0
  19. package/dist/application-definition.js.map +1 -0
  20. package/dist/application-error.d.ts +10 -0
  21. package/dist/application-error.js +2 -0
  22. package/dist/application-error.js.map +1 -0
  23. package/dist/application-execution.d.ts +43 -0
  24. package/dist/application-execution.js +2 -0
  25. package/dist/application-execution.js.map +1 -0
  26. package/dist/application-feature-bindings.d.ts +11 -0
  27. package/dist/application-feature-bindings.js +2 -0
  28. package/dist/application-feature-bindings.js.map +1 -0
  29. package/dist/application-feature-instance.d.ts +5 -0
  30. package/dist/application-feature-instance.js +2 -0
  31. package/dist/application-feature-instance.js.map +1 -0
  32. package/dist/application-group-order.d.ts +29 -0
  33. package/dist/application-group-order.js +2 -0
  34. package/dist/application-group-order.js.map +1 -0
  35. package/dist/application-instance-retirement.d.ts +36 -0
  36. package/dist/application-instance-retirement.js +2 -0
  37. package/dist/application-instance-retirement.js.map +1 -0
  38. package/dist/application-open-options.d.ts +29 -0
  39. package/dist/application-open-options.js +2 -0
  40. package/dist/application-open-options.js.map +1 -0
  41. package/dist/application-port-compiler.d.ts +28 -0
  42. package/dist/application-port-compiler.js +2 -0
  43. package/dist/application-port-compiler.js.map +1 -0
  44. package/dist/attachment-call-declaration.d.ts +43 -0
  45. package/dist/attachment-call-declaration.js +2 -0
  46. package/dist/attachment-call-declaration.js.map +1 -0
  47. package/dist/attachment-declaration.d.ts +68 -0
  48. package/dist/attachment-declaration.js +2 -0
  49. package/dist/attachment-declaration.js.map +1 -0
  50. package/dist/attachment-execution.d.ts +9 -0
  51. package/dist/attachment-execution.js +2 -0
  52. package/dist/attachment-execution.js.map +1 -0
  53. package/dist/attachment-retirement-scheduler.d.ts +14 -0
  54. package/dist/attachment-retirement-scheduler.js +2 -0
  55. package/dist/attachment-retirement-scheduler.js.map +1 -0
  56. package/dist/call-option-snapshot.d.ts +20 -0
  57. package/dist/call-option-snapshot.js +2 -0
  58. package/dist/call-option-snapshot.js.map +1 -0
  59. package/dist/compile-call-target-bindings.d.ts +14 -0
  60. package/dist/compile-call-target-bindings.js +2 -0
  61. package/dist/compile-call-target-bindings.js.map +1 -0
  62. package/dist/compile-module-template.d.ts +38 -0
  63. package/dist/compile-module-template.js +2 -0
  64. package/dist/compile-module-template.js.map +1 -0
  65. package/dist/condition-group-execution.d.ts +44 -0
  66. package/dist/condition-group-execution.js +2 -0
  67. package/dist/condition-group-execution.js.map +1 -0
  68. package/dist/condition-override.d.ts +28 -0
  69. package/dist/condition-override.js +2 -0
  70. package/dist/condition-override.js.map +1 -0
  71. package/dist/condition-source.d.ts +10 -0
  72. package/dist/condition-source.js +2 -0
  73. package/dist/condition-source.js.map +1 -0
  74. package/dist/condition-types.d.ts +14 -0
  75. package/dist/condition.d.ts +28 -0
  76. package/dist/condition.js +2 -0
  77. package/dist/condition.js.map +1 -0
  78. package/dist/control-registry.d.ts +56 -0
  79. package/dist/control-registry.js +2 -0
  80. package/dist/control-registry.js.map +1 -0
  81. package/dist/dynamic-scope-child.d.ts +23 -0
  82. package/dist/dynamic-scope-child.js +2 -0
  83. package/dist/dynamic-scope-child.js.map +1 -0
  84. package/dist/dynamic-scope-controller.d.ts +16 -0
  85. package/dist/dynamic-scope-controller.js +2 -0
  86. package/dist/dynamic-scope-controller.js.map +1 -0
  87. package/dist/feature-attachment-authoring-types.d.ts +51 -0
  88. package/dist/feature-attachment-lowering.d.ts +18 -0
  89. package/dist/feature-attachment-lowering.js +2 -0
  90. package/dist/feature-attachment-lowering.js.map +1 -0
  91. package/dist/feature-attachment.d.ts +45 -0
  92. package/dist/feature-attachment.js +2 -0
  93. package/dist/feature-attachment.js.map +1 -0
  94. package/dist/feature-authoring-types.d.ts +196 -0
  95. package/dist/feature-authoring.d.ts +15 -0
  96. package/dist/feature-authoring.js +2 -0
  97. package/dist/feature-authoring.js.map +1 -0
  98. package/dist/feature-body.d.ts +53 -0
  99. package/dist/feature-body.js +2 -0
  100. package/dist/feature-body.js.map +1 -0
  101. package/dist/feature-call-authority.d.ts +8 -0
  102. package/dist/feature-call-authority.js +2 -0
  103. package/dist/feature-call-authority.js.map +1 -0
  104. package/dist/feature-call-types.d.ts +39 -0
  105. package/dist/feature-call.d.ts +18 -0
  106. package/dist/feature-call.js +2 -0
  107. package/dist/feature-call.js.map +1 -0
  108. package/dist/feature-calls.d.ts +6 -0
  109. package/dist/feature-calls.js +2 -0
  110. package/dist/feature-calls.js.map +1 -0
  111. package/dist/feature-contract.d.ts +70 -0
  112. package/dist/feature-contract.js +2 -0
  113. package/dist/feature-contract.js.map +1 -0
  114. package/dist/feature-contribution-model.d.ts +38 -0
  115. package/dist/feature-contribution-model.js +2 -0
  116. package/dist/feature-contribution-model.js.map +1 -0
  117. package/dist/feature-contribution.d.ts +126 -0
  118. package/dist/feature-contribution.js +2 -0
  119. package/dist/feature-contribution.js.map +1 -0
  120. package/dist/feature-definition-api.d.ts +53 -0
  121. package/dist/feature-definition-support.d.ts +21 -0
  122. package/dist/feature-definition-support.js +2 -0
  123. package/dist/feature-definition-support.js.map +1 -0
  124. package/dist/feature-effect.d.ts +26 -0
  125. package/dist/feature-effect.js +2 -0
  126. package/dist/feature-effect.js.map +1 -0
  127. package/dist/feature-event.d.ts +32 -0
  128. package/dist/feature-event.js +2 -0
  129. package/dist/feature-event.js.map +1 -0
  130. package/dist/feature-generation.d.ts +34 -0
  131. package/dist/feature-generation.js +2 -0
  132. package/dist/feature-generation.js.map +1 -0
  133. package/dist/feature-lazy-generation.d.ts +6 -0
  134. package/dist/feature-lazy-generation.js +2 -0
  135. package/dist/feature-lazy-generation.js.map +1 -0
  136. package/dist/feature-lazy.d.ts +44 -0
  137. package/dist/feature-lazy.js +2 -0
  138. package/dist/feature-lazy.js.map +1 -0
  139. package/dist/feature-materialization-binding.d.ts +31 -0
  140. package/dist/feature-materialization-binding.js +2 -0
  141. package/dist/feature-materialization-binding.js.map +1 -0
  142. package/dist/feature-materialization-types.d.ts +45 -0
  143. package/dist/feature-model-dependencies.d.ts +23 -0
  144. package/dist/feature-model-dependencies.js +2 -0
  145. package/dist/feature-model-dependencies.js.map +1 -0
  146. package/dist/feature-model.d.ts +60 -0
  147. package/dist/feature-model.js +2 -0
  148. package/dist/feature-model.js.map +1 -0
  149. package/dist/feature-optional.d.ts +15 -0
  150. package/dist/feature-optional.js +2 -0
  151. package/dist/feature-optional.js.map +1 -0
  152. package/dist/feature-own-lowering.d.ts +22 -0
  153. package/dist/feature-own-lowering.js +2 -0
  154. package/dist/feature-own-lowering.js.map +1 -0
  155. package/dist/feature-port-binding.d.ts +6 -0
  156. package/dist/feature-port-binding.js +2 -0
  157. package/dist/feature-port-binding.js.map +1 -0
  158. package/dist/feature-port.d.ts +66 -0
  159. package/dist/feature-port.js +2 -0
  160. package/dist/feature-port.js.map +1 -0
  161. package/dist/feature-record.d.ts +6 -0
  162. package/dist/feature-record.js +2 -0
  163. package/dist/feature-record.js.map +1 -0
  164. package/dist/feature-resource.d.ts +29 -0
  165. package/dist/feature-resource.js +2 -0
  166. package/dist/feature-resource.js.map +1 -0
  167. package/dist/feature-scope-types.d.ts +35 -0
  168. package/dist/feature-scope.d.ts +29 -0
  169. package/dist/feature-scope.js +2 -0
  170. package/dist/feature-scope.js.map +1 -0
  171. package/dist/feature-stream.d.ts +37 -0
  172. package/dist/feature-stream.js +2 -0
  173. package/dist/feature-stream.js.map +1 -0
  174. package/dist/feature-timers.d.ts +14 -0
  175. package/dist/feature-timers.js +2 -0
  176. package/dist/feature-timers.js.map +1 -0
  177. package/dist/index.d.ts +19 -0
  178. package/dist/index.js +2 -0
  179. package/dist/index.js.map +1 -0
  180. package/dist/inspection-activity-protocol.d.ts +63 -0
  181. package/dist/inspection-activity.d.ts +54 -0
  182. package/dist/inspection-activity.js +2 -0
  183. package/dist/inspection-activity.js.map +1 -0
  184. package/dist/inspection-diff.d.ts +10 -0
  185. package/dist/inspection-diff.js +2 -0
  186. package/dist/inspection-diff.js.map +1 -0
  187. package/dist/inspection-module-activity.d.ts +4 -0
  188. package/dist/inspection-module-activity.js +2 -0
  189. package/dist/inspection-module-activity.js.map +1 -0
  190. package/dist/inspection-observer.d.ts +38 -0
  191. package/dist/inspection-observer.js +2 -0
  192. package/dist/inspection-observer.js.map +1 -0
  193. package/dist/inspection-plan.d.ts +33 -0
  194. package/dist/inspection-plan.js +2 -0
  195. package/dist/inspection-plan.js.map +1 -0
  196. package/dist/inspection-protocol.d.ts +333 -0
  197. package/dist/inspection-protocol.js +2 -0
  198. package/dist/inspection-protocol.js.map +1 -0
  199. package/dist/inspection-registry.d.ts +41 -0
  200. package/dist/inspection-registry.js +2 -0
  201. package/dist/inspection-registry.js.map +1 -0
  202. package/dist/inspection-session.d.ts +92 -0
  203. package/dist/inspection-session.js +2 -0
  204. package/dist/inspection-session.js.map +1 -0
  205. package/dist/inspection-snapshot.d.ts +4 -0
  206. package/dist/inspection-snapshot.js +2 -0
  207. package/dist/inspection-snapshot.js.map +1 -0
  208. package/dist/inspection-state.d.ts +91 -0
  209. package/dist/inspection-state.js +2 -0
  210. package/dist/inspection-state.js.map +1 -0
  211. package/dist/instance-demand.d.ts +31 -0
  212. package/dist/instance-demand.js +2 -0
  213. package/dist/instance-demand.js.map +1 -0
  214. package/dist/internal.d.ts +41 -0
  215. package/dist/internal.js +2 -0
  216. package/dist/internal.js.map +1 -0
  217. package/dist/keyed-scope-controller.d.ts +11 -0
  218. package/dist/keyed-scope-controller.js +2 -0
  219. package/dist/keyed-scope-controller.js.map +1 -0
  220. package/dist/model-kernel.d.ts +18 -0
  221. package/dist/model-kernel.js +2 -0
  222. package/dist/model-kernel.js.map +1 -0
  223. package/dist/module-call-context.d.ts +7 -0
  224. package/dist/module-call-context.js +2 -0
  225. package/dist/module-call-context.js.map +1 -0
  226. package/dist/module-call-runtime.d.ts +21 -0
  227. package/dist/module-call-runtime.js +2 -0
  228. package/dist/module-call-runtime.js.map +1 -0
  229. package/dist/module-generation.d.ts +57 -0
  230. package/dist/module-generation.js +2 -0
  231. package/dist/module-generation.js.map +1 -0
  232. package/dist/module-instance-types.d.ts +156 -0
  233. package/dist/module-instance.d.ts +19 -0
  234. package/dist/module-instance.js +2 -0
  235. package/dist/module-instance.js.map +1 -0
  236. package/dist/module-runtime-identity.d.ts +4 -0
  237. package/dist/module-runtime-identity.js +2 -0
  238. package/dist/module-runtime-identity.js.map +1 -0
  239. package/dist/module-scope-open.d.ts +4 -0
  240. package/dist/module-scope-open.js +2 -0
  241. package/dist/module-scope-open.js.map +1 -0
  242. package/dist/module-scope-retirement.d.ts +3 -0
  243. package/dist/module-scope-retirement.js +2 -0
  244. package/dist/module-scope-retirement.js.map +1 -0
  245. package/dist/module-template-ir.d.ts +59 -0
  246. package/dist/owner-generation-retirement.d.ts +6 -0
  247. package/dist/owner-generation-retirement.js +2 -0
  248. package/dist/owner-generation-retirement.js.map +1 -0
  249. package/dist/owner-generation-state.d.ts +123 -0
  250. package/dist/owner-generation-state.js +2 -0
  251. package/dist/owner-generation-state.js.map +1 -0
  252. package/dist/owner-generation.d.ts +16 -0
  253. package/dist/owner-generation.js +2 -0
  254. package/dist/owner-generation.js.map +1 -0
  255. package/dist/public-module-definition.d.ts +9 -0
  256. package/dist/public-module-definition.js +2 -0
  257. package/dist/public-module-definition.js.map +1 -0
  258. package/dist/public-module-instance.d.ts +6 -0
  259. package/dist/public-module-instance.js +2 -0
  260. package/dist/public-module-instance.js.map +1 -0
  261. package/dist/public-module-retirement-diagnostics.d.ts +5 -0
  262. package/dist/public-module-retirement-diagnostics.js +2 -0
  263. package/dist/public-module-retirement-diagnostics.js.map +1 -0
  264. package/dist/public-module-retirement.d.ts +3 -0
  265. package/dist/public-module-retirement.js +2 -0
  266. package/dist/public-module-retirement.js.map +1 -0
  267. package/dist/public-module-scope.d.ts +5 -0
  268. package/dist/public-module-scope.js +2 -0
  269. package/dist/public-module-scope.js.map +1 -0
  270. package/dist/public-module-state.d.ts +28 -0
  271. package/dist/public-module-state.js +2 -0
  272. package/dist/public-module-state.js.map +1 -0
  273. package/dist/public-module-types.d.ts +295 -0
  274. package/dist/public-module.d.ts +4 -0
  275. package/dist/resource-cache.d.ts +12 -0
  276. package/dist/resource-cache.js +2 -0
  277. package/dist/resource-cache.js.map +1 -0
  278. package/dist/resource-controller.d.ts +29 -0
  279. package/dist/resource-controller.js +2 -0
  280. package/dist/resource-controller.js.map +1 -0
  281. package/dist/resource-policy.d.ts +16 -0
  282. package/dist/resource-policy.js +2 -0
  283. package/dist/resource-policy.js.map +1 -0
  284. package/dist/resource-snapshot.d.ts +12 -0
  285. package/dist/resource-snapshot.js +2 -0
  286. package/dist/resource-snapshot.js.map +1 -0
  287. package/dist/resource-types.d.ts +3 -0
  288. package/dist/runtime-error-reporting.d.ts +4 -0
  289. package/dist/runtime-error-reporting.js +2 -0
  290. package/dist/runtime-error-reporting.js.map +1 -0
  291. package/dist/stream-backpressure.d.ts +18 -0
  292. package/dist/stream-backpressure.js +2 -0
  293. package/dist/stream-backpressure.js.map +1 -0
  294. package/dist/stream-cleanup.d.ts +17 -0
  295. package/dist/stream-cleanup.js +2 -0
  296. package/dist/stream-cleanup.js.map +1 -0
  297. package/dist/stream-controller-types.d.ts +51 -0
  298. package/dist/stream-controller.d.ts +5 -0
  299. package/dist/stream-controller.js +2 -0
  300. package/dist/stream-controller.js.map +1 -0
  301. package/docs/agent-guide.md +214 -0
  302. package/docs/agent-guide.ru.md +208 -0
  303. package/docs/cookbook.md +734 -0
  304. package/docs/cookbook.ru.md +729 -0
  305. package/docs/decisions.md +1437 -0
  306. package/docs/devtools.md +423 -0
  307. package/docs/devtools.ru.md +419 -0
  308. package/docs/how-it-works.md +521 -0
  309. package/docs/how-it-works.ru.md +495 -0
  310. package/docs/releases.md +78 -0
  311. package/docs/releases.ru.md +78 -0
  312. package/docs/spec.md +874 -0
  313. package/docs/spec.ru.md +884 -0
  314. package/package.json +72 -0
@@ -0,0 +1,729 @@
1
+ # Opetope: книга рецептов
2
+
3
+ > Рецепты используют публичные входы; сокращённые фрагменты предполагают окружающие объявления из рецепта.
4
+ > Stress компилирует примеры spec §2, app type gate проверяет production-пилоты; каждый фрагмент cookbook он не
5
+ > компилирует. В §3 и §9 имена _иллюстративные_ (`hasTasks`, `sessionsFeature`, `Badge`): API настоящий,
6
+ > production-вызова пока нет.
7
+
8
+ Практические варианты использования фреймворка на примерах приложения. Каждый рецепт: когда применять, код,
9
+ что проверяет фреймворк, типичные ошибки. Нормативные законы — в [spec.md](spec.md); как это устроено внутри — в
10
+ [how-it-works.md](how-it-works.md); правила для агентов — в [agent-guide.md](agent-guide.md).
11
+
12
+ ## 0. Форма фичи за минуту
13
+
14
+ ```ts
15
+ const feature = defineFeature({
16
+ id: 'area.feature', // обязателен, единственный
17
+ when: [authorizedCondition], // время жизни: пока все условия истинны; без when — с приложением
18
+ imports: { platform: hostContract, other: otherFeature, maybe: optional(thirdFeature) },
19
+ requires: { resolve: resolveItemPort },
20
+ own: ({ imports, requires, calls, attach, call, lane, effect, event, resource, stream, scope, model }) => ({ … }),
21
+ exports: ({ own }) => ({ x: own.x }), // Call | Readable | Resource из own; модель целиком — нельзя
22
+ provides: ({ slot, pipe, register, port, own }) => ({ … }),
23
+ });
24
+ ```
25
+
26
+ Стадии видят только предыдущие: `imports`/`requires` → `own` → `exports`/`provides`. Три слова рёбер:
27
+ жёсткое (`imports: { x: feature }`, `port`) — провайдер живёт не меньше меня и открывается раньше;
28
+ слабое (`optional(x)`) — провайдера может не быть или он может выключиться, я читаю `Lookup`;
29
+ `when` — условия моего собственного времени жизни.
30
+
31
+ ## 1. Хост-контракт и вызовы хоста (`calls`), провайдер порта
32
+
33
+ Когда: фича берёт внешнюю возможность у приложения и отдаёт её другим фичам через порт.
34
+
35
+ ```ts
36
+ // contract.ts — нейтральный файл, без импорта фич
37
+ const catalogResolveSource = defineHostContract<{
38
+ resolveItem: (input: ResolveItemInput, signal: AbortSignal) => ItemSummary | null;
39
+ }>('catalog.resolve.platform');
40
+ const resolveItemPort = definePort<ResolveItemInput, ItemSummary | null>('checkoutControl.catalog.resolve');
41
+
42
+ // feature.ts
43
+ const catalogResolveFeature = defineFeature({
44
+ id: catalogResolveFeatureId,
45
+ imports: { platform: catalogResolveSource },
46
+ own: ({ calls, imports }) => ({ ...calls(imports.platform, ['resolveItem']) }), // методы хоста как Call
47
+ provides: ({ own, port }) => ({ catalog: port(resolveItemPort, own.resolveItem) }),
48
+ });
49
+ ```
50
+
51
+ Проверяется: у порта ровно один провайдер в приложении (два — ошибка компиляции топологии); `calls` берёт только
52
+ существующие ключи контракта (тип). Ошибка: класть в `own` сырой `imports.platform` или число — `own` принимает
53
+ только значения builder-а.
54
+
55
+ ### Обычные методы и адаптеры клиентов
56
+
57
+ Не добавляйте `_signal` в обычные методы только ради выбора через `calls` (D244):
58
+
59
+ ```ts
60
+ import type { ModelContext } from '@opetope/core';
61
+
62
+ type Input = { readonly id: string };
63
+ type Result = { readonly title: string };
64
+ declare const ctx: ModelContext;
65
+ declare const form: { clear(): void; setAmount(amount: number): void };
66
+ declare const client: { load(input: Input, options: { signal: AbortSignal }): Promise<Result> };
67
+
68
+ const editing = ctx.calls(form, ['clear', 'setAmount']);
69
+ const api = {
70
+ load: (input: Input, signal: AbortSignal) => client.load(input, { signal }),
71
+ };
72
+ const { load } = ctx.calls(api, ['load']);
73
+ const reload = ctx.call({
74
+ run: (input: Input, { invoke }) => invoke(load, input),
75
+ });
76
+ ```
77
+
78
+ `editing.clear` принимает `void`, `editing.setAmount` — число, а `load`/`reload` возвращают `Result`.
79
+ `calls` сохраняет `form` как receiver методов. Адаптер явно преобразует options клиента, не меняя клиент и не
80
+ добавляя runtime primitive. Необязательные input и signal принимаются; возможный третий параметр, произвольный
81
+ options-объект на второй позиции или неограниченный rest требуют адаптера.
82
+
83
+ ## 2. Требование порта: сам порт и `optional`
84
+
85
+ ```ts
86
+ const checkoutItemLookupFeature = defineFeature({
87
+ id: checkoutItemLookupFeatureId,
88
+ requires: { catalog: resolveItemPort }, // провайдер обязан быть в приложении
89
+ own: ({ requires }) => ({ resolveItem: requires.catalog }), // requires.x — ref, Call появится у экземпляра
90
+ });
91
+ // слабое требование: requires: { catalog: optional(resolveItemPort) } — вызов без живого провайдера
92
+ // отклоняется CallError с кодом 'unavailable'; useCommand покажет { status: 'failed', error }
93
+ ```
94
+
95
+ ## 3. Время жизни: условия и `when`
96
+
97
+ Когда: фича должна жить только при авторизации, на роуте, при включённом флаге.
98
+
99
+ ```ts
100
+ // условие, которое хост привязывает по id при открытии приложения:
101
+ const flagEnabled = defineCondition({ id: 'sampleApp.flag' });
102
+ // features/auth/…/feature.ts — условие, вычисленное из экспортов фичи; хост привязывает её источник, а не условие,
103
+ // и условие живёт рядом с фичей, которую читает (D184):
104
+ const authorizedCondition = defineCondition({
105
+ from: authSessionFeature,
106
+ id: 'sampleApp.authorized',
107
+ select: exports => exports.authorized,
108
+ });
109
+
110
+ // у одной фичи может быть несколько условий с разным смыслом: используйте отдельные условия
111
+ // для видимости UI и строгой авторизации, если их предикаты различаются (D204)
112
+ const sessionCondition = defineCondition({
113
+ from: authSessionFeature,
114
+ id: 'sampleApp.session',
115
+ select: exports => exports.hasSession,
116
+ });
117
+
118
+ const confirmActionFeature = defineFeature({ id, when: [authorizedCondition], … });
119
+ const logOutFeature = defineFeature({ id, when: [sessionCondition], … });
120
+
121
+ // приложение
122
+ const app = defineApplication({ id: 'sampleApp', features: [...], reporter });
123
+ openApplication(app, { conditions: { 'sampleApp.flag': flagReadable }, imports: [bind(contract, value)] });
124
+ ```
125
+
126
+ Законы: фичи с одним набором условий образуют группу, открываются в каноническом порядке и закрываются в обратном;
127
+ жёсткое ребро законно только если `provider.when ⊆ consumer.when` (иначе компилятор укажет на `optional`); группа-
128
+ потребитель ждёт готовности провайдеров из других групп и закрывается раньше них (D123). Приложение, называющее
129
+ вычисленное условие, обязано включать фичу, из которой оно вычислено, иначе топология не компилируется. Условие из
130
+ закрытой фичи
131
+ читается как `false`.
132
+
133
+ ## 4. Ленивый host-контракт, `attach`, лейны и политики вызовов
134
+
135
+ Когда: host-контракт приобретается по спросу, а вызовы должны идти строго по очереди с
136
+ инициализацией и без дублей.
137
+
138
+ ```ts
139
+ own: ({ attach, call, imports, lane }) => {
140
+ const commands = attach(imports.commands); // imports.commands = onDemand(actionHost)
141
+ const commandsLane = lane({ within: commands }); // очередь, живущая пока источник подключён
142
+ const initialize = call({ lane: commandsLane, once: true, within: commands,
143
+ run: (_input: void, { signal, source }) => source.initialize(signal) });
144
+ const retry = call({ lane: commandsLane, within: commands,
145
+ run: (_input: void, context) => context.invoke(initialize, undefined) });
146
+ const confirmAction = call({ policy: 'parallel', within: commands,
147
+ singleFlight: (intent: ConfirmActionIntent) => intent.itemId,
148
+ run: (intent, { signal, source }) => runConfirmActionWorkflow(source, intent, signal) });
149
+ return { commands, initialize, retry, confirmAction };
150
+ },
151
+ exports: ({ own }) => ({ retry: own.retry, confirmAction: own.confirmAction }),
152
+ ```
153
+
154
+ `once` кэширует первый успешный результат; `singleFlight` склеивает параллельные вызовы с одним ключом; `policy:
155
+ 'parallel'` снимает очередь лейна; `within` фенсит вызов вместе с источником. Отмена — это исход `cancelled`, не
156
+ ошибка продукта.
157
+
158
+ ## 5. Модель фичи от импорта и живые экспорты
159
+
160
+ ```ts
161
+ own: ({ imports, model, resource }) => ({
162
+ form: model(CheckoutCreateFormView, { platform: imports.platform }, (ctx, { platform }) => createCheckoutCreateFormModel(ctx, platform)),
163
+ tasks: resource(imports.platform, platform => platform.target, {
164
+ key: target => target, load: (_t, { signal, source }) => source.load(signal),
165
+ retention: scoped({ capacity: 1 }),
166
+ }),
167
+ }),
168
+ exports: ({ own }) => ({ tasks: own.tasks }), // Resource наружу; владеемое состояние сужается до Readable
169
+ ```
170
+
171
+ `model(Decl, { source: imports.source }, (ctx, { source }) => …)` передаёт readonly-запись материализованных
172
+ зависимостей, в том числе для одного источника. `model(Decl, create)` не требует зависимостей. Внутри фабрики: `ctx.state` (владеемое состояние, писать только через `ctx.update`), `ctx.call`, `ctx.lane`,
173
+ `ctx.effect`, `ctx.event`, `ctx.resource`, `ctx.stream`, `ctx.scope`, `ctx.timers`, `ctx.cleanup`. Поля модели —
174
+ только `Readable` и `Call`.
175
+
176
+ В модели `context.calls(deps, ['setAmount', 'setDirection'], { lane })` выбирает обычные методы без параметров,
177
+ с одним input либо `(input, signal: AbortSignal)` в Calls на одном lane модели (D143, D244). Необязательные
178
+ параметры поддерживаются. Единственный параметр всегда input: для signal-only метода объявите команду через
179
+ `context.call({ run: (_input: void, { signal }) => deps.load(signal) })` или используйте явный адаптер.
180
+
181
+ Модель может соединить импорт хоста и требуемый вызов, не получая контекст фичи. Фабрика объявляет свой интерфейс
182
+ зависимостей. Вызов модели также может реализовать порт; проекция выполняется один раз до публикации, а время
183
+ жизни провайдера ограничивает выбранный вызов, даже переданный напрямую из зависимости (D169).
184
+
185
+ ```ts
186
+ own: ({ imports, model, requires }) => ({
187
+ order: model(OrderModel, { platform: imports.platform, lookup: requires.lookup }, createOrderModel),
188
+ }),
189
+ provides: ({ own, port }) => ({
190
+ submit: port(SubmitPort, { from: own.order, select: order => order.submit }),
191
+ }),
192
+ ```
193
+
194
+ ## 6. UI как вклад в слот: per-mount модель, адаптер props, `requiresModels`
195
+
196
+ ```ts
197
+ // ui/…/contracts.ts
198
+ const CheckoutCreateFormActions = defineModel<{ deposit: Call<{ accountId: number; currency: string }, void>; … }>('checkout.createForm.actions');
199
+ const checkoutCreateFormContentSlot = defineSlot<CheckoutCreateFormContentProps>({ id: 'checkout.createForm.content' });
200
+
201
+ // ui/…/CheckoutCreateFormContent.tsx — компонент объявляет только модели, создаваемые на монтирование
202
+ const CheckoutCreateFormContent = requiresModels([CheckoutCreateFormActions])(({ renderForm }) => renderForm());
203
+
204
+ // integration/platform/…/feature.tsx
205
+ provides: ({ slot }) => ({
206
+ content: slot(checkoutCreateFormContentSlot, ({ model }) => ({
207
+ Component: CheckoutCreateFormContent,
208
+ models: [model(CheckoutCreateFormActions, (ctx, props: Readable<CheckoutCreateFormContentProps>) => ({
209
+ deposit: ctx.call({ run: ({ accountId, currency }) => props.getSnapshot().onDepositClick(accountId, currency) }),
210
+ }))],
211
+ props: ({ renderForm }: CheckoutCreateFormContentProps) => ({ renderForm }), // что видит компонент
212
+ })),
213
+ }),
214
+ ```
215
+
216
+ Модели `own` доступны компоненту вклада автоматически (`useModel(CheckoutCreateFormView)`), per-mount модели живут с
217
+ монтированием, создаются в коммите и закрываются на unmount; их команды после unmount отменяются. Брошенный рендер
218
+ не создаёт модели (D188). Компонент или hook, читающий per-mount модель, обязан объявить её через `requiresModels`:
219
+ проверяйте также вложенные компоненты и хуки. В runtime отсутствие модели среди разрешённых
220
+ монтированию приводит к `ContributionError('missing')`.
221
+
222
+ UI-модель оборачивает экспорт, когда контракт UI отличается по входу или ошибке:
223
+
224
+ ```ts
225
+ confirmAction: ctx.call({ run: async (_input: void, { invoke }) => {
226
+ const outcome = await invoke(exports.confirmAction, { itemId: props.getSnapshot().itemId });
227
+ if (outcome.type !== 'ok') throw new ConfirmActionCommandError(outcome.code, outcome.message);
228
+ }}),
229
+ ```
230
+
231
+ В React: `const { run, inFlight, result, lastError } = useCommand(actions.confirmAction)`; исходы — значения,
232
+ `throw` только для исключительных ситуаций.
233
+
234
+ Эффект, вызывающий команду, зависит от полученного деструктуризацией `run`: его ссылка стабильна для одного
235
+ invoker. Весь объект результата хука меняется со статусом. Сам хук ничего не планирует: каждый `run` доходит до
236
+ вызова, и решает политика, с которой вызов создан. Setter абсолютного значения — сумма, флаг enabled — объявляется
237
+ `policy: 'latest'` в `context.call` модели, и тогда новый вход заменяет ожидающий, а вытесненный получает
238
+ `cancelled` без callbacks успеха и ошибки (D185, D203). Для submit-кнопок оставляйте `queue` по умолчанию: `latest`
239
+ отбрасывает намерения, которые не успели начаться, и не подходит, если каждую операцию нужно выполнить.
240
+
241
+ Кнопка, которая не должна сработать дважды, говорит об этом в своём вызове: `submit` объявляет `singleFlight`,
242
+ поэтому второй клик при незавершённой отправке присоединяется к тому же полёту, а не ставит в очередь второй ордер.
243
+ `inFlight` по-прежнему блокирует кнопку; безопасным быстрый двойной клик делает вызов.
244
+
245
+ Два контрола, перезаписывающих одно значение, используют одну команду: AmountInput передаёт
246
+ `{ amount, currency }` или `{ percent, currency }` в тот же вызов `setAmount`, где и живёт политика. Два вызова
247
+ держали бы по своему месту ожидания и могли бы переставить последнее намерение между контролами. Модель направляет
248
+ union в прежние host-методы.
249
+
250
+ Прямые пропсы, необязательный адаптер и пропсы модели читают один снимок
251
+ монтирования; новый снимок слота публикуется в layout до paint (D170).
252
+
253
+ ## 7. `pipe` с отслеживаемым чтением и `slot` с `when`
254
+
255
+ ```ts
256
+ provides: ({ pipe, slot }) => ({
257
+ homeHeader: slot(exampleToolbarSlot('home'), { Component: ExampleActionButton },
258
+ { priority: 1, when: ({ imports, read }) => read(imports.platform.actions).config.actionId !== '' }),
259
+ itemCount: pipe(exampleCountPipe, { // дескриптор, связан один раз (D223)
260
+ fold: (count: number, _meta, { imports, read }) =>
261
+ count + read(imports.platform.actions).pendingCount, // read делает fold отслеживаемым (D82)
262
+ }),
263
+ }),
264
+ ```
265
+
266
+ `pipe` объявляет обработчик дескриптором `{ fold }`: он видит тот же контекст экземпляра, что и предикат, и
267
+ выполняется на свёртке, а не при объявлении или предзагрузке. Предикат отвечает boolean, а его `read` записывает,
268
+ от чего зависит ответ, поэтому динамическая ветка следует за источником, который прочитан последним. `when: false`
269
+ убирает вклад из `entries` без снятия публикации; смена ответа пересчитывает цель одной транзакцией.
270
+
271
+ ## 8. Ресурсы, потоки, события, эффекты, области
272
+
273
+ ```ts
274
+ own: ({ effect, event, imports, resource, scope, stream }) => ({
275
+ initialLoad: resource(imports.platform, p => p.target, { key: t => t,
276
+ load: (_t, { signal, source }) => source.initialize(signal),
277
+ retention: scoped({ capacity: 1 }), retry: { attempts: 2, delayMs: 1_000 } }),
278
+ quote: stream(imports.platform, p => p.target, { backpressure: latest(),
279
+ connect: (target, { emit, source }) => source.connect(target, emit),
280
+ consume: ({ data, source, target }) => source.updateTitle(`${data.middle} ${target.title}`),
281
+ key: t => `${t.pageId}:${t.itemId}`, retention: scoped({ capacity: 8 }) }),
282
+ polling: event(imports.platform, ({ emit, source, timers }) => {
283
+ timers.interval(pollingIntervalMs, () => emit(undefined));
284
+ return source.subscribeForeground(() => emit(undefined));
285
+ }, { run: ({ signal, source }) => (source.isOnline() ? source.refresh(signal) : undefined) }),
286
+ autoOpen: effect({ from: imports.platform, run: ({ current, source, timers }) => {
287
+ if (current === null || current.viewed) return;
288
+ return timers.delay(3_000, () => { if (!source.getSnapshot()?.viewed) source.showDetails(); });
289
+ }}),
290
+ inactivePageReset: effect({ from: imports.lifetime,
291
+ when: (current, previous) => !current.active && (previous === undefined || (!previous.active && previous.pageId !== current.pageId)),
292
+ run: ({ current }) => current.resetTitle() }),
293
+ activePage: scope.while({ from: imports.lifetime, when: l => l.active, open: l => () => l.resetTitle() }),
294
+ }),
295
+ ```
296
+
297
+ Позиционная форма везде: `resource(from, target, {…})`, `stream(from, target, {…})`, `event(from, subscribe, {…})`.
298
+ Таймеры (`timers.delay/interval`) живут с экземпляром и отменяются на retire. Диспозеры `effect/event/resource/
299
+ stream/scope/cleanup` выполняются в одном drain в обратном порядке; регистрация после фенса — `TypeError`.
300
+
301
+ Target-селектор ресурса или стрима возвращает `Readable<T | null | undefined>`, а не скалярный snapshot.
302
+ Nullish target закрывает текущую материализацию. Этот контракт и необязательный `retention` одинаковы у фичи
303
+ и модели; `scoped({ capacity })` использует LRU. Предикат эффекта — `when(current, previous)`.
304
+ Фича закрывается через `await instance.close()`; повтор уборки вызывается только после проверки
305
+ `error.retryCleanup` у карантинного `FeatureError`. Карантин бывает только там, где его попросил хост:
306
+ `openFeature(feature, { cleanupFailure: 'quarantine' })` для одного экземпляра и
307
+ `openApplication(app, { cleanupFailure: 'quarantine', … })` для всех экземпляров приложения; по умолчанию `report`
308
+ отдаёт отказ репортеру и считает экземпляр закрытым (D182). Retry ресурса и бизнес-команды `retry` сохраняют свой
309
+ смысл.
310
+
311
+ ### Контракт внешнего source-адаптера
312
+
313
+ Адаптер принадлежит хосту, работа — фиче. Адаптер это обычная запись методов и readable-полей, чья форма и есть
314
+ контракт; он не хранит состояние фичи, ничего не открывает и ничего не закрывает.
315
+
316
+ ```ts
317
+ // features/tasks/integration/platform/resourceSource.ts — хостовая сторона `tasks.resource.platform`
318
+ const tasksTarget = Object.freeze({
319
+ getSnapshot: () => 'tasks' as const, // Readable, поэтому момент чтения выбирает фича
320
+ subscribe: (_listener: () => void) => () => undefined, // даже постоянный target возвращает disposer
321
+ });
322
+
323
+ function createTasksResourceSource(tasks: TasksInitializer): TasksResourceSource {
324
+ return Object.freeze({
325
+ load: async (signal: AbortSignal) => {
326
+ signal.throwIfAborted(); // сигнал это контракт, а не украшение
327
+ await tasks.init({ state: 'load' });
328
+ signal.throwIfAborted(); // и он проверяется ещё раз после await
329
+ },
330
+ target: tasksTarget,
331
+ });
332
+ }
333
+ ```
334
+
335
+ Четыре правила для любого адаптера: отменяемая работа использует сигнал операции; значение, за которым фича должна
336
+ следить, это `Readable`, а не снимок, который адаптер обновляет сам; `subscribe` возвращает
337
+ disposer нижележащего источника, поэтому освобождение снимает ровно то, что было взято; адаптер не импортирует фичу,
338
+ поэтому направление зависимости совпадает с направлением контракта.
339
+
340
+ В `load`, `connect` и `consume` используйте сигнал, переданный callback: он принадлежит текущему исполнению target,
341
+ которое может закончиться раньше модели. `ModelContext.signal` живёт со всей моделью. `run` команды также получает
342
+ сигнал собственного исполнения, а вложенный `invoke` наследует отмену; не подставляйте вместо него сигнал модели
343
+ (D243, D244).
344
+
345
+ Синхронному setter сигнал не нужен. Если внешняя работа неотменяема, укажите это в адаптере вместо косметического
346
+ `_signal`. Вызывающий всё равно может завершиться отменой, а Runtime отвергнет поздний результат Call или публикацию
347
+ Resource/Stream, но физическая работа может продолжиться, и drain обязан её дождаться. Произвольные записи состояния
348
+ внутри этой работы и внешние побочные эффекты требуют собственных проверок signal; отмена не может их откатить.
349
+ Игнорируемый сигнал или проверка после `await` не означают физическую отмену запроса нижележащего клиента.
350
+
351
+ ## 9. Слабое ребро на практике
352
+
353
+ ```ts
354
+ imports: { sessions: optional(sessionsFeature) },
355
+ own: ({ calls, imports, model }) => ({
356
+ // вызовы того провайдера, который есть сейчас; пока его нет — CallError 'unavailable' (D187)
357
+ ...calls(imports.sessions, ['confirmAction']),
358
+ badge: model(Badge, { sessions: imports.sessions }, (ctx, { sessions }) => ({ // sessions: Readable<Lookup<Exports>>
359
+ // половина данных: `select` выполняется только на found и вправе вернуть значение или Readable провайдера
360
+ count: fromOptional(sessions, found => found.sessions.count, { missing: 0 }),
361
+ })),
362
+ }),
363
+ ```
364
+
365
+ Голый `derive({ from: sessions, select: lookup => … })` пишите только когда ветки это действительно разная работа;
366
+ `fromOptional` делает то же самое без церемонии с `lookup.kind` и подписывается на вложенный `Readable` только пока
367
+ провайдер есть.
368
+
369
+ `found`/`missing` не различают причину: провайдера нет в приложении или его группа условий закрыта. Слабое ребро не
370
+ втягивает провайдера в приложение и не влияет на порядок активации. Вызов слабого порта без провайдера отклоняется `CallError` `unavailable`, и `useCommand` показывает его исходом `failed` с ошибкой в `lastError` — это ответ, а не отмена (D138).
371
+
372
+ Закрытие распространяется (D146): когда экземпляр провайдера ушёл, его состояние закрыто, `derive`/`computed` над прямым (жёстким) импортом отвечают `ReadableError` `closed` вместо последнего значения; слабое ребро вместо этого показывает `missing` — это и есть причина брать `optional`, если провайдер может умереть раньше.
373
+
374
+ ### Граница спроса на фичу
375
+
376
+ Когда интеграция с хостом предоставляет `FeatureDemandSource`, `FeatureBoundary` удерживает его lease и отображает
377
+ состояния готовности, ошибки и retry:
378
+
379
+ ```tsx
380
+ // узел в обеих ветвях: поддерево ошибки читает свой retry через `useFeatureRetry`
381
+ <FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
382
+ <Slot props={{ itemId }} target={confirmActionContentSlot} />
383
+ </FeatureBoundary>
384
+
385
+ // render-колбэки, когда ветви нужно то, что знает только граница (D177)
386
+ <FeatureBoundary
387
+ demand={tasksDemand}
388
+ error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
389
+ fallback={null}
390
+ >
391
+ {({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
392
+ </FeatureBoundary>
393
+ ```
394
+
395
+ Колбэк `children` типизирован по `demand`, поэтому `exports` это запись экспортов той самой фичи; колбэк `error`
396
+ получает `{ error, retry }`. Выполняется только показанная ветвь, а фичу граница уже держит — готовому потребителю
397
+ не нужен второй `useFeature` и он не берёт второе удержание. Хуки живут в дочерних компонентах, которые вернул
398
+ колбэк, а не в самом колбэке: колбэк выполняется в рендере границы, и права на `useModel`/`useCommand` по-прежнему
399
+ даёт вклад через `Slot`.
400
+
401
+ ### Фича двумя файлами
402
+
403
+ Когда реализация тяжёлая — модели, компоненты, их библиотеки — фича пишется заголовком и телом, и приложение с
404
+ consumers импортируют только заголовок (D186, D207):
405
+
406
+ ```ts
407
+ // features/tasks/…/feature.ts — заголовок: identity, рёбра, объявленная граница, loader
408
+ const tasksFeature = defineFeature({
409
+ id: 'tasks.resource',
410
+ imports: { platform: tasksResourcePlatform },
411
+ when: [authorizedCondition],
412
+ provides: { screen: tasksScreenSlot }, // метаданные: цель, а когда важен порядок — `{ priority, target }`
413
+ body: (): Promise<FeatureBody<TasksExports>> =>
414
+ import(/* webpackChunkName: "feature.tasks" */ './feature.body').then(module => module.tasksBody),
415
+ });
416
+
417
+ // features/tasks/…/feature.body.ts — тело: те же три секции, в чанке, который грузит заголовок
418
+ const tasksBody = defineFeature.body(tasksFeature, {
419
+ own: ({ imports, resource }) => ({ tasks: resource(imports.platform /* … */) }),
420
+ exports: ({ own }) => ({ tasks: own.tasks }),
421
+ });
422
+ ```
423
+
424
+ Тип экспортов пишется один раз, в нейтральном файле контрактов, и называется в loader — именно это не даёт двум
425
+ файлам выводить типы друг из друга по кругу. Тело реализует ровно ту границу, которую объявил заголовок: другая цель,
426
+ другой приоритет или другой порт отвергаются до открытия. Загрузка начинается при открытии экземпляра, а не при
427
+ объявлении, поэтому план компилируется и проверяется, пока тело ещё в сети.
428
+
429
+ ## 10. Тестирование
430
+
431
+ ```ts
432
+ // монтирование вклада без приложения
433
+ const harness = renderSlot(checkoutCreateFormOverlayHeaderSlot, { props, models: [[HeaderModel, { itemId }]] });
434
+ // команда-фикстура для модели
435
+ const confirm = command<ConfirmActionIntent, void>(async () => undefined);
436
+ // одна фича со стабами импортов
437
+ const instance = openFeature(confirmActionFeature, { imports: { commands, view }, reporter: () => undefined });
438
+ await instance.ready; … await instance.close();
439
+ ```
440
+
441
+ Приёмка порта наблюдения — `assertInspectionSessionContract` из `@opetope/devtools/testing` (гейт `ci:inspection` в `tooling/stress`).
442
+
443
+ ## 11. Анти-паттерны
444
+
445
+ - `exports: ({ own }) => ({ order: own.order })`, где `order` — модель: модель целиком не экспортируется, только поля.
446
+ - `throw` внутри команд для продуктовых исходов: возвращай авторскую запись исхода, например `{ type: 'error', message }`, `throw` — только исключения.
447
+ - `onDemand(feature)` — слова нет; слабое ребро это `optional`. `onDemand` применяется только к host-контрактам.
448
+ - Чтение per-mount модели вклада через `useModel(X)` в компоненте или хуке без `requiresModels([X])`, включая вложенных читателей (D158); owner-модели объявлять не нужно.
449
+ - `imports.platform.submit` внутри фабрики модели — ref, не значение; используй `model(Decl, { platform: imports.platform }, (ctx, { platform }) => platform.submit…)`.
450
+ - Запись в чужой `Readable`: `ctx.update` принимает только `OwnedState`, созданный этим контекстом.
451
+
452
+ ## 12. Практики
453
+
454
+ 0. Разделяйте композицию фичи, модели и UI, например как `features/<f>/{integration,models,ui}`. Хост объявляет
455
+ граф приложения и передаёт внешние привязки.
456
+ 1. Контракты (`defineHostContract`, `definePort`, `defineSlot`, `defineModel`, `defineCondition`) — в нейтральных
457
+ `*.contract.ts`, без импортов фич; фича импортирует контракт, а не другую фичу, кроме случаев жёсткого ребра.
458
+ 2. Один факт объявляется один раз: не дублируй форму хост-контракта в модели вклада, бери `own`-модель.
459
+ 3. Держи `own` плоским: имена полей — это словарь `exports` и вкладов.
460
+ 4. Ошибки читай по `code` (`FeatureError`, `CallError`, `ReadableError`, `ContributionError`, `ApplicationError`); отмена это только `CallError` `cancelled`/`closed`, `FeatureError` `retired` и `ContributionError` `inactive` (D138) — `unavailable` и `publication-rejected` продукт показывает как ошибку.
461
+ 5. Перед коммитом: `tsc`, пакетные `ci:test`, `ci:eslint` и `ci:size-limit`; запустите тесты затронутых фич и
462
+ проверки production-сборки приложения-потребителя. При изменении публичных имён — `ci:public-surface` и строка
463
+ в decision log.
464
+
465
+ ## Выбрать контракт компонента из одной модели
466
+
467
+ Для формы, которая уже получает модель через Slot, выбирайте значения и команды вместе.
468
+ Политика Call остаётся в модели. Команды разных выданных моделей по-прежнему можно объединить через `useCommands`.
469
+ Самодостаточный [React-пример](../../react/README.ru.md#hello-ui) содержит declarations и imports.
470
+
471
+ ```tsx
472
+ const { quantity, setQuantity, submit } = useModel(OrderForm, (model, { read }) => ({
473
+ quantity: read(model.state, state => state.quantity),
474
+ setQuantity: model.setQuantity,
475
+ submit: model.submit,
476
+ }));
477
+ ```
478
+
479
+ Из данных render меняет только выбранное quantity. `submit.inFlight` локален для этого потребителя; aliases независимы.
480
+ Если проекция возвращает объект, раскройте его поля в selection или сохраняйте стабильную ссылку.
481
+ Селектор, вынесенный из тела компонента (функция модуля или `useCallback`), на посторонний render не вызывается
482
+ вовсе; инлайн выполняется снова, а равная selection сохраняет прежний снимок, и работа ниже хука не
483
+ повторяется.
484
+
485
+ ## Проверить приложение через его Slot
486
+
487
+ Тест передаёт свой renderer и управляемые fixtures репозитория. Эскиз проводки использует React Testing Library.
488
+
489
+ ```tsx
490
+ import { createScenario } from '@opetope/react/testing';
491
+
492
+ const scenario = createScenario(application, {
493
+ conditions: {},
494
+ imports: bindings,
495
+ host: { mount: Component => render(<Component />) },
496
+ });
497
+ try {
498
+ await scenario.ready;
499
+ const screen = scenario.mount(OrderScreenSlot);
500
+ fireEvent.click(screen.host.getByRole('button', { name: 'Submit' }));
501
+ await scenario.waitFor(() => repository.submitted.length === 1, { label: 'order submitted' });
502
+ } finally {
503
+ await scenario.close();
504
+ }
505
+ expect(scenario.ownership()).toMatchObject({ scope: 'registered-runtime', status: 'complete', resources: 0 });
506
+ ```
507
+
508
+ Используйте именованный predicate для ожидания lane или управляемой загрузки. Timeout содержит те же graph/activity facts, что inspector; произвольная работа хоста остаётся неизвестной. См. [контракт сценариев](../../react/README.ru.md).
509
+
510
+ ## Читать модель одним селектором
511
+
512
+ Компонент показывает несколько полей модели и запускает две её команды. Хук за хуком это превращается в список,
513
+ который никто не читает целиком:
514
+
515
+ ```tsx
516
+ // как это написали в первый раз: модель, потом хук на поле, потом хук на команду
517
+ const actions = useModel(OrderActions);
518
+ const amount = useReadable(actions.amount);
519
+ const currency = useReadable(actions.currency);
520
+ const submit = useCommand(actions.submit);
521
+ const reset = useCommand(actions.reset);
522
+ ```
523
+
524
+ В самом компоненте не сказано, от чего он зависит: ответ собирается чтением тела сверху вниз, а следующее поле — это
525
+ одно `actions.total.getSnapshot()`, чтение без подписки за ним: отрисуется один раз и больше не обновится.
526
+
527
+ Один селектор говорит это в одном месте:
528
+
529
+ ```tsx
530
+ const { amount, currency, reset, submit } = useModel(OrderActions, (model, { read }) => ({
531
+ amount: read(model.amount),
532
+ currency: read(model.currency, value => value.code),
533
+ reset: model.reset,
534
+ submit: model.submit,
535
+ }));
536
+ ```
537
+
538
+ Запись это контракт компонента с моделью: `read` единственный вход, поэтому непрочитанное поле не подписано и не
539
+ может тихо устареть; каждый отдельный `Readable` подписан один раз; а аутентичный `Call`, взятый полем, становится
540
+ тем же `CommandHook`, что возвращает отдельный хук, со своими `run`, `inFlight` и `lastError` на каждый алиас
541
+ (D205, D214). Селектор чистый — ни хуков, ни команд, ни побочных эффектов, — а его поля сравниваются по
542
+ `Object.is`, поэтому выбирайте скаляры или проекции, а не заново созданные объекты. Полный контракт выборки — в
543
+ разделе про контракт компонента из одной модели выше.
544
+
545
+ Это не обещание меньшего числа рендеров: объединение хуков меняет то, что компонент объявляет, а не скорость React.
546
+ Триада остаётся уместной там, где выбирать не из чего: `Readable`, пришедший пропсом или от `Resource`
547
+ (`useReadable`, `useResource`), компонент, который только передаёт выданную модель дальше (одноаргументный
548
+ `useModel`), и общий хук, читающий одно поле для нескольких компонентов, — там один `useReadable` просто меньше
549
+ селектора.
550
+
551
+ ## Решить, слабое ли ребро
552
+
553
+ Поверхность настроек показывает раздел диагностики, если он есть в этой сборке. Написанное жёстким ребром, это не
554
+ компилируется:
555
+
556
+ ```ts
557
+ // фича настроек живёт всегда; провайдер живёт под флагом разработки
558
+ imports: { devTools: devToolsFeature },
559
+ when: [settingsVisible],
560
+ ```
561
+
562
+ Жёсткое ребро требует `provider.when ⊆ consumer.when`, поэтому компилятор отказывает приложению и называет
563
+ `optional`. Увести потребителя под тот же флаг было бы хуже: вместе с флагом исчезла бы вся поверхность настроек, а
564
+ показать она должна была один раздел.
565
+
566
+ Ответ это слабое ребро, а его данные читаются без ручного разбора lookup:
567
+
568
+ ```ts
569
+ imports: { devTools: optional(devToolsFeature) },
570
+ own: ({ imports, model }) => ({
571
+ panel: model(SettingsPanel, { devTools: imports.devTools }, (_context, { devTools }) => ({
572
+ // devTools: Readable<Lookup<Exports>>; `select` выполняется только на found, а запасной ответ пишется один раз
573
+ diagnostics: fromOptional(devTools, found => found.diagnostics, { missing: [] }),
574
+ })),
575
+ }),
576
+ ```
577
+
578
+ Критерий это один вопрос: живёт ли провайдер не меньше этой фичи и открывается ли раньше неё? Да — жёсткий импорт,
579
+ и тип отдаёт экспорты напрямую. Нет — `optional`: другая группа условий, другой флаг, сборка, в которой провайдера
580
+ нет вовсе, или провайдер, которого приложение просто может не включить.
581
+
582
+ Что это даёт типам и графу: одно слово покрывает и отсутствие, и спрос, а экспорты приходят как
583
+ `Readable<Lookup<Exports>>`, а не чем-то `undefined` на компиляции (D105); слабое ребро не втягивает провайдера в
584
+ приложение и не меняет порядок активации; вызов через слабый порт отвечает `CallError` `unavailable`, а не бросает
585
+ (D187). `found`/`missing` никогда не говорит, какая именно причина — провайдера нет в приложении или он закрыт со
586
+ своей группой условий, — и это намеренно: читатель показывает один и тот же раздел в обоих случаях, а причина, с
587
+ которой ничего не сделать, в ветке не нужна.
588
+
589
+ ## Регистрировать через цель, а не рядом с ней
590
+
591
+ Несколько фич вкладывают именованные вещи в одно место: способы оплаты, инструменты по id, экраны по маршруту.
592
+ Первое, к чему тянется рука, — модульная map с самодельными уведомлениями:
593
+
594
+ ```ts
595
+ // ui/methods.ts — реестр вне графа
596
+ const entries = new Map<string, PaymentMethod>();
597
+ const listeners = new Set<() => void>();
598
+
599
+ const registerMethod = (key: string, value: PaymentMethod): (() => void) => {
600
+ entries.set(key, value);
601
+ for (const listener of listeners) listener();
602
+
603
+ return () => {
604
+ entries.delete(key);
605
+ for (const listener of listeners) listener();
606
+ };
607
+ };
608
+ ```
609
+
610
+ Фича зовёт `registerMethod` из эффекта и освобождает его в cleanup. Запись при этом ничем не связана с фичей: fence
611
+ её не снимает, наполовину провалившееся открытие оставляет её лежать, две фичи с одним ключом обнаруживает тот, кто
612
+ рендерит последним, а инспектор не показывает никакого вклада — пропавший способ оплаты приходится искать чтением
613
+ кода, а не рантайм-графа.
614
+
615
+ Цель уже существует, и живёт она вне фич:
616
+
617
+ ```ts
618
+ // contracts.ts
619
+ const paymentMethods = defineRegistry<string, PaymentMethodEntry>({ id: 'checkout.methods' });
620
+
621
+ // feature.ts — запись это вклад, поэтому она открывается и закрывается с экземпляром, который её сделал
622
+ provides: ({ register }) => ({
623
+ card: register(paymentMethods, ({ exports }) => ({ key: 'card', value: { submit: exports.submit, title: 'Card' } })),
624
+ }),
625
+
626
+ // UI читает цель, а не модуль провайдера
627
+ const method = useReadable(paymentMethods.select(selectedKey));
628
+ const methods = useReadable(paymentMethods.list);
629
+ ```
630
+
631
+ Публикация атомарна и принадлежит владельцу: запись ложится, когда экземпляр готов, fence снимает её на закрытии,
632
+ дубль ключа отвергается до того, как что-то сдвинулось, порядок это `priority`, затем id, а инспектор показывает
633
+ запись вместе с фичей-владельцем (D127, D196). `select(key)` отвечает тем же `Lookup`, что и слабое ребро, поэтому
634
+ отсутствующий способ это ветка, а не `undefined`.
635
+
636
+ Локальное состояние UI остаётся локальным: какой способ выбран, открыта ли панель, где курсор. Это поле модели
637
+ монтирования, а не запись реестра: реестр отвечает на «кто что вложил», модель — на «что этот экран делает сейчас».
638
+
639
+ ## Проверить композицию через `createScenario`
640
+
641
+ Юнит-тесты компонента ничего не говорят о композиции: действительно ли фича под условием публикуется в слот и
642
+ действительно ли забирает вклад обратно. Это один сценарий:
643
+
644
+ ```tsx
645
+ const scenario = createScenario(application, {
646
+ conditions: { 'checkout.promo': promo }, // источник принадлежит тесту: `getSnapshot`/`subscribe` над локальным флагом
647
+ host: { mount: Component => render(<Component />) },
648
+ imports: bindings,
649
+ });
650
+ const ready = (snapshot: RuntimeGraphSnapshot): boolean =>
651
+ snapshot.runtime.instances.some(node => node.id.includes('checkout.promo') && node.state.kind === 'ready');
652
+
653
+ try {
654
+ await scenario.ready;
655
+ const view = scenario.mount(checkoutAsideSlot);
656
+
657
+ expect(view.host.queryByTestId('promo')).toBeNull();
658
+ promo.set(true);
659
+ scenario.notify();
660
+ await scenario.waitFor(ready, { label: 'promo feature ready' });
661
+ expect(view.host.getByTestId('promo')).toBeTruthy();
662
+
663
+ promo.set(false);
664
+ scenario.notify();
665
+ await scenario.waitFor(snapshot => !ready(snapshot), { label: 'promo feature retired' });
666
+ expect(view.host.queryByTestId('promo')).toBeNull();
667
+ } finally {
668
+ await scenario.close();
669
+ }
670
+ ```
671
+
672
+ Тест ведёт настоящее приложение: свой источник условия, свой рендерер, граф между ними. `notify()` будит предикаты
673
+ после смены фикстуры, не публикуя рантайм-события, а дедлайн `waitFor` сообщает те же факты графа и активности,
674
+ которые держит инспектор, а не голый таймаут (D206, D215).
675
+
676
+ При обновлении фреймворка он ловит то, чего не поймает тест компонента: форму `when` и момент открытия группы,
677
+ публикацию в слот и её порядок, снятие вклада на закрытии — включая вклад, задержавшийся в `entries` на такт
678
+ дольше. Закрывайте в `finally`, а `scenario.ownership()` проверяйте тогда, когда тест утверждает и то, что
679
+ приложение освободило принадлежавшее ему.
680
+
681
+ ## Сгладить всплески host-источника
682
+
683
+ Host-источник срабатывает несколько раз за кадр — тик цены, resize, позиция скролла, — и один `run` за ним не
684
+ успевает. Очередь `event` держит один payload, поэтому всплеск стоит по записи отказа `queue-capacity` на каждый
685
+ отброшенный emit, и потребитель, который видит эти записи в своём reporter, обычно сглаживает источник руками:
686
+
687
+ ```ts
688
+ // models/…/TickModel.ts — вторая очередь перед очередью рантайма
689
+ let scheduled = false;
690
+ let last: Tick | undefined;
691
+
692
+ const emitLatest = (emit: (tick: Tick) => void, tick: Tick): void => {
693
+ last = tick;
694
+
695
+ if (scheduled) return;
696
+
697
+ scheduled = true;
698
+ queueMicrotask(() => {
699
+ scheduled = false;
700
+
701
+ if (last !== undefined) emit(last);
702
+ });
703
+ };
704
+ ```
705
+
706
+ Эта очередь вне всего: её микрозадача не принадлежит экземпляру, поэтому fence её не отменяет и payload может дойти
707
+ до `emit` после закрытия владельца; она прячет отбрасывания вместо ответа на них; и две очереди теперь расходятся в
708
+ том, какой payload считать последним.
709
+
710
+ Политика это опция события, и слово у неё то же, что у потока:
711
+
712
+ ```ts
713
+ own: ({ event, imports }) => ({
714
+ ticks: event(imports.platform, ({ emit, source }) => source.subscribeTicks(emit), {
715
+ backpressure: latest(), // ожидающий слот достаётся новому payload (D247)
716
+ run: ({ payload, signal, source }) => source.applyTick(payload, signal),
717
+ }),
718
+ }),
719
+ ```
720
+
721
+ Пока `run` занят и один payload ждёт, следующий `emit` заменяет ожидающего, и ничего не репортится — репортить
722
+ нечего, потому что доставки того payload никто не просил. Идущий `run` при этом не прерывается: применяемый тик
723
+ доходит до конца сам, меняется только владелец ожидающего слота. Закрытие владельца отбрасывает то, что в слоте
724
+ ждёт, — и при этой политике, и при дефолтной.
725
+
726
+ Оставляйте дефолт там, где событие это триггер или команда, а не значение: перезапрос по фокусу, отправка,
727
+ намерение «открыть детали». Там важно первое событие всплеска, второе это действительно лишняя работа, а запись
728
+ `queue-capacity` — то, чем reporter сообщает, что источник громче обработчика. Очередь без потерь это ни та, ни
729
+ другая политика, и она остаётся открытым вопросом (D183).