@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/README.ru.md +246 -0
  5. package/dist/abort-compat.d.ts +10 -0
  6. package/dist/abort-compat.js +2 -0
  7. package/dist/abort-compat.js.map +1 -0
  8. package/dist/adapter-method.d.ts +27 -0
  9. package/dist/attachment-lifecycle-cleanup.d.ts +3 -0
  10. package/dist/attachment-lifecycle-cleanup.js +2 -0
  11. package/dist/attachment-lifecycle-cleanup.js.map +1 -0
  12. package/dist/attachment-lifecycle-contract.d.ts +260 -0
  13. package/dist/attachment-lifecycle-contract.js +2 -0
  14. package/dist/attachment-lifecycle-contract.js.map +1 -0
  15. package/dist/attachment-lifecycle-helpers.d.ts +34 -0
  16. package/dist/attachment-lifecycle-helpers.js +2 -0
  17. package/dist/attachment-lifecycle-helpers.js.map +1 -0
  18. package/dist/attachment-lifecycle-machine.d.ts +3 -0
  19. package/dist/attachment-lifecycle-machine.js +2 -0
  20. package/dist/attachment-lifecycle-machine.js.map +1 -0
  21. package/dist/attachment-lifecycle-mailbox.d.ts +10 -0
  22. package/dist/attachment-lifecycle-mailbox.js +2 -0
  23. package/dist/attachment-lifecycle-mailbox.js.map +1 -0
  24. package/dist/attachment-lifecycle-open.d.ts +3 -0
  25. package/dist/attachment-lifecycle-open.js +2 -0
  26. package/dist/attachment-lifecycle-open.js.map +1 -0
  27. package/dist/attachment-lifecycle.d.ts +4 -0
  28. package/dist/attachment-lifecycle.js +2 -0
  29. package/dist/attachment-lifecycle.js.map +1 -0
  30. package/dist/attachment.d.ts +49 -0
  31. package/dist/attachment.js +2 -0
  32. package/dist/attachment.js.map +1 -0
  33. package/dist/call-authority.d.ts +26 -0
  34. package/dist/call-controller-support.d.ts +77 -0
  35. package/dist/call-controller-support.js +2 -0
  36. package/dist/call-controller-support.js.map +1 -0
  37. package/dist/call-controller.d.ts +49 -0
  38. package/dist/call-controller.js +2 -0
  39. package/dist/call-controller.js.map +1 -0
  40. package/dist/call-execution-inspection.d.ts +26 -0
  41. package/dist/call-execution-inspection.js +2 -0
  42. package/dist/call-execution-inspection.js.map +1 -0
  43. package/dist/call-executor-factory.d.ts +5 -0
  44. package/dist/call-executor-factory.js +2 -0
  45. package/dist/call-executor-factory.js.map +1 -0
  46. package/dist/call-executor-record.d.ts +66 -0
  47. package/dist/call-executor-record.js +2 -0
  48. package/dist/call-executor-record.js.map +1 -0
  49. package/dist/call-executor-support.d.ts +25 -0
  50. package/dist/call-executor-support.js +2 -0
  51. package/dist/call-executor-support.js.map +1 -0
  52. package/dist/call-executor-types.d.ts +56 -0
  53. package/dist/call-executor-waiters.d.ts +23 -0
  54. package/dist/call-executor-waiters.js +2 -0
  55. package/dist/call-executor-waiters.js.map +1 -0
  56. package/dist/call-executor.d.ts +6 -0
  57. package/dist/call-executor.js +2 -0
  58. package/dist/call-executor.js.map +1 -0
  59. package/dist/call-invocation.d.ts +61 -0
  60. package/dist/call-invocation.js +2 -0
  61. package/dist/call-invocation.js.map +1 -0
  62. package/dist/call-lane.d.ts +28 -0
  63. package/dist/call-lane.js +2 -0
  64. package/dist/call-lane.js.map +1 -0
  65. package/dist/call-target-result.d.ts +22 -0
  66. package/dist/call-target-result.js +2 -0
  67. package/dist/call-target-result.js.map +1 -0
  68. package/dist/call-target.d.ts +42 -0
  69. package/dist/call-target.js +2 -0
  70. package/dist/call-target.js.map +1 -0
  71. package/dist/call.d.ts +11 -0
  72. package/dist/call.js +2 -0
  73. package/dist/call.js.map +1 -0
  74. package/dist/contribution-hub.d.ts +100 -0
  75. package/dist/contribution-hub.js +2 -0
  76. package/dist/contribution-hub.js.map +1 -0
  77. package/dist/contribution-transaction.d.ts +20 -0
  78. package/dist/contribution-transaction.js +2 -0
  79. package/dist/contribution-transaction.js.map +1 -0
  80. package/dist/contribution.d.ts +64 -0
  81. package/dist/contribution.js +2 -0
  82. package/dist/contribution.js.map +1 -0
  83. package/dist/data-scheduler.d.ts +4 -0
  84. package/dist/data-scheduler.js +2 -0
  85. package/dist/data-scheduler.js.map +1 -0
  86. package/dist/errors.d.ts +56 -0
  87. package/dist/errors.js +2 -0
  88. package/dist/errors.js.map +1 -0
  89. package/dist/identity.d.ts +6 -0
  90. package/dist/identity.js +2 -0
  91. package/dist/identity.js.map +1 -0
  92. package/dist/index.d.ts +17 -0
  93. package/dist/index.js +2 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/internal.d.ts +46 -0
  96. package/dist/internal.js +2 -0
  97. package/dist/internal.js.map +1 -0
  98. package/dist/lifetime.d.ts +19 -0
  99. package/dist/lifetime.js +2 -0
  100. package/dist/lifetime.js.map +1 -0
  101. package/dist/model-context.d.ts +155 -0
  102. package/dist/model.d.ts +21 -0
  103. package/dist/model.js +2 -0
  104. package/dist/model.js.map +1 -0
  105. package/dist/platform-compat.d.ts +9 -0
  106. package/dist/platform-compat.js +2 -0
  107. package/dist/platform-compat.js.map +1 -0
  108. package/dist/port.d.ts +26 -0
  109. package/dist/port.js +2 -0
  110. package/dist/port.js.map +1 -0
  111. package/dist/reactive-collection.d.ts +27 -0
  112. package/dist/reactive-collection.js +2 -0
  113. package/dist/reactive-collection.js.map +1 -0
  114. package/dist/reactive-node.d.ts +62 -0
  115. package/dist/reactive-node.js +2 -0
  116. package/dist/reactive-node.js.map +1 -0
  117. package/dist/reactive-scheduler-control.d.ts +34 -0
  118. package/dist/reactive-scheduler-control.js +2 -0
  119. package/dist/reactive-scheduler-control.js.map +1 -0
  120. package/dist/reactive-scheduler.d.ts +18 -0
  121. package/dist/reactive-scheduler.js +2 -0
  122. package/dist/reactive-scheduler.js.map +1 -0
  123. package/dist/reactive.d.ts +41 -0
  124. package/dist/reactive.js +2 -0
  125. package/dist/reactive.js.map +1 -0
  126. package/dist/readable.d.ts +13 -0
  127. package/dist/readable.js +2 -0
  128. package/dist/readable.js.map +1 -0
  129. package/dist/resource.d.ts +82 -0
  130. package/dist/type-helpers.d.ts +14 -0
  131. package/package.json +66 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # @opetope/core
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release. See the package README for the supported API and usage.
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"}