@opetope/core 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 +250 -0
- package/README.ru.md +246 -0
- package/dist/abort-compat.d.ts +10 -0
- package/dist/abort-compat.js +2 -0
- package/dist/abort-compat.js.map +1 -0
- package/dist/adapter-method.d.ts +27 -0
- package/dist/attachment-lifecycle-cleanup.d.ts +3 -0
- package/dist/attachment-lifecycle-cleanup.js +2 -0
- package/dist/attachment-lifecycle-cleanup.js.map +1 -0
- package/dist/attachment-lifecycle-contract.d.ts +260 -0
- package/dist/attachment-lifecycle-contract.js +2 -0
- package/dist/attachment-lifecycle-contract.js.map +1 -0
- package/dist/attachment-lifecycle-helpers.d.ts +34 -0
- package/dist/attachment-lifecycle-helpers.js +2 -0
- package/dist/attachment-lifecycle-helpers.js.map +1 -0
- package/dist/attachment-lifecycle-machine.d.ts +3 -0
- package/dist/attachment-lifecycle-machine.js +2 -0
- package/dist/attachment-lifecycle-machine.js.map +1 -0
- package/dist/attachment-lifecycle-mailbox.d.ts +10 -0
- package/dist/attachment-lifecycle-mailbox.js +2 -0
- package/dist/attachment-lifecycle-mailbox.js.map +1 -0
- package/dist/attachment-lifecycle-open.d.ts +3 -0
- package/dist/attachment-lifecycle-open.js +2 -0
- package/dist/attachment-lifecycle-open.js.map +1 -0
- package/dist/attachment-lifecycle.d.ts +4 -0
- package/dist/attachment-lifecycle.js +2 -0
- package/dist/attachment-lifecycle.js.map +1 -0
- package/dist/attachment.d.ts +49 -0
- package/dist/attachment.js +2 -0
- package/dist/attachment.js.map +1 -0
- package/dist/call-authority.d.ts +26 -0
- package/dist/call-controller-support.d.ts +77 -0
- package/dist/call-controller-support.js +2 -0
- package/dist/call-controller-support.js.map +1 -0
- package/dist/call-controller.d.ts +49 -0
- package/dist/call-controller.js +2 -0
- package/dist/call-controller.js.map +1 -0
- package/dist/call-execution-inspection.d.ts +26 -0
- package/dist/call-execution-inspection.js +2 -0
- package/dist/call-execution-inspection.js.map +1 -0
- package/dist/call-executor-factory.d.ts +5 -0
- package/dist/call-executor-factory.js +2 -0
- package/dist/call-executor-factory.js.map +1 -0
- package/dist/call-executor-record.d.ts +66 -0
- package/dist/call-executor-record.js +2 -0
- package/dist/call-executor-record.js.map +1 -0
- package/dist/call-executor-support.d.ts +25 -0
- package/dist/call-executor-support.js +2 -0
- package/dist/call-executor-support.js.map +1 -0
- package/dist/call-executor-types.d.ts +56 -0
- package/dist/call-executor-waiters.d.ts +23 -0
- package/dist/call-executor-waiters.js +2 -0
- package/dist/call-executor-waiters.js.map +1 -0
- package/dist/call-executor.d.ts +6 -0
- package/dist/call-executor.js +2 -0
- package/dist/call-executor.js.map +1 -0
- package/dist/call-invocation.d.ts +61 -0
- package/dist/call-invocation.js +2 -0
- package/dist/call-invocation.js.map +1 -0
- package/dist/call-lane.d.ts +28 -0
- package/dist/call-lane.js +2 -0
- package/dist/call-lane.js.map +1 -0
- package/dist/call-target-result.d.ts +22 -0
- package/dist/call-target-result.js +2 -0
- package/dist/call-target-result.js.map +1 -0
- package/dist/call-target.d.ts +42 -0
- package/dist/call-target.js +2 -0
- package/dist/call-target.js.map +1 -0
- package/dist/call.d.ts +11 -0
- package/dist/call.js +2 -0
- package/dist/call.js.map +1 -0
- package/dist/contribution-hub.d.ts +100 -0
- package/dist/contribution-hub.js +2 -0
- package/dist/contribution-hub.js.map +1 -0
- package/dist/contribution-transaction.d.ts +20 -0
- package/dist/contribution-transaction.js +2 -0
- package/dist/contribution-transaction.js.map +1 -0
- package/dist/contribution.d.ts +64 -0
- package/dist/contribution.js +2 -0
- package/dist/contribution.js.map +1 -0
- package/dist/data-scheduler.d.ts +4 -0
- package/dist/data-scheduler.js +2 -0
- package/dist/data-scheduler.js.map +1 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +2 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +46 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -0
- package/dist/lifetime.d.ts +19 -0
- package/dist/lifetime.js +2 -0
- package/dist/lifetime.js.map +1 -0
- package/dist/model-context.d.ts +155 -0
- package/dist/model.d.ts +21 -0
- package/dist/model.js +2 -0
- package/dist/model.js.map +1 -0
- package/dist/platform-compat.d.ts +9 -0
- package/dist/platform-compat.js +2 -0
- package/dist/platform-compat.js.map +1 -0
- package/dist/port.d.ts +26 -0
- package/dist/port.js +2 -0
- package/dist/port.js.map +1 -0
- package/dist/reactive-collection.d.ts +27 -0
- package/dist/reactive-collection.js +2 -0
- package/dist/reactive-collection.js.map +1 -0
- package/dist/reactive-node.d.ts +62 -0
- package/dist/reactive-node.js +2 -0
- package/dist/reactive-node.js.map +1 -0
- package/dist/reactive-scheduler-control.d.ts +34 -0
- package/dist/reactive-scheduler-control.js +2 -0
- package/dist/reactive-scheduler-control.js.map +1 -0
- package/dist/reactive-scheduler.d.ts +18 -0
- package/dist/reactive-scheduler.js +2 -0
- package/dist/reactive-scheduler.js.map +1 -0
- package/dist/reactive.d.ts +41 -0
- package/dist/reactive.js +2 -0
- package/dist/reactive.js.map +1 -0
- package/dist/readable.d.ts +13 -0
- package/dist/readable.js +2 -0
- package/dist/readable.js.map +1 -0
- package/dist/resource.d.ts +82 -0
- package/dist/type-helpers.d.ts +14 -0
- package/package.json +66 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksei Berezin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# `@opetope/core`
|
|
2
|
+
|
|
3
|
+
The renderer-neutral core: `Readable` and its derivatives, keyed collections, declarations of models, ports and
|
|
4
|
+
extension points, calls with a lifetime. [The specification](https://www.npmjs.com/package/@opetope/runtime) §3 sets the package vocabulary;
|
|
5
|
+
the runtime implementation uses the lower-level primitives on `@opetope/core/internal`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @opetope/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use matching Opetope versions. For release candidates, append `@next` to every `@opetope/*` package in the command.
|
|
14
|
+
The API is ESM-only; Node 20.19+ is required. Development check commands below apply to a contributor checkout.
|
|
15
|
+
|
|
16
|
+
The normative EN/RU guides are shipped in `@opetope/runtime`: after installing it, open
|
|
17
|
+
`node_modules/@opetope/runtime/docs/spec.md` or `spec.ru.md`; recipes are in `cookbook.md` and `cookbook.ru.md`.
|
|
18
|
+
No GitHub access is needed to read those installed guides.
|
|
19
|
+
|
|
20
|
+
## Hello data boundary
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { defineModel } from '@opetope/core';
|
|
24
|
+
import type { Readable } from '@opetope/core';
|
|
25
|
+
import { createState } from '@opetope/core/internal';
|
|
26
|
+
|
|
27
|
+
type Counter = Readonly<{ value: number }>;
|
|
28
|
+
const counter = createState<Counter>({ value: 0 });
|
|
29
|
+
const CounterModel = defineModel<{ readonly counter: Readable<Counter> }>('example.counter.model');
|
|
30
|
+
|
|
31
|
+
let latest = counter.getSnapshot();
|
|
32
|
+
const unsubscribe = counter.subscribe(() => {
|
|
33
|
+
latest = counter.getSnapshot();
|
|
34
|
+
});
|
|
35
|
+
counter.update(current => ({ value: current.value + 1 }));
|
|
36
|
+
void latest;
|
|
37
|
+
unsubscribe();
|
|
38
|
+
counter.close();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`createState` lives on `@opetope/core/internal` (D142): an author gets owned state from `ctx.state` of their own
|
|
42
|
+
model, while the direct constructor is what host adapters and tests need. That is why the examples import it from the
|
|
43
|
+
internal entry.
|
|
44
|
+
|
|
45
|
+
`Model` holds the type of a boundary, but owns no state and is not a service locator. A feature creates the
|
|
46
|
+
implementation of a model through `model()` in its own `own` section, passing a `ModelContext`: a non-generic context
|
|
47
|
+
with `state`, `update`, `signal`, `call`, `calls`, `lane`, `effect`, `event`, `resource`, `stream`, `scope`, `timers`
|
|
48
|
+
and `cleanup`. `calls(deps, ['setAmount', 'submit'], { lane })` takes methods shaped as `() => Output`,
|
|
49
|
+
`(input) => Output` or `(input, signal: AbortSignal) => Output`
|
|
50
|
+
from the dependencies and returns one `Call` per key, the way `own.calls` does for a feature (D143, D244). Optional
|
|
51
|
+
input and signal parameters are supported; no parameters means `void` input. A second parameter must be exactly
|
|
52
|
+
`AbortSignal` or `AbortSignal | undefined`; options objects or more parameters need an explicit adapter. `state`
|
|
53
|
+
returns `OwnedState<Value>`, and only that is accepted by `update`; the remaining members create nodes that live
|
|
54
|
+
exactly as long as the generation of the model, while `cleanup(disposer)` puts its own cleanup into the same drain, in
|
|
55
|
+
reverse order of registration. A model declares its dependencies with its own interface; about the feature, the
|
|
56
|
+
imports and the ports it knows nothing.
|
|
57
|
+
|
|
58
|
+
Each model context records ownership of its states privately: `OwnedState` gives the static write contract,
|
|
59
|
+
and `update` also checks the creating context at runtime. The fence closes all owned states synchronously and
|
|
60
|
+
prevents new calls, resources, streams, effects, events, scopes and state from being created afterwards.
|
|
61
|
+
User work already admitted is physically drained before its source is released; logical cancellation of a caller
|
|
62
|
+
does not replace that drain. A failed model factory drains its partial kernel (D170).
|
|
63
|
+
|
|
64
|
+
A retryable quarantined `FeatureError` may expose `retryCleanup`; other errors have no retry capability.
|
|
65
|
+
Guard the optional function before invoking it. `retired` remains the cancellation code (D168).
|
|
66
|
+
|
|
67
|
+
## Derived data
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { collection, computed, derive, selectByKey } from '@opetope/core';
|
|
71
|
+
import { createState } from '@opetope/core/internal';
|
|
72
|
+
|
|
73
|
+
type Session = Readonly<{ id: string; title: string }>;
|
|
74
|
+
const sessionList = createState<readonly Session[]>([]);
|
|
75
|
+
const selectedSessionId = createState('active');
|
|
76
|
+
const isVisible = createState(true);
|
|
77
|
+
const sessions = collection({ from: sessionList, key: session => session.id });
|
|
78
|
+
const selected = selectByKey(sessions, selectedSessionId);
|
|
79
|
+
const title = derive({ from: selected, select: result => (result.kind === 'found' ? result.value.title : '') });
|
|
80
|
+
const visibleTitle = computed({ read: get => (get(isVisible) ? get(title) : '') });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`derive({ from, select })` is the main explicit primitive; a tuple `from` is computed by a single formula.
|
|
84
|
+
`computed({ read })` is the advanced autotracked variant with dynamic dependencies. Both use one
|
|
85
|
+
scheduler: a source update stabilises the chain/diamond before notification, `Object.is` or an explicit `equals`
|
|
86
|
+
stops an unchanged branch, and a formula error is rethrown on read and can recover
|
|
87
|
+
after invalidation.
|
|
88
|
+
|
|
89
|
+
`externalReadable` performs a subscribe-before-reread handshake, shares one source subscription between
|
|
90
|
+
observers and releases it after a microtask of waiting. `collection` builds stable `list`/`byId`, and
|
|
91
|
+
`selectByKey` notifies only the key that changed and drops an unused selector from the cache.
|
|
92
|
+
|
|
93
|
+
`close()` on a `State` is a change, not silence: dependent nodes get one notification, after which their
|
|
94
|
+
`getSnapshot` throws a `ReadableError` with the code `closed`, and from then on they stay quiet. An unsubscribed
|
|
95
|
+
derived node gets the same rejection on its next read, because it reads a closed source (D146).
|
|
96
|
+
|
|
97
|
+
## Contributions
|
|
98
|
+
|
|
99
|
+
`ContributionTarget` is an authentic extension point with no string locator and no global registry.
|
|
100
|
+
The runtime publishes and withdraws the entries that belong to one instance as an atomic batch; a consumer reads an
|
|
101
|
+
ordered `entries` snapshot. The order is static: `priority` first, then the full contribution `id`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { defineRegistry, definePipe } from '@opetope/core';
|
|
105
|
+
|
|
106
|
+
const counter = definePipe<number>({ id: 'inbox.entry-counter' });
|
|
107
|
+
const pages = defineRegistry<string, { readonly title: string }>({ id: 'layout.pages' });
|
|
108
|
+
|
|
109
|
+
counter.fold(0, undefined);
|
|
110
|
+
pages.select('dashboard').getSnapshot();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
In its first version a Pipe is an ordered-many fold only: zero handlers return the seed, and the first exception
|
|
114
|
+
interrupts the fold. The query form and dynamic ordering are not part of the API. A keyed registry distinguishes a
|
|
115
|
+
missing value from a found one through the same `Lookup` as `selectByKey`.
|
|
116
|
+
|
|
117
|
+
## Calls and lanes
|
|
118
|
+
|
|
119
|
+
`CallInvocation<Output>` is an authentic frozen native `Promise<Output>`, so it keeps ordinary `await`, `.then()` and
|
|
120
|
+
Promise assimilation. The additional `callId`/`parentCallId`, `cancel()`, `getSnapshot()` and `subscribe()` separate
|
|
121
|
+
the logical outcome of the caller (`settlement`) from the physical state of the body (`execution`). Cancellation
|
|
122
|
+
settles immediately through a `CallError` with the code `cancelled`; a body that is still exiting on its `AbortSignal`
|
|
123
|
+
stays visible as `draining`.
|
|
124
|
+
|
|
125
|
+
A feature author never sees this layer: they write `own.call`/`own.calls` or `ctx.call`/`ctx.calls`, while the
|
|
126
|
+
executor and the lane live on `@opetope/core/internal` for runtime implementation and host integration.
|
|
127
|
+
|
|
128
|
+
These factory methods declare commands. Inside a command body, `run: (input, { invoke }) => invoke(target, input)`
|
|
129
|
+
executes an existing command and inherits cancellation and the lane stack. The execution context exposes only
|
|
130
|
+
`invoke`, with no former `call` alias; `ModelContext.call(options)` remains the declaration method (D243).
|
|
131
|
+
|
|
132
|
+
Use the command's `run` signal for its execution, not `ModelContext.signal`, which lasts for the whole model.
|
|
133
|
+
Resource loads and Stream connections/consumption likewise receive the signal of their current target execution.
|
|
134
|
+
`calls` preserves the source receiver and always supplies `(input, signal)`, even when a method needs neither.
|
|
135
|
+
Methods without I/O need no dummy signal parameter. Work that ignores cancellation can outlive the caller's logical
|
|
136
|
+
outcome; physical drain still waits for it. Map an external client's options explicitly, for example
|
|
137
|
+
`load: (input: Input, signal: AbortSignal) => client.load(input, { signal })` (D244).
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { createCallExecutor, defineCallLane } from '@opetope/core/internal';
|
|
141
|
+
|
|
142
|
+
const writes = defineCallLane({ id: 'accounts.write.lane' });
|
|
143
|
+
const refresh = createCallExecutor<{ accountId: string }, void>({
|
|
144
|
+
id: 'accounts.refresh.call',
|
|
145
|
+
scheduling: { lane: writes, policy: 'queue' },
|
|
146
|
+
cache: { key: input => input.accountId, policy: 'once' },
|
|
147
|
+
singleFlight: { key: input => input.accountId },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const invocation = refresh.start({ accountId: 'primary' }, ({ signal }) => loadAccount(signal));
|
|
151
|
+
invocation.cancel();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Without `scheduling` every executor gets a private FIFO lane. A shared `CallLane` sets explicit serialisation between
|
|
155
|
+
executors; `{ policy: 'parallel' }` disables the lane explicitly. The `{ policy: 'once' }` cache keeps only a
|
|
156
|
+
successful result inside one instance — an author writes it as `once: true` or `once: input => key` — while
|
|
157
|
+
`singleFlight` merges concurrent execution but leaves every waiter its own `CallInvocation`. An author may declare
|
|
158
|
+
`policy: 'latest'` on a Call (D185): it replaces an input still waiting at the end of the lane, never the running
|
|
159
|
+
body or a waiting input followed by another Call. React consumers keep status; they add no scheduling policy (D203).
|
|
160
|
+
|
|
161
|
+
## Word map
|
|
162
|
+
|
|
163
|
+
One page of the package vocabulary: spec §3 sets the names, the README explains what each one is for.
|
|
164
|
+
|
|
165
|
+
| Word | What for |
|
|
166
|
+
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
167
|
+
| `Readable`, `State`, `Collection`, `Lookup` | a value that can be read and subscribed to |
|
|
168
|
+
| `derive`, `computed`, `fromOptional`, `collection`, `selectByKey`, `externalReadable` | how to get a `Readable`: derived, computed, external |
|
|
169
|
+
| `Equality` | when a derived value counts as unchanged |
|
|
170
|
+
| `Resource`, `ResourceSnapshot`, `ScopedRetention`, `LatestBackpressure` | the resource and policy types; the `scoped` and `latest` values live in `@opetope/runtime` |
|
|
171
|
+
| `defineModel`, `Model`, `ModelOf`, `ModelContext`, `OwnedState` | the model declaration, its shape, its context and its owned state |
|
|
172
|
+
| `Call` | a call with a lifetime: the unit of model behaviour |
|
|
173
|
+
| `definePort`, `Port`, `PortRef` | a port: one feature declares it, another requires it |
|
|
174
|
+
| `definePipe`, `Pipe`, `defineRegistry`, `Registry` | extension points: a stream of values and a keyed set |
|
|
175
|
+
| `declarationId`, `DeclarationId` | validation of a declaration id |
|
|
176
|
+
| `CallError` `cancelled`, `closed` | the call is dropped, cancellation (`isCancellation` on the internal entry) |
|
|
177
|
+
| `CallError` `unavailable`, `publication-rejected` | an answer the product shows; there is no cancellation here |
|
|
178
|
+
| `CancellationError`, `FeatureError`, `ReadableError`, `DeclarationError`, `CallError` | one class per subject, the state in the `code` field |
|
|
179
|
+
|
|
180
|
+
The kernel words `Attachment`, `Executor` and `Authority` live only in `@opetope/core/internal`: they are not on the safe entry (`Call` is an author word of the safe entry; `Blueprint` and `IR` are runtime names). `defineSlot`, `defineSwitchSlot`, `Slot` and
|
|
181
|
+
`SwitchSlotTarget` are declared in `@opetope/react`, not here: a slot is a markup extension point.
|
|
182
|
+
|
|
183
|
+
`Model` is a typed key for a record of `Readable` and `Call`. It declares the data and commands a consumer can
|
|
184
|
+
access; a model factory creates the implementation within an owned lifetime. UI components consume this contract.
|
|
185
|
+
|
|
186
|
+
The package stays `private` while the design is under review: the current API is experimental.
|
|
187
|
+
|
|
188
|
+
## Boundary
|
|
189
|
+
|
|
190
|
+
- the model and the data of a feature declare `Model`, `Readable` and `Call` — and nothing else;
|
|
191
|
+
- the feature instance is not passed into the arguments of an author: the lifetime is checked by an opaque lease;
|
|
192
|
+
- the internal call controller keeps the state of the last call separately from the total count of unclosed ones;
|
|
193
|
+
- `close()` aborts unclosed calls, releases listeners and callback cells and accepts no late
|
|
194
|
+
result;
|
|
195
|
+
- `isCancellation(error)` on `@opetope/core/internal` recognises the normal termination of an instance and of a call
|
|
196
|
+
for runtime adapters; the product reads `CommandOutcome` from `useCommand` and never recognises cancellation by
|
|
197
|
+
hand (D142). Cancellation is a code, not a class: on `CallError` it is meant only by
|
|
198
|
+
`cancelled` and `closed`, while `unavailable` and `publication-rejected` reach the product as an error (D138);
|
|
199
|
+
- `FeatureError`, `CallError`, `ReadableError` and `DeclarationError` give one class per subject, and the state is
|
|
200
|
+
read from `code`: `retired`, `not-ready`, `quarantined`, `cleanup-failed` for a feature, `cancelled`, `closed`,
|
|
201
|
+
`unavailable`, `publication-rejected` for a call, `closed` for a `Readable`, `invalid-id` for a declaration;
|
|
202
|
+
- React, view frames, module lowering and compatibility knowledge are outside the package.
|
|
203
|
+
|
|
204
|
+
An author writes a declaration id as `<feature>.<entity>.<kind>`. The grammar still accepts the `/` and `:`
|
|
205
|
+
separators for the sake of future namespaces, but the application does not use them.
|
|
206
|
+
|
|
207
|
+
## Attachment readiness
|
|
208
|
+
|
|
209
|
+
This whole section is about the internal entry used by the runtime implementation. A feature author writes
|
|
210
|
+
`own.attach`.
|
|
211
|
+
|
|
212
|
+
`AttachmentReadinessRef` is a read-only projection of readiness. The controller that creates it and
|
|
213
|
+
`bindCallTargetToAttachment` live on `@opetope/core/internal`: this is a narrow lowering ABI, not a service locator
|
|
214
|
+
and not a security boundary. The source of authority is never handed outward.
|
|
215
|
+
|
|
216
|
+
The binding mints a separate authentic target with an exact provenance by object identity. Minting the same
|
|
217
|
+
`(target, attachment)` pair again returns the same identity, and the call itself takes the authority out of the
|
|
218
|
+
target. That is why a feature and the UI never assemble a "target plus lifetime" pair by hand and cannot accidentally
|
|
219
|
+
take the target of one instance and the readiness of another.
|
|
220
|
+
|
|
221
|
+
On top of that seam `createAttachmentLifecycleController` holds the `closed | opening | ready | closing |
|
|
222
|
+
failed-close` state machine: the last intent wins, cleanup is adopted in advance, rollback runs in reverse order on a
|
|
223
|
+
quiesced node, a retry touches only the handles that failed, and a quarantine does not open the next generation.
|
|
224
|
+
Lowering `own.attach` into that state machine and minting the bound targets is the runtime's job, so an author writes
|
|
225
|
+
neither `request` nor a retry by hand.
|
|
226
|
+
|
|
227
|
+
The feature runtime checks `assertRetirable(expectedKey)` before a mutation and calls `retireExpected(expectedKey)`
|
|
228
|
+
directly on it. Both checks look at the real current or pending key, not at an external copy.
|
|
229
|
+
The plain `retire()` is kept, but the coordinator always uses the exact variant.
|
|
230
|
+
|
|
231
|
+
Every cleanup must be safe on a partial opening, idempotent and repeatable. The strict waiting
|
|
232
|
+
policy deliberately does not open a new generation on top of an opening or closing that never finishes: deadlines and
|
|
233
|
+
drain supervision belong to the application. Opetope raises the fence at once and publishes diagnostics, but does not
|
|
234
|
+
declare a physical resource closed on an artificial timeout.
|
|
235
|
+
|
|
236
|
+
`createState`, `externalReadable`, `derive`, `computed`, `collection` and `selectByKey` work through one
|
|
237
|
+
data scheduler. Two independent external adapters do not become one transaction without a shared host boundary; one
|
|
238
|
+
source update and all of its derivatives stabilise atomically.
|
|
239
|
+
|
|
240
|
+
## Compatibility contract
|
|
241
|
+
|
|
242
|
+
The published ESM is built for Chrome 82+, Firefox 110+, Safari/iOS 15+ and Android 82+; the Node entry requires
|
|
243
|
+
Node 20.19+. The package installs no global polyfills: the host must provide `Promise`, `Map`/`Set`, weak collections,
|
|
244
|
+
`AbortController`/`AbortSignal`, `EventTarget`, `queueMicrotask` and `Reflect`. What the lower bound does not have —
|
|
245
|
+
`AbortSignal.reason`, `throwIfAborted`, `AbortSignal.any`, the cancellation reason, `AggregateError` and
|
|
246
|
+
`Error.cause` — goes only through the package's own compatibility implementation.
|
|
247
|
+
|
|
248
|
+
A deterministic syntax-target gate checks the raw ESM, and a separate smoke test of an HTTP import checks current
|
|
249
|
+
Chromium, Firefox and WebKit. This is not a claim that historical browser builds run in the repository: exact
|
|
250
|
+
certification of the lower bound requires an external farm.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# `@opetope/core`
|
|
2
|
+
|
|
3
|
+
Renderer-neutral ядро: `Readable` и его производные, keyed collections, объявления моделей, портов и точек
|
|
4
|
+
расширения, вызовы с временем жизни. Словарь пакета задаёт §3 [спецификации](https://www.npmjs.com/package/@opetope/runtime);
|
|
5
|
+
реализация рантайма использует низкоуровневые примитивы из `@opetope/core/internal`.
|
|
6
|
+
|
|
7
|
+
## Установка
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @opetope/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Используйте согласованные версии Opetope. Для release candidate добавьте `@next` каждому пакету `@opetope/*` в команде.
|
|
14
|
+
API поставляется только в ESM; требуется Node 20.19+. Команды разработки ниже относятся к contributor checkout.
|
|
15
|
+
|
|
16
|
+
Нормативные руководства EN/RU поставляются в `@opetope/runtime`: после его установки откройте
|
|
17
|
+
`node_modules/@opetope/runtime/docs/spec.md` или `spec.ru.md`; рецепты находятся в `cookbook.md` и `cookbook.ru.md`.
|
|
18
|
+
Для чтения установленных руководств доступ к GitHub не нужен.
|
|
19
|
+
|
|
20
|
+
## Hello data boundary
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { defineModel } from '@opetope/core';
|
|
24
|
+
import type { Readable } from '@opetope/core';
|
|
25
|
+
import { createState } from '@opetope/core/internal';
|
|
26
|
+
|
|
27
|
+
type Counter = Readonly<{ value: number }>;
|
|
28
|
+
const counter = createState<Counter>({ value: 0 });
|
|
29
|
+
const CounterModel = defineModel<{ readonly counter: Readable<Counter> }>('example.counter.model');
|
|
30
|
+
|
|
31
|
+
let latest = counter.getSnapshot();
|
|
32
|
+
const unsubscribe = counter.subscribe(() => {
|
|
33
|
+
latest = counter.getSnapshot();
|
|
34
|
+
});
|
|
35
|
+
counter.update(current => ({ value: current.value + 1 }));
|
|
36
|
+
void latest;
|
|
37
|
+
unsubscribe();
|
|
38
|
+
counter.close();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`createState` живёт на `@opetope/core/internal` (D142): автор получает владеемое состояние из `ctx.state` своей
|
|
42
|
+
модели, а прямой конструктор нужен адаптерам хоста и тестам. В примерах он поэтому импортируется с internal-входа.
|
|
43
|
+
|
|
44
|
+
`Model` хранит тип границы, но не владеет состоянием и не является service locator. Реализацию модели
|
|
45
|
+
создаёт фича через `model()` в своей секции `own`, передавая `ModelContext`: не generic контекст со `state`,
|
|
46
|
+
`update`, `signal`, `call`, `calls`, `lane`, `effect`, `event`, `resource`, `stream`, `scope`, `timers` и `cleanup`.
|
|
47
|
+
`calls(deps, ['setAmount', 'submit'], { lane })` берёт из зависимостей методы форм `() => Output`, `(input) => Output`
|
|
48
|
+
или `(input, signal: AbortSignal) => Output` и
|
|
49
|
+
отдаёт по одному `Call` на ключ, как `own.calls` у фичи (D143, D244). Поддерживаются необязательные input и signal;
|
|
50
|
+
отсутствие параметров означает input `void`. Второй параметр должен быть точно `AbortSignal` или
|
|
51
|
+
`AbortSignal | undefined`; options-объекты и дополнительные параметры требуют явного адаптера. `state`
|
|
52
|
+
возвращает `OwnedState<Value>`, и только его принимает `update`; остальные члены создают узлы, которые живут ровно
|
|
53
|
+
столько, сколько generation модели, а `cleanup(disposer)` кладёт свою уборку в тот же drain, в обратном порядке
|
|
54
|
+
регистрации. Свои зависимости модель объявляет собственным интерфейсом, о фиче,
|
|
55
|
+
импортах и портах она не знает.
|
|
56
|
+
|
|
57
|
+
Каждый контекст модели хранит принадлежность состояния приватно: `OwnedState` задаёт статический контракт
|
|
58
|
+
записи, а `update` дополнительно проверяет создавший контекст во время исполнения. Фенс синхронно закрывает всё
|
|
59
|
+
владеемое состояние и запрещает затем создавать вызовы, ресурсы, стримы, эффекты, события, scope-ы и state.
|
|
60
|
+
Уже допущенная пользовательская работа физически дренируется до освобождения источника; логическая отмена
|
|
61
|
+
вызывающего не заменяет этот дренаж. Отказ фабрики модели дренирует частичный kernel (D170).
|
|
62
|
+
|
|
63
|
+
Повторяемый карантинный `FeatureError` может предоставить `retryCleanup`; у других ошибок capability повтора нет.
|
|
64
|
+
Проверяйте необязательную функцию перед вызовом. `retired` остаётся кодом отмены (D168).
|
|
65
|
+
|
|
66
|
+
## Derived data
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { collection, computed, derive, selectByKey } from '@opetope/core';
|
|
70
|
+
import { createState } from '@opetope/core/internal';
|
|
71
|
+
|
|
72
|
+
type Session = Readonly<{ id: string; title: string }>;
|
|
73
|
+
const sessionList = createState<readonly Session[]>([]);
|
|
74
|
+
const selectedSessionId = createState('active');
|
|
75
|
+
const isVisible = createState(true);
|
|
76
|
+
const sessions = collection({ from: sessionList, key: session => session.id });
|
|
77
|
+
const selected = selectByKey(sessions, selectedSessionId);
|
|
78
|
+
const title = derive({ from: selected, select: result => (result.kind === 'found' ? result.value.title : '') });
|
|
79
|
+
const visibleTitle = computed({ read: get => (get(isVisible) ? get(title) : '') });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`derive({ from, select })` — основной явный primitive; tuple `from` вычисляется одной формулой.
|
|
83
|
+
`computed({ read })` — advanced autotracked-вариант с dynamic dependencies. Оба используют один
|
|
84
|
+
scheduler: source update стабилизирует chain/diamond до notification, `Object.is` или явный `equals`
|
|
85
|
+
останавливает неизменившуюся ветку, formula error повторно выбрасывается при чтении и может
|
|
86
|
+
восстановиться после invalidation.
|
|
87
|
+
|
|
88
|
+
`externalReadable` делает subscribe-before-reread handshake, разделяет одну source subscription между
|
|
89
|
+
наблюдателями и освобождает её после микрозадачи ожидания. `collection` строит стабильные `list`/`byId`, а
|
|
90
|
+
`selectByKey` уведомляет только изменившийся ключ и удаляет неиспользуемый selector из cache.
|
|
91
|
+
|
|
92
|
+
`close()` у `State` это изменение, а не тишина: зависимые узлы получают одно уведомление, после него их
|
|
93
|
+
`getSnapshot` бросает `ReadableError` с кодом `closed`, и дальше они молчат. Неподписанный производный узел
|
|
94
|
+
получает тот же отказ на следующем чтении, потому что читает закрытый источник (D146).
|
|
95
|
+
|
|
96
|
+
## Contributions
|
|
97
|
+
|
|
98
|
+
`ContributionTarget` — authentic extension point без строкового locator-а и глобального registry.
|
|
99
|
+
Runtime публикует и отзывает принадлежащие одного экземпляра записи атомарным batch-ем; consumer читает
|
|
100
|
+
упорядоченный `entries` snapshot. Порядок статический: сначала `priority`, затем полный contribution `id`.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { defineRegistry, definePipe } from '@opetope/core';
|
|
104
|
+
|
|
105
|
+
const counter = definePipe<number>({ id: 'inbox.entry-counter' });
|
|
106
|
+
const pages = defineRegistry<string, { readonly title: string }>({ id: 'layout.pages' });
|
|
107
|
+
|
|
108
|
+
counter.fold(0, undefined);
|
|
109
|
+
pages.select('dashboard').getSnapshot();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pipe в первой версии — только ordered-many fold: ноль handlers возвращает seed, первый exception прерывает
|
|
113
|
+
fold. Query-форма и динамический порядок не входят в API. Keyed registry различает missing и найденное значение
|
|
114
|
+
через тот же `Lookup`, что и `selectByKey`.
|
|
115
|
+
|
|
116
|
+
## Calls and lanes
|
|
117
|
+
|
|
118
|
+
`CallInvocation<Output>` — authentic frozen native `Promise<Output>`, поэтому сохраняет обычные `await`, `.then()` и
|
|
119
|
+
Promise assimilation. Дополнительные `callId`/`parentCallId`, `cancel()`, `getSnapshot()` и `subscribe()` разделяют
|
|
120
|
+
логический outcome caller-а (`settlement`) и физическое состояние body (`execution`). Отмена немедленно завершает
|
|
121
|
+
settlement через `CallError` с кодом `cancelled`; body, который ещё выходит по `AbortSignal`, остаётся видимым как `draining`.
|
|
122
|
+
|
|
123
|
+
Автор фичи этого слоя не видит: он пишет `own.call`/`own.calls` или `ctx.call`/`ctx.calls`, а исполнитель и lane
|
|
124
|
+
живут на `@opetope/core/internal` для реализации рантайма и интеграции с хостом.
|
|
125
|
+
|
|
126
|
+
Эти методы фабрики объявляют команды. В теле команды `run: (input, { invoke }) => invoke(target, input)` исполняет
|
|
127
|
+
существующую команду и наследует отмену и стек lane. В контексте исполнения есть только `invoke`, без прежнего
|
|
128
|
+
алиаса `call`; `ModelContext.call(options)` остаётся методом объявления (D243).
|
|
129
|
+
|
|
130
|
+
Для исполнения команды используйте сигнал её `run`, а не `ModelContext.signal`, который живёт со всей моделью.
|
|
131
|
+
Загрузка Resource, соединение и consume Stream также получают сигнал текущего исполнения target.
|
|
132
|
+
`calls` сохраняет receiver источника и всегда передаёт `(input, signal)`, даже если методу они не нужны.
|
|
133
|
+
Методам без I/O не нужен фиктивный signal. Работа, игнорирующая отмену, может продолжаться после логического
|
|
134
|
+
исхода вызывающего; физический drain всё ещё ждёт её. Options внешнего клиента преобразуйте явно, например
|
|
135
|
+
`load: (input: Input, signal: AbortSignal) => client.load(input, { signal })` (D244).
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createCallExecutor, defineCallLane } from '@opetope/core/internal';
|
|
139
|
+
|
|
140
|
+
const writes = defineCallLane({ id: 'accounts.write.lane' });
|
|
141
|
+
const refresh = createCallExecutor<{ accountId: string }, void>({
|
|
142
|
+
id: 'accounts.refresh.call',
|
|
143
|
+
scheduling: { lane: writes, policy: 'queue' },
|
|
144
|
+
cache: { key: input => input.accountId, policy: 'once' },
|
|
145
|
+
singleFlight: { key: input => input.accountId },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const invocation = refresh.start({ accountId: 'primary' }, ({ signal }) => loadAccount(signal));
|
|
149
|
+
invocation.cancel();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Без `scheduling` каждый исполнитель получает приватную FIFO lane. Общая `CallLane` задаёт явную сериализацию между
|
|
153
|
+
исполнителями; `{ policy: 'parallel' }` явно отключает lane. Кэш `{ policy: 'once' }` хранит только успешный
|
|
154
|
+
результат внутри одного экземпляра — автор пишет его как `once: true` или `once: input => key`, — а `singleFlight`
|
|
155
|
+
объединяет одновременное исполнение, но оставляет каждому ожидающему отдельный `CallInvocation`. Автор может задать
|
|
156
|
+
`policy: 'latest'` у Call (D185): заменяется вход, ещё ожидающий в конце lane, но не исполняющееся тело и не вход, за
|
|
157
|
+
которым уже стоит другой Call. React-потребители ведут статус и не добавляют политику исполнения (D203).
|
|
158
|
+
|
|
159
|
+
## Карта слов
|
|
160
|
+
|
|
161
|
+
Одна страница словаря пакета: spec §3 задаёт имена, README объясняет, зачем каждое.
|
|
162
|
+
|
|
163
|
+
| Слово | Зачем |
|
|
164
|
+
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
165
|
+
| `Readable`, `State`, `Collection`, `Lookup` | значение, которое можно прочитать и на которое можно подписаться |
|
|
166
|
+
| `derive`, `computed`, `fromOptional`, `collection`, `selectByKey`, `externalReadable` | как получить `Readable`: производное, вычисленное, внешнее |
|
|
167
|
+
| `Equality` | когда производное значение считается прежним |
|
|
168
|
+
| `Resource`, `ResourceSnapshot`, `ScopedRetention`, `LatestBackpressure` | типы ресурса и его политик; сами `scoped` и `latest` — в `@opetope/runtime` |
|
|
169
|
+
| `defineModel`, `Model`, `ModelOf`, `ModelContext`, `OwnedState` | объявление модели, её форма, контекст и владеемый state |
|
|
170
|
+
| `Call` | вызов с lifetime: единица поведения модели |
|
|
171
|
+
| `definePort`, `Port`, `PortRef` | порт: одна фича объявляет его, другая требует |
|
|
172
|
+
| `definePipe`, `Pipe`, `defineRegistry`, `Registry` | точки расширения: поток значений и набор по ключу |
|
|
173
|
+
| `declarationId`, `DeclarationId` | проверка id объявления |
|
|
174
|
+
| `CallError` `cancelled`, `closed` | вызов брошен, отмена (`isCancellation` на internal-входе) |
|
|
175
|
+
| `CallError` `unavailable`, `publication-rejected` | ответ, который показывает продукт; отмены здесь нет |
|
|
176
|
+
| `CancellationError`, `FeatureError`, `ReadableError`, `DeclarationError`, `CallError` | один класс на субъект, состояние в поле `code` |
|
|
177
|
+
|
|
178
|
+
Kernel-слова `Attachment`, `Executor` и `Authority` живут только в `@opetope/core/internal`: на безопасном входе их нет (`Call` — авторское слово безопасного входа, `Blueprint` и `IR` — имена рантайма). `defineSlot`, `defineSwitchSlot`, `Slot` и
|
|
179
|
+
`SwitchSlotTarget` объявлены в `@opetope/react`, а не здесь: слот это точка расширения разметки.
|
|
180
|
+
|
|
181
|
+
`Model` — типизированный ключ записи из `Readable` и `Call`. Он объявляет доступные потребителю данные и команды;
|
|
182
|
+
фабрика модели создаёт реализацию в пределах принадлежащего ей времени жизни. UI-компоненты используют этот контракт.
|
|
183
|
+
|
|
184
|
+
Пакет остаётся `private`, пока продолжается проработка дизайна: текущее API экспериментальное.
|
|
185
|
+
|
|
186
|
+
## Граница
|
|
187
|
+
|
|
188
|
+
- модель и данные фичи объявляют `Model`, `Readable` и `Call` — и больше ничего;
|
|
189
|
+
- экземпляр фичи не передаётся в аргументы автора: время жизни проверяется непрозрачным lease-ом;
|
|
190
|
+
- внутренний контроллер вызова держит состояние последнего вызова отдельно от общего счёта незакрытых;
|
|
191
|
+
- `close()` abort-ит незакрытые вызовы, освобождает слушателей и ячейки колбэков и не принимает поздний
|
|
192
|
+
результат;
|
|
193
|
+
- `isCancellation(error)` на `@opetope/core/internal` распознаёт штатное завершение экземпляра и вызова для
|
|
194
|
+
адаптеров рантайма; продукт читает `CommandOutcome` из `useCommand` и руками отмену не распознаёт (D142). Отмена это код, а не класс: у `CallError` её означают только
|
|
195
|
+
`cancelled` и `closed`, а `unavailable` и `publication-rejected` продукт показывает как ошибку (D138);
|
|
196
|
+
- `FeatureError`, `CallError`, `ReadableError` и `DeclarationError` дают один класс на субъект, а состояние
|
|
197
|
+
читается из `code`: `retired`, `not-ready`, `quarantined`, `cleanup-failed` у фичи, `cancelled`, `closed`,
|
|
198
|
+
`unavailable`, `publication-rejected` у вызова, `closed` у `Readable`, `invalid-id` у объявления;
|
|
199
|
+
- React, view frames, module lowering и compatibility knowledge находятся вне пакета.
|
|
200
|
+
|
|
201
|
+
Id объявления автор пишет как `<feature>.<entity>.<kind>`. Разделители `/` и `:` грамматика пока принимает
|
|
202
|
+
ради будущих пространств имён, но в приложении они не используются.
|
|
203
|
+
|
|
204
|
+
## Attachment readiness
|
|
205
|
+
|
|
206
|
+
Весь этот раздел про internal-вход, который использует реализация рантайма. Автор фичи пишет `own.attach`.
|
|
207
|
+
|
|
208
|
+
`AttachmentReadinessRef` это read-only проекция готовности. Контроллер, который её создаёт, и
|
|
209
|
+
`bindCallTargetToAttachment` живут на `@opetope/core/internal`: это узкий ABI понижения, а не локатор сервисов и не
|
|
210
|
+
граница безопасности. Источник авторитета наружу не выдаётся.
|
|
211
|
+
|
|
212
|
+
Привязка чеканит отдельный подлинный target с точным происхождением по идентичности объекта. Повторная чеканка той
|
|
213
|
+
же пары `(target, вложение)` возвращает ту же идентичность, а вызов сам достаёт авторитет из target. Поэтому фича и
|
|
214
|
+
UI не собирают пару «target плюс время жизни» руками и не могут случайно взять target одного экземпляра, а
|
|
215
|
+
готовность другого.
|
|
216
|
+
|
|
217
|
+
`createAttachmentLifecycleController` поверх этого шва держит автомат `closed | opening | ready | closing |
|
|
218
|
+
failed-close`: последнее намерение выигрывает, уборка усыновляется заранее, откат идёт в обратном порядке на
|
|
219
|
+
затихшем узле, повтор трогает только отказавшие ручки, а карантин не открывает следующее поколение. Понижение
|
|
220
|
+
`own.attach` в этот автомат и чеканку связанных targets делает рантайм, поэтому автор не пишет ни `request`, ни
|
|
221
|
+
повтор руками.
|
|
222
|
+
|
|
223
|
+
Рантайм фичи проверяет `assertRetirable(expectedKey)` перед мутацией и вызывает `retireExpected(expectedKey)`
|
|
224
|
+
непосредственно на ней. Обе проверки смотрят на настоящий текущий или ожидающий ключ, а не на внешнюю копию.
|
|
225
|
+
Обычный `retire()` сохранён, но координатор всегда пользуется точным вариантом.
|
|
226
|
+
|
|
227
|
+
Каждая уборка обязана быть безопасной при частичном открытии, идемпотентной и повторяемой. Политика строгого
|
|
228
|
+
ожидания намеренно не открывает новое поколение поверх незавершающегося открытия или закрытия: дедлайны и надзор за
|
|
229
|
+
дренажом принадлежат приложению. Opetope сразу ставит фенс и публикует диагностику, но не объявляет физический
|
|
230
|
+
ресурс закрытым по искусственному таймауту.
|
|
231
|
+
|
|
232
|
+
`createState`, `externalReadable`, `derive`, `computed`, `collection` и `selectByKey` работают через один
|
|
233
|
+
планировщик данных. Два независимых внешних адаптера не становятся одной транзакцией без общей границы хоста; одно
|
|
234
|
+
обновление источника и все его производные стабилизируются атомарно.
|
|
235
|
+
|
|
236
|
+
## Compatibility contract
|
|
237
|
+
|
|
238
|
+
Опубликованный ESM собирается для Chrome 82+, Firefox 110+, Safari/iOS 15+ и Android 82+; вход для Node требует
|
|
239
|
+
Node 20.19+. Пакет не ставит глобальные полифилы: хост обязан дать `Promise`, `Map`/`Set`, слабые коллекции,
|
|
240
|
+
`AbortController`/`AbortSignal`, `EventTarget`, `queueMicrotask` и `Reflect`. То, чего на нижней границе нет —
|
|
241
|
+
`AbortSignal.reason`, `throwIfAborted`, `AbortSignal.any`, причина отмены, `AggregateError` и `Error.cause`, —
|
|
242
|
+
проходит только через собственную реализацию совместимости пакета.
|
|
243
|
+
|
|
244
|
+
Сырой ESM проверяет детерминированный гейт синтаксической цели, а текущие Chromium, Firefox и WebKit — отдельный
|
|
245
|
+
smoke на импорт по HTTP. Это не заявление, что в репозитории запускаются исторические сборки браузеров: точная
|
|
246
|
+
сертификация нижней границы требует внешней фермы.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface CombinedAbortSignal {
|
|
2
|
+
readonly dispose: () => void;
|
|
3
|
+
readonly signal: AbortSignal;
|
|
4
|
+
}
|
|
5
|
+
declare function abortWithReason(controller: AbortController, reason: unknown): void;
|
|
6
|
+
declare function compatibleAbortReason(signal: AbortSignal): unknown;
|
|
7
|
+
declare function throwIfSignalAborted(signal: AbortSignal): void;
|
|
8
|
+
declare function combineAbortSignals(signals: readonly AbortSignal[]): CombinedAbortSignal;
|
|
9
|
+
export { abortWithReason, combineAbortSignals, compatibleAbortReason, throwIfSignalAborted };
|
|
10
|
+
export type { CombinedAbortSignal };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const i=new WeakMap;function c(){if(typeof DOMException=="function")return new DOMException("This operation was aborted.","AbortError");const e=new Error("This operation was aborted.");return e.name="AbortError",e}function b(e,r){e.signal.aborted||(r!==void 0&&i.set(e.signal,r),e.abort(r))}function s(e){return i.has(e)?{found:!0,value:i.get(e)}:"reason"in e?{found:!0,value:e.reason}:{found:!1,value:void 0}}function d(e){return s(e).value}function l(e){if(!e.aborted)return;const r=s(e);throw r.found?r.value:c()}function p(e){const r=new AbortController,o=new Map;let a=!0;const u=()=>{if(a){a=!1;for(const[n,t]of o)n.removeEventListener("abort",t);o.clear()}},f=n=>{if(!a)return;const t=s(n);u(),b(r,t.found?t.value:c())};for(const n of e){if(o.has(n))continue;if(n.aborted){f(n);break}const t=()=>f(n);o.set(n,t),n.addEventListener("abort",t,{once:!0})}return Object.freeze({dispose:u,signal:r.signal})}export{b as abortWithReason,p as combineAbortSignals,d as compatibleAbortReason,l as throwIfSignalAborted};
|
|
2
|
+
//# sourceMappingURL=abort-compat.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"abort-compat.js","sources":["../src/abort-compat.ts"],"sourcesContent":["const abortReasons = new WeakMap<AbortSignal, unknown>();\n\ninterface CombinedAbortSignal {\n readonly dispose: () => void;\n readonly signal: AbortSignal;\n}\n\ntype AbortReasonRead = { readonly found: boolean; readonly value: unknown };\n\nfunction defaultAbortReason(): unknown {\n if (typeof DOMException === 'function') return new DOMException('This operation was aborted.', 'AbortError');\n\n const error = new Error('This operation was aborted.');\n error.name = 'AbortError';\n\n return error;\n}\n\nfunction abortWithReason(controller: AbortController, reason: unknown): void {\n if (controller.signal.aborted) return;\n\n if (reason !== undefined) abortReasons.set(controller.signal, reason);\n\n // Older browsers may ignore the argument, so the sidecar remains the canonical fallback. Modern\n // consumers must still observe the exact reason through the native signal.reason API.\n controller.abort(reason);\n}\n\nfunction readAbortReason(signal: AbortSignal): AbortReasonRead {\n if (abortReasons.has(signal)) return { found: true, value: abortReasons.get(signal) };\n\n if ('reason' in signal) return { found: true, value: signal.reason };\n\n return { found: false, value: undefined };\n}\n\nfunction compatibleAbortReason(signal: AbortSignal): unknown {\n return readAbortReason(signal).value;\n}\n\nfunction throwIfSignalAborted(signal: AbortSignal): void {\n if (!signal.aborted) return;\n\n const reason = readAbortReason(signal);\n\n throw reason.found ? reason.value : defaultAbortReason();\n}\n\nfunction combineAbortSignals(signals: readonly AbortSignal[]): CombinedAbortSignal {\n const controller = new AbortController();\n const listeners = new Map<AbortSignal, () => void>();\n let active = true;\n\n const dispose = (): void => {\n if (!active) return;\n\n active = false;\n\n for (const [signal, listener] of listeners) signal.removeEventListener('abort', listener);\n\n listeners.clear();\n };\n const abortFrom = (source: AbortSignal): void => {\n if (!active) return;\n\n const reason = readAbortReason(source);\n dispose();\n abortWithReason(controller, reason.found ? reason.value : defaultAbortReason());\n };\n\n for (const signal of signals) {\n if (listeners.has(signal)) continue;\n\n if (signal.aborted) {\n abortFrom(signal);\n break;\n }\n\n const listener = (): void => abortFrom(signal);\n listeners.set(signal, listener);\n signal.addEventListener('abort', listener, { once: true });\n }\n\n return Object.freeze({ dispose, signal: controller.signal });\n}\n\nexport { abortWithReason, combineAbortSignals, compatibleAbortReason, throwIfSignalAborted };\nexport type { CombinedAbortSignal };\n"],"names":["abortReasons","defaultAbortReason","error","abortWithReason","controller","reason","readAbortReason","signal","compatibleAbortReason","throwIfSignalAborted","combineAbortSignals","signals","listeners","active","dispose","listener","abortFrom","source"],"mappings":"AAAA,MAAMA,EAAe,IAAI,QASzB,SAASC,GAAkB,CACzB,GAAI,OAAO,cAAiB,WAAY,OAAO,IAAI,aAAa,8BAA+B,YAAY,EAE3G,MAAMC,EAAQ,IAAI,MAAM,6BAA6B,EACrD,OAAAA,EAAM,KAAO,aAENA,CACT,CAEA,SAASC,EAAgBC,EAA6BC,EAAe,CAC/DD,EAAW,OAAO,UAElBC,IAAW,QAAWL,EAAa,IAAII,EAAW,OAAQC,CAAM,EAIpED,EAAW,MAAMC,CAAM,EACzB,CAEA,SAASC,EAAgBC,EAAmB,CAC1C,OAAIP,EAAa,IAAIO,CAAM,EAAU,CAAE,MAAO,GAAM,MAAOP,EAAa,IAAIO,CAAM,CAAC,EAE/E,WAAYA,EAAe,CAAE,MAAO,GAAM,MAAOA,EAAO,MAAM,EAE3D,CAAE,MAAO,GAAO,MAAO,MAAS,CACzC,CAEA,SAASC,EAAsBD,EAAmB,CAChD,OAAOD,EAAgBC,CAAM,EAAE,KACjC,CAEA,SAASE,EAAqBF,EAAmB,CAC/C,GAAI,CAACA,EAAO,QAAS,OAErB,MAAMF,EAASC,EAAgBC,CAAM,EAErC,MAAMF,EAAO,MAAQA,EAAO,MAAQJ,EAAkB,CACxD,CAEA,SAASS,EAAoBC,EAA+B,CAC1D,MAAMP,EAAa,IAAI,gBACjBQ,EAAY,IAAI,IACtB,IAAIC,EAAS,GAEb,MAAMC,EAAU,IAAW,CACzB,GAAKD,EAEL,CAAAA,EAAS,GAET,SAAW,CAACN,EAAQQ,CAAQ,IAAKH,EAAWL,EAAO,oBAAoB,QAASQ,CAAQ,EAExFH,EAAU,MAAK,EACjB,EACMI,EAAaC,GAA6B,CAC9C,GAAI,CAACJ,EAAQ,OAEb,MAAMR,EAASC,EAAgBW,CAAM,EACrCH,EAAO,EACPX,EAAgBC,EAAYC,EAAO,MAAQA,EAAO,MAAQJ,GAAoB,CAChF,EAEA,UAAWM,KAAUI,EAAS,CAC5B,GAAIC,EAAU,IAAIL,CAAM,EAAG,SAE3B,GAAIA,EAAO,QAAS,CAClBS,EAAUT,CAAM,EAChB,KACF,CAEA,MAAMQ,EAAW,IAAYC,EAAUT,CAAM,EAC7CK,EAAU,IAAIL,EAAQQ,CAAQ,EAC9BR,EAAO,iBAAiB,QAASQ,EAAU,CAAE,KAAM,GAAM,CAC3D,CAEA,OAAO,OAAO,OAAO,CAAE,QAAAD,EAAS,OAAQV,EAAW,OAAQ,CAC7D"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExactType, IsAny } from './type-helpers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Plain dependency methods accept no arguments, one input, or input plus an optional lifetime signal. Both
|
|
4
|
+
* `ctx.calls` and `own.calls` share this inference (D143); invocation always passes input and signal without
|
|
5
|
+
* inspecting function arity. A method that ignores its signal still belongs to the owner's lifetime.
|
|
6
|
+
*/
|
|
7
|
+
type AdapterMethodParameters<Arguments extends readonly unknown[]> = Arguments['length'] extends 0 | 1 | 2 ? 2 extends Arguments['length'] ? IsAny<Arguments[0]> extends true ? never : IsAny<Arguments[1]> extends true ? never : ExactType<Exclude<Arguments[1], undefined>, AbortSignal> extends true ? {
|
|
8
|
+
readonly input: Arguments[0];
|
|
9
|
+
} : never : 1 extends Arguments['length'] ? IsAny<Arguments[0]> extends true ? never : {
|
|
10
|
+
readonly input: Arguments[0];
|
|
11
|
+
} : {
|
|
12
|
+
readonly input: void;
|
|
13
|
+
} : never;
|
|
14
|
+
type AdapterMethodSignature<Method> = IsAny<Method> extends true ? never : Method extends (...arguments_: infer Arguments) => infer Result ? IsAny<Awaited<Result>> extends true ? never : AdapterMethodParameters<Arguments> & {
|
|
15
|
+
readonly output: Awaited<Result>;
|
|
16
|
+
} : never;
|
|
17
|
+
/** Every key of the source whose method matches the call shape; anything else is not selectable. */
|
|
18
|
+
type AdapterMethodKey<Value> = {
|
|
19
|
+
readonly [Key in keyof Value]-?: Key extends string ? AdapterMethodSignature<Value[Key]> extends never ? never : Key : never;
|
|
20
|
+
}[keyof Value];
|
|
21
|
+
type TupleHasDuplicate<Keys extends readonly PropertyKey[], Seen extends PropertyKey = never> = Keys extends readonly [
|
|
22
|
+
infer Head extends PropertyKey,
|
|
23
|
+
...infer Tail extends readonly PropertyKey[]
|
|
24
|
+
] ? Head extends Seen ? true : TupleHasDuplicate<Tail, Head | Seen> : false;
|
|
25
|
+
/** One key may be selected once: a repeated key would mint two calls under one name. */
|
|
26
|
+
type UniqueExposeKeys<Keys extends readonly PropertyKey[]> = TupleHasDuplicate<Keys> extends true ? never : unknown;
|
|
27
|
+
export type { AdapterMethodKey, AdapterMethodSignature, UniqueExposeKeys };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AttachmentAttempt, AttachmentCleanupOutcome } from './attachment-lifecycle-contract.js';
|
|
2
|
+
declare function runAttachmentCleanup<Key extends object>(ownerId: string, attempt: AttachmentAttempt<Key>): Promise<AttachmentCleanupOutcome>;
|
|
3
|
+
export { runAttachmentCleanup };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{throwIfSignalAborted as d}from"./abort-compat.js";import{makeCleanupOutcome as f}from"./attachment-lifecycle-helpers.js";import{settleGuardedResult as p}from"./call-target-result.js";async function g(i,n){const t=new AbortController,s=Object.freeze({signal:t.signal}),a=[],c=[];let l=0;for(let r=n.cleanups.length-1;r>=0;r-=1){const e=n.cleanups[r];if(e===void 0)continue;const u=e.cleanup;if(u!==void 0){l+=1;try{const o=u(s);await p(o,{assertCurrent:()=>d(t.signal),signal:t.signal}),e.cleanup=void 0}catch(o){a.push(Object.freeze({error:o,id:e.id,ordinal:l})),c.push(e)}}}return n.cleanups=c.reverse(),f(i,a)}export{g as runAttachmentCleanup};
|
|
2
|
+
//# sourceMappingURL=attachment-lifecycle-cleanup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachment-lifecycle-cleanup.js","sources":["../src/attachment-lifecycle-cleanup.ts"],"sourcesContent":["import { throwIfSignalAborted } from './abort-compat';\nimport type {\n AttachmentAttempt,\n AttachmentCleanupFailure,\n AttachmentCleanupOutcome,\n OwnedCleanup,\n} from './attachment-lifecycle-contract';\nimport { makeCleanupOutcome } from './attachment-lifecycle-helpers';\nimport { settleGuardedResult } from './call-target';\n\nasync function runAttachmentCleanup<Key extends object>(\n ownerId: string,\n attempt: AttachmentAttempt<Key>,\n): Promise<AttachmentCleanupOutcome> {\n const cleanupAbort = new AbortController();\n const context = Object.freeze({ signal: cleanupAbort.signal });\n const failures: AttachmentCleanupFailure[] = [];\n const retainedInInvocationOrder: OwnedCleanup[] = [];\n let ordinal = 0;\n\n for (let index = attempt.cleanups.length - 1; index >= 0; index -= 1) {\n const ownedCleanup = attempt.cleanups[index];\n\n if (ownedCleanup === undefined) continue;\n\n const cleanup = ownedCleanup.cleanup;\n\n if (cleanup === undefined) continue;\n\n ordinal += 1;\n\n try {\n const result = cleanup(context);\n\n await settleGuardedResult(result, {\n assertCurrent: () => throwIfSignalAborted(cleanupAbort.signal),\n signal: cleanupAbort.signal,\n });\n ownedCleanup.cleanup = undefined;\n } catch (error) {\n failures.push(Object.freeze({ error, id: ownedCleanup.id, ordinal }));\n retainedInInvocationOrder.push(ownedCleanup);\n }\n }\n\n attempt.cleanups = retainedInInvocationOrder.reverse();\n\n return makeCleanupOutcome(ownerId, failures);\n}\n\nexport { runAttachmentCleanup };\n"],"names":["runAttachmentCleanup","ownerId","attempt","cleanupAbort","context","failures","retainedInInvocationOrder","ordinal","index","ownedCleanup","cleanup","result","settleGuardedResult","throwIfSignalAborted","error","makeCleanupOutcome"],"mappings":"8LAUA,eAAeA,EACbC,EACAC,EAA+B,CAE/B,MAAMC,EAAe,IAAI,gBACnBC,EAAU,OAAO,OAAO,CAAE,OAAQD,EAAa,OAAQ,EACvDE,EAAuC,CAAA,EACvCC,EAA4C,CAAA,EAClD,IAAIC,EAAU,EAEd,QAASC,EAAQN,EAAQ,SAAS,OAAS,EAAGM,GAAS,EAAGA,GAAS,EAAG,CACpE,MAAMC,EAAeP,EAAQ,SAASM,CAAK,EAE3C,GAAIC,IAAiB,OAAW,SAEhC,MAAMC,EAAUD,EAAa,QAE7B,GAAIC,IAAY,OAEhB,CAAAH,GAAW,EAEX,GAAI,CACF,MAAMI,EAASD,EAAQN,CAAO,EAE9B,MAAMQ,EAAoBD,EAAQ,CAChC,cAAe,IAAME,EAAqBV,EAAa,MAAM,EAC7D,OAAQA,EAAa,MACtB,CAAA,EACDM,EAAa,QAAU,MACzB,OAASK,EAAO,CACdT,EAAS,KAAK,OAAO,OAAO,CAAE,MAAAS,EAAO,GAAIL,EAAa,GAAI,QAAAF,CAAO,CAAE,CAAC,EACpED,EAA0B,KAAKG,CAAY,CAC7C,EACF,CAEA,OAAAP,EAAQ,SAAWI,EAA0B,QAAO,EAE7CS,EAAmBd,EAASI,CAAQ,CAC7C"}
|