@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/README.ru.md +344 -0
- package/dist/application-compiler-edges.d.ts +3 -0
- package/dist/application-compiler-edges.js +2 -0
- package/dist/application-compiler-edges.js.map +1 -0
- package/dist/application-compiler-graph.d.ts +8 -0
- package/dist/application-compiler-graph.js +2 -0
- package/dist/application-compiler-graph.js.map +1 -0
- package/dist/application-compiler.d.ts +116 -0
- package/dist/application-compiler.js +2 -0
- package/dist/application-compiler.js.map +1 -0
- package/dist/application-conditions.d.ts +18 -0
- package/dist/application-conditions.js +2 -0
- package/dist/application-conditions.js.map +1 -0
- package/dist/application-definition.d.ts +30 -0
- package/dist/application-definition.js +2 -0
- package/dist/application-definition.js.map +1 -0
- package/dist/application-error.d.ts +10 -0
- package/dist/application-error.js +2 -0
- package/dist/application-error.js.map +1 -0
- package/dist/application-execution.d.ts +43 -0
- package/dist/application-execution.js +2 -0
- package/dist/application-execution.js.map +1 -0
- package/dist/application-feature-bindings.d.ts +11 -0
- package/dist/application-feature-bindings.js +2 -0
- package/dist/application-feature-bindings.js.map +1 -0
- package/dist/application-feature-instance.d.ts +5 -0
- package/dist/application-feature-instance.js +2 -0
- package/dist/application-feature-instance.js.map +1 -0
- package/dist/application-group-order.d.ts +29 -0
- package/dist/application-group-order.js +2 -0
- package/dist/application-group-order.js.map +1 -0
- package/dist/application-instance-retirement.d.ts +36 -0
- package/dist/application-instance-retirement.js +2 -0
- package/dist/application-instance-retirement.js.map +1 -0
- package/dist/application-open-options.d.ts +29 -0
- package/dist/application-open-options.js +2 -0
- package/dist/application-open-options.js.map +1 -0
- package/dist/application-port-compiler.d.ts +28 -0
- package/dist/application-port-compiler.js +2 -0
- package/dist/application-port-compiler.js.map +1 -0
- package/dist/attachment-call-declaration.d.ts +43 -0
- package/dist/attachment-call-declaration.js +2 -0
- package/dist/attachment-call-declaration.js.map +1 -0
- package/dist/attachment-declaration.d.ts +68 -0
- package/dist/attachment-declaration.js +2 -0
- package/dist/attachment-declaration.js.map +1 -0
- package/dist/attachment-execution.d.ts +9 -0
- package/dist/attachment-execution.js +2 -0
- package/dist/attachment-execution.js.map +1 -0
- package/dist/attachment-retirement-scheduler.d.ts +14 -0
- package/dist/attachment-retirement-scheduler.js +2 -0
- package/dist/attachment-retirement-scheduler.js.map +1 -0
- package/dist/call-option-snapshot.d.ts +20 -0
- package/dist/call-option-snapshot.js +2 -0
- package/dist/call-option-snapshot.js.map +1 -0
- package/dist/compile-call-target-bindings.d.ts +14 -0
- package/dist/compile-call-target-bindings.js +2 -0
- package/dist/compile-call-target-bindings.js.map +1 -0
- package/dist/compile-module-template.d.ts +38 -0
- package/dist/compile-module-template.js +2 -0
- package/dist/compile-module-template.js.map +1 -0
- package/dist/condition-group-execution.d.ts +44 -0
- package/dist/condition-group-execution.js +2 -0
- package/dist/condition-group-execution.js.map +1 -0
- package/dist/condition-override.d.ts +28 -0
- package/dist/condition-override.js +2 -0
- package/dist/condition-override.js.map +1 -0
- package/dist/condition-source.d.ts +10 -0
- package/dist/condition-source.js +2 -0
- package/dist/condition-source.js.map +1 -0
- package/dist/condition-types.d.ts +14 -0
- package/dist/condition.d.ts +28 -0
- package/dist/condition.js +2 -0
- package/dist/condition.js.map +1 -0
- package/dist/control-registry.d.ts +56 -0
- package/dist/control-registry.js +2 -0
- package/dist/control-registry.js.map +1 -0
- package/dist/dynamic-scope-child.d.ts +23 -0
- package/dist/dynamic-scope-child.js +2 -0
- package/dist/dynamic-scope-child.js.map +1 -0
- package/dist/dynamic-scope-controller.d.ts +16 -0
- package/dist/dynamic-scope-controller.js +2 -0
- package/dist/dynamic-scope-controller.js.map +1 -0
- package/dist/feature-attachment-authoring-types.d.ts +51 -0
- package/dist/feature-attachment-lowering.d.ts +18 -0
- package/dist/feature-attachment-lowering.js +2 -0
- package/dist/feature-attachment-lowering.js.map +1 -0
- package/dist/feature-attachment.d.ts +45 -0
- package/dist/feature-attachment.js +2 -0
- package/dist/feature-attachment.js.map +1 -0
- package/dist/feature-authoring-types.d.ts +196 -0
- package/dist/feature-authoring.d.ts +15 -0
- package/dist/feature-authoring.js +2 -0
- package/dist/feature-authoring.js.map +1 -0
- package/dist/feature-body.d.ts +53 -0
- package/dist/feature-body.js +2 -0
- package/dist/feature-body.js.map +1 -0
- package/dist/feature-call-authority.d.ts +8 -0
- package/dist/feature-call-authority.js +2 -0
- package/dist/feature-call-authority.js.map +1 -0
- package/dist/feature-call-types.d.ts +39 -0
- package/dist/feature-call.d.ts +18 -0
- package/dist/feature-call.js +2 -0
- package/dist/feature-call.js.map +1 -0
- package/dist/feature-calls.d.ts +6 -0
- package/dist/feature-calls.js +2 -0
- package/dist/feature-calls.js.map +1 -0
- package/dist/feature-contract.d.ts +70 -0
- package/dist/feature-contract.js +2 -0
- package/dist/feature-contract.js.map +1 -0
- package/dist/feature-contribution-model.d.ts +38 -0
- package/dist/feature-contribution-model.js +2 -0
- package/dist/feature-contribution-model.js.map +1 -0
- package/dist/feature-contribution.d.ts +126 -0
- package/dist/feature-contribution.js +2 -0
- package/dist/feature-contribution.js.map +1 -0
- package/dist/feature-definition-api.d.ts +53 -0
- package/dist/feature-definition-support.d.ts +21 -0
- package/dist/feature-definition-support.js +2 -0
- package/dist/feature-definition-support.js.map +1 -0
- package/dist/feature-effect.d.ts +26 -0
- package/dist/feature-effect.js +2 -0
- package/dist/feature-effect.js.map +1 -0
- package/dist/feature-event.d.ts +32 -0
- package/dist/feature-event.js +2 -0
- package/dist/feature-event.js.map +1 -0
- package/dist/feature-generation.d.ts +34 -0
- package/dist/feature-generation.js +2 -0
- package/dist/feature-generation.js.map +1 -0
- package/dist/feature-lazy-generation.d.ts +6 -0
- package/dist/feature-lazy-generation.js +2 -0
- package/dist/feature-lazy-generation.js.map +1 -0
- package/dist/feature-lazy.d.ts +44 -0
- package/dist/feature-lazy.js +2 -0
- package/dist/feature-lazy.js.map +1 -0
- package/dist/feature-materialization-binding.d.ts +31 -0
- package/dist/feature-materialization-binding.js +2 -0
- package/dist/feature-materialization-binding.js.map +1 -0
- package/dist/feature-materialization-types.d.ts +45 -0
- package/dist/feature-model-dependencies.d.ts +23 -0
- package/dist/feature-model-dependencies.js +2 -0
- package/dist/feature-model-dependencies.js.map +1 -0
- package/dist/feature-model.d.ts +60 -0
- package/dist/feature-model.js +2 -0
- package/dist/feature-model.js.map +1 -0
- package/dist/feature-optional.d.ts +15 -0
- package/dist/feature-optional.js +2 -0
- package/dist/feature-optional.js.map +1 -0
- package/dist/feature-own-lowering.d.ts +22 -0
- package/dist/feature-own-lowering.js +2 -0
- package/dist/feature-own-lowering.js.map +1 -0
- package/dist/feature-port-binding.d.ts +6 -0
- package/dist/feature-port-binding.js +2 -0
- package/dist/feature-port-binding.js.map +1 -0
- package/dist/feature-port.d.ts +66 -0
- package/dist/feature-port.js +2 -0
- package/dist/feature-port.js.map +1 -0
- package/dist/feature-record.d.ts +6 -0
- package/dist/feature-record.js +2 -0
- package/dist/feature-record.js.map +1 -0
- package/dist/feature-resource.d.ts +29 -0
- package/dist/feature-resource.js +2 -0
- package/dist/feature-resource.js.map +1 -0
- package/dist/feature-scope-types.d.ts +35 -0
- package/dist/feature-scope.d.ts +29 -0
- package/dist/feature-scope.js +2 -0
- package/dist/feature-scope.js.map +1 -0
- package/dist/feature-stream.d.ts +37 -0
- package/dist/feature-stream.js +2 -0
- package/dist/feature-stream.js.map +1 -0
- package/dist/feature-timers.d.ts +14 -0
- package/dist/feature-timers.js +2 -0
- package/dist/feature-timers.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/inspection-activity-protocol.d.ts +63 -0
- package/dist/inspection-activity.d.ts +54 -0
- package/dist/inspection-activity.js +2 -0
- package/dist/inspection-activity.js.map +1 -0
- package/dist/inspection-diff.d.ts +10 -0
- package/dist/inspection-diff.js +2 -0
- package/dist/inspection-diff.js.map +1 -0
- package/dist/inspection-module-activity.d.ts +4 -0
- package/dist/inspection-module-activity.js +2 -0
- package/dist/inspection-module-activity.js.map +1 -0
- package/dist/inspection-observer.d.ts +38 -0
- package/dist/inspection-observer.js +2 -0
- package/dist/inspection-observer.js.map +1 -0
- package/dist/inspection-plan.d.ts +33 -0
- package/dist/inspection-plan.js +2 -0
- package/dist/inspection-plan.js.map +1 -0
- package/dist/inspection-protocol.d.ts +333 -0
- package/dist/inspection-protocol.js +2 -0
- package/dist/inspection-protocol.js.map +1 -0
- package/dist/inspection-registry.d.ts +41 -0
- package/dist/inspection-registry.js +2 -0
- package/dist/inspection-registry.js.map +1 -0
- package/dist/inspection-session.d.ts +92 -0
- package/dist/inspection-session.js +2 -0
- package/dist/inspection-session.js.map +1 -0
- package/dist/inspection-snapshot.d.ts +4 -0
- package/dist/inspection-snapshot.js +2 -0
- package/dist/inspection-snapshot.js.map +1 -0
- package/dist/inspection-state.d.ts +91 -0
- package/dist/inspection-state.js +2 -0
- package/dist/inspection-state.js.map +1 -0
- package/dist/instance-demand.d.ts +31 -0
- package/dist/instance-demand.js +2 -0
- package/dist/instance-demand.js.map +1 -0
- package/dist/internal.d.ts +41 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -0
- package/dist/keyed-scope-controller.d.ts +11 -0
- package/dist/keyed-scope-controller.js +2 -0
- package/dist/keyed-scope-controller.js.map +1 -0
- package/dist/model-kernel.d.ts +18 -0
- package/dist/model-kernel.js +2 -0
- package/dist/model-kernel.js.map +1 -0
- package/dist/module-call-context.d.ts +7 -0
- package/dist/module-call-context.js +2 -0
- package/dist/module-call-context.js.map +1 -0
- package/dist/module-call-runtime.d.ts +21 -0
- package/dist/module-call-runtime.js +2 -0
- package/dist/module-call-runtime.js.map +1 -0
- package/dist/module-generation.d.ts +57 -0
- package/dist/module-generation.js +2 -0
- package/dist/module-generation.js.map +1 -0
- package/dist/module-instance-types.d.ts +156 -0
- package/dist/module-instance.d.ts +19 -0
- package/dist/module-instance.js +2 -0
- package/dist/module-instance.js.map +1 -0
- package/dist/module-runtime-identity.d.ts +4 -0
- package/dist/module-runtime-identity.js +2 -0
- package/dist/module-runtime-identity.js.map +1 -0
- package/dist/module-scope-open.d.ts +4 -0
- package/dist/module-scope-open.js +2 -0
- package/dist/module-scope-open.js.map +1 -0
- package/dist/module-scope-retirement.d.ts +3 -0
- package/dist/module-scope-retirement.js +2 -0
- package/dist/module-scope-retirement.js.map +1 -0
- package/dist/module-template-ir.d.ts +59 -0
- package/dist/owner-generation-retirement.d.ts +6 -0
- package/dist/owner-generation-retirement.js +2 -0
- package/dist/owner-generation-retirement.js.map +1 -0
- package/dist/owner-generation-state.d.ts +123 -0
- package/dist/owner-generation-state.js +2 -0
- package/dist/owner-generation-state.js.map +1 -0
- package/dist/owner-generation.d.ts +16 -0
- package/dist/owner-generation.js +2 -0
- package/dist/owner-generation.js.map +1 -0
- package/dist/public-module-definition.d.ts +9 -0
- package/dist/public-module-definition.js +2 -0
- package/dist/public-module-definition.js.map +1 -0
- package/dist/public-module-instance.d.ts +6 -0
- package/dist/public-module-instance.js +2 -0
- package/dist/public-module-instance.js.map +1 -0
- package/dist/public-module-retirement-diagnostics.d.ts +5 -0
- package/dist/public-module-retirement-diagnostics.js +2 -0
- package/dist/public-module-retirement-diagnostics.js.map +1 -0
- package/dist/public-module-retirement.d.ts +3 -0
- package/dist/public-module-retirement.js +2 -0
- package/dist/public-module-retirement.js.map +1 -0
- package/dist/public-module-scope.d.ts +5 -0
- package/dist/public-module-scope.js +2 -0
- package/dist/public-module-scope.js.map +1 -0
- package/dist/public-module-state.d.ts +28 -0
- package/dist/public-module-state.js +2 -0
- package/dist/public-module-state.js.map +1 -0
- package/dist/public-module-types.d.ts +295 -0
- package/dist/public-module.d.ts +4 -0
- package/dist/resource-cache.d.ts +12 -0
- package/dist/resource-cache.js +2 -0
- package/dist/resource-cache.js.map +1 -0
- package/dist/resource-controller.d.ts +29 -0
- package/dist/resource-controller.js +2 -0
- package/dist/resource-controller.js.map +1 -0
- package/dist/resource-policy.d.ts +16 -0
- package/dist/resource-policy.js +2 -0
- package/dist/resource-policy.js.map +1 -0
- package/dist/resource-snapshot.d.ts +12 -0
- package/dist/resource-snapshot.js +2 -0
- package/dist/resource-snapshot.js.map +1 -0
- package/dist/resource-types.d.ts +3 -0
- package/dist/runtime-error-reporting.d.ts +4 -0
- package/dist/runtime-error-reporting.js +2 -0
- package/dist/runtime-error-reporting.js.map +1 -0
- package/dist/stream-backpressure.d.ts +18 -0
- package/dist/stream-backpressure.js +2 -0
- package/dist/stream-backpressure.js.map +1 -0
- package/dist/stream-cleanup.d.ts +17 -0
- package/dist/stream-cleanup.js +2 -0
- package/dist/stream-cleanup.js.map +1 -0
- package/dist/stream-controller-types.d.ts +51 -0
- package/dist/stream-controller.d.ts +5 -0
- package/dist/stream-controller.js +2 -0
- package/dist/stream-controller.js.map +1 -0
- package/docs/agent-guide.md +214 -0
- package/docs/agent-guide.ru.md +208 -0
- package/docs/cookbook.md +734 -0
- package/docs/cookbook.ru.md +729 -0
- package/docs/decisions.md +1437 -0
- package/docs/devtools.md +423 -0
- package/docs/devtools.ru.md +419 -0
- package/docs/how-it-works.md +521 -0
- package/docs/how-it-works.ru.md +495 -0
- package/docs/releases.md +78 -0
- package/docs/releases.ru.md +78 -0
- package/docs/spec.md +874 -0
- package/docs/spec.ru.md +884 -0
- package/package.json +72 -0
package/docs/cookbook.md
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
# Opetope: cookbook
|
|
2
|
+
|
|
3
|
+
> Recipes use the public entries; abbreviated snippets assume the surrounding declarations shown in the recipe.
|
|
4
|
+
> Stress compiles spec §2 examples, and the app type gate checks production pilots; it does not compile every
|
|
5
|
+
> cookbook fragment. Sections 3 and 9 use _illustrative_ names (`hasTasks`, `sessionsFeature`, `Badge`): the API is
|
|
6
|
+
> real, the production caller does not exist yet.
|
|
7
|
+
|
|
8
|
+
Practical uses of the framework through application examples. Every recipe gives you: when to apply it, the
|
|
9
|
+
code, what the framework checks, and the typical mistakes. The normative laws are in [spec.md](spec.md); how it
|
|
10
|
+
works inside is in [how-it-works.md](how-it-works.md); the rules for agents are in
|
|
11
|
+
[agent-guide.md](agent-guide.md).
|
|
12
|
+
|
|
13
|
+
## 0. The shape of a feature in one minute
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const feature = defineFeature({
|
|
17
|
+
id: 'area.feature', // required, and the only required option
|
|
18
|
+
when: [authorizedCondition], // lifetime: while every condition holds; without when — with the application
|
|
19
|
+
imports: { platform: hostContract, other: otherFeature, maybe: optional(thirdFeature) },
|
|
20
|
+
requires: { resolve: resolveItemPort },
|
|
21
|
+
own: ({ imports, requires, calls, attach, call, lane, effect, event, resource, stream, scope, model }) => ({ … }),
|
|
22
|
+
exports: ({ own }) => ({ x: own.x }), // Call | Readable | Resource out of own; a whole model is not allowed
|
|
23
|
+
provides: ({ slot, pipe, register, port, own }) => ({ … }),
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A stage sees only the stages before it: `imports`/`requires` → `own` → `exports`/`provides`. Three words for edges:
|
|
28
|
+
hard (`imports: { x: feature }`, `port`) — the provider lives at least as long as I do and opens earlier;
|
|
29
|
+
weak (`optional(x)`) — the provider may be absent or may switch off, and I read a `Lookup`;
|
|
30
|
+
`when` — the conditions of my own lifetime.
|
|
31
|
+
|
|
32
|
+
## 1. Host contract and host calls (`calls`), port provider
|
|
33
|
+
|
|
34
|
+
When: the feature takes an external capability from the application and hands it to other
|
|
35
|
+
features through a port.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// contract.ts — a neutral file, with no feature imports
|
|
39
|
+
const catalogResolveSource = defineHostContract<{
|
|
40
|
+
resolveItem: (input: ResolveItemInput, signal: AbortSignal) => ItemSummary | null;
|
|
41
|
+
}>('catalog.resolve.platform');
|
|
42
|
+
const resolveItemPort = definePort<ResolveItemInput, ItemSummary | null>('checkoutControl.catalog.resolve');
|
|
43
|
+
|
|
44
|
+
// feature.ts
|
|
45
|
+
const catalogResolveFeature = defineFeature({
|
|
46
|
+
id: catalogResolveFeatureId,
|
|
47
|
+
imports: { platform: catalogResolveSource },
|
|
48
|
+
own: ({ calls, imports }) => ({ ...calls(imports.platform, ['resolveItem']) }), // host methods as Call
|
|
49
|
+
provides: ({ own, port }) => ({ catalog: port(resolveItemPort, own.resolveItem) }),
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
What is checked: a port has exactly one provider in the application (two is a topology compile error); `calls` takes
|
|
54
|
+
only keys that exist on the contract (a type check). The mistake: putting the raw `imports.platform`, or a number,
|
|
55
|
+
into `own` — `own` accepts only values the builder produced.
|
|
56
|
+
|
|
57
|
+
### Plain methods and client adapters
|
|
58
|
+
|
|
59
|
+
Do not add `_signal` to ordinary methods just to select them with `calls` (D244):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import type { ModelContext } from '@opetope/core';
|
|
63
|
+
|
|
64
|
+
type Input = { readonly id: string };
|
|
65
|
+
type Result = { readonly title: string };
|
|
66
|
+
declare const ctx: ModelContext;
|
|
67
|
+
declare const form: { clear(): void; setAmount(amount: number): void };
|
|
68
|
+
declare const client: { load(input: Input, options: { signal: AbortSignal }): Promise<Result> };
|
|
69
|
+
|
|
70
|
+
const editing = ctx.calls(form, ['clear', 'setAmount']);
|
|
71
|
+
const api = {
|
|
72
|
+
load: (input: Input, signal: AbortSignal) => client.load(input, { signal }),
|
|
73
|
+
};
|
|
74
|
+
const { load } = ctx.calls(api, ['load']);
|
|
75
|
+
const reload = ctx.call({
|
|
76
|
+
run: (input: Input, { invoke }) => invoke(load, input),
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`editing.clear` takes `void`, `editing.setAmount` takes a number, and `load`/`reload` return `Result`.
|
|
81
|
+
`calls` preserves `form` as the methods' receiver. The adapter explicitly maps a client's options without changing
|
|
82
|
+
the client or adding a runtime primitive. Optional inputs and optional signals are accepted; a possible third
|
|
83
|
+
parameter, an arbitrary options object in the second position or an unbounded rest parameter needs an adapter.
|
|
84
|
+
|
|
85
|
+
## 2. Requiring a port: a bare port and `optional`
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const checkoutItemLookupFeature = defineFeature({
|
|
89
|
+
id: checkoutItemLookupFeatureId,
|
|
90
|
+
requires: { catalog: resolveItemPort }, // the provider must be present in the application
|
|
91
|
+
own: ({ requires }) => ({ resolveItem: requires.catalog }), // requires.x is a ref; the Call appears on the instance
|
|
92
|
+
});
|
|
93
|
+
// a weak requirement: requires: { catalog: optional(resolveItemPort) } — a call with no live provider
|
|
94
|
+
// rejects with CallError code 'unavailable'; useCommand shows it as { status: 'failed', error }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 3. Lifetime: conditions and `when`
|
|
98
|
+
|
|
99
|
+
When: the feature must live only while authorized, on a route, or behind an enabled flag.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// a condition the host binds by id when it opens the application:
|
|
103
|
+
const flagEnabled = defineCondition({ id: 'sampleApp.flag' });
|
|
104
|
+
// features/auth/…/feature.ts — a condition computed from the exports of a feature; the host binds its source, not
|
|
105
|
+
// the condition, and the condition lives next to the feature it reads (D184):
|
|
106
|
+
const authorizedCondition = defineCondition({
|
|
107
|
+
from: authSessionFeature,
|
|
108
|
+
id: 'sampleApp.authorized',
|
|
109
|
+
select: exports => exports.authorized,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// one feature may own several conditions with different meanings: use separate conditions for
|
|
113
|
+
// UI visibility and strict authorization when their predicates differ (D204)
|
|
114
|
+
const sessionCondition = defineCondition({
|
|
115
|
+
from: authSessionFeature,
|
|
116
|
+
id: 'sampleApp.session',
|
|
117
|
+
select: exports => exports.hasSession,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const confirmActionFeature = defineFeature({ id, when: [authorizedCondition], … });
|
|
121
|
+
const logOutFeature = defineFeature({ id, when: [sessionCondition], … });
|
|
122
|
+
|
|
123
|
+
// the application
|
|
124
|
+
const app = defineApplication({ id: 'sampleApp', features: [...], reporter });
|
|
125
|
+
openApplication(app, { conditions: { 'sampleApp.flag': flagReadable }, imports: [bind(contract, value)] });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The laws: features that share one set of conditions form a group, open in canonical order and close in reverse; a
|
|
129
|
+
hard edge is legal only when `provider.when ⊆ consumer.when` (otherwise the compiler points you at `optional`); a
|
|
130
|
+
consumer group waits for the providers in other groups to be ready and closes before them (D123). A condition read
|
|
131
|
+
from a closed feature reads as `false`. An application that names a computed condition must enable the feature it is
|
|
132
|
+
computed from; otherwise the topology does not compile.
|
|
133
|
+
|
|
134
|
+
## 4. Lazy host contract, `attach`, lanes and call policies
|
|
135
|
+
|
|
136
|
+
When: the host contract is acquired on demand, and the calls must run strictly in order, with
|
|
137
|
+
an initialization step and without duplicates.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
own: ({ attach, call, imports, lane }) => {
|
|
141
|
+
const commands = attach(imports.commands); // imports.commands = onDemand(actionHost)
|
|
142
|
+
const commandsLane = lane({ within: commands }); // a queue that lives while the source is attached
|
|
143
|
+
const initialize = call({ lane: commandsLane, once: true, within: commands,
|
|
144
|
+
run: (_input: void, { signal, source }) => source.initialize(signal) });
|
|
145
|
+
const retry = call({ lane: commandsLane, within: commands,
|
|
146
|
+
run: (_input: void, context) => context.invoke(initialize, undefined) });
|
|
147
|
+
const confirmAction = call({ policy: 'parallel', within: commands,
|
|
148
|
+
singleFlight: (intent: ConfirmActionIntent) => intent.itemId,
|
|
149
|
+
run: (intent, { signal, source }) => runConfirmActionWorkflow(source, intent, signal) });
|
|
150
|
+
return { commands, initialize, retry, confirmAction };
|
|
151
|
+
},
|
|
152
|
+
exports: ({ own }) => ({ retry: own.retry, confirmAction: own.confirmAction }),
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`once` caches the first successful result; `singleFlight` joins parallel calls that share one key; `policy:
|
|
156
|
+
'parallel'` lifts the lane queue; `within` fences the call together with its source. Cancellation is a `cancelled`
|
|
157
|
+
outcome, not a product error.
|
|
158
|
+
|
|
159
|
+
## 5. A feature model built from an import, and live exports
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
own: ({ imports, model, resource }) => ({
|
|
163
|
+
form: model(CheckoutCreateFormView, { platform: imports.platform }, (ctx, { platform }) => createCheckoutCreateFormModel(ctx, platform)),
|
|
164
|
+
tasks: resource(imports.platform, platform => platform.target, {
|
|
165
|
+
key: target => target, load: (_t, { signal, source }) => source.load(signal),
|
|
166
|
+
retention: scoped({ capacity: 1 }),
|
|
167
|
+
}),
|
|
168
|
+
}),
|
|
169
|
+
exports: ({ own }) => ({ tasks: own.tasks }), // a Resource goes out; owned state narrows to Readable
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`model(Decl, { source: imports.source }, (ctx, { source }) => …)` passes a readonly map of materialized
|
|
173
|
+
dependencies, including a single source. `model(Decl, create)` needs no dependencies. Inside the factory: `ctx.state` (owned state, written only through `ctx.update`), `ctx.call`, `ctx.lane`,
|
|
174
|
+
`ctx.effect`, `ctx.event`, `ctx.resource`, `ctx.stream`, `ctx.scope`, `ctx.timers`, `ctx.cleanup`. Model fields are
|
|
175
|
+
only `Readable` and `Call`.
|
|
176
|
+
|
|
177
|
+
Inside a model, `context.calls(deps, ['setAmount', 'setDirection'], { lane })` selects ordinary methods with no
|
|
178
|
+
parameters, one input, or `(input, signal: AbortSignal)` into Calls on one model lane (D143, D244). Optional
|
|
179
|
+
parameters are supported. A single parameter is always the input: for a signal-only method declare a command
|
|
180
|
+
with `context.call({ run: (_input: void, { signal }) => deps.load(signal) })` or use an explicit adapter.
|
|
181
|
+
|
|
182
|
+
A model can compose a host import and a required call without receiving the feature context. Its factory declares
|
|
183
|
+
its own dependency interface. A model call may also implement a port; the projection runs once before publication
|
|
184
|
+
and the provider's lifetime fences the selected call even when it is passed through from a dependency (D169).
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
own: ({ imports, model, requires }) => ({
|
|
188
|
+
order: model(OrderModel, { platform: imports.platform, lookup: requires.lookup }, createOrderModel),
|
|
189
|
+
}),
|
|
190
|
+
provides: ({ own, port }) => ({
|
|
191
|
+
submit: port(SubmitPort, { from: own.order, select: order => order.submit }),
|
|
192
|
+
}),
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 6. UI as a slot contribution: per-mount model, props adapter, `requiresModels`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
// ui/…/contracts.ts
|
|
199
|
+
const CheckoutCreateFormActions = defineModel<{ deposit: Call<{ accountId: number; currency: string }, void>; … }>('checkout.createForm.actions');
|
|
200
|
+
const checkoutCreateFormContentSlot = defineSlot<CheckoutCreateFormContentProps>({ id: 'checkout.createForm.content' });
|
|
201
|
+
|
|
202
|
+
// ui/…/CheckoutCreateFormContent.tsx — the component declares only the models created per mount
|
|
203
|
+
const CheckoutCreateFormContent = requiresModels([CheckoutCreateFormActions])(({ renderForm }) => renderForm());
|
|
204
|
+
|
|
205
|
+
// integration/platform/…/feature.tsx
|
|
206
|
+
provides: ({ slot }) => ({
|
|
207
|
+
content: slot(checkoutCreateFormContentSlot, ({ model }) => ({
|
|
208
|
+
Component: CheckoutCreateFormContent,
|
|
209
|
+
models: [model(CheckoutCreateFormActions, (ctx, props: Readable<CheckoutCreateFormContentProps>) => ({
|
|
210
|
+
deposit: ctx.call({ run: ({ accountId, currency }) => props.getSnapshot().onDepositClick(accountId, currency) }),
|
|
211
|
+
}))],
|
|
212
|
+
props: ({ renderForm }: CheckoutCreateFormContentProps) => ({ renderForm }), // what the component sees
|
|
213
|
+
})),
|
|
214
|
+
}),
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The `own` models are available to the contribution component automatically (`useModel(CheckoutCreateFormView)`);
|
|
218
|
+
per-mount models are created in commit and close on unmount, and their commands are cancelled after unmount. An
|
|
219
|
+
abandoned render creates no models (D188). A component or hook reading a per-mount model must declare it through
|
|
220
|
+
`requiresModels`; check nested components and hooks as well. At runtime, a model absent
|
|
221
|
+
from the mount’s granted models fails with `ContributionError('missing')`.
|
|
222
|
+
|
|
223
|
+
A UI model wraps an export when the UI contract differs in input or in error shape:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
confirmAction: ctx.call({ run: async (_input: void, { invoke }) => {
|
|
227
|
+
const outcome = await invoke(exports.confirmAction, { itemId: props.getSnapshot().itemId });
|
|
228
|
+
if (outcome.type !== 'ok') throw new ConfirmActionCommandError(outcome.code, outcome.message);
|
|
229
|
+
}}),
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
In React: `const { run, inFlight, result, lastError } = useCommand(actions.confirmAction)`; outcomes are values, and
|
|
233
|
+
`throw` is only for exceptional situations.
|
|
234
|
+
|
|
235
|
+
For an effect that invokes a command, depend on its destructured `run`, whose identity is stable for one invoker.
|
|
236
|
+
The whole hook result object changes with status. The hook itself schedules nothing: every `run` reaches the call,
|
|
237
|
+
and the policy the call was created with decides. An absolute setter such as amount or an enabled flag is declared
|
|
238
|
+
`policy: 'latest'` in the model's `context.call`, so the newest input replaces the waiting one and the displaced
|
|
239
|
+
input settles as `cancelled` without success or failure callbacks (D185, D203). Leave the default `queue` for submit
|
|
240
|
+
buttons: `latest` drops intents that never started and is wrong when every operation must execute.
|
|
241
|
+
|
|
242
|
+
A button that must not act twice says so in its call: `submit` declares `singleFlight`, so a second click while the
|
|
243
|
+
first submit is in flight joins that flight instead of queueing a second order. `inFlight` still disables the button;
|
|
244
|
+
the call is what makes a fast double click harmless.
|
|
245
|
+
|
|
246
|
+
Two controls that overwrite the same value need one command intent: AmountInput sends either
|
|
247
|
+
`{ amount, currency }` or `{ percent, currency }` to the same `setAmount` call, which is where the policy lives.
|
|
248
|
+
Two calls would each keep their own waiting place and could reorder the newest intent across controls. The model
|
|
249
|
+
dispatches the union to the existing host methods.
|
|
250
|
+
|
|
251
|
+
Direct props, an optional props adapter and model props all read the same mount
|
|
252
|
+
snapshot; a new slot snapshot publishes in layout before paint (D170).
|
|
253
|
+
|
|
254
|
+
## 7. `pipe` with tracked reads and `slot` with `when`
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
provides: ({ pipe, slot }) => ({
|
|
258
|
+
homeHeader: slot(exampleToolbarSlot('home'), { Component: ExampleActionButton },
|
|
259
|
+
{ priority: 1, when: ({ imports, read }) => read(imports.platform.actions).config.actionId !== '' }),
|
|
260
|
+
itemCount: pipe(exampleCountPipe, { // a descriptor, bound once (D223)
|
|
261
|
+
fold: (count: number, _meta, { imports, read }) =>
|
|
262
|
+
count + read(imports.platform.actions).pendingCount, // read makes the fold trackable (D82)
|
|
263
|
+
}),
|
|
264
|
+
}),
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A `pipe` declares its handler as a `{ fold }` descriptor, which sees the same instance context as the predicate and
|
|
268
|
+
runs on a fold, never at declaration or preload. A predicate answers a boolean and its `read` records what the answer
|
|
269
|
+
depends on, so a dynamic branch follows the source it read last. `when: false` removes the contribution from
|
|
270
|
+
`entries` without withdrawing the publication; a change of the answer recomputes the target in one transaction.
|
|
271
|
+
|
|
272
|
+
## 8. Resources, streams, events, effects, scopes
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
own: ({ effect, event, imports, resource, scope, stream }) => ({
|
|
276
|
+
initialLoad: resource(imports.platform, p => p.target, { key: t => t,
|
|
277
|
+
load: (_t, { signal, source }) => source.initialize(signal),
|
|
278
|
+
retention: scoped({ capacity: 1 }), retry: { attempts: 2, delayMs: 1_000 } }),
|
|
279
|
+
quote: stream(imports.platform, p => p.target, { backpressure: latest(),
|
|
280
|
+
connect: (target, { emit, source }) => source.connect(target, emit),
|
|
281
|
+
consume: ({ data, source, target }) => source.updateTitle(`${data.middle} ${target.title}`),
|
|
282
|
+
key: t => `${t.pageId}:${t.itemId}`, retention: scoped({ capacity: 8 }) }),
|
|
283
|
+
polling: event(imports.platform, ({ emit, source, timers }) => {
|
|
284
|
+
timers.interval(pollingIntervalMs, () => emit(undefined));
|
|
285
|
+
return source.subscribeForeground(() => emit(undefined));
|
|
286
|
+
}, { run: ({ signal, source }) => (source.isOnline() ? source.refresh(signal) : undefined) }),
|
|
287
|
+
autoOpen: effect({ from: imports.platform, run: ({ current, source, timers }) => {
|
|
288
|
+
if (current === null || current.viewed) return;
|
|
289
|
+
return timers.delay(3_000, () => { if (!source.getSnapshot()?.viewed) source.showDetails(); });
|
|
290
|
+
}}),
|
|
291
|
+
inactivePageReset: effect({ from: imports.lifetime,
|
|
292
|
+
when: (current, previous) => !current.active && (previous === undefined || (!previous.active && previous.pageId !== current.pageId)),
|
|
293
|
+
run: ({ current }) => current.resetTitle() }),
|
|
294
|
+
activePage: scope.while({ from: imports.lifetime, when: l => l.active, open: l => () => l.resetTitle() }),
|
|
295
|
+
}),
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The positional form everywhere: `resource(from, target, {…})`, `stream(from, target, {…})`, `event(from, subscribe, {…})`.
|
|
299
|
+
Timers (`timers.delay/interval`) live with the instance and are cancelled on retire. The disposers of `effect/event/
|
|
300
|
+
resource/stream/scope/cleanup` run in one drain, in reverse order; registering after the fence is a `TypeError`.
|
|
301
|
+
|
|
302
|
+
The `target` selector of a resource or stream returns a `Readable<T | null | undefined>`, never a scalar snapshot.
|
|
303
|
+
Nullish targets close the current materialization. This contract and optional `retention` are identical in feature
|
|
304
|
+
and model factories; `scoped({ capacity })` uses LRU. The effect predicate is `when(current, previous)`.
|
|
305
|
+
Close a feature with `await instance.close()`; retry cleanup only after checking `error.retryCleanup` on a
|
|
306
|
+
quarantined `FeatureError`. A `FeatureError` is quarantined only when the host asked for it:
|
|
307
|
+
`openFeature(feature, { cleanupFailure: 'quarantine' })` for one instance and
|
|
308
|
+
`openApplication(app, { cleanupFailure: 'quarantine', … })` for every instance of an application; the default,
|
|
309
|
+
`report`, hands the failure to the reporter and calls the instance closed (D182). Resource retry and business commands named `retry` keep their existing meaning.
|
|
310
|
+
|
|
311
|
+
### The contract of an external source adapter
|
|
312
|
+
|
|
313
|
+
The host owns the adapter, the feature owns the work. An adapter is a plain record of methods and readables whose
|
|
314
|
+
shape is the contract; it holds no state of the feature, opens nothing, and closes nothing.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// features/tasks/integration/platform/resourceSource.ts — the host side of `tasks.resource.platform`
|
|
318
|
+
const tasksTarget = Object.freeze({
|
|
319
|
+
getSnapshot: () => 'tasks' as const, // a Readable, so the feature decides when to read it
|
|
320
|
+
subscribe: (_listener: () => void) => () => undefined, // a constant target still returns a disposer
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
function createTasksResourceSource(tasks: TasksInitializer): TasksResourceSource {
|
|
324
|
+
return Object.freeze({
|
|
325
|
+
load: async (signal: AbortSignal) => {
|
|
326
|
+
signal.throwIfAborted(); // the signal is the contract, not decoration
|
|
327
|
+
await tasks.init({ state: 'load' });
|
|
328
|
+
signal.throwIfAborted(); // and it is checked again after the await
|
|
329
|
+
},
|
|
330
|
+
target: tasksTarget,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Four rules hold for every adapter: cancellable work uses the operation's signal; a value the feature must follow
|
|
336
|
+
is a `Readable`, never a snapshot the adapter refreshes itself; `subscribe` returns the disposer
|
|
337
|
+
of the underlying source, so releasing it releases exactly what was taken; and the adapter never imports the feature,
|
|
338
|
+
so the direction of the dependency is the direction of the contract.
|
|
339
|
+
|
|
340
|
+
In `load`, `connect` and `consume`, keep the signal passed by that callback: it belongs to the current target
|
|
341
|
+
execution, which can end before the model does. `ModelContext.signal` lasts for the whole model. A command's `run`
|
|
342
|
+
likewise gets its own execution signal, and nested `invoke` inherits cancellation; do not substitute the model
|
|
343
|
+
signal for it (D243, D244).
|
|
344
|
+
|
|
345
|
+
A synchronous setter needs no signal. If external work cannot be cancelled, state that in the adapter instead of
|
|
346
|
+
adding a cosmetic `_signal`. The caller can still settle as cancelled and Runtime rejects late Call results or
|
|
347
|
+
Resource/Stream publication, but the physical work may continue and drain must wait for it. Arbitrary state writes
|
|
348
|
+
inside that work and external side effects need their own signal checks; cancellation cannot undo them. Never assume
|
|
349
|
+
that an ignored signal or a check after `await` physically cancels the underlying client request.
|
|
350
|
+
|
|
351
|
+
## 9. A weak edge in practice
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
imports: { sessions: optional(sessionsFeature) },
|
|
355
|
+
own: ({ calls, imports, model }) => ({
|
|
356
|
+
// the calls of the provider that is there now; while nothing provides them: CallError 'unavailable' (D187)
|
|
357
|
+
...calls(imports.sessions, ['confirmAction']),
|
|
358
|
+
badge: model(Badge, { sessions: imports.sessions }, (ctx, { sessions }) => ({ // sessions: Readable<Lookup<Exports>>
|
|
359
|
+
// the data half: `select` runs only on found, may return a value or the provider's own Readable
|
|
360
|
+
count: fromOptional(sessions, found => found.sessions.count, { missing: 0 }),
|
|
361
|
+
})),
|
|
362
|
+
}),
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Write the raw `derive({ from: sessions, select: lookup => … })` only when the two branches are genuinely different work;
|
|
366
|
+
`fromOptional` is the same thing without the `lookup.kind` ceremony, and it tracks a nested `Readable` only while
|
|
367
|
+
the provider exists.
|
|
368
|
+
|
|
369
|
+
`found`/`missing` do not tell you the reason: either the provider is absent from the application, or its condition
|
|
370
|
+
group is closed. A weak edge does not pull the provider into the application and does not affect activation order.
|
|
371
|
+
A call on a weak port without a provider rejects with `CallError` `unavailable`, and `useCommand` reports it as `failed` with the error in `lastError` — an answer, not a cancellation (D138).
|
|
372
|
+
|
|
373
|
+
Closing propagates (D146): once the provider instance is gone, its state is closed, and `derive`/`computed` over a direct (hard) import answer with a `ReadableError` `closed` instead of the last value; a weak edge shows `missing` instead — which is exactly the reason to take `optional` when the provider can die first.
|
|
374
|
+
|
|
375
|
+
### A feature demand boundary
|
|
376
|
+
|
|
377
|
+
When host integration supplies a `FeatureDemandSource`, `FeatureBoundary` holds its lease and renders readiness,
|
|
378
|
+
failure and retry states:
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// a node in both branches: the error subtree reads its retry with `useFeatureRetry`
|
|
382
|
+
<FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
|
|
383
|
+
<Slot props={{ itemId }} target={confirmActionContentSlot} />
|
|
384
|
+
</FeatureBoundary>
|
|
385
|
+
|
|
386
|
+
// render callbacks, when the branch needs what only the boundary knows (D177)
|
|
387
|
+
<FeatureBoundary
|
|
388
|
+
demand={tasksDemand}
|
|
389
|
+
error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
|
|
390
|
+
fallback={null}
|
|
391
|
+
>
|
|
392
|
+
{({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
|
|
393
|
+
</FeatureBoundary>
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The callback of `children` is typed by the demand, so `exports` is the export record of that feature; the callback of
|
|
397
|
+
`error` receives `{ error, retry }`. Only the branch that is shown runs its callback, and the boundary already holds
|
|
398
|
+
the feature — a ready consumer therefore needs no second `useFeature` and takes no second lease. Hooks belong in the
|
|
399
|
+
child components the callback returns, not in the callback itself: the callback runs during the boundary's own
|
|
400
|
+
render, and the authority for `useModel`/`useCommand` still comes from a contribution through `Slot`.
|
|
401
|
+
|
|
402
|
+
### A feature in two files
|
|
403
|
+
|
|
404
|
+
When the implementation is heavy — models, components, their libraries — the feature is written as a header and a
|
|
405
|
+
body, and only the header is imported by the application and by consumers (D186, D207):
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
// features/tasks/…/feature.ts — the header: identity, edges, declared border, loader
|
|
409
|
+
const tasksFeature = defineFeature({
|
|
410
|
+
id: 'tasks.resource',
|
|
411
|
+
imports: { platform: tasksResourcePlatform },
|
|
412
|
+
when: [authorizedCondition],
|
|
413
|
+
provides: { screen: tasksScreenSlot }, // metadata: the target, and `{ priority, target }` when it matters
|
|
414
|
+
body: (): Promise<FeatureBody<TasksExports>> =>
|
|
415
|
+
import(/* webpackChunkName: "feature.tasks" */ './feature.body').then(module => module.tasksBody),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// features/tasks/…/feature.body.ts — the body: the three sections, in the chunk the header loads
|
|
419
|
+
const tasksBody = defineFeature.body(tasksFeature, {
|
|
420
|
+
own: ({ imports, resource }) => ({ tasks: resource(imports.platform /* … */) }),
|
|
421
|
+
exports: ({ own }) => ({ tasks: own.tasks }),
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
The export type is written once, in the neutral contract file, and named by the loader — that is what keeps the two
|
|
426
|
+
files from inferring each other in a circle. The body implements exactly the border the header declared: another
|
|
427
|
+
target, another priority or another port is refused before anything opens. Loading starts when an instance opens,
|
|
428
|
+
never at declaration, so the plan is compiled and checked while the body is still on the network.
|
|
429
|
+
|
|
430
|
+
## 10. Testing
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// mounting a contribution with no application
|
|
434
|
+
const harness = renderSlot(checkoutCreateFormOverlayHeaderSlot, { props, models: [[HeaderModel, { itemId }]] });
|
|
435
|
+
// a command fixture for a model
|
|
436
|
+
const confirm = command<ConfirmActionIntent, void>(async () => undefined);
|
|
437
|
+
// one feature with stubbed imports
|
|
438
|
+
const instance = openFeature(confirmActionFeature, { imports: { commands, view }, reporter: () => undefined });
|
|
439
|
+
await instance.ready; … await instance.close();
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The acceptance check for the inspection port is `assertInspectionSessionContract` from `@opetope/devtools/testing` (the `ci:inspection` gate in
|
|
443
|
+
`tooling/stress`).
|
|
444
|
+
|
|
445
|
+
## 11. Anti-patterns
|
|
446
|
+
|
|
447
|
+
- `exports: ({ own }) => ({ order: own.order })` where `order` is a model: a whole model is not exported, only its fields.
|
|
448
|
+
- `throw` inside commands for product outcomes: return an author-defined outcome record such as `{ type: 'error', message }`; `throw` is for exceptions only.
|
|
449
|
+
- `onDemand(feature)` — no such word; a weak edge is `optional`. `onDemand` applies only to host contracts.
|
|
450
|
+
- Reading a per-mount contribution model with `useModel(X)` in a component or hook without `requiresModels([X])`, including nested readers (D158); `own` models need no declaration.
|
|
451
|
+
- `imports.platform.submit` inside a model factory — that is a ref, not a value; use `model(Decl, { platform: imports.platform }, (ctx, { platform }) => platform.submit…)`.
|
|
452
|
+
- Writing into someone else's `Readable`: `ctx.update` accepts only an `OwnedState` created by this context.
|
|
453
|
+
|
|
454
|
+
## 12. Practices
|
|
455
|
+
|
|
456
|
+
0. Separate feature composition, models and UI, for example as `features/<f>/{integration,models,ui}`. The host
|
|
457
|
+
declares the application graph and supplies external bindings.
|
|
458
|
+
1. Contracts (`defineHostContract`, `definePort`, `defineSlot`, `defineModel`, `defineCondition`) go into neutral
|
|
459
|
+
`*.contract.ts` files with no feature imports; a feature imports a contract, not another feature, except for a
|
|
460
|
+
hard edge.
|
|
461
|
+
2. One fact is declared once: do not duplicate the shape of a host contract in a contribution model, take the
|
|
462
|
+
`own` model.
|
|
463
|
+
3. Keep `own` flat: the field names are the vocabulary of `exports` and of the contributions.
|
|
464
|
+
4. Read errors by `code` (`FeatureError`, `CallError`, `ReadableError`, `ContributionError`, `ApplicationError`); cancellation is only `CallError` `cancelled`/`closed`, `FeatureError` `retired` and `ContributionError` `inactive` (D138) — `unavailable` and `publication-rejected` are shown by the product as an error.
|
|
465
|
+
5. Before a commit: `tsc`, the package `ci:test`, `ci:eslint` and `ci:size-limit`; run the consuming application's
|
|
466
|
+
affected feature tests and production build checks. When a public name changes — `ci:public-surface` and a line
|
|
467
|
+
in the decision log.
|
|
468
|
+
|
|
469
|
+
## Select a component contract from one model
|
|
470
|
+
|
|
471
|
+
For a form that already receives its model through a Slot, select the values and commands together.
|
|
472
|
+
Keep the Call policy in the model. Different granted models can still be combined with `useCommands`.
|
|
473
|
+
The standalone [React example](../../react/README.md#hello-ui) contains declarations and imports.
|
|
474
|
+
|
|
475
|
+
```tsx
|
|
476
|
+
const { quantity, setQuantity, submit } = useModel(OrderForm, (model, { read }) => ({
|
|
477
|
+
quantity: read(model.state, state => state.quantity),
|
|
478
|
+
setQuantity: model.setQuantity,
|
|
479
|
+
submit: model.submit,
|
|
480
|
+
}));
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Only the selected quantity drives data renders. `submit.inFlight` is local to this consumer; aliases are independent.
|
|
484
|
+
For a projection returning an object, spread its fields into the returned selection or keep its reference stable.
|
|
485
|
+
A selector extracted from the component body (a module function or `useCallback`) is not called at all on an
|
|
486
|
+
unrelated render; an inline one runs again, and an equal selection keeps the previous snapshot, so the work
|
|
487
|
+
below the hook does not repeat.
|
|
488
|
+
|
|
489
|
+
## Exercise an application through its Slot
|
|
490
|
+
|
|
491
|
+
The test supplies its existing renderer and controlled repository fixtures. This wiring sketch uses React Testing Library.
|
|
492
|
+
|
|
493
|
+
```tsx
|
|
494
|
+
import { createScenario } from '@opetope/react/testing';
|
|
495
|
+
|
|
496
|
+
const scenario = createScenario(application, {
|
|
497
|
+
conditions: {},
|
|
498
|
+
imports: bindings,
|
|
499
|
+
host: { mount: Component => render(<Component />) },
|
|
500
|
+
});
|
|
501
|
+
try {
|
|
502
|
+
await scenario.ready;
|
|
503
|
+
const screen = scenario.mount(OrderScreenSlot);
|
|
504
|
+
fireEvent.click(screen.host.getByRole('button', { name: 'Submit' }));
|
|
505
|
+
await scenario.waitFor(() => repository.submitted.length === 1, { label: 'order submitted' });
|
|
506
|
+
} finally {
|
|
507
|
+
await scenario.close();
|
|
508
|
+
}
|
|
509
|
+
expect(scenario.ownership()).toMatchObject({ scope: 'registered-runtime', status: 'complete', resources: 0 });
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Use a named predicate for a lane wait or controlled load. A timeout contains the same graph/activity facts as the inspector; arbitrary host work remains unknown. See the [scenario contract](../../react/README.md).
|
|
513
|
+
|
|
514
|
+
## Read a model through one selector
|
|
515
|
+
|
|
516
|
+
A component shows several fields of a model and runs two of its commands. Taken hook by hook, that becomes a list
|
|
517
|
+
nobody reads as a whole:
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
// what a consumer wrote first: the model, then a hook per field, then a hook per command
|
|
521
|
+
const actions = useModel(OrderActions);
|
|
522
|
+
const amount = useReadable(actions.amount);
|
|
523
|
+
const currency = useReadable(actions.currency);
|
|
524
|
+
const submit = useCommand(actions.submit);
|
|
525
|
+
const reset = useCommand(actions.reset);
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Nothing in the component states what it depends on: the answer is assembled by reading the body top to bottom, and
|
|
529
|
+
the next field is one `actions.total.getSnapshot()` away — a read with no subscription behind it, which renders once
|
|
530
|
+
and never updates again.
|
|
531
|
+
|
|
532
|
+
One selection says it in one place:
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
const { amount, currency, reset, submit } = useModel(OrderActions, (model, { read }) => ({
|
|
536
|
+
amount: read(model.amount),
|
|
537
|
+
currency: read(model.currency, value => value.code),
|
|
538
|
+
reset: model.reset,
|
|
539
|
+
submit: model.submit,
|
|
540
|
+
}));
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
The record is the component's contract with the model: `read` is the only way in, so a field that is not read is not
|
|
544
|
+
subscribed and cannot silently go stale; each distinct `Readable` is subscribed once; and an authentic `Call` taken
|
|
545
|
+
as a field becomes the same `CommandHook` the individual hook returns, with its own `run`, `inFlight` and
|
|
546
|
+
`lastError` per alias (D205, D214). The selector is pure — no hooks, no commands, no side effects — and its fields
|
|
547
|
+
are compared with `Object.is`, so select scalars or projections rather than freshly allocated objects. The full
|
|
548
|
+
contract of the selection is in the section on selecting a component contract above.
|
|
549
|
+
|
|
550
|
+
This is not a promise of fewer renders: combining hooks changes what the component declares, not how fast React is.
|
|
551
|
+
The triad stays right where there is no model selection to make — a `Readable` that arrived as a prop or from a
|
|
552
|
+
`Resource` (`useReadable`, `useResource`), a component that only passes the granted model on (the one-argument
|
|
553
|
+
`useModel`), and a shared hook that reads one field for several components, where one `useReadable` is simply
|
|
554
|
+
smaller than a selector.
|
|
555
|
+
|
|
556
|
+
## Decide whether an edge is weak
|
|
557
|
+
|
|
558
|
+
A settings surface shows a diagnostics section when the build has one. Written as a hard edge, it does not compile:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
// the settings feature, always alive; the provider lives under a development flag
|
|
562
|
+
imports: { devTools: devToolsFeature },
|
|
563
|
+
when: [settingsVisible],
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
A hard edge requires `provider.when ⊆ consumer.when`, so the compiler refuses this application and names `optional`.
|
|
567
|
+
Putting the consumer under the same flag would compile and be worse: the whole settings surface would then disappear
|
|
568
|
+
with the flag it was only supposed to show one section for.
|
|
569
|
+
|
|
570
|
+
The weak edge is the answer, and its data is read without unfolding the lookup by hand:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
imports: { devTools: optional(devToolsFeature) },
|
|
574
|
+
own: ({ imports, model }) => ({
|
|
575
|
+
panel: model(SettingsPanel, { devTools: imports.devTools }, (_context, { devTools }) => ({
|
|
576
|
+
// devTools: Readable<Lookup<Exports>>; `select` runs only on found, and the fallback is written once
|
|
577
|
+
diagnostics: fromOptional(devTools, found => found.diagnostics, { missing: [] }),
|
|
578
|
+
})),
|
|
579
|
+
}),
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The criterion is one question: does the provider live at least as long as this feature and open before it? Yes — a
|
|
583
|
+
hard import, and the type gives the exports directly. No — `optional`: another condition group, another flag, a
|
|
584
|
+
build that does not ship the provider at all, or a provider the application may simply not enable.
|
|
585
|
+
|
|
586
|
+
What it gives the types and the graph: one word covers both absence and demand, and the exports arrive as
|
|
587
|
+
`Readable<Lookup<Exports>>` rather than something `undefined` at compile time (D105); a weak edge neither pulls the
|
|
588
|
+
provider into the application nor changes activation order; a call through a weak port answers `CallError`
|
|
589
|
+
`unavailable` instead of throwing (D187). `found`/`missing` never says which reason applies — absent from the
|
|
590
|
+
application or closed with its condition group — and that is deliberate: the reader shows the same section either
|
|
591
|
+
way, and a reason that cannot be acted on has no place in the branch.
|
|
592
|
+
|
|
593
|
+
## Register through the target, not beside it
|
|
594
|
+
|
|
595
|
+
Several features contribute named things to one place: payment methods, tools by id, screens by route. The shape a
|
|
596
|
+
consumer reaches for first is a module-level map with hand-made notifications:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
// ui/methods.ts — a registry outside the graph
|
|
600
|
+
const entries = new Map<string, PaymentMethod>();
|
|
601
|
+
const listeners = new Set<() => void>();
|
|
602
|
+
|
|
603
|
+
const registerMethod = (key: string, value: PaymentMethod): (() => void) => {
|
|
604
|
+
entries.set(key, value);
|
|
605
|
+
for (const listener of listeners) listener();
|
|
606
|
+
|
|
607
|
+
return () => {
|
|
608
|
+
entries.delete(key);
|
|
609
|
+
for (const listener of listeners) listener();
|
|
610
|
+
};
|
|
611
|
+
};
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
A feature then calls `registerMethod` from an effect and releases it in cleanup. Nothing ties that entry to the
|
|
615
|
+
feature: a fence does not withdraw it, an opening that failed halfway leaves it behind, two features that claim one
|
|
616
|
+
key are discovered by whoever renders last, and the inspector shows no contribution at all — a missing payment
|
|
617
|
+
method is then debugged by reading code instead of the runtime graph.
|
|
618
|
+
|
|
619
|
+
The target already exists, and it belongs outside features:
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
// contracts.ts
|
|
623
|
+
const paymentMethods = defineRegistry<string, PaymentMethodEntry>({ id: 'checkout.methods' });
|
|
624
|
+
|
|
625
|
+
// feature.ts — the entry is a contribution, so it opens and closes with the instance that made it
|
|
626
|
+
provides: ({ register }) => ({
|
|
627
|
+
card: register(paymentMethods, ({ exports }) => ({ key: 'card', value: { submit: exports.submit, title: 'Card' } })),
|
|
628
|
+
}),
|
|
629
|
+
|
|
630
|
+
// UI reads the target, never the provider's module
|
|
631
|
+
const method = useReadable(paymentMethods.select(selectedKey));
|
|
632
|
+
const methods = useReadable(paymentMethods.list);
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
The publication is atomic and owned: the entry lands when the instance is ready, the fence withdraws it on close, a
|
|
636
|
+
duplicate key is refused before anything moves, the order is `priority` and then id, and the inspector lists the
|
|
637
|
+
entry with the feature that owns it (D127, D196). `select(key)` answers the same `Lookup` as a weak edge, so an
|
|
638
|
+
absent method is a branch rather than an `undefined`.
|
|
639
|
+
|
|
640
|
+
Local UI state is still local: which method is selected, whether the panel is open, what the cursor is on. That is a
|
|
641
|
+
model field of the mount, not a registry entry — a registry answers "who contributed what", a model answers "what
|
|
642
|
+
this screen is doing right now".
|
|
643
|
+
|
|
644
|
+
## Test a composition with `createScenario`
|
|
645
|
+
|
|
646
|
+
The unit tests of a component say nothing about the composition: whether the feature under a condition really
|
|
647
|
+
publishes into the slot, and whether it really takes the contribution back. That is one scenario:
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
const scenario = createScenario(application, {
|
|
651
|
+
conditions: { 'checkout.promo': promo }, // the test owns this source: `getSnapshot`/`subscribe` over a local flag
|
|
652
|
+
host: { mount: Component => render(<Component />) },
|
|
653
|
+
imports: bindings,
|
|
654
|
+
});
|
|
655
|
+
const ready = (snapshot: RuntimeGraphSnapshot): boolean =>
|
|
656
|
+
snapshot.runtime.instances.some(node => node.id.includes('checkout.promo') && node.state.kind === 'ready');
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
await scenario.ready;
|
|
660
|
+
const view = scenario.mount(checkoutAsideSlot);
|
|
661
|
+
|
|
662
|
+
expect(view.host.queryByTestId('promo')).toBeNull();
|
|
663
|
+
promo.set(true);
|
|
664
|
+
scenario.notify();
|
|
665
|
+
await scenario.waitFor(ready, { label: 'promo feature ready' });
|
|
666
|
+
expect(view.host.getByTestId('promo')).toBeTruthy();
|
|
667
|
+
|
|
668
|
+
promo.set(false);
|
|
669
|
+
scenario.notify();
|
|
670
|
+
await scenario.waitFor(snapshot => !ready(snapshot), { label: 'promo feature retired' });
|
|
671
|
+
expect(view.host.queryByTestId('promo')).toBeNull();
|
|
672
|
+
} finally {
|
|
673
|
+
await scenario.close();
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
The test drives the real application: its own condition source, its own renderer, the graph in between. `notify()`
|
|
678
|
+
wakes the predicates after a fixture change without publishing a runtime event, and a `waitFor` deadline reports the
|
|
679
|
+
graph and activity facts the inspector holds rather than a bare timeout (D206, D215).
|
|
680
|
+
|
|
681
|
+
What it catches on an upgrade of the framework is what a component test cannot: the form of `when` and the moment a
|
|
682
|
+
group opens, the publication into the slot and its ordering, and the withdrawal on close — including a contribution
|
|
683
|
+
that stays in `entries` one turn too long. Close in `finally`, and assert `scenario.ownership()` when the test also
|
|
684
|
+
claims the application released what it owned.
|
|
685
|
+
|
|
686
|
+
## Smooth a bursty host source
|
|
687
|
+
|
|
688
|
+
A host source fires several times per frame — a price tick, a resize, a scroll position — and one `run` cannot keep
|
|
689
|
+
up with it. The `event` queue holds one payload, so a burst costs a `queue-capacity` failure record per dropped
|
|
690
|
+
emit, and a consumer that sees those records in its reporter usually smooths the source by hand:
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
// models/…/TickModel.ts — a second queue in front of the runtime's own
|
|
694
|
+
let scheduled = false;
|
|
695
|
+
let last: Tick | undefined;
|
|
696
|
+
|
|
697
|
+
const emitLatest = (emit: (tick: Tick) => void, tick: Tick): void => {
|
|
698
|
+
last = tick;
|
|
699
|
+
|
|
700
|
+
if (scheduled) return;
|
|
701
|
+
|
|
702
|
+
scheduled = true;
|
|
703
|
+
queueMicrotask(() => {
|
|
704
|
+
scheduled = false;
|
|
705
|
+
|
|
706
|
+
if (last !== undefined) emit(last);
|
|
707
|
+
});
|
|
708
|
+
};
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
That queue is outside everything: its microtask is not owned by the instance, so a fence does not cancel it and a
|
|
712
|
+
payload can reach `emit` after the owner closed; it hides the drops instead of answering them; and the two queues
|
|
713
|
+
now disagree about which payload is the newest one.
|
|
714
|
+
|
|
715
|
+
The policy is an option of the event, and it is the same word a stream uses:
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
own: ({ event, imports }) => ({
|
|
719
|
+
ticks: event(imports.platform, ({ emit, source }) => source.subscribeTicks(emit), {
|
|
720
|
+
backpressure: latest(), // the newest payload takes the waiting slot (D247)
|
|
721
|
+
run: ({ payload, signal, source }) => source.applyTick(payload, signal),
|
|
722
|
+
}),
|
|
723
|
+
}),
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
While `run` is busy and one payload waits, the next `emit` replaces the one that waits, and nothing is reported —
|
|
727
|
+
there is nothing to report, because no author asked for that payload to be delivered. A `run` in flight is never
|
|
728
|
+
interrupted, so the tick being applied finishes on its own; only the waiting slot changes hands. Closing the owner
|
|
729
|
+
drops whatever waits in it, under this policy and under the default alike.
|
|
730
|
+
|
|
731
|
+
Keep the default where an event is a trigger or a command rather than a value: a refetch on focus, a submit, an
|
|
732
|
+
"open the details" intent. There the first event of a burst is the one that matters, the second is genuinely
|
|
733
|
+
excess work, and the `queue-capacity` record is how the reporter tells you the source is louder than the handler.
|
|
734
|
+
A queue that loses nothing is neither policy and remains an open question (D183).
|