injeca 0.1.8 → 0.3.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/README.md +359 -32
- package/dist/decision-odiPb2Au.mjs +194 -0
- package/dist/decision-odiPb2Au.mjs.map +1 -0
- package/dist/global-B9Bk2EG2.d.mts +153 -0
- package/dist/global-C0Nk0pU8.mjs +3790 -0
- package/dist/global-C0Nk0pU8.mjs.map +1 -0
- package/dist/global.d.mts +2 -2
- package/dist/global.mjs +1 -3
- package/dist/index-DXguNx2M.d.mts +643 -0
- package/dist/index.d.mts +174 -2
- package/dist/index.mjs +210 -2
- package/dist/index.mjs.map +1 -0
- package/dist/testing.d.mts +31 -0
- package/dist/testing.mjs +45 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +12 -25
- package/dist/builtin-BPDNf8Z-.mjs +0 -25
- package/dist/builtin-BPDNf8Z-.mjs.map +0 -1
- package/dist/global-BLY6_SI_.d.mts +0 -130
- package/dist/src-UXkTj4Np.mjs +0 -294
- package/dist/src-UXkTj4Np.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Injeca
|
|
2
2
|
|
|
3
|
-
Pure functional
|
|
3
|
+
Pure functional dependency injection for TypeScript, with service lifecycles, run-time changes, and status propagation. No classes, no decorators, and no extra wrappers.
|
|
4
4
|
|
|
5
|
-
> Heavily inspired by [uber/fig](https://go.uber.org/fig) and [uber/dig](https://go.uber.org/dig) in
|
|
5
|
+
> Heavily inspired by [uber/fx](https://go.uber.org/fx), [uber/fig](https://go.uber.org/fig), and [uber/dig](https://go.uber.org/dig) in the Go ecosystem.
|
|
6
6
|
|
|
7
7
|
[![npm version][npm-version-src]][npm-version-href]
|
|
8
8
|
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
@@ -20,6 +20,8 @@ ni injeca
|
|
|
20
20
|
yarn add injeca
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
injeca works in Node.js, Electron, browsers, and workers. It has no Node-only code.
|
|
24
|
+
|
|
23
25
|
### Agent Skills
|
|
24
26
|
|
|
25
27
|
Install the [injeca skill](https://skills.sh) to your AI coding agent:
|
|
@@ -30,25 +32,23 @@ npx skills add moeru-ai/injeca
|
|
|
30
32
|
|
|
31
33
|
## Basic usage
|
|
32
34
|
|
|
33
|
-
`injeca` ships with a global
|
|
34
|
-
providers, declare their dependencies, and run invocations once everything is ready.
|
|
35
|
+
`injeca` ships with a global container exposed as `injeca`. Register services, declare their dependencies, and start.
|
|
35
36
|
|
|
36
37
|
```ts
|
|
37
38
|
import { createServer } from 'node:http'
|
|
38
39
|
|
|
39
|
-
import { injeca
|
|
40
|
+
import { injeca } from 'injeca'
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
+
// A service returns a value once. The container caches it.
|
|
42
43
|
const config = injeca.provide('config', () => ({ port: 3000 }))
|
|
43
44
|
|
|
44
45
|
const server = injeca.provide({
|
|
45
|
-
dependsOn: { config
|
|
46
|
-
|
|
47
|
-
const { config, lifecycle } = dependsOn
|
|
46
|
+
dependsOn: { config },
|
|
47
|
+
build({ dependsOn, on }) {
|
|
48
48
|
const app = createServer()
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
on('start', () => app.listen(dependsOn.config.port))
|
|
51
|
+
on('stop', () => app.close())
|
|
52
52
|
|
|
53
53
|
return app
|
|
54
54
|
},
|
|
@@ -56,9 +56,8 @@ const server = injeca.provide({
|
|
|
56
56
|
|
|
57
57
|
injeca.invoke({
|
|
58
58
|
dependsOn: { server },
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
console.log('HTTP server ready:', await server.address())
|
|
59
|
+
callback({ server }) {
|
|
60
|
+
console.info('HTTP server ready:', server.address())
|
|
62
61
|
},
|
|
63
62
|
})
|
|
64
63
|
|
|
@@ -69,19 +68,17 @@ process.once('SIGINT', async () => {
|
|
|
69
68
|
})
|
|
70
69
|
```
|
|
71
70
|
|
|
71
|
+
- `provide` returns a typed key. Use it in `dependsOn`. injeca infers the type of each dependency.
|
|
72
|
+
- `start()` builds each service when its dependencies are running, then runs its `start` hooks. It resolves when everything is running.
|
|
73
|
+
- `stop()` stops dependents before their providers. The registrations stay, so a later `start()` builds them again.
|
|
74
|
+
|
|
72
75
|
> [!WARNING]
|
|
73
76
|
>
|
|
74
|
-
> **`injeca`
|
|
75
|
-
>
|
|
76
|
-
> This means every value returned by `build` will be stored and `build` will only
|
|
77
|
-
> got called once.
|
|
77
|
+
> **`injeca` caches each value that `build` returns.**
|
|
78
78
|
>
|
|
79
|
-
> If `build` returns a function, that function
|
|
80
|
-
> it). This is useful for lazy dependencies, "getters", or wrappers for singleton,
|
|
81
|
-
> but it also means any side effects inside the returned function will run on every call.
|
|
79
|
+
> `build` runs once for each instance. If `build` returns a function, injeca caches that function, not the result of a call. This is useful for lazy dependencies and getters. Side effects inside the returned function run on every call.
|
|
82
80
|
>
|
|
83
|
-
>
|
|
84
|
-
> the reusable closure once inside `build`, and return a wrapper that calls it:
|
|
81
|
+
> To reuse one resource, for example a single window, create it once inside `build` and return a wrapper:
|
|
85
82
|
>
|
|
86
83
|
> ```ts
|
|
87
84
|
> const settingsWindow = injeca.provide('windows:settings', {
|
|
@@ -93,17 +90,230 @@ process.once('SIGINT', async () => {
|
|
|
93
90
|
> })
|
|
94
91
|
> ```
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
## Lifecycle in `build`
|
|
94
|
+
|
|
95
|
+
The build context gives each service its own lifecycle:
|
|
96
|
+
|
|
97
|
+
| Field | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `dependsOn` | The resolved dependencies |
|
|
100
|
+
| `on('start', handler)` | Runs after the service is published and its dependencies run |
|
|
101
|
+
| `on('stop', handler)` | Runs before the service stops. Its dependencies are still available. |
|
|
102
|
+
| `effect(setup)` | Runs `setup` at once. The disposer that it returns runs when the instance is disposed, after the `stop` hooks. |
|
|
103
|
+
| `signal` | An `AbortSignal` that aborts when a newer target replaces this build, or when the container is disposed |
|
|
104
|
+
| `setStatus(status, reason?)` | Sets the status of the instance. See [Status](#status). |
|
|
105
|
+
|
|
106
|
+
A hook gets a hook context with a typed `decide`. A return value that is not a decision, for example the result of `app.listen()`, selects the default of the hook. Return a decision to tell injeca what to do next:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
on('start', async ({ decide, attempt }) => {
|
|
110
|
+
try {
|
|
111
|
+
await app.listen(port)
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return decide.fail(error)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Retries
|
|
120
|
+
|
|
121
|
+
A `start` hook can return `decide.requeue()` to try again later. The service keeps its instance, so `build` does not run again.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
on('start', async ({ decide }) => {
|
|
125
|
+
try {
|
|
126
|
+
await device.connect()
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return decide.requeue({
|
|
130
|
+
backoff: { kind: 'exponential', initial: 500, max: 30_000, jitter: 0.2 },
|
|
131
|
+
maxAttempts: 5,
|
|
132
|
+
reason: `connect failed: ${String(error)}`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- During the wait, the service is `retrying`. It is not available, so its dependents stay `pending`. It still holds its dependencies.
|
|
139
|
+
- `after: ms` sets a fixed delay. `backoff` doubles `initial` for each attempt, up to `max`. `jitter` is a fraction from 0 to 1 that takes a random part away from each delay.
|
|
140
|
+
- The next attempt runs the `start` handler that requeued, then the handlers after it.
|
|
141
|
+
- Changes during the wait merge into one attempt. A withdrawn required dependency, or `stop()`, cancels the wait and stops the service at once.
|
|
142
|
+
- A dependency hook can requeue too. The service stays `running` with its current dependencies during the wait.
|
|
143
|
+
- injeca has no built-in delay or limit. Without `maxAttempts`, the retries continue.
|
|
144
|
+
|
|
145
|
+
`maxAttempts` limits the runs of one hook for one instance. It can come from the decision, the `provide` options, or the container options. The decision wins over the service, and the service wins over the container. A child container uses the value of its nearest ancestor.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const container = createContainer({ maxAttempts: 10, random: () => 0.5 }) // a fixed random source for deterministic tests
|
|
149
|
+
injeca.provide('modules:device', { maxAttempts: 3, build: () => createDevice() })
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When the attempts end, a running service stops first. Then it goes to `failed` with the last reason. `start()` again, `stop()` and `start()`, or `replace()` moves it out of `failed`.
|
|
153
|
+
|
|
154
|
+
### Timeouts
|
|
155
|
+
|
|
156
|
+
`start` and `stop` hooks wait until they settle. Set `timeouts` to limit them. A service value overrides the container value. injeca has no built-in timeout.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const container = createContainer({ timeouts: { stop: 5_000 } })
|
|
160
|
+
|
|
161
|
+
provide(container, 'modules:camera', {
|
|
162
|
+
timeouts: { start: 10_000 },
|
|
163
|
+
onTimeout: (context) => {
|
|
164
|
+
if (context.hook === 'start')
|
|
165
|
+
return context.decide.requeue({ after: 1_000, maxAttempts: 3 })
|
|
166
|
+
return context.decide.fail(new Error('The camera did not close.'))
|
|
167
|
+
},
|
|
168
|
+
build: () => createCamera(),
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- The `start` timeout is for all `start` handlers of one attempt. The `stop` timeout is for all `stop` handlers and effect disposers of one instance.
|
|
173
|
+
- When a timeout passes, `observe` gets a `timeout` record. Then `onTimeout` of the service, or of the container, decides. Without it, the result is `fail` with a `HookTimeoutError` (`hook-timeout`).
|
|
174
|
+
- `onTimeout` runs synchronously. `context.decide` has the methods of the hook that timed out.
|
|
175
|
+
- After a `stop` timeout, the remaining cleanups still run, but injeca does not wait for them. So a cleanup that awaits the disposal of its own container cannot block that disposal.
|
|
176
|
+
|
|
177
|
+
## Changes at run time
|
|
178
|
+
|
|
179
|
+
Services can appear and disappear after `start()`. Dependents converge automatically.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
await injeca.start()
|
|
183
|
+
|
|
184
|
+
// A provide after start() builds and starts when its dependencies run.
|
|
185
|
+
const vision = injeca.provide('modules:vision', {
|
|
186
|
+
dependsOn: { config },
|
|
187
|
+
build: ({ dependsOn }) => createVision(dependsOn.config),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Withdraw. Dependents stop first. A stale registration returns false.
|
|
191
|
+
await vision.withdraw()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
When a dependency gets a new instance, a dependent rebuilds by default. A `dependency-change` handler can move to the new instance in place:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
on('dependency-change', ({ dependsOn, decide }) => {
|
|
198
|
+
speech.setTts(dependsOn.tts?.value)
|
|
199
|
+
return decide.handled()
|
|
200
|
+
})
|
|
201
|
+
```
|
|
98
202
|
|
|
99
|
-
###
|
|
203
|
+
### Atomic replacement
|
|
100
204
|
|
|
101
|
-
|
|
102
|
-
explicitly and pass the container reference into `provide`, `invoke`, `start`, and
|
|
103
|
-
`stop`.
|
|
205
|
+
A withdrawal followed by a new `provide` leaves a gap: all dependents stop. `replace()` has no gap. A hot reload tool uses it.
|
|
104
206
|
|
|
105
207
|
```ts
|
|
106
|
-
|
|
208
|
+
const tts = injeca.provide(ttsKey, () => createTts())
|
|
209
|
+
|
|
210
|
+
// The old instance keeps serving while the new one runs `build` and `start`.
|
|
211
|
+
await tts.replace(() => createTtsV2())
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- The swap is one batch. It occurs only after the new instance completes `start`. The new instance is `ready`.
|
|
215
|
+
- If the new `build` or `start` fails, `replace()` rejects and the old instance does not change. This is also the usual fix for an `unavailable` provider.
|
|
216
|
+
- The old instance stops after each dependent releases it. A dependent with a `dependency-change` handler moves in place. Other dependents rebuild.
|
|
217
|
+
- A stale registration, or a call that a newer `replace()` or a withdrawal supersedes, resolves `false`. A service in `stopping` rejects with `ReplaceWhileStoppingError`.
|
|
218
|
+
|
|
219
|
+
### Transactions
|
|
220
|
+
|
|
221
|
+
A transaction publishes many services together, for example all exports of one plugin.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { transaction } from 'injeca'
|
|
225
|
+
|
|
226
|
+
const tx = transaction(container)
|
|
227
|
+
tx.declare(statusKitKey, { dependsOn: { character: characterKey } })
|
|
228
|
+
tx.provide(statusKitKey, { build: ({ dependsOn }) => createStatusKit(dependsOn) })
|
|
229
|
+
tx.provide(characterKey, () => createCharacter())
|
|
230
|
+
await tx.commit()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- Nothing is visible to consumers before `commit()`. Graph queries show the declared services.
|
|
234
|
+
- `commit()` builds in dependency order. A service can use another service of the same transaction.
|
|
235
|
+
- If all builds succeed, the services publish in one batch, then their `start` hooks run. If one build fails, nothing publishes and `commit()` rejects.
|
|
236
|
+
- A service whose dependencies are not available goes to `pending`. That is not a failure.
|
|
237
|
+
- A declared service without `provide` fails the commit with `TransactionIncompleteError`, unless it is declared with `optional: true`.
|
|
238
|
+
- `tx.replace(key, option)` replaces a registration at the commit. `tx.abort()` and the disposal of the container drop everything.
|
|
239
|
+
|
|
240
|
+
## Keys without a provider
|
|
241
|
+
|
|
242
|
+
`defineKey` creates a typed key. A consumer can depend on it before any provider exists. It stays `pending` and runs no code until a provider registers.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import { defineKey, firstAvailable, injeca, optional } from 'injeca'
|
|
246
|
+
|
|
247
|
+
export const llmKey = defineKey<LlmClient>('airi.llm')
|
|
248
|
+
|
|
249
|
+
injeca.provide('voice:speech', {
|
|
250
|
+
dependsOn: {
|
|
251
|
+
llm: firstAvailable(remoteLlmKey, llmKey), // the first available provider
|
|
252
|
+
tts: optional(ttsKey), // `{ value, status } | undefined`
|
|
253
|
+
},
|
|
254
|
+
build: ({ dependsOn }) => createSpeech(dependsOn.llm, dependsOn.tts?.value),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// A plugin provides it later. The build result must match LlmClient.
|
|
258
|
+
injeca.provide(llmKey, () => createLocalLlm())
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Exclusive keys
|
|
262
|
+
|
|
263
|
+
An exclusive key can have only one provider in its resolution area: the container that has the key, with its descendants.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const whiteboardKey = defineKey<Whiteboard>('dev.airi.whiteboard', { exclusive: true })
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
If two containers in one area provide it, both go to `conflict`, in any order. They run no code, and their dependents stay `pending`. When one provider is removed, the other converges. `graph(container).explain()` lists the owner of each provider.
|
|
270
|
+
|
|
271
|
+
### Resolution conditions
|
|
272
|
+
|
|
273
|
+
`where(key, condition)` accepts a provider only when the condition returns `true`. The condition reads the key, the owner, and the `metadata` of the provider.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
import { defineKey, optional, where } from 'injeca'
|
|
277
|
+
|
|
278
|
+
const panelKey = injeca.provide('whiteboard:panel', { metadata: { version: '2.1.0' }, build: () => createPanel() })
|
|
279
|
+
const themeKey = defineKey<Theme>('ui:theme')
|
|
280
|
+
|
|
281
|
+
injeca.provide('whiteboard:view', {
|
|
282
|
+
dependsOn: {
|
|
283
|
+
panel: where(panelKey, ({ metadata }) => String(metadata.version).startsWith('2.')),
|
|
284
|
+
theme: optional(where(themeKey, ({ owner }) => owner.kind === 'host')),
|
|
285
|
+
},
|
|
286
|
+
build: ({ dependsOn }) => createView(dependsOn.panel, dependsOn.theme?.value),
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- A rejected required dependency keeps the dependent `pending`. A rejected optional dependency is `undefined`.
|
|
291
|
+
- The condition must be pure and synchronous. A thrown error counts as `false` and goes to the observe stream.
|
|
292
|
+
|
|
293
|
+
## Status
|
|
294
|
+
|
|
295
|
+
A running service is `ready`, `degraded`, or `unavailable`.
|
|
296
|
+
|
|
297
|
+
- `degraded` means that calls still complete, at lower quality.
|
|
298
|
+
- `unavailable` means that the instance exists, but calls fail now.
|
|
299
|
+
- The effective status of a service is the worst of its own status and the effective status of each required dependency. It goes down the graph.
|
|
300
|
+
- A dependency that becomes `unavailable` does not stop or rebuild its dependents. They get a `dependency-status-change` call if they register a handler.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
build: ({ setStatus }) => {
|
|
304
|
+
const client = connect()
|
|
305
|
+
client.on('reconnecting', () => setStatus('unavailable', { reason: 'reconnecting' }))
|
|
306
|
+
client.on('ready', () => setStatus('ready'))
|
|
307
|
+
return client
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Containers
|
|
312
|
+
|
|
313
|
+
Create containers to isolate tests, or to give each plugin its own child container.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { createContainer, dispose, effect, invoke, provide, start, stop } from 'injeca'
|
|
107
317
|
|
|
108
318
|
const container = createContainer()
|
|
109
319
|
|
|
@@ -111,14 +321,131 @@ const token = provide(container, 'token', () => crypto.randomUUID())
|
|
|
111
321
|
|
|
112
322
|
invoke(container, {
|
|
113
323
|
dependsOn: { token },
|
|
114
|
-
|
|
115
|
-
callback: ({ token }) => console.log('token', token),
|
|
324
|
+
callback: ({ token }) => console.info('token', token),
|
|
116
325
|
})
|
|
117
326
|
|
|
118
327
|
await start(container)
|
|
119
328
|
await stop(container)
|
|
120
329
|
```
|
|
121
330
|
|
|
331
|
+
A child container resolves keys from its ancestors. It does not start until `start(child)` runs, so a caller can register all its services first.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
const plugin = createContainer({
|
|
335
|
+
parent: injeca.getContainer(),
|
|
336
|
+
owner: { kind: 'plugin', id: 'agent-activity' },
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
provide(plugin, 'agent-activity:reaction', { dependsOn: { llm: llmKey }, build: () => createReaction() })
|
|
340
|
+
effect(plugin, () => () => console.info('plugin cleaned up'))
|
|
341
|
+
|
|
342
|
+
await start(plugin, { timeoutMs: 10_000 })
|
|
343
|
+
await dispose(plugin)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- `stop()` stops. `dispose()` also withdraws the registrations and runs the container effects. A disposed container cannot be used again.
|
|
347
|
+
- `start(container, { timeoutMs })` rejects with a `StartTimeoutError` that explains each service that is not running. injeca has no built-in timeout.
|
|
348
|
+
- A service with a missing provider, or in a dependency cycle, stays `pending`. Use `timeoutMs` to find it.
|
|
349
|
+
- A provider in a child container shadows the provider of an ancestor only while it is `running`. Until then, and after it stops, dependents use the ancestor provider. So shadowing never leaves a gap.
|
|
350
|
+
- `createContainer({ isolate: [key] })` stops a key from resolving from the ancestors.
|
|
351
|
+
|
|
352
|
+
### Per-consumer services
|
|
353
|
+
|
|
354
|
+
`perConsumer: true` runs `build` once for each consumer container. The build context has `consumer`, the owner record of that container. The instance disposes with the consumer container.
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
const hostClient = injeca.provide('host:client', {
|
|
358
|
+
perConsumer: true,
|
|
359
|
+
metadata: { api: 'v1' },
|
|
360
|
+
build: ({ consumer }) => createHostClient(consumer),
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`metadata` is a frozen record on any registration. Resolution conditions and graph queries read it.
|
|
365
|
+
|
|
366
|
+
## Reading state
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { observe, waitFor, watch } from 'injeca'
|
|
370
|
+
|
|
371
|
+
const container = injeca.getContainer()
|
|
372
|
+
|
|
373
|
+
const stopWatching = watch(container, server, ({ phase, status }) => {})
|
|
374
|
+
await waitFor(container, server, { status: 'ready', timeoutMs: 5_000 })
|
|
375
|
+
const stopObserving = observe(container, (record) => {})
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- `watch` sends the current state at once, then each change, in order.
|
|
379
|
+
- `observe` sends each phase change, status change, decision, and hook timeout in the container and its children.
|
|
380
|
+
|
|
381
|
+
### Graph queries
|
|
382
|
+
|
|
383
|
+
`graph(container)` reads the graph of the container and its children. It never changes state.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { graph } from 'injeca'
|
|
387
|
+
|
|
388
|
+
const queries = graph(container)
|
|
389
|
+
|
|
390
|
+
queries.services() // all services, with the declared services of open transactions
|
|
391
|
+
queries.dependencies(server) // the direct edges of a service
|
|
392
|
+
queries.dependents(config) // the services that use a service now
|
|
393
|
+
queries.order() // providers before their dependents
|
|
394
|
+
queries.cycles() // each dependency cycle
|
|
395
|
+
queries.explain(server) // why a service is not running
|
|
396
|
+
queries.impact({ kind: 'withdraw', key: llmKey }) // { stopped, rebuilt, notified }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`impact()` runs on a copy of the graph, so a user interface can preview an action: "Disabling the plugin also stops X and Y." The change can be `withdraw`, `replace`, or `dispose`.
|
|
400
|
+
|
|
401
|
+
### Listener registry
|
|
402
|
+
|
|
403
|
+
injeca does not dispatch events. It stores listeners that a container owns, so that a consumer can run them by its own rules.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
import { listen, listeners } from 'injeca'
|
|
407
|
+
|
|
408
|
+
const gate = { id: 'airi.harness.gate' } // any object with a string id, for example an eventa definition
|
|
409
|
+
|
|
410
|
+
const remove = listen(plugin, gate, handler, { priority: 10 })
|
|
411
|
+
const entries = listeners(container, gate) // [{ handler, owner, metadata }]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`listeners()` returns the listeners of the container and its children, in registration order. `dispose(container)` removes the listeners of the container.
|
|
415
|
+
|
|
416
|
+
## Migration from 0.2
|
|
417
|
+
|
|
418
|
+
0.3 keeps the 0.2 call style: `provide`, `invoke`, `resolve`, `start`, `stop`, `createContainer`, and the global `injeca` object. One API changed:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// 0.2
|
|
422
|
+
const server = injeca.provide({
|
|
423
|
+
dependsOn: { lifecycle },
|
|
424
|
+
build({ dependsOn }) {
|
|
425
|
+
const app = createServer()
|
|
426
|
+
dependsOn.lifecycle.appHooks.onStart(() => app.listen(3000))
|
|
427
|
+
dependsOn.lifecycle.appHooks.onStop(() => app.close())
|
|
428
|
+
return app
|
|
429
|
+
},
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// 0.3
|
|
433
|
+
const server = injeca.provide({
|
|
434
|
+
build({ on }) {
|
|
435
|
+
const app = createServer()
|
|
436
|
+
on('start', () => app.listen(3000))
|
|
437
|
+
on('stop', () => app.close())
|
|
438
|
+
return app
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Other differences:
|
|
444
|
+
|
|
445
|
+
- `start()` waits for a missing provider. 0.2 threw an error. Use `start({ timeoutMs })` to fail after a time.
|
|
446
|
+
- `resolve()` waits for a missing provider too. Before `start()`, it builds the services that it needs, with their `start` hooks.
|
|
447
|
+
- `invoke()` returns a handle with `withdraw()`. Its callback runs again when a required dependency gets a new instance.
|
|
448
|
+
|
|
122
449
|
## License
|
|
123
450
|
|
|
124
451
|
MIT
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// src/diagnostics/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* The base class of all injeca errors. Use `code` to identify an error. The message text is not stable.
|
|
4
|
+
*/
|
|
5
|
+
var InjecaError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = "InjecaError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** A started container already has a current registration for the key. */
|
|
14
|
+
var DuplicateServiceError = class extends InjecaError {
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super("duplicate-service", message, options);
|
|
17
|
+
this.name = "DuplicateServiceError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** A registration is no longer the current registration of its service. */
|
|
21
|
+
var StaleRegistrationError = class extends InjecaError {
|
|
22
|
+
constructor(message, options) {
|
|
23
|
+
super("stale-registration", message, options);
|
|
24
|
+
this.name = "StaleRegistrationError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/** A hook returned a decision from another run, or a kind that the hook does not accept. */
|
|
28
|
+
var InvalidDecisionError = class extends InjecaError {
|
|
29
|
+
constructor(message, options) {
|
|
30
|
+
super("invalid-decision", message, options);
|
|
31
|
+
this.name = "InvalidDecisionError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** A caller used a container or an instance after its disposal started. */
|
|
35
|
+
var BuildAfterDisposeError = class extends InjecaError {
|
|
36
|
+
constructor(message, options) {
|
|
37
|
+
super("build-after-dispose", message, options);
|
|
38
|
+
this.name = "BuildAfterDisposeError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** `on` was called after `build` settled. */
|
|
42
|
+
var HookOutsideBuildError = class extends InjecaError {
|
|
43
|
+
constructor(message, options) {
|
|
44
|
+
super("hook-outside-build", message, options);
|
|
45
|
+
this.name = "HookOutsideBuildError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** A transaction commit found a declared service without `provide`. */
|
|
49
|
+
var TransactionIncompleteError = class extends InjecaError {
|
|
50
|
+
constructor(message, options) {
|
|
51
|
+
super("transaction-incomplete", message, options);
|
|
52
|
+
this.name = "TransactionIncompleteError";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** `dispose()` was called from the cleanup chain of the same container. */
|
|
56
|
+
var ReentrantDisposeError = class extends InjecaError {
|
|
57
|
+
constructor(message, options) {
|
|
58
|
+
super("reentrant-dispose", message, options);
|
|
59
|
+
this.name = "ReentrantDisposeError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** A `start` or `stop` hook did not settle before its configured timeout. */
|
|
63
|
+
var HookTimeoutError = class extends InjecaError {
|
|
64
|
+
hook;
|
|
65
|
+
timeoutMs;
|
|
66
|
+
constructor(message, hook, timeoutMs, options) {
|
|
67
|
+
super("hook-timeout", message, options);
|
|
68
|
+
this.name = "HookTimeoutError";
|
|
69
|
+
this.hook = hook;
|
|
70
|
+
this.timeoutMs = timeoutMs;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/** `replace` was called on a service in `stopping`. */
|
|
74
|
+
var ReplaceWhileStoppingError = class extends InjecaError {
|
|
75
|
+
constructor(message, options) {
|
|
76
|
+
super("replace-while-stopping", message, options);
|
|
77
|
+
this.name = "ReplaceWhileStoppingError";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** `start()` did not reach `running` for every service before its configured timeout. */
|
|
81
|
+
var StartTimeoutError = class extends InjecaError {
|
|
82
|
+
/** One entry for each service or invocation that is not `running`. */
|
|
83
|
+
explanations;
|
|
84
|
+
constructor(message, explanations, options) {
|
|
85
|
+
super("start-timeout", message, options);
|
|
86
|
+
this.name = "StartTimeoutError";
|
|
87
|
+
this.explanations = explanations;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** `waitFor()` did not match before its configured timeout. */
|
|
91
|
+
var WaitTimeoutError = class extends InjecaError {
|
|
92
|
+
constructor(message, options) {
|
|
93
|
+
super("wait-timeout", message, options);
|
|
94
|
+
this.name = "WaitTimeoutError";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** A caller passed a value with the wrong shape, or reading a caller-owned property failed. */
|
|
98
|
+
var InvalidArgumentError = class extends InjecaError {
|
|
99
|
+
constructor(message, options) {
|
|
100
|
+
super("invalid-argument", message, options);
|
|
101
|
+
this.name = "InvalidArgumentError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// src/primitives/decision.ts
|
|
105
|
+
const issued = /* @__PURE__ */ new WeakMap();
|
|
106
|
+
function reasonFrom(options) {
|
|
107
|
+
return options?.reason === void 0 ? {} : { reason: options.reason };
|
|
108
|
+
}
|
|
109
|
+
/** Creates the `decide` object of one run. */
|
|
110
|
+
function createDecide(run) {
|
|
111
|
+
function issue(fields) {
|
|
112
|
+
const decision = Object.freeze({
|
|
113
|
+
...fields,
|
|
114
|
+
service: run.service,
|
|
115
|
+
instanceId: run.instanceId,
|
|
116
|
+
hook: run.hook,
|
|
117
|
+
attempt: run.attempt,
|
|
118
|
+
issuedAt: run.now()
|
|
119
|
+
});
|
|
120
|
+
issued.set(decision, run);
|
|
121
|
+
return decision;
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
handled: (options) => issue({
|
|
125
|
+
kind: "handled",
|
|
126
|
+
...reasonFrom(options)
|
|
127
|
+
}),
|
|
128
|
+
rebuild: (options) => issue({
|
|
129
|
+
kind: "rebuild",
|
|
130
|
+
...reasonFrom(options)
|
|
131
|
+
}),
|
|
132
|
+
requeue: (options) => issue({
|
|
133
|
+
kind: "requeue",
|
|
134
|
+
...reasonFrom(options),
|
|
135
|
+
...options?.after === void 0 ? {} : { after: options.after },
|
|
136
|
+
...options?.backoff === void 0 ? {} : { backoff: Object.freeze({ ...options.backoff }) },
|
|
137
|
+
...options?.maxAttempts === void 0 ? {} : { maxAttempts: options.maxAttempts }
|
|
138
|
+
}),
|
|
139
|
+
stop: (options) => issue({
|
|
140
|
+
kind: "stop",
|
|
141
|
+
...reasonFrom(options)
|
|
142
|
+
}),
|
|
143
|
+
fail: (error, options) => issue({
|
|
144
|
+
kind: "fail",
|
|
145
|
+
error,
|
|
146
|
+
...reasonFrom(options)
|
|
147
|
+
})
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const validKinds = {
|
|
151
|
+
"start": /* @__PURE__ */ new Set([
|
|
152
|
+
"handled",
|
|
153
|
+
"requeue",
|
|
154
|
+
"stop",
|
|
155
|
+
"fail"
|
|
156
|
+
]),
|
|
157
|
+
"stop": /* @__PURE__ */ new Set(["handled", "fail"]),
|
|
158
|
+
"dependency-change": /* @__PURE__ */ new Set([
|
|
159
|
+
"handled",
|
|
160
|
+
"rebuild",
|
|
161
|
+
"requeue",
|
|
162
|
+
"stop",
|
|
163
|
+
"fail"
|
|
164
|
+
]),
|
|
165
|
+
"dependency-status-change": /* @__PURE__ */ new Set([
|
|
166
|
+
"handled",
|
|
167
|
+
"rebuild",
|
|
168
|
+
"requeue",
|
|
169
|
+
"stop",
|
|
170
|
+
"fail"
|
|
171
|
+
])
|
|
172
|
+
};
|
|
173
|
+
/** The decision for `undefined`. A `dependency-change` handler without a decision gets the default: rebuild. */
|
|
174
|
+
function defaultDecision(run) {
|
|
175
|
+
const decide = createDecide(run);
|
|
176
|
+
return run.hook === "dependency-change" ? decide.rebuild() : decide.handled();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Checks the value that a hook handler returned. It gives `undefined` for a value that `decide` did not create, so that the caller applies the default.
|
|
180
|
+
* This keeps a shorthand such as `on('start', () => app.listen(port))` valid.
|
|
181
|
+
*
|
|
182
|
+
* @throws {InvalidDecisionError} when another run created the decision, or when the hook does not accept its kind.
|
|
183
|
+
*/
|
|
184
|
+
function validateDecision(run, value) {
|
|
185
|
+
const owner = typeof value === "object" && value !== null ? issued.get(value) : void 0;
|
|
186
|
+
if (!owner) return void 0;
|
|
187
|
+
if (owner !== run) throw new InvalidDecisionError(`The '${run.hook}' hook of '${run.service}' returned a decision from another run: the '${owner.hook}' hook of '${owner.service}', instance '${owner.instanceId}', attempt ${owner.attempt}.`);
|
|
188
|
+
const decision = value;
|
|
189
|
+
if (!validKinds[run.hook].has(decision.kind)) throw new InvalidDecisionError(`The '${run.hook}' hook cannot return a '${decision.kind}' decision.`);
|
|
190
|
+
return decision;
|
|
191
|
+
}
|
|
192
|
+
export { DuplicateServiceError as a, InjecaError as c, ReentrantDisposeError as d, ReplaceWhileStoppingError as f, WaitTimeoutError as g, TransactionIncompleteError as h, BuildAfterDisposeError as i, InvalidArgumentError as l, StartTimeoutError as m, defaultDecision as n, HookOutsideBuildError as o, StaleRegistrationError as p, validateDecision as r, HookTimeoutError as s, createDecide as t, InvalidDecisionError as u };
|
|
193
|
+
|
|
194
|
+
//# sourceMappingURL=decision-odiPb2Au.mjs.map
|